mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into importhandover
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
SERVICES=()
|
||||
|
||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$"
|
||||
|
||||
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
|
||||
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,6 +18,7 @@ FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
@@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||
"predev": "pnpm run clean",
|
||||
"dev": "nest start --watch",
|
||||
"dev": "nest start --watch --clearScreen false",
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
|
||||
@@ -33,7 +33,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.invoices (
|
||||
CREATE TABLE IF NOT EXISTS freight.invoices (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_number varchar(64) NOT NULL,
|
||||
company_id uuid NOT NULL,
|
||||
@@ -102,6 +102,8 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.invoices_status_enum;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Extend `freight.invoices` into the billing record of record for every source
|
||||
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
|
||||
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
|
||||
*
|
||||
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
|
||||
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
|
||||
* statuses the warehouse flow uses.
|
||||
*
|
||||
* Matches billing/entities/invoice.entity.ts. All columns are additive with
|
||||
* defaults, so existing booking/demurrage rows are unaffected.
|
||||
*/
|
||||
export class ExtendInvoicesForPartialPayment1828000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "ExtendInvoicesForPartialPayment1828000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
|
||||
// as the value is not referenced in the same transaction (it is not here).
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
|
||||
`);
|
||||
|
||||
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
|
||||
// the outstanding balance is the full total for unpaid invoices.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET subtotal_amount = total_amount,
|
||||
balance_amount = total_amount;
|
||||
`);
|
||||
|
||||
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.invoices
|
||||
SET paid_amount = total_amount,
|
||||
balance_amount = 0,
|
||||
paid_at = updated_at
|
||||
WHERE status = 'PAID';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS payments,
|
||||
DROP COLUMN IF EXISTS paid_at,
|
||||
DROP COLUMN IF EXISTS balance_amount,
|
||||
DROP COLUMN IF EXISTS paid_amount,
|
||||
DROP COLUMN IF EXISTS tax_amount,
|
||||
DROP COLUMN IF EXISTS subtotal_amount;
|
||||
`);
|
||||
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
|
||||
// left on freight.invoices_status_enum (harmless, unused after down).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fold warehouse fee invoices into the central billing system.
|
||||
*
|
||||
* Warehouse fee invoices are no longer a standalone aggregate: each becomes a
|
||||
* global `freight.invoices` row (`source = 'warehouse'`, `source_id =
|
||||
* inventory_id`) with its items as `freight.invoice_lines`. The warehouse
|
||||
* service is now a thin layer over `BillingService`. This migration backfills the
|
||||
* existing rows (preserving ids, numbers, status, amounts and payment history),
|
||||
* then drops the two legacy tables.
|
||||
*
|
||||
* Rows that cannot be billed centrally — no company to bill (`company_id` /
|
||||
* `company_profile_id` underivable from the customer or the booking) — are not
|
||||
* migrated; they could never have been charged through the gateway and are
|
||||
* dropped with the table.
|
||||
*/
|
||||
export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
|
||||
name = 'CentralizeWarehouseInvoices1829000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. Invoice headers. Keep the same id so items still link, and so any
|
||||
// external reference to the invoice id stays valid.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.invoices (
|
||||
id, invoice_number, company_id, company_profile_id,
|
||||
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, status, source, source_id, type,
|
||||
issued_at, paid_at, payments, payment_id, due_at,
|
||||
created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
fee.id,
|
||||
fee.invoice_number,
|
||||
COALESCE(fee.customer_id, b.company_id),
|
||||
COALESCE(
|
||||
b.company_profile_id,
|
||||
(SELECT cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||
AND cp.deleted_at IS NULL
|
||||
ORDER BY cp.created_at ASC
|
||||
LIMIT 1)
|
||||
),
|
||||
fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
|
||||
fee.currency,
|
||||
fee.status::freight.invoices_status_enum,
|
||||
'warehouse',
|
||||
fee.inventory_id,
|
||||
fee.invoice_type,
|
||||
fee.issued_at,
|
||||
fee.paid_at,
|
||||
COALESCE(fee.payments, '[]'::jsonb),
|
||||
NULL,
|
||||
COALESCE(fee.due_date, fee.issued_at, fee.created_at),
|
||||
fee.created_at, fee.updated_at, fee.deleted_at
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id
|
||||
WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
|
||||
AND COALESCE(
|
||||
b.company_profile_id,
|
||||
(SELECT cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||
AND cp.deleted_at IS NULL
|
||||
ORDER BY cp.created_at ASC
|
||||
LIMIT 1)
|
||||
) IS NOT NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 2. Invoice lines — only for items whose parent invoice migrated. Warehouse
|
||||
// fee fields (fee_rule_id / chargeable_days / free_days) move into the
|
||||
// line's jsonb metadata.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.invoice_lines (
|
||||
id, invoice_id, charge_type, description, quantity, unit_rate, amount,
|
||||
currency, metadata, created_at, updated_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
item.id,
|
||||
item.invoice_id,
|
||||
item.fee_type,
|
||||
item.description,
|
||||
item.quantity,
|
||||
item.unit_rate,
|
||||
item.amount,
|
||||
item.currency,
|
||||
jsonb_build_object(
|
||||
'feeRuleId', item.fee_rule_id,
|
||||
'chargeableDays', item.chargeable_days,
|
||||
'freeDays', item.free_days
|
||||
),
|
||||
item.created_at, item.updated_at, item.deleted_at
|
||||
FROM freight.warehouse_fee_invoice_items item
|
||||
JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 3. Drop the legacy tables (items first — FK to invoices).
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Recreate the legacy tables …
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
invoice_number varchar(40) NOT NULL,
|
||||
booking_id uuid,
|
||||
customer_id uuid,
|
||||
inventory_id uuid NOT NULL,
|
||||
facility_id uuid,
|
||||
warehouse_id uuid,
|
||||
yard_id uuid,
|
||||
zone_id uuid,
|
||||
invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
|
||||
status varchar(20) NOT NULL DEFAULT 'DRAFT',
|
||||
subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
tax_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
total_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
paid_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
balance_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||
period_start timestamptz,
|
||||
period_end timestamptz,
|
||||
issued_at timestamptz,
|
||||
due_date timestamptz,
|
||||
paid_at timestamptz,
|
||||
cancelled_at timestamptz,
|
||||
payments jsonb NOT NULL DEFAULT '[]',
|
||||
notes text,
|
||||
CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
|
||||
CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
invoice_id uuid NOT NULL,
|
||||
fee_rule_id uuid,
|
||||
fee_type varchar(32) NOT NULL,
|
||||
description varchar(255) NOT NULL,
|
||||
quantity numeric(12,2) NOT NULL DEFAULT 1,
|
||||
unit_rate numeric(14,2) NOT NULL DEFAULT 0,
|
||||
amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||
chargeable_days int,
|
||||
free_days int,
|
||||
CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
|
||||
CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
|
||||
FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
|
||||
);
|
||||
|
||||
// … then copy the warehouse-source invoices back, deriving the typed FKs and
|
||||
// period from the linked inventory item.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_fee_invoices (
|
||||
id, created_at, updated_at, deleted_at, invoice_number,
|
||||
booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
|
||||
invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
|
||||
)
|
||||
SELECT
|
||||
i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
|
||||
inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
|
||||
i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
|
||||
i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
|
||||
CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
|
||||
i.payments, NULL
|
||||
FROM freight.invoices i
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_fee_invoice_items (
|
||||
id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
|
||||
description, quantity, unit_rate, amount, currency, chargeable_days, free_days
|
||||
)
|
||||
SELECT
|
||||
l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
|
||||
NULLIF(l.metadata->>'feeRuleId', '')::uuid,
|
||||
l.charge_type,
|
||||
COALESCE(l.description, ''),
|
||||
l.quantity, l.unit_rate, l.amount, l.currency,
|
||||
NULLIF(l.metadata->>'chargeableDays', '')::int,
|
||||
NULLIF(l.metadata->>'freeDays', '')::int
|
||||
FROM freight.invoice_lines l
|
||||
JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// Remove the migrated rows from the central tables.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.invoice_lines
|
||||
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { PortalBillingController } from "./portal-billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { DocumentsModule } from "./documents/documents.module";
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
@@ -16,6 +17,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
DocumentsModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
|
||||
@@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
});
|
||||
|
||||
@@ -88,7 +89,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
expect(invoice.sourceId).toBe("booking-1");
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -134,6 +135,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -171,6 +173,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -180,6 +183,89 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.recordPayment", () => {
|
||||
function serviceFor(invoice: Record<string, unknown> | null) {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(invoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
|
||||
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
source: "warehouse",
|
||||
sourceId: "inv-item-1",
|
||||
totalAmount: 1000,
|
||||
paidAmount: 0,
|
||||
balanceAmount: 1000,
|
||||
payments: [],
|
||||
paidAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
|
||||
const { service, mg, events } = serviceFor(openInvoice());
|
||||
|
||||
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
|
||||
|
||||
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
|
||||
expect(updated.paidAmount).toBe(400);
|
||||
expect(updated.balanceAmount).toBe(600);
|
||||
expect(updated.payments).toHaveLength(1);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
expect.objectContaining({
|
||||
status: Freight.InvoiceStatus.PartiallyPaid,
|
||||
paidAmount: 400,
|
||||
balanceAmount: 600,
|
||||
}),
|
||||
);
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
|
||||
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
|
||||
|
||||
const updated = await service.recordPayment("inv-1", { amount: 600 });
|
||||
|
||||
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(updated.balanceAmount).toBe(0);
|
||||
expect(updated.paidAt).toBeInstanceOf(Date);
|
||||
expect(mg.update).toHaveBeenCalled();
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"warehouse.invoice.paid",
|
||||
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-positive amount", async () => {
|
||||
const { service, mg } = serviceFor(openInvoice());
|
||||
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payment against a cancelled invoice", async () => {
|
||||
const { service, mg } = serviceFor(
|
||||
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
|
||||
);
|
||||
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.settlePayable", () => {
|
||||
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
@@ -200,6 +286,7 @@ describe("BillingService.settlePayable", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
@@ -234,6 +321,7 @@ describe("BillingService.settlePayable", () => {
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
||||
import { applySettlement, round2 } from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||
export interface PayInvoiceOptions {
|
||||
@@ -20,13 +33,26 @@ export interface PayInvoiceOptions {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** A single manual/offline settlement to record against an invoice. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
/** When the settlement occurred; defaults to now. */
|
||||
paidAt?: Date;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Draft,
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
@@ -56,7 +82,11 @@ export interface GenerateInvoiceInput {
|
||||
companyProfileId: string;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit total; defaults to the sum of line amounts. */
|
||||
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
|
||||
subtotalAmount?: number;
|
||||
/** Tax applied on top of the subtotal; defaults to 0. */
|
||||
taxAmount?: number;
|
||||
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
|
||||
totalAmount?: number;
|
||||
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||
dueAt?: Date;
|
||||
@@ -95,6 +125,7 @@ export class BillingService {
|
||||
@Inject(forwardRef(() => PaymentService))
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -115,6 +146,69 @@ export class BillingService {
|
||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
}
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE"));
|
||||
}
|
||||
|
||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException("A receipt is available only after payment is recorded.");
|
||||
}
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT"));
|
||||
}
|
||||
|
||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||
private toDocumentModel(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
kind: "INVOICE" | "RECEIPT",
|
||||
): InvoiceDocumentModel {
|
||||
const title = invoice.source
|
||||
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
||||
: "EDR";
|
||||
const totals: InvoiceDocumentModel["totals"] = [
|
||||
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
|
||||
];
|
||||
if (Number(invoice.taxAmount) > 0) {
|
||||
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
|
||||
}
|
||||
totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true });
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{ label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null },
|
||||
{ label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null },
|
||||
],
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
description: l.description ?? l.chargeType,
|
||||
category: l.chargeType,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitRate,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||
|
||||
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||
@@ -175,18 +269,9 @@ export class BillingService {
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `FRT-${ymd}-`;
|
||||
const [row] = await mg.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||
FROM freight.invoices WHERE invoice_number LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,8 +315,12 @@ export class BillingService {
|
||||
};
|
||||
});
|
||||
|
||||
const subtotalAmount =
|
||||
input.subtotalAmount ??
|
||||
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
const taxAmount = input.taxAmount ?? 0;
|
||||
const totalAmount =
|
||||
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
||||
|
||||
const dueAt =
|
||||
input.dueAt ??
|
||||
@@ -250,7 +339,12 @@ export class BillingService {
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
totalAmount,
|
||||
subtotalAmount: round2(subtotalAmount),
|
||||
taxAmount: round2(taxAmount),
|
||||
totalAmount: round2(totalAmount),
|
||||
paidAmount: 0,
|
||||
balanceAmount: round2(totalAmount),
|
||||
payments: [],
|
||||
currency,
|
||||
status,
|
||||
issuedAt: issued ? new Date() : null,
|
||||
@@ -293,6 +387,85 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a (possibly partial) settlement against an invoice and sync its
|
||||
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
|
||||
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
|
||||
* balance reaches zero — PAID, stamping `paidAt` and emitting
|
||||
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
|
||||
* at the warehouse counter); gateway settlement goes through
|
||||
* {@link markInvoiceAsPaid}.
|
||||
*
|
||||
* Throws when the invoice is missing, cancelled, refunded, already fully paid,
|
||||
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
|
||||
* transaction.
|
||||
*/
|
||||
async recordPayment(
|
||||
invoiceId: string,
|
||||
input: RecordPaymentInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice> {
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException("Payment amount must be greater than zero.");
|
||||
}
|
||||
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
|
||||
throw new BadRequestException("Cannot pay a cancelled invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Refunded) {
|
||||
throw new BadRequestException("Cannot pay a refunded invoice.");
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
|
||||
const at = input.paidAt ?? new Date();
|
||||
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||
invoice.totalAmount,
|
||||
invoice.paidAmount,
|
||||
input.amount,
|
||||
);
|
||||
const status = fullyPaid
|
||||
? Freight.InvoiceStatus.Paid
|
||||
: Freight.InvoiceStatus.PartiallyPaid;
|
||||
|
||||
const entry: InvoicePayment = {
|
||||
amount: round2(input.amount),
|
||||
method: input.method ?? null,
|
||||
reference: input.reference ?? null,
|
||||
paidAt: at.toISOString(),
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
const payments = [...(invoice.payments ?? []), entry];
|
||||
|
||||
await mg.update(
|
||||
Invoice,
|
||||
{ id: invoice.id },
|
||||
{
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||
} as never,
|
||||
);
|
||||
|
||||
const updated = {
|
||||
...invoice,
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status,
|
||||
payments,
|
||||
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||
} as Invoice;
|
||||
|
||||
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||
* No-op when already refunded.
|
||||
@@ -487,11 +660,12 @@ export class BillingService {
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: sourceId,
|
||||
source: invoice.source,
|
||||
// Gateway reference type derives from the invoice source by convention
|
||||
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
||||
// the domain never supplies it. New sources add their uppercased value to
|
||||
// the PaymentReferenceType enum.
|
||||
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
||||
// Freight payments settle under the generic SHIPMENT reference — how the
|
||||
// payment service attributes them to the freight API. The payment ↔ invoice
|
||||
// link is the intent id (`paymentId`); per-source post-payment reactions live
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
currency: invoice.currency,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { InvoiceDocumentService } from "./invoice-document.service";
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
|
||||
/**
|
||||
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
||||
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
||||
* warehouses, …) can import it to print invoices without coupling to the
|
||||
* billing payment graph.
|
||||
*/
|
||||
@Module({
|
||||
providers: [PdfRenderService, InvoiceDocumentService],
|
||||
exports: [PdfRenderService, InvoiceDocumentService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
|
||||
category?: string | null;
|
||||
quantity?: number | null;
|
||||
unitRate?: number | null;
|
||||
amount?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
/** A labelled total row in the totals box; mark `grand` for the headline total. */
|
||||
export interface InvoiceDocumentTotal {
|
||||
label: string;
|
||||
amount: number;
|
||||
grand?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-agnostic description of a printable invoice/receipt. Each billing
|
||||
* source maps its own entity onto this shape; the renderer owns the layout so
|
||||
* every EDR invoice document looks identical regardless of source.
|
||||
*/
|
||||
export interface InvoiceDocumentModel {
|
||||
kind: InvoiceDocumentKind;
|
||||
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
|
||||
title: string;
|
||||
documentNumber: string;
|
||||
issuedAt?: Date | string | null;
|
||||
status: string;
|
||||
currency: string;
|
||||
/** Free-form summary grid (label/value pairs). */
|
||||
summary: Array<{ label: string; value: string | null }>;
|
||||
/** Header for the line-item category column; column hidden when omitted. */
|
||||
categoryHeader?: string;
|
||||
lines: InvoiceDocumentLine[];
|
||||
totals: InvoiceDocumentTotal[];
|
||||
/** Override the round seal text; defaults from kind/status. */
|
||||
sealText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
|
||||
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
|
||||
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
|
||||
* `WarehouseInvoiceService`; it now serves all invoices.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvoiceDocumentService {
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
|
||||
async render(
|
||||
model: InvoiceDocumentModel,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const html = this.buildHtml(model);
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
|
||||
};
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const money = (amount: unknown, currency = model.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||
.join("");
|
||||
|
||||
const itemRows = model.lines
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${esc(item.description)}</td>
|
||||
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
|
||||
<td class="num">${esc(item.quantity ?? 0)}</td>
|
||||
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
|
||||
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const totalRows = model.totals
|
||||
.map(
|
||||
(total) =>
|
||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||
.doc { padding: 16px 8px; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
||||
td.num, th.num { text-align: right; }
|
||||
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
||||
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
||||
.grand { font-size: 16px; font-weight: 800; }
|
||||
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Document no.
|
||||
<strong>${esc(model.documentNumber)}</strong>
|
||||
Issued: ${esc(date(model.issuedAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">${summaryRows}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
|
||||
<th class="num">Qty</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemRows}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">${totalRows}</div>
|
||||
<div class="footer">
|
||||
<div class="line">Prepared by EDR finance</div>
|
||||
<div class="line">Authorized seal / signature</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { existsSync } from "fs";
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const PDF_PRINT_STYLES = `
|
||||
<style id="edr-pdf-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
export interface PdfRenderOptions {
|
||||
/** Label used in logs to identify the document kind. */
|
||||
label?: string;
|
||||
/**
|
||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||
* header). When omitted, a generic single-page fallback is produced.
|
||||
*/
|
||||
fallback?: (preparedHtml: string) => Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic HTML → PDF renderer shared by every document producer (invoices,
|
||||
* receipts, warehouse release orders). Renders via headless Chromium when
|
||||
* available and degrades to a caller-supplied (or generic) hand-built PDF
|
||||
* otherwise. This is pure infrastructure — it knows nothing about invoices.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PdfRenderService {
|
||||
private readonly logger = new Logger(PdfRenderService.name);
|
||||
|
||||
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
|
||||
const label = opts.label ?? "document";
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import("puppeteer");
|
||||
const launchOptions: import("puppeteer").LaunchOptions = {
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||
await page.emulateMediaType("print");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
|
||||
}
|
||||
this.logger.log(
|
||||
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes("edr-pdf-print-fix")) return html;
|
||||
if (html.includes("</head>")) {
|
||||
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${PDF_PRINT_STYLES}${html}`;
|
||||
}
|
||||
|
||||
private resolveExecutablePath(): string | undefined {
|
||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
];
|
||||
return candidates.find((path) => existsSync(path));
|
||||
}
|
||||
|
||||
isValidPdf(buffer: Buffer): boolean {
|
||||
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
||||
}
|
||||
|
||||
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
|
||||
private genericFallbackPdf(html: string): Buffer {
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/[^\x20-\x7e]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 900);
|
||||
|
||||
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
|
||||
const stream =
|
||||
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
|
||||
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
|
||||
"ET";
|
||||
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
|
||||
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||
import { Company } from "../../companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||
|
||||
/** A single recorded settlement against an invoice (payment ledger entry). */
|
||||
export interface InvoicePayment {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
/** ISO timestamp of when the settlement was recorded. */
|
||||
paidAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "invoices" })
|
||||
@Index(["companyId"])
|
||||
@Index(["companyProfileId"])
|
||||
@@ -28,9 +38,24 @@ export class Invoice extends BaseEntity {
|
||||
@JoinColumn({ name: "company_profile_id" })
|
||||
companyProfile?: CompanyProfile;
|
||||
|
||||
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
|
||||
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
subtotalAmount!: number;
|
||||
|
||||
@Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
taxAmount!: number;
|
||||
|
||||
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
||||
totalAmount!: number;
|
||||
|
||||
/** Cumulative amount settled so far (supports partial payment). */
|
||||
@Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
paidAmount!: number;
|
||||
|
||||
/** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */
|
||||
@Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
balanceAmount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@@ -62,6 +87,14 @@ export class Invoice extends BaseEntity {
|
||||
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
||||
issuedAt?: Date | null;
|
||||
|
||||
/** Set when the invoice is fully settled. */
|
||||
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
/** Ledger of individual settlements (manual or gateway), newest last. */
|
||||
@Column({ name: "payments", type: "jsonb", default: () => "'[]'" })
|
||||
payments!: InvoicePayment[];
|
||||
|
||||
/** The ID of the payment that generated this invoice. */
|
||||
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
||||
paymentId?: string | null;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Shared per-day sequential invoice numbering, used by every billing source
|
||||
* (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the
|
||||
* `MAX(seq)+1` allocation live in one place instead of being copy-pasted per
|
||||
* service.
|
||||
*
|
||||
* Produces `<CODE>-YYYYMMDD-00001`: the sequence is the max existing suffix for
|
||||
* the day + 1. Run inside the caller's transaction (pass that transaction's
|
||||
* manager) so concurrent generation within a transaction stays consistent.
|
||||
*/
|
||||
|
||||
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
|
||||
export interface SqlRunner {
|
||||
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>;
|
||||
}
|
||||
|
||||
export interface InvoiceNumberOptions {
|
||||
/** Schema-qualified table to scan, e.g. `freight.invoices`. */
|
||||
table: string;
|
||||
/** Document code prefix, e.g. `FRT` or `WHF`. */
|
||||
code: string;
|
||||
/** Column holding the number; defaults to `invoice_number`. */
|
||||
column?: string;
|
||||
/** Clock injection point (tests); defaults to now. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export async function nextDailyInvoiceNumber(
|
||||
runner: SqlRunner,
|
||||
opts: InvoiceNumberOptions,
|
||||
): Promise<string> {
|
||||
const now = opts.now ?? new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `${opts.code}-${ymd}-`;
|
||||
const column = opts.column ?? "invoice_number";
|
||||
|
||||
const [row] = await runner.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
|
||||
FROM ${opts.table} WHERE ${column} LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared payment/settlement math for invoices. Both the global
|
||||
* `BillingService.recordPayment` and the warehouse fee invoice flow apply a
|
||||
* payment the same way — accumulate `paidAmount`, derive the outstanding
|
||||
* `balanceAmount`, and decide whether the invoice is now fully settled. Keeping
|
||||
* it here means the two flows can never drift on rounding or the
|
||||
* partial-vs-full threshold.
|
||||
*/
|
||||
|
||||
/** Round to 2 decimals, avoiding binary float drift. */
|
||||
export const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
export interface SettlementResult {
|
||||
/** New cumulative amount paid. */
|
||||
paidAmount: number;
|
||||
/** Remaining balance (0 once fully paid). */
|
||||
balanceAmount: number;
|
||||
/** True once the balance reaches zero. */
|
||||
fullyPaid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single payment of `amount` to an invoice with `totalAmount` already
|
||||
* carrying `currentPaid`. Caller is responsible for validating `amount > 0` and
|
||||
* the invoice being in a payable state.
|
||||
*/
|
||||
export function applySettlement(
|
||||
totalAmount: number,
|
||||
currentPaid: number,
|
||||
amount: number,
|
||||
): SettlementResult {
|
||||
const total = Number(totalAmount);
|
||||
const paidAmount = round2(Number(currentPaid) + Number(amount));
|
||||
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
||||
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
Body,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -18,9 +16,9 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingView } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
||||
import { IntentStatusDto } from "./payments.dto";
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
@@ -73,13 +71,6 @@ export class PaymentController {
|
||||
return this.paymentService.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
|
||||
refund(@Body() dto: RefundDto) {
|
||||
return this.paymentService.refund(dto);
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity';
|
||||
|
||||
export const WAREHOUSE_FEE_TYPES = [
|
||||
'CONTAINER_DEMURRAGE',
|
||||
'BULK_DEMURRAGE',
|
||||
'STORAGE_FEE',
|
||||
'HANDLING_FEE',
|
||||
] as const;
|
||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' })
|
||||
@Index(['invoiceId'])
|
||||
export class WarehouseFeeInvoiceItem extends BaseEntity {
|
||||
@Column({ name: 'invoice_id', type: 'uuid' })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'invoice_id' })
|
||||
invoice?: WarehouseFeeInvoice;
|
||||
|
||||
@Column({ name: 'fee_rule_id', type: 'uuid', nullable: true })
|
||||
feeRuleId?: string | null;
|
||||
|
||||
@Column({ name: 'fee_type', type: 'varchar', length: 32 })
|
||||
feeType!: WarehouseFeeType;
|
||||
|
||||
@Column({ name: 'description', type: 'varchar', length: 255 })
|
||||
description!: string;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
unitRate!: number;
|
||||
|
||||
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'chargeable_days', type: 'int', nullable: true })
|
||||
chargeableDays?: number | null;
|
||||
|
||||
@Column({ name: 'free_days', type: 'int', nullable: true })
|
||||
freeDays?: number | null;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
|
||||
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_INVOICE_STATUSES = [
|
||||
'DRAFT',
|
||||
'ISSUED',
|
||||
'PARTIALLY_PAID',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
|
||||
|
||||
/** A single recorded payment against a warehouse fee invoice (history). */
|
||||
export interface WarehouseInvoicePayment {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation.
|
||||
* Owns warehouse fees; links to booking/customer/inventory/location so it can
|
||||
* connect to the existing payment module without duplicating it.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' })
|
||||
@Index(['invoiceNumber'], { unique: true })
|
||||
@Index(['bookingId'])
|
||||
@Index(['inventoryId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseFeeInvoice extends BaseEntity {
|
||||
@Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
@Column({ name: 'inventory_id', type: 'uuid' })
|
||||
inventoryId!: string;
|
||||
|
||||
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
|
||||
facilityId?: string | null;
|
||||
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId?: string | null;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId?: string | null;
|
||||
|
||||
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
|
||||
zoneId?: string | null;
|
||||
|
||||
@Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' })
|
||||
invoiceType!: WarehouseInvoiceType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||
status!: WarehouseInvoiceStatus;
|
||||
|
||||
@Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
subtotalAmount!: number;
|
||||
|
||||
@Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
taxAmount!: number;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
totalAmount!: number;
|
||||
|
||||
@Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
paidAmount!: number;
|
||||
|
||||
@Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
balanceAmount!: number;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
/** Charge window covered by this invoice — used to allow a later invoice for a new period. */
|
||||
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
|
||||
periodStart?: Date | null;
|
||||
|
||||
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
|
||||
periodEnd?: Date | null;
|
||||
|
||||
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
|
||||
issuedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
|
||||
dueDate?: Date | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" })
|
||||
payments!: WarehouseInvoicePayment[];
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeInvoiceItemRepository extends BaseRepository<WarehouseFeeInvoiceItem> {
|
||||
constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository<WarehouseFeeInvoiceItem>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeInvoiceRepository extends BaseRepository<WarehouseFeeInvoice> {
|
||||
constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository<WarehouseFeeInvoice>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import {
|
||||
WarehouseFeeInvoice,
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from '../billing/documents/invoice-document.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import {
|
||||
WarehouseFeeInvoiceView,
|
||||
WarehouseFeeType,
|
||||
WarehouseInvoiceItemView,
|
||||
WarehouseInvoiceStatus,
|
||||
WarehouseInvoiceType,
|
||||
} from './entities/warehouse-fee-invoice.entity';
|
||||
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
|
||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
} from './warehouse-invoice.types';
|
||||
|
||||
interface GenerateOptions {
|
||||
confirmZero?: boolean;
|
||||
@@ -27,9 +34,18 @@ export interface PayInvoiceDto {
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
/** Invoices that still owe money and therefore block terminal release. */
|
||||
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
|
||||
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
|
||||
/** Warehouse fee invoices live in the global billing system under this source. */
|
||||
const SOURCE = Freight.InvoiceSource.Warehouse;
|
||||
|
||||
/** Global statuses that still owe money and therefore block terminal release. */
|
||||
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
/** Global statuses considered an "active" invoice for per-inventory dedup. */
|
||||
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
|
||||
|
||||
export interface InvoiceDocumentDetails {
|
||||
bookingReference: string | null;
|
||||
@@ -45,28 +61,75 @@ export interface InvoiceDocumentDetails {
|
||||
zoneName: string | null;
|
||||
}
|
||||
|
||||
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
|
||||
export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView &
|
||||
Partial<InvoiceDocumentDetails> & { items: WarehouseInvoiceItemView[] };
|
||||
|
||||
/** The warehouse-specific columns derived from the linked inventory item. */
|
||||
interface InventoryContext {
|
||||
bookingId: string | null;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
zoneId: string | null;
|
||||
periodStart: Date | null;
|
||||
}
|
||||
|
||||
/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */
|
||||
interface ViewSource {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
companyId: string;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
status: Freight.InvoiceStatus | string;
|
||||
subtotalAmount: number | string;
|
||||
taxAmount: number | string;
|
||||
totalAmount: number | string;
|
||||
paidAmount: number | string;
|
||||
balanceAmount: number | string;
|
||||
currency: string;
|
||||
issuedAt?: Date | null;
|
||||
dueAt?: Date | null;
|
||||
paidAt?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
payments?: Array<{
|
||||
amount: number | string;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin warehouse layer over the central {@link BillingService}. Warehouse fee
|
||||
* invoices are global `Invoice` rows (`source = warehouse`, `sourceId =
|
||||
* inventoryId`); this service owns only the warehouse-specific concerns —
|
||||
* computing fees, per-inventory dedup, release-blocking, SMS notifications, the
|
||||
* sealed PDF, and reshaping the global invoice back into the historical
|
||||
* `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering,
|
||||
* status, and payment math live in billing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseInvoiceService {
|
||||
private readonly logger = new Logger(WarehouseInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly documents: WarehouseReleaseDocumentService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
|
||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const [item] = await this.dataSource.query(
|
||||
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
|
||||
w.facility_id AS "facilityId",
|
||||
b.company_id AS "customerId", b.freight_type AS "freightType"
|
||||
b.company_id AS "companyId", b.company_profile_id AS "companyProfileId",
|
||||
b.freight_type AS "freightType"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -75,9 +138,16 @@ export class WarehouseInvoiceService {
|
||||
);
|
||||
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
|
||||
// Routing through the global invoice requires a billable company + profile,
|
||||
// both of which come from the inventory's booking.
|
||||
if (!item.companyId || !item.companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
|
||||
);
|
||||
}
|
||||
|
||||
// Dedup: only one active (non-cancelled) invoice per inventory item.
|
||||
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
|
||||
if (await this.hasActiveInvoice(inventoryId)) {
|
||||
throw new ConflictException(
|
||||
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
|
||||
);
|
||||
@@ -112,9 +182,7 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
});
|
||||
|
||||
const subtotal = items.reduce((s, i) => s + i.amount, 0);
|
||||
const total = subtotal; // tax model can be layered on later
|
||||
|
||||
const total = items.reduce((s, i) => s + i.amount, 0);
|
||||
if (total <= 0 && !opts.confirmZero) {
|
||||
throw new BadRequestException('No payable warehouse fee found for this item.');
|
||||
}
|
||||
@@ -124,74 +192,81 @@ export class WarehouseInvoiceService {
|
||||
const invoiceType: WarehouseInvoiceType =
|
||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||
|
||||
const currency = billingCurrency;
|
||||
const now = new Date();
|
||||
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
||||
const lines: InvoiceLineInput[] = items.map((it) => ({
|
||||
chargeType: it.feeType,
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unitRate: it.unitRate,
|
||||
amount: it.amount,
|
||||
currency: it.currency,
|
||||
metadata: {
|
||||
feeRuleId: it.feeRuleId ?? null,
|
||||
chargeableDays: it.chargeableDays ?? null,
|
||||
freeDays: it.freeDays ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
const invoice = await this.invoiceRepository.create({
|
||||
invoiceNumber: await this.nextInvoiceNumber(),
|
||||
bookingId: item.bookingId ?? null,
|
||||
customerId: item.customerId ?? null,
|
||||
inventoryId,
|
||||
facilityId: item.facilityId ?? null,
|
||||
warehouseId: item.warehouseId ?? null,
|
||||
yardId: item.yardId ?? null,
|
||||
zoneId: item.zoneId ?? null,
|
||||
invoiceType,
|
||||
status: 'ISSUED',
|
||||
subtotalAmount: subtotal,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
paidAmount: 0,
|
||||
balanceAmount: total,
|
||||
currency,
|
||||
periodStart: item.arrivedAt ?? null,
|
||||
periodEnd,
|
||||
issuedAt: now,
|
||||
payments: [],
|
||||
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: SOURCE,
|
||||
sourceId: inventoryId,
|
||||
type: invoiceType,
|
||||
companyId: item.companyId,
|
||||
companyProfileId: item.companyProfileId,
|
||||
currency: billingCurrency,
|
||||
lines,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
|
||||
for (const it of items) {
|
||||
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
||||
}
|
||||
|
||||
const saved = await this.findById(invoice.id);
|
||||
await this.notifyWarehouseFeeIssued(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
||||
private async nextInvoiceNumber(): Promise<string> {
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const prefix = `WHF-${ymd}-`;
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, '0')}`;
|
||||
const detail = await this.findById(invoice.id);
|
||||
await this.notifyWarehouseFeeIssued(detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────
|
||||
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
|
||||
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
const ctx = await this.getInventoryContext(invoice.sourceId);
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
|
||||
const items = invoice.lines.map((l) => this.lineToItem(l));
|
||||
return { ...this.buildView(invoice, ctx), ...details, items };
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
}
|
||||
|
||||
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
|
||||
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filter: Partial<
|
||||
Pick<
|
||||
WarehouseFeeInvoiceView,
|
||||
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
|
||||
>
|
||||
>,
|
||||
): Promise<WarehouseFeeInvoiceView[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
const add = (sql: (p: string) => string, value: unknown) => {
|
||||
params.push(value);
|
||||
conditions.push(sql(`$${params.length}`));
|
||||
};
|
||||
|
||||
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
|
||||
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
|
||||
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
|
||||
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
|
||||
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
|
||||
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
|
||||
|
||||
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
|
||||
}
|
||||
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
|
||||
return {
|
||||
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
||||
};
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
|
||||
}
|
||||
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
@@ -199,76 +274,65 @@ export class WarehouseInvoiceService {
|
||||
if (Number(invoice.paidAmount) <= 0) {
|
||||
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
||||
}
|
||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
||||
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
|
||||
return {
|
||||
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
||||
};
|
||||
}
|
||||
|
||||
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
|
||||
return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
listForBooking(bookingId: string): Promise<WarehouseFeeInvoice[]> {
|
||||
return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
findAll(filter: Partial<Pick<WarehouseFeeInvoice, 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'>>): Promise<WarehouseFeeInvoice[]> {
|
||||
const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null));
|
||||
return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } });
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
||||
}
|
||||
|
||||
// ── State changes ────────────────────────────────────────────────────────
|
||||
async cancel(id: string): Promise<WarehouseFeeInvoice> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
|
||||
return updated as WarehouseFeeInvoice;
|
||||
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||
const invoice = await this.loadWarehouseInvoice(id);
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||
}
|
||||
await this.billing.cancelInvoice(id);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Record a payment against the invoice and sync status (links to existing payment flow). */
|
||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
|
||||
const invoice = await this.invoiceRepository.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
|
||||
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
||||
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
||||
|
||||
const paidAmount = Number(invoice.paidAmount) + dto.amount;
|
||||
const total = Number(invoice.totalAmount);
|
||||
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
|
||||
const fullyPaid = paidAmount >= total;
|
||||
|
||||
const payments = [
|
||||
...(invoice.payments ?? []),
|
||||
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
const updated = await this.invoiceRepository.update(id, {
|
||||
paidAmount: Math.round(paidAmount * 100) / 100,
|
||||
balanceAmount: balance,
|
||||
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||
payments,
|
||||
/** Record a payment against the invoice (delegates settlement to billing). */
|
||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
|
||||
// Guard that this is a warehouse invoice before recording (404 otherwise).
|
||||
await this.loadWarehouseInvoice(id);
|
||||
await this.billing.recordPayment(id, {
|
||||
amount: dto.amount,
|
||||
method: dto.method ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
metadata:
|
||||
dto.driverName || dto.driverPhone
|
||||
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
|
||||
: null,
|
||||
});
|
||||
const paidInvoice = updated as WarehouseFeeInvoice;
|
||||
await this.notifyWarehouseFeePayment(paidInvoice, dto);
|
||||
return paidInvoice;
|
||||
const detail = await this.findById(id);
|
||||
await this.notifyWarehouseFeePayment(detail, dto);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
|
||||
* fee being paid through billing's payment flow. The counter {@link pay} path
|
||||
* notifies inline (and carries driver details from the request), so this only
|
||||
* handles gateway payments: those stamp the invoice `paymentId`, whereas a
|
||||
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
|
||||
* double-notifying a counter payment that already sent its SMS.
|
||||
*/
|
||||
@OnEvent('warehouse.invoice.paid')
|
||||
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (!payload.paymentId) return;
|
||||
const detail = await this.findById(payload.invoiceId);
|
||||
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
|
||||
}
|
||||
|
||||
// ── Release blocking ──────────────────────────────────────────────────────
|
||||
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
|
||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
|
||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
|
||||
const blocking = await this.queryViews(
|
||||
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
|
||||
[inventoryId, BLOCKING_STATUSES],
|
||||
);
|
||||
return blocking[0] ?? null;
|
||||
}
|
||||
|
||||
async assertClearanceAllowed(inventoryId: string): Promise<void> {
|
||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
|
||||
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
if (blocking) {
|
||||
throw new BadRequestException(
|
||||
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
|
||||
@@ -286,12 +350,218 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
|
||||
// ── Internal: loading & projection ─────────────────────────────────────────
|
||||
|
||||
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
|
||||
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.billing.findById(id);
|
||||
if (invoice.source !== SOURCE) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private async hasActiveInvoice(inventoryId: string): Promise<boolean> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT 1
|
||||
FROM freight.invoices
|
||||
WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[SOURCE, inventoryId, ACTIVE_STATUSES],
|
||||
);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project warehouse-source global invoices into the historical view, joined to
|
||||
* their inventory item for the typed FKs. Powers every list/filter read.
|
||||
*/
|
||||
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
|
||||
i.source_id AS "sourceId", i.type, i.status,
|
||||
i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount",
|
||||
i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount",
|
||||
i.balance_amount AS "balanceAmount", i.currency, i.payments,
|
||||
i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt",
|
||||
i.created_at AS "createdAt", i.updated_at AS "updatedAt",
|
||||
inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
w.facility_id AS "facilityId"
|
||||
FROM freight.invoices i
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
|
||||
ORDER BY i.created_at DESC`,
|
||||
[...params, SOURCE],
|
||||
);
|
||||
|
||||
return (rows as Array<ViewSource & InventoryContext>).map((row) =>
|
||||
this.buildView(row, {
|
||||
bookingId: row.bookingId ?? null,
|
||||
facilityId: row.facilityId ?? null,
|
||||
warehouseId: row.warehouseId ?? null,
|
||||
yardId: row.yardId ?? null,
|
||||
zoneId: row.zoneId ?? null,
|
||||
periodStart: row.periodStart ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
|
||||
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
|
||||
const status = this.toWarehouseStatus(inv.status);
|
||||
return {
|
||||
id: inv.id,
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
bookingId: ctx.bookingId,
|
||||
customerId: inv.companyId ?? null,
|
||||
inventoryId: inv.sourceId,
|
||||
facilityId: ctx.facilityId,
|
||||
warehouseId: ctx.warehouseId,
|
||||
yardId: ctx.yardId,
|
||||
zoneId: ctx.zoneId,
|
||||
invoiceType: inv.type as WarehouseInvoiceType,
|
||||
status,
|
||||
subtotalAmount: Number(inv.subtotalAmount),
|
||||
taxAmount: Number(inv.taxAmount),
|
||||
totalAmount: Number(inv.totalAmount),
|
||||
paidAmount: Number(inv.paidAmount),
|
||||
balanceAmount: Number(inv.balanceAmount),
|
||||
currency: inv.currency,
|
||||
periodStart: ctx.periodStart,
|
||||
// No standalone period column once centralized: the charge window ends at
|
||||
// issuance, so `issuedAt` is the period end.
|
||||
periodEnd: inv.issuedAt ?? null,
|
||||
issuedAt: inv.issuedAt ?? null,
|
||||
dueDate: inv.dueAt ?? null,
|
||||
paidAt: inv.paidAt ?? null,
|
||||
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
|
||||
payments: (inv.payments ?? []).map((p) => ({
|
||||
amount: Number(p.amount),
|
||||
method: p.method ?? null,
|
||||
reference: p.reference ?? null,
|
||||
paidAt: p.paidAt,
|
||||
})),
|
||||
notes: null,
|
||||
createdAt: inv.createdAt,
|
||||
updatedAt: inv.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView {
|
||||
const meta = (line.metadata ?? {}) as {
|
||||
feeRuleId?: string | null;
|
||||
chargeableDays?: number | null;
|
||||
freeDays?: number | null;
|
||||
};
|
||||
return {
|
||||
feeRuleId: meta.feeRuleId ?? null,
|
||||
feeType: line.chargeType as WarehouseFeeType,
|
||||
description: line.description ?? '',
|
||||
quantity: Number(line.quantity),
|
||||
unitRate: Number(line.unitRate),
|
||||
amount: Number(line.amount),
|
||||
currency: line.currency,
|
||||
chargeableDays: meta.chargeableDays ?? null,
|
||||
freeDays: meta.freeDays ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
|
||||
switch (status) {
|
||||
case Freight.InvoiceStatus.Draft:
|
||||
return 'DRAFT';
|
||||
case Freight.InvoiceStatus.PartiallyPaid:
|
||||
return 'PARTIALLY_PAID';
|
||||
case Freight.InvoiceStatus.Paid:
|
||||
return 'PAID';
|
||||
case Freight.InvoiceStatus.Cancelled:
|
||||
case Freight.InvoiceStatus.Refunded:
|
||||
return 'CANCELLED';
|
||||
default:
|
||||
// Issued / Pending / Overdue → an issued, still-owed invoice.
|
||||
return 'ISSUED';
|
||||
}
|
||||
}
|
||||
|
||||
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return Freight.InvoiceStatus.Draft;
|
||||
case 'PARTIALLY_PAID':
|
||||
return Freight.InvoiceStatus.PartiallyPaid;
|
||||
case 'PAID':
|
||||
return Freight.InvoiceStatus.Paid;
|
||||
case 'CANCELLED':
|
||||
return Freight.InvoiceStatus.Cancelled;
|
||||
default:
|
||||
return Freight.InvoiceStatus.Issued;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a warehouse fee invoice view onto the shared document model. */
|
||||
private toDocumentModel(
|
||||
invoice: WarehouseFeeInvoiceDetail,
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
): InvoiceDocumentModel {
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
|
||||
|
||||
return {
|
||||
kind,
|
||||
title: 'Warehouse Fee',
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
|
||||
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
|
||||
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
|
||||
{ label: 'Customer', value: invoice.customerName ?? null },
|
||||
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
|
||||
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
|
||||
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
|
||||
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
|
||||
{
|
||||
label: 'Yard / Zone',
|
||||
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
|
||||
},
|
||||
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
|
||||
{
|
||||
label: 'Payment',
|
||||
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
|
||||
},
|
||||
],
|
||||
categoryHeader: 'Fee type',
|
||||
lines: invoice.items.map((item) => ({
|
||||
description: item.description ?? null,
|
||||
category: item.feeType ?? null,
|
||||
quantity: item.quantity ?? item.chargeableDays ?? 0,
|
||||
unitRate: item.unitRate,
|
||||
amount: item.amount,
|
||||
currency: item.currency ?? invoice.currency,
|
||||
})),
|
||||
totals: [
|
||||
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
|
||||
{ label: 'Tax', amount: Number(invoice.taxAmount) },
|
||||
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
|
||||
{ label: 'Paid', amount: Number(invoice.paidAmount) },
|
||||
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Warehouse-specific display details, derived from the linked inventory item. */
|
||||
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
||||
inv.status AS "inventoryStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||
CONCAT_WS(
|
||||
@@ -302,16 +572,10 @@ export class WarehouseInvoiceService {
|
||||
) AS "inventoryInfo",
|
||||
wh.name AS "warehouseName",
|
||||
yard.name AS "yardName",
|
||||
zone.name AS "zoneName",
|
||||
CASE
|
||||
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
|
||||
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
|
||||
ELSE 'PENDING PAYMENT'
|
||||
END AS "clearanceStatus"
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
|
||||
zone.name AS "zoneName"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
@@ -319,14 +583,21 @@ export class WarehouseInvoiceService {
|
||||
)
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
|
||||
WHERE fee.id = $1
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[invoice.id, invoice.status],
|
||||
[invoice.sourceId],
|
||||
);
|
||||
|
||||
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
|
||||
const clearanceStatus = row?.releaseDate
|
||||
? 'RELEASE ISSUED'
|
||||
: fullyPaid
|
||||
? 'FEE PAID - READY FOR RELEASE'
|
||||
: 'PENDING PAYMENT';
|
||||
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
@@ -338,11 +609,33 @@ export class WarehouseInvoiceService {
|
||||
warehouseName: row?.warehouseName ?? null,
|
||||
yardName: row?.yardName ?? null,
|
||||
zoneName: row?.zoneName ?? null,
|
||||
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
|
||||
clearanceStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
|
||||
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
|
||||
w.facility_id AS "facilityId"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
);
|
||||
return {
|
||||
bookingId: row?.bookingId ?? null,
|
||||
facilityId: row?.facilityId ?? null,
|
||||
warehouseId: row?.warehouseId ?? null,
|
||||
yardId: row?.yardId ?? null,
|
||||
zoneId: row?.zoneId ?? null,
|
||||
periodStart: row?.periodStart ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Notifications ──────────────────────────────────────────────────────────
|
||||
private async getInvoiceNotificationContacts(inventoryId: string): Promise<{
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
customerPhone: string | null;
|
||||
@@ -364,10 +657,9 @@ export class WarehouseInvoiceService {
|
||||
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
@@ -393,9 +685,9 @@ export class WarehouseInvoiceService {
|
||||
) latest_first_mile ON true
|
||||
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
|
||||
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
|
||||
WHERE fee.id = $1
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[invoice.id],
|
||||
[inventoryId],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -419,8 +711,8 @@ export class WarehouseInvoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
@@ -433,8 +725,8 @@ export class WarehouseInvoiceService {
|
||||
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
const statusText =
|
||||
@@ -460,131 +752,4 @@ export class WarehouseInvoiceService {
|
||||
|
||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
||||
}
|
||||
|
||||
private buildInvoiceDocumentHtml(
|
||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
details: InvoiceDocumentDetails,
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const money = (amount: unknown, currency = invoice.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const items = invoice.items as Array<{
|
||||
id?: string;
|
||||
description?: string;
|
||||
feeType?: string;
|
||||
quantity?: number;
|
||||
unitRate?: number;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
chargeableDays?: number | null;
|
||||
}>;
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||
.doc { padding: 16px 8px; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
||||
td.num, th.num { text-align: right; }
|
||||
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
||||
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
||||
.grand { font-size: 16px; font-weight: 800; }
|
||||
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Document no.
|
||||
<strong>${esc(invoice.invoiceNumber)}</strong>
|
||||
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">
|
||||
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
|
||||
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
|
||||
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
|
||||
<div><span>Customer</span>${esc(details.customerName)}</div>
|
||||
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
|
||||
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
|
||||
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
|
||||
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
|
||||
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
|
||||
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
|
||||
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Fee type</th>
|
||||
<th class="num">Qty</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${esc(item.description)}</td>
|
||||
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
|
||||
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
|
||||
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
|
||||
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
|
||||
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
|
||||
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="line">Prepared by EDR warehouse finance</div>
|
||||
<div class="line">Authorized seal / signature</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Public shapes for warehouse fee invoices.
|
||||
*
|
||||
* Warehouse fee invoices are no longer a standalone table — they are global
|
||||
* `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the
|
||||
* central {@link BillingService}. These types preserve the warehouse-facing API
|
||||
* contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines +
|
||||
* inventory context) back into the historical `WarehouseFeeInvoice` JSON so the
|
||||
* portal/backoffice stay untouched.
|
||||
*/
|
||||
|
||||
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
|
||||
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_INVOICE_STATUSES = [
|
||||
'DRAFT',
|
||||
'ISSUED',
|
||||
'PARTIALLY_PAID',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
|
||||
|
||||
export const WAREHOUSE_FEE_TYPES = [
|
||||
'CONTAINER_DEMURRAGE',
|
||||
'BULK_DEMURRAGE',
|
||||
'STORAGE_FEE',
|
||||
'HANDLING_FEE',
|
||||
] as const;
|
||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||
|
||||
/** A single recorded payment against a warehouse fee invoice (history). */
|
||||
export interface WarehouseInvoicePayment {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
/** A billed warehouse fee line, projected from a global `InvoiceLine`. */
|
||||
export interface WarehouseInvoiceItemView {
|
||||
feeRuleId: string | null;
|
||||
feeType: WarehouseFeeType;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitRate: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
chargeableDays: number | null;
|
||||
freeDays: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The warehouse-facing invoice header — same field set the old
|
||||
* `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The
|
||||
* typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the
|
||||
* charge `period` are derived from the linked inventory item; `customerId` is the
|
||||
* billed company; `invoiceType` is the invoice `type`.
|
||||
*/
|
||||
export interface WarehouseFeeInvoiceView {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
bookingId: string | null;
|
||||
customerId: string | null;
|
||||
inventoryId: string;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
zoneId: string | null;
|
||||
invoiceType: WarehouseInvoiceType;
|
||||
status: WarehouseInvoiceStatus;
|
||||
subtotalAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
balanceAmount: number;
|
||||
currency: string;
|
||||
periodStart: Date | null;
|
||||
periodEnd: Date | null;
|
||||
issuedAt: Date | null;
|
||||
dueDate: Date | null;
|
||||
paidAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
payments: WarehouseInvoicePayment[];
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,101 +1,23 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const RELEASE_DOCUMENT_PRINT_STYLES = `
|
||||
<style id="warehouse-release-document-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseReleaseDocumentService {
|
||||
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
|
||||
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const launchOptions: import('puppeteer').LaunchOptions = {
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
|
||||
await page.emulateMediaType('print');
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
|
||||
}
|
||||
this.logger.log(
|
||||
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
|
||||
);
|
||||
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes('warehouse-release-document-print-fix')) return html;
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
|
||||
}
|
||||
|
||||
private resolveExecutablePath(): string | undefined {
|
||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/google-chrome',
|
||||
];
|
||||
return candidates.find((path) => existsSync(path));
|
||||
}
|
||||
|
||||
private isValidPdf(buffer: Buffer): boolean {
|
||||
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
|
||||
/**
|
||||
* Render the gate-clearance release document to PDF via the shared renderer,
|
||||
* falling back to the release-specific hand-built layout when Chromium is
|
||||
* unavailable.
|
||||
*/
|
||||
htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
return this.pdf.htmlToPdfBuffer(html, {
|
||||
label: 'Warehouse release',
|
||||
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
|
||||
});
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
@@ -10,8 +12,6 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
@@ -38,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||
import { WarehouseInvoiceController } from './warehouse-invoice.controller';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
import { WarehouseRulesController } from './warehouse-rules.controller';
|
||||
@@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInspectionReport,
|
||||
WarehouseAllocationRule,
|
||||
WarehouseFeeRule,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseFeeInvoiceItem,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
FilesModule,
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
@@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInspectionRepository,
|
||||
WarehouseAllocationRuleRepository,
|
||||
WarehouseFeeRuleRepository,
|
||||
WarehouseFeeInvoiceRepository,
|
||||
WarehouseFeeInvoiceItemRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"noEmit": false,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"preserveWatchOutput": true,
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5183",
|
||||
"dev": "vite --port 5183 --clearScreen false",
|
||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5183",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<<<<<<< HEAD
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
=======
|
||||
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
|
||||
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
>>>>>>> 8616f6dcdfa44089be2fd7b6a9867b20e320b305
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"dev": "vite --port 5173 --clearScreen false",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<<<<<<< HEAD
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
=======
|
||||
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
|
||||
>>>>>>> 8616f6dcdfa44089be2fd7b6a9867b20e320b305
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
@@ -12,4 +16,3 @@ export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
# `migration` stage, invoked as a one-shot container in CI before deploy.
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Put the pnpm content-addressable store under PNPM_HOME so the BuildKit
|
||||
# `--mount=type=cache,target=/pnpm/store` below actually persists it across
|
||||
# builds. Without this, pnpm stores in ~/.local/share/pnpm/store and the
|
||||
# cache mount is a no-op — deps re-download on every pipeline run.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
FROM base AS pruner
|
||||
@@ -22,11 +28,14 @@ RUN pnpm --filter "@edr/passenger-api" exec prisma generate
|
||||
RUN pnpm turbo build --filter="@edr/passenger-api..."
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
|
||||
# Copy prisma directory and generate client in deploy location
|
||||
RUN cp -r apps/edr-passenger-api/prisma /deploy/ && \
|
||||
cd /deploy && \
|
||||
npx prisma generate --schema=prisma/schema.prisma
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
|
||||
# The generated Prisma client is NOT in the pnpm store (it's an output of
|
||||
# `prisma generate`), so `pnpm deploy` does not copy it into /deploy. Regenerate
|
||||
# it here so the runtime enum values imported from @prisma/client (Currency, …)
|
||||
# are real objects instead of undefined — otherwise @IsEnum(Currency) throws
|
||||
# "Cannot convert undefined or null to object" at module load.
|
||||
RUN cd /deploy && npm run prisma:generate
|
||||
|
||||
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
|
||||
# against the real DB, as its own gated step *before* the app image is built/deployed.
|
||||
@@ -37,10 +46,7 @@ WORKDIR /deploy
|
||||
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
||||
ENV CI=true
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
# Copy the resolution script
|
||||
COPY apps/edr-passenger-api/scripts/resolve-migrations.sh /deploy/scripts/
|
||||
RUN chmod +x /deploy/scripts/resolve-migrations.sh
|
||||
CMD ["sh", "-c", "/deploy/scripts/resolve-migrations.sh && npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
|
||||
CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- This migration fixes the failed state of 20240101000000_individual_tickets_no_timezone
|
||||
-- It marks the failed migration as rolled back so it can be retried
|
||||
|
||||
-- Mark the failed migration as rolled back
|
||||
UPDATE passenger._prisma_migrations
|
||||
SET rolled_back_at = CURRENT_TIMESTAMP,
|
||||
logs = 'Migration failed due to missing TicketSeat table. Automatically rolled back by fix migration to allow retry with idempotent SQL.'
|
||||
WHERE migration_name = '20240101000000_individual_tickets_no_timezone'
|
||||
AND rolled_back_at IS NULL
|
||||
AND finished_at IS NULL;
|
||||
@@ -1,77 +0,0 @@
|
||||
-- DropForeignKey (only if table exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'passenger'
|
||||
AND table_name = 'TicketSeat'
|
||||
) THEN
|
||||
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
|
||||
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key";
|
||||
|
||||
-- AlterTable: Station
|
||||
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";
|
||||
|
||||
-- AlterTable: Ticket — add columns with safe defaults
|
||||
ALTER TABLE "passenger"."Ticket"
|
||||
ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS "passengerName" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS "scheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "seatId" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE IF EXISTS "passenger"."TicketSeat";
|
||||
|
||||
-- Remove GateValidationLog rows referencing orphan tickets first (only if tickets have seatId column)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger'
|
||||
AND table_name = 'Ticket'
|
||||
AND column_name = 'seatId'
|
||||
) THEN
|
||||
DELETE FROM "passenger"."GateValidationLog"
|
||||
WHERE "ticketId" IN (
|
||||
SELECT "id" FROM "passenger"."Ticket"
|
||||
WHERE "seatId" = ''
|
||||
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat")
|
||||
);
|
||||
|
||||
-- Remove orphan ticket rows
|
||||
DELETE FROM "passenger"."Ticket"
|
||||
WHERE "seatId" = ''
|
||||
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId");
|
||||
|
||||
-- AddForeignKey (only if not already exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger'
|
||||
AND table_name = 'Ticket'
|
||||
AND column_name = 'seatId'
|
||||
) AND NOT EXISTS (
|
||||
SELECT FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'passenger'
|
||||
AND constraint_name = 'Ticket_seatId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "passenger"."Ticket"
|
||||
ADD CONSTRAINT "Ticket_seatId_fkey"
|
||||
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Drop temporary defaults that were only needed for the backfill
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "passengerName" DROP DEFAULT;
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "seatId" DROP DEFAULT;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Remove timezone column if it still exists
|
||||
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT;
|
||||
@@ -1,127 +0,0 @@
|
||||
-- Migration: Add Configurable Fare Management System
|
||||
|
||||
-- Main fare configuration table
|
||||
CREATE TABLE IF NOT EXISTS "fare_configurations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"effective_date" TIMESTAMP(3) NOT NULL,
|
||||
"expiry_date" TIMESTAMP(3),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_by" TEXT,
|
||||
"approved_by" TEXT,
|
||||
"approved_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fare_configurations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Rate structure by nationality and coach/position
|
||||
CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
|
||||
"coach_type" TEXT NOT NULL, -- 'REGULAR_SEAT', 'ECONOMY_BED', 'VIP_BED'
|
||||
"bed_position" TEXT, -- 'UPPER', 'MIDDLE', 'LOWER', NULL for seats
|
||||
"rate_per_km_minor" INTEGER NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fare_rate_rules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Configurable fare components (insurance, premiums, service charges, taxes)
|
||||
CREATE TABLE IF NOT EXISTS "fare_components" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
|
||||
"component_name" TEXT NOT NULL,
|
||||
"calculation_method" TEXT NOT NULL, -- 'MULTIPLIER', 'PERCENTAGE', 'FIXED_AMOUNT'
|
||||
"value_minor" INTEGER, -- For fixed amounts
|
||||
"percentage_value" DECIMAL(10,6), -- For percentages (e.g., 0.02 for 2%)
|
||||
"applies_to" TEXT NOT NULL DEFAULT 'SUBTOTAL', -- 'BASE_FARE', 'SUBTOTAL', 'TOTAL'
|
||||
"apply_order" INTEGER NOT NULL DEFAULT 1, -- Order of application
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fare_components_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Age-based pricing rules
|
||||
CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"rule_name" TEXT NOT NULL,
|
||||
"min_age" INTEGER NOT NULL,
|
||||
"max_age" INTEGER,
|
||||
"pricing_type" TEXT NOT NULL, -- 'FREE', 'FULL_FARE', 'DISCOUNTED'
|
||||
"discount_percentage" DECIMAL(5,4), -- For discounted fares
|
||||
"max_free_passengers" INTEGER, -- For free fares (e.g., 1 free child)
|
||||
"applies_to_components" BOOLEAN NOT NULL DEFAULT false, -- Whether discount applies to components too
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "age_pricing_rules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Audit trail for configuration changes
|
||||
CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
|
||||
"changed_by" TEXT,
|
||||
"changes" JSONB, -- Store the actual changes made
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Foreign key constraints (idempotent)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Indexes for performance (idempotent)
|
||||
CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
||||
CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
||||
CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
||||
CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
||||
|
||||
-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
|
||||
ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||
ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||
|
||||
-- Add feature flag support
|
||||
CREATE TABLE IF NOT EXISTS "system_features" (
|
||||
"id" TEXT NOT NULL,
|
||||
"feature_name" TEXT NOT NULL UNIQUE,
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"config" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Insert the configurable fares feature flag
|
||||
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
|
||||
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);
|
||||
@@ -1,14 +0,0 @@
|
||||
-- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced)
|
||||
ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT;
|
||||
|
||||
-- Unique constraint: one IAM user maps to exactly one Passenger
|
||||
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
|
||||
|
||||
-- Index for fast lookup by iamUserId on every protected request
|
||||
CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId");
|
||||
|
||||
-- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users)
|
||||
ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT;
|
||||
|
||||
-- Index for Fayda callback to resolve IAM user
|
||||
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId");
|
||||
@@ -1,28 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SegmentFareRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"routeId" TEXT NOT NULL,
|
||||
"originStopSequence" INTEGER NOT NULL,
|
||||
"destinationStopSequence" INTEGER NOT NULL,
|
||||
"seatClassId" TEXT NOT NULL,
|
||||
"baseFareMinor" INTEGER NOT NULL,
|
||||
"nationality" TEXT,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"validFrom" TIMESTAMP(3) NOT NULL,
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,54 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"seatId" TEXT NOT NULL,
|
||||
"seatIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey'
|
||||
AND conrelid = 'passenger."Passenger"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey'
|
||||
AND conrelid = 'passenger."TicketSeat"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey"
|
||||
FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey'
|
||||
AND conrelid = 'passenger."TicketSeat"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey"
|
||||
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema)
|
||||
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
|
||||
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
|
||||
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
|
||||
|
||||
-- Rename columns (preserves all existing data)
|
||||
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
|
||||
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
|
||||
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
|
||||
|
||||
-- Rename indexes on FraudAlert to match new column name
|
||||
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
|
||||
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");
|
||||
@@ -1,10 +0,0 @@
|
||||
-- AuditLog: drop FK, rename column, update index
|
||||
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
|
||||
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
|
||||
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
|
||||
|
||||
-- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data)
|
||||
ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey";
|
||||
ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId";
|
||||
DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx";
|
||||
@@ -1,5 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3);
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key";
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';
|
||||
@@ -1,275 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT,
|
||||
ADD COLUMN "returnHoldId" TEXT,
|
||||
ADD COLUMN "returnOriginStationId" TEXT,
|
||||
ADD COLUMN "returnScheduleId" TEXT,
|
||||
ADD COLUMN "returnSeatClassId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
-- gender column already TEXT from init migration
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Empty placeholder migration
|
||||
SELECT 1;
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence");
|
||||
|
||||
-- Ensure all indexes exist
|
||||
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
|
||||
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
|
||||
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId");
|
||||
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
|
||||
@@ -1,164 +0,0 @@
|
||||
-- Add CASCADE delete to all foreign key constraints that are missing it
|
||||
|
||||
-- TrainSchedule relations
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Coach relation
|
||||
ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE;
|
||||
|
||||
-- CoachAssignment relations
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Booking relations
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BookingSeat relations
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
|
||||
|
||||
-- PaymentIntent
|
||||
ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- PaymentRefund
|
||||
ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Ticket
|
||||
ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- TicketSeat
|
||||
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
|
||||
|
||||
-- WalletLedgerEntry
|
||||
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Notification
|
||||
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
|
||||
|
||||
-- MenuItem
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FoodOrder
|
||||
ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FoodOrderItem
|
||||
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FaqArticle
|
||||
ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SupportMessage
|
||||
ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE;
|
||||
|
||||
-- TripStopTime
|
||||
ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- TripLiveStatus
|
||||
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- JourneySegment
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- AgentBooking
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- AgentShift
|
||||
ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
|
||||
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
|
||||
|
||||
-- AgentCommission
|
||||
ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
|
||||
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BookingModification
|
||||
ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
|
||||
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BookingCancellation
|
||||
ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
|
||||
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- GateValidationLog
|
||||
ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
|
||||
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BaggageBooking
|
||||
ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- RouteFareRule
|
||||
ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SegmentFareRule
|
||||
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
|
||||
|
||||
-- StationCrowdSignal
|
||||
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SeatBlock
|
||||
ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SavedRoute
|
||||
ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
|
||||
|
||||
-- LoyaltyLedgerEntry
|
||||
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||
|
||||
-- LoyaltyReward
|
||||
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FareRule
|
||||
ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
|
||||
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
|
||||
@@ -1,82 +0,0 @@
|
||||
-- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.*
|
||||
-- but ran when tables were still in public schema (before 20260626 moved them).
|
||||
-- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run.
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 1. Passenger.iamUserId
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'Passenger_iamUserId_key'
|
||||
AND conrelid = 'passenger."Passenger"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId");
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 2. FaydaVerificationSession.iamUserId
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId");
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 3. UserPreferences: rename userId → iamUserId (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey";
|
||||
ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 4. Device: rename userId → iamUserId (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey";
|
||||
ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey";
|
||||
ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId";
|
||||
DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx";
|
||||
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId'
|
||||
) THEN
|
||||
ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey";
|
||||
ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId";
|
||||
DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx";
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt");
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat).
|
||||
-- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL.
|
||||
|
||||
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
|
||||
ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
@@ -1,46 +0,0 @@
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 1. Add iamUserId to Agent
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'Agent_iamUserId_key'
|
||||
AND conrelid = 'passenger."Agent"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 2. Populate iamUserId for existing agent records
|
||||
-- Match via User.email → iam.users.email (skip if iam schema absent)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'iam' AND table_name = 'users'
|
||||
) THEN
|
||||
UPDATE passenger."Agent" a
|
||||
SET "iamUserId" = iu.id
|
||||
FROM passenger."User" u
|
||||
JOIN iam.users iu ON iu.email = u.email
|
||||
WHERE a."userId" = u.id
|
||||
AND a."iamUserId" IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
|
||||
DROP INDEX IF EXISTS passenger."Agent_userId_key";
|
||||
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 4. Drop Passenger.userId FK (column stays as plain nullable string)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
|
||||
@@ -1,290 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "Journey_bookingId_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SystemConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'passenger' AND table_name = 'Journey' AND column_name = 'bookingId'
|
||||
) THEN
|
||||
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey"
|
||||
FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
|
||||
|
||||
-- AlterTable: add return leg tracking columns to Booking
|
||||
ALTER TABLE "Booking"
|
||||
ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Set NEITHER_USED for existing confirmed round-trip bookings
|
||||
UPDATE "Booking"
|
||||
SET "returnLegStatus" = 'NEITHER_USED'
|
||||
WHERE "bookingType" = 'ROUND_TRIP'
|
||||
AND "status" IN ('CONFIRMED', 'BOARDED');
|
||||
|
||||
-- AlterTable: add leg column to GateValidationLog
|
||||
ALTER TABLE "GateValidationLog"
|
||||
ADD COLUMN "leg" TEXT;
|
||||
@@ -1,70 +0,0 @@
|
||||
-- Create passenger schema if it doesn't exist
|
||||
CREATE SCHEMA IF NOT EXISTS passenger;
|
||||
|
||||
-- Move enums from public to passenger schema (only if they exist in public)
|
||||
DO $$
|
||||
DECLARE
|
||||
e text;
|
||||
BEGIN
|
||||
FOR e IN
|
||||
SELECT typname FROM pg_type
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
|
||||
WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
|
||||
END LOOP;
|
||||
EXCEPTION WHEN others THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Move tables from public to passenger schema (only if they exist in public)
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
BEGIN
|
||||
FOR t IN
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations')
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
|
||||
END LOOP;
|
||||
EXCEPTION WHEN others THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Add missing columns to Booking
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Add ReturnLegStatus enum and column
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
|
||||
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE';
|
||||
|
||||
-- Add missing columns to other tables
|
||||
ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT;
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT;
|
||||
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Add bookingId to Journey for per-booking segment release
|
||||
ALTER TABLE "passenger"."Journey"
|
||||
ADD COLUMN IF NOT EXISTS "bookingId" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId");
|
||||
CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId");
|
||||
|
||||
-- AddForeignKey (column created above; FK was misplaced in 20260623073543_config)
|
||||
ALTER TABLE "passenger"."Journey"
|
||||
DROP CONSTRAINT IF EXISTS "Journey_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."Journey"
|
||||
ADD CONSTRAINT "Journey_bookingId_fkey"
|
||||
FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Ensure JourneySegment cascades on Journey delete
|
||||
ALTER TABLE "passenger"."JourneySegment"
|
||||
DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
|
||||
|
||||
ALTER TABLE "passenger"."JourneySegment"
|
||||
ADD CONSTRAINT "JourneySegment_journeyId_fkey"
|
||||
FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Migration already applied directly to the database.
|
||||
-- This file exists only to satisfy Prisma's migration directory check (P3015).
|
||||
@@ -1,153 +0,0 @@
|
||||
-- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema)
|
||||
ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'Agent_iamUserId_key'
|
||||
AND conrelid = 'passenger."Agent"'::regclass
|
||||
) THEN
|
||||
ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId");
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId");
|
||||
|
||||
-- Drop old Agent.userId FK and column if they still exist
|
||||
ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey";
|
||||
DROP INDEX IF EXISTS passenger."Agent_userId_key";
|
||||
ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId";
|
||||
|
||||
-- Drop old Passenger.userId FK (column stays as plain nullable string)
|
||||
ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey";
|
||||
|
||||
-- TravelPackage
|
||||
CREATE TABLE IF NOT EXISTS passenger."TravelPackage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
"outboundScheduleId" TEXT NOT NULL,
|
||||
"returnScheduleId" TEXT NOT NULL,
|
||||
"originStationId" TEXT NOT NULL,
|
||||
"destinationStationId" TEXT NOT NULL,
|
||||
"boardingTime" TIMESTAMP(3) NOT NULL,
|
||||
"departureTime" TIMESTAMP(3) NOT NULL,
|
||||
"arrivalTime" TIMESTAMP(3) NOT NULL,
|
||||
"totalCapacity" INTEGER NOT NULL,
|
||||
"bookedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"includedServices" JSONB NOT NULL,
|
||||
"coachConfiguration" TEXT,
|
||||
"busTransferIncluded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"busTransferRoute" TEXT,
|
||||
"validFrom" TIMESTAMP(3) NOT NULL,
|
||||
"validUntil" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code");
|
||||
CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom");
|
||||
|
||||
-- PackagePriceTier
|
||||
CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" (
|
||||
"id" TEXT NOT NULL,
|
||||
"packageId" TEXT NOT NULL,
|
||||
"seatType" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"priceMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"availableSeats" INTEGER NOT NULL DEFAULT 0,
|
||||
"bookedSeats" INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType");
|
||||
|
||||
-- PackageBooking
|
||||
CREATE TABLE IF NOT EXISTS passenger."PackageBooking" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingRef" TEXT NOT NULL,
|
||||
"packageId" TEXT NOT NULL,
|
||||
"priceTierId" TEXT NOT NULL,
|
||||
"passengerId" TEXT,
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT',
|
||||
"passengerCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"displayCurrency" TEXT,
|
||||
"displayTotalMinor" INTEGER,
|
||||
"promoCode" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'WEB',
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef");
|
||||
CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status");
|
||||
|
||||
-- PackageBookingPassenger
|
||||
CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"passengerName" TEXT NOT NULL,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"idDocumentType" TEXT,
|
||||
"idDocumentNumber" TEXT,
|
||||
"passportNumber" TEXT,
|
||||
"passportCountry" TEXT,
|
||||
"seatLabel" TEXT,
|
||||
CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- PackagePaymentIntent
|
||||
CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"packageBookingId" TEXT NOT NULL,
|
||||
"amountMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"method" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION',
|
||||
"providerRef" TEXT,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId");
|
||||
|
||||
-- Foreign keys
|
||||
ALTER TABLE passenger."TravelPackage"
|
||||
ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey"
|
||||
FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE passenger."TravelPackage"
|
||||
ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey"
|
||||
FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE passenger."PackagePriceTier"
|
||||
ADD CONSTRAINT "PackagePriceTier_packageId_fkey"
|
||||
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE passenger."PackageBooking"
|
||||
ADD CONSTRAINT "PackageBooking_packageId_fkey"
|
||||
FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE passenger."PackageBooking"
|
||||
ADD CONSTRAINT "PackageBooking_priceTierId_fkey"
|
||||
FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE passenger."PackageBooking"
|
||||
ADD CONSTRAINT "PackageBooking_passengerId_fkey"
|
||||
FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE passenger."PackageBookingPassenger"
|
||||
ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey"
|
||||
FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE passenger."PackagePaymentIntent"
|
||||
ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey"
|
||||
FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Create PackageStatus enum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Drop default, cast column to enum, restore default
|
||||
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT;
|
||||
ALTER TABLE passenger."TravelPackage"
|
||||
ALTER COLUMN "status" TYPE passenger."PackageStatus"
|
||||
USING "status"::passenger."PackageStatus";
|
||||
ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus";
|
||||
@@ -1,42 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "passenger"."ExcessBaggageCharge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"excessWeightKg" INTEGER NOT NULL,
|
||||
"feePerKgMinor" INTEGER NOT NULL,
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"paymentToken" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"waivedBy" TEXT,
|
||||
"waivedReason" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"contactEmail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "passenger"."ExcessBaggageCharge"
|
||||
ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey"
|
||||
FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Seed default paymentToken using gen_random_uuid() for any rows that may exist
|
||||
UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = '';
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3);
|
||||
@@ -22,11 +22,14 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI');
|
||||
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI', 'DMONEY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
|
||||
@@ -58,6 +61,9 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PackageStatus" AS ENUM ('DRAFT', 'ACTIVE', 'SOLD_OUT', 'EXPIRED', 'CANCELLED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CoachType" (
|
||||
"id" TEXT NOT NULL,
|
||||
@@ -130,9 +136,11 @@ CREATE TABLE "Session" (
|
||||
-- CreateTable
|
||||
CREATE TABLE "Passenger" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"iamUserId" TEXT,
|
||||
"defaultTravelerProfileId" TEXT,
|
||||
"preferredLanguage" TEXT,
|
||||
"blockedUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id")
|
||||
@@ -143,6 +151,7 @@ CREATE TABLE "TravelerProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"gender" TEXT,
|
||||
"relationship" TEXT NOT NULL,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"nationalId" TEXT,
|
||||
@@ -161,7 +170,6 @@ CREATE TABLE "Station" (
|
||||
"countryCode" TEXT,
|
||||
"sequence" INTEGER NOT NULL DEFAULT 0,
|
||||
"isOperational" BOOLEAN NOT NULL DEFAULT true,
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
|
||||
"lat" DECIMAL(9,6),
|
||||
"lng" DECIMAL(9,6),
|
||||
|
||||
@@ -314,6 +322,7 @@ CREATE TABLE "Booking" (
|
||||
"bookingRef" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"scheduleId" TEXT NOT NULL,
|
||||
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
|
||||
"status" "BookingStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
@@ -321,13 +330,29 @@ CREATE TABLE "Booking" (
|
||||
"childCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"displayCurrency" "Currency",
|
||||
"displayTotalMinor" INTEGER,
|
||||
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
|
||||
"returnScheduleId" TEXT,
|
||||
"returnOriginStationId" TEXT,
|
||||
"returnDestinationStationId" TEXT,
|
||||
"returnHoldId" TEXT,
|
||||
"returnSeatClassId" TEXT,
|
||||
"returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
"leg2ScheduleId" TEXT,
|
||||
"leg2OriginStationId" TEXT,
|
||||
"leg2DestinationStationId" TEXT,
|
||||
"leg2SeatClassId" TEXT,
|
||||
"returnLeg2ScheduleId" TEXT,
|
||||
"returnLeg2OriginStationId" TEXT,
|
||||
"returnLeg2DestStationId" TEXT,
|
||||
"returnLeg2SeatClassId" TEXT,
|
||||
"outboundBoardedAt" TIMESTAMP(3),
|
||||
"returnBoardedAt" TIMESTAMP(3),
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'WEB',
|
||||
"promoCode" TEXT,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"paymentReminderSentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
@@ -339,6 +364,8 @@ CREATE TABLE "BookingSeat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"seatId" TEXT NOT NULL,
|
||||
"leg" INTEGER NOT NULL DEFAULT 1,
|
||||
"scheduleId" TEXT,
|
||||
"passengerName" TEXT NOT NULL,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT',
|
||||
@@ -438,7 +465,11 @@ CREATE TABLE "Ticket" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"bookingRef" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'CONFIRMED',
|
||||
"passengerName" TEXT NOT NULL,
|
||||
"seatId" TEXT NOT NULL,
|
||||
"leg" INTEGER NOT NULL DEFAULT 1,
|
||||
"scheduleId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"qrPayload" TEXT NOT NULL,
|
||||
"barcodePayload" TEXT,
|
||||
"pdfUrl" TEXT,
|
||||
@@ -446,20 +477,11 @@ CREATE TABLE "Ticket" (
|
||||
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"validatedAt" TIMESTAMP(3),
|
||||
"validatorId" TEXT,
|
||||
"boardedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketSeat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"seatId" TEXT NOT NULL,
|
||||
"seatIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LoyaltyAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
@@ -679,7 +701,7 @@ CREATE TABLE "SupportMessage" (
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserPreferences" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"iamUserId" TEXT NOT NULL,
|
||||
"pushEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"smsEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
@@ -699,7 +721,7 @@ CREATE TABLE "UserPreferences" (
|
||||
-- CreateTable
|
||||
CREATE TABLE "Device" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"iamUserId" TEXT NOT NULL,
|
||||
"platform" "DevicePlatform" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"pushToken" TEXT,
|
||||
@@ -727,6 +749,7 @@ CREATE TABLE "SavedRoute" (
|
||||
CREATE TABLE "Journey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"passengerId" TEXT NOT NULL,
|
||||
"bookingId" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
@@ -796,7 +819,7 @@ CREATE TABLE "RouteStop" (
|
||||
"routeId" TEXT NOT NULL,
|
||||
"stationId" TEXT NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"distanceKm" INTEGER,
|
||||
"distanceKm" DOUBLE PRECISION,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RouteStop_pkey" PRIMARY KEY ("id")
|
||||
@@ -820,10 +843,27 @@ CREATE TABLE "RouteFareRule" (
|
||||
CONSTRAINT "RouteFareRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SegmentFareRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"routeId" TEXT NOT NULL,
|
||||
"originStopSequence" INTEGER NOT NULL,
|
||||
"destinationStopSequence" INTEGER NOT NULL,
|
||||
"seatClassId" TEXT NOT NULL,
|
||||
"baseFareMinor" INTEGER NOT NULL,
|
||||
"nationality" TEXT,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"validFrom" TIMESTAMP(3) NOT NULL,
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Agent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"iamUserId" TEXT,
|
||||
"agentCode" TEXT NOT NULL,
|
||||
"stationId" TEXT,
|
||||
"commissionRate" INTEGER NOT NULL DEFAULT 5,
|
||||
@@ -910,6 +950,7 @@ CREATE TABLE "GateValidationLog" (
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"validatorId" TEXT NOT NULL,
|
||||
"gateId" TEXT,
|
||||
"leg" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"reason" TEXT,
|
||||
"validatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -943,10 +984,32 @@ CREATE TABLE "BaggageBooking" (
|
||||
CONSTRAINT "BaggageBooking_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExcessBaggageCharge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"agentId" TEXT NOT NULL,
|
||||
"excessWeightKg" INTEGER NOT NULL,
|
||||
"feePerKgMinor" INTEGER NOT NULL,
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"paymentToken" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"waivedBy" TEXT,
|
||||
"waivedReason" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"contactEmail" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"iamUserId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
@@ -1014,7 +1077,7 @@ CREATE TABLE "FraudRule" (
|
||||
-- CreateTable
|
||||
CREATE TABLE "FraudAlert" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"iamUserId" TEXT NOT NULL,
|
||||
"eventType" TEXT NOT NULL,
|
||||
"triggeredRules" TEXT[],
|
||||
"context" JSONB NOT NULL,
|
||||
@@ -1077,7 +1140,7 @@ CREATE TABLE "FaydaVerificationSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"state" TEXT NOT NULL,
|
||||
"codeVerifier" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
|
||||
"purpose" TEXT NOT NULL DEFAULT 'VERIFY',
|
||||
"platform" TEXT NOT NULL DEFAULT 'WEB',
|
||||
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
@@ -1087,12 +1150,137 @@ CREATE TABLE "FaydaVerificationSession" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"userId" TEXT,
|
||||
"iamUserId" TEXT,
|
||||
"bookingId" TEXT,
|
||||
|
||||
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SystemConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TravelPackage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "PackageStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"outboundScheduleId" TEXT NOT NULL,
|
||||
"returnScheduleId" TEXT NOT NULL,
|
||||
"originStationId" TEXT NOT NULL,
|
||||
"destinationStationId" TEXT NOT NULL,
|
||||
"boardingTime" TIMESTAMP(3) NOT NULL,
|
||||
"departureTime" TIMESTAMP(3) NOT NULL,
|
||||
"arrivalTime" TIMESTAMP(3) NOT NULL,
|
||||
"totalCapacity" INTEGER NOT NULL,
|
||||
"bookedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"includedServices" JSONB NOT NULL,
|
||||
"coachConfiguration" TEXT,
|
||||
"busTransferIncluded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"busTransferRoute" TEXT,
|
||||
"validFrom" TIMESTAMP(3) NOT NULL,
|
||||
"validUntil" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PackagePriceTier" (
|
||||
"id" TEXT NOT NULL,
|
||||
"packageId" TEXT NOT NULL,
|
||||
"seatType" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"priceMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"availableSeats" INTEGER NOT NULL DEFAULT 0,
|
||||
"bookedSeats" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PackageBooking" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingRef" TEXT NOT NULL,
|
||||
"packageId" TEXT NOT NULL,
|
||||
"priceTierId" TEXT NOT NULL,
|
||||
"passengerId" TEXT,
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING_PAYMENT',
|
||||
"passengerCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"totalMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"displayCurrency" "Currency",
|
||||
"displayTotalMinor" INTEGER,
|
||||
"promoCode" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'WEB',
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PackageBookingPassenger" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"passengerName" TEXT NOT NULL,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"idDocumentType" "IdDocumentType",
|
||||
"idDocumentNumber" TEXT,
|
||||
"passportNumber" TEXT,
|
||||
"passportCountry" TEXT,
|
||||
"seatLabel" TEXT,
|
||||
|
||||
CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PackagePaymentIntent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"packageBookingId" TEXT NOT NULL,
|
||||
"amountMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"method" "PaymentMethodType" NOT NULL,
|
||||
"status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION',
|
||||
"providerRef" TEXT,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PackageInquiry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"packageId" TEXT NOT NULL,
|
||||
"priceTierId" TEXT,
|
||||
"travelerCount" INTEGER NOT NULL,
|
||||
"contactName" TEXT NOT NULL,
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"notes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'NEW',
|
||||
"enquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PackageInquiry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId");
|
||||
|
||||
@@ -1114,15 +1302,24 @@ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Passenger_iamUserId_key" ON "Passenger"("iamUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Passenger_userId_idx" ON "Passenger"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Passenger_iamUserId_idx" ON "Passenger"("iamUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Station_sequence_idx" ON "Station"("sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Train_number_key" ON "Train"("number");
|
||||
|
||||
@@ -1141,6 +1338,9 @@ CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number");
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Coach_sequence_idx" ON "Coach"("sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
|
||||
|
||||
@@ -1165,6 +1365,9 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
|
||||
|
||||
@@ -1187,13 +1390,10 @@ CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"(
|
||||
CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
|
||||
CREATE INDEX "Ticket_bookingId_idx" ON "Ticket"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
|
||||
CREATE INDEX "Ticket_seatId_idx" ON "Ticket"("seatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
|
||||
@@ -1208,7 +1408,10 @@ CREATE INDEX "WalletAccount_passengerId_idx" ON "WalletAccount"("passengerId");
|
||||
CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
|
||||
CREATE UNIQUE INDEX "UserPreferences_iamUserId_key" ON "UserPreferences"("iamUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Journey_bookingId_key" ON "Journey"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OtpCode_email_phone_idx" ON "OtpCode"("email", "phone");
|
||||
@@ -1232,11 +1435,20 @@ CREATE UNIQUE INDEX "RouteStop_routeId_sequence_key" ON "RouteStop"("routeId", "
|
||||
CREATE INDEX "RouteFareRule_routeId_seatClassId_idx" ON "RouteFareRule"("routeId", "seatClassId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Agent_userId_key" ON "Agent"("userId");
|
||||
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Agent_iamUserId_key" ON "Agent"("iamUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Agent_agentCode_key" ON "Agent"("agentCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Agent_iamUserId_idx" ON "Agent"("iamUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AgentBooking_bookingId_key" ON "AgentBooking"("bookingId");
|
||||
|
||||
@@ -1262,7 +1474,19 @@ CREATE INDEX "GateValidationLog_validatorId_idx" ON "GateValidationLog"("validat
|
||||
CREATE INDEX "BaggageBooking_bookingId_idx" ON "BaggageBooking"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt");
|
||||
CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "ExcessBaggageCharge"("paymentToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "ExcessBaggageCharge"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "ExcessBaggageCharge"("paymentToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExcessBaggageCharge_status_idx" ON "ExcessBaggageCharge"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_iamUserId_createdAt_idx" ON "AuditLog"("iamUserId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
|
||||
@@ -1280,7 +1504,7 @@ CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"(
|
||||
CREATE UNIQUE INDEX "FraudRule_type_key" ON "FraudRule"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt");
|
||||
CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON "FraudAlert"("iamUserId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged");
|
||||
@@ -1307,7 +1531,7 @@ CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("de
|
||||
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId");
|
||||
CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "FaydaVerificationSession"("iamUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId");
|
||||
@@ -1318,6 +1542,30 @@ CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"(
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TravelPackage_code_key" ON "TravelPackage"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TravelPackage_status_validFrom_idx" ON "TravelPackage"("status", "validFrom");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PackagePriceTier_packageId_seatType_key" ON "PackagePriceTier"("packageId", "seatType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PackageBooking_bookingRef_key" ON "PackageBooking"("bookingRef");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PackageBooking_packageId_status_idx" ON "PackageBooking"("packageId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PackagePaymentIntent_packageBookingId_key" ON "PackagePaymentIntent"("packageBookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PackageInquiry_packageId_idx" ON "PackageInquiry"("packageId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -1325,7 +1573,7 @@ ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1372,6 +1620,9 @@ ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("pa
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -1388,10 +1639,7 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey"
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1432,15 +1680,12 @@ ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -1457,7 +1702,10 @@ ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_routeId_fkey" FOREIGN
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1484,13 +1732,37 @@ ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey"
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ExcessBaggageCharge" ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" FOREIGN KEY ("outboundScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackageBookingPassenger" ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackagePaymentIntent" ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" FOREIGN KEY ("packageBookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -3,14 +3,11 @@ set -e
|
||||
|
||||
echo "🔍 Checking for failed migrations..."
|
||||
|
||||
# Mark legacy migrations as applied (these are from an old schema that doesn't match current DB)
|
||||
# These migrations were designed for a different schema version and should be skipped
|
||||
# All migrations before 20260605195213_init should be resolved as they modify tables that don't exist yet
|
||||
# Mark legacy migrations as applied
|
||||
npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true
|
||||
npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true
|
||||
npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true
|
||||
npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true
|
||||
npx prisma migrate resolve --applied "20250106070000_add_gender_to_traveler_profile" || true
|
||||
npx prisma migrate resolve --applied "20260101000000_add_configurable_fare_system" || true
|
||||
|
||||
echo "✅ Migration resolution complete"
|
||||
echo "✅ Migration resolution complete"
|
||||
@@ -29,6 +29,11 @@ export class PaymentEventsConsumer {
|
||||
},
|
||||
})
|
||||
async handle(event: PaymentEvent): Promise<Nack | void> {
|
||||
// Logged the instant RabbitMQ delivers the message, before any DB work — proves the
|
||||
// payment -> passenger broker connection works even if processing later fails/hangs.
|
||||
this.logger.log(
|
||||
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
|
||||
);
|
||||
try {
|
||||
const result = await this.paymentsService.handlePaymentEvent(
|
||||
event as unknown as PaymentEventDto,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
|
||||
import { IsString, IsInt, IsNumber, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class RouteStopInputDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@@ -34,7 +34,7 @@ export class CreateRouteDto {
|
||||
export class AddRouteStopDto {
|
||||
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
|
||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||
@ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number;
|
||||
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
|
||||
}
|
||||
|
||||
export class UpdateRouteDto {
|
||||
|
||||
@@ -34,7 +34,7 @@ export class RoutesService {
|
||||
create: dto.stops.map(s => ({
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm,
|
||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -135,7 +135,12 @@ export class RoutesService {
|
||||
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
|
||||
|
||||
return this.prisma.routeStop.create({
|
||||
data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm },
|
||||
data: {
|
||||
routeId,
|
||||
stationId: dto.stationId,
|
||||
sequence: dto.sequence,
|
||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# Build from monorepo root: docker build -f apps/edr-payment-api/Dockerfile .
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
|
||||
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
FROM base AS pruner
|
||||
@@ -11,6 +15,7 @@ FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
@@ -18,7 +23,8 @@ COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="@edr/payment-api..."
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy
|
||||
|
||||
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
|
||||
# against the real DB, as its own gated step *before* the app image is built/deployed.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
} from "class-validator";
|
||||
import {
|
||||
PaymentEventType,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* Body for the dev-only POST /test/payment-event endpoint. Every field is optional — the
|
||||
* controller fills sensible defaults so an empty `{}` publishes a `payment.succeeded` to the
|
||||
* passenger queue. Set `referenceId` to a real bookingId to exercise the consumer's side effects
|
||||
* (seat confirm / ticket issue); leave it blank to only prove RabbitMQ delivery.
|
||||
*/
|
||||
export class TestPaymentEventDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: ["payment.succeeded", "payment.failed"],
|
||||
default: "payment.succeeded",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["payment.succeeded", "payment.failed"])
|
||||
eventType?: PaymentEventType;
|
||||
|
||||
@ApiPropertyOptional({ enum: PaymentService, default: PaymentService.PASSENGER })
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentService)
|
||||
service?: PaymentService;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: PaymentReferenceType,
|
||||
default: PaymentReferenceType.BOOKING,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType?: PaymentReferenceType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Domain order id (e.g. bookingId). Defaults to a random uuid.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
referenceId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Defaults to a random uuid." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
intentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Defaults to test-<random>." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
merchantOrderId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProviderMethod, default: ProviderMethod.WAAFI })
|
||||
@IsOptional()
|
||||
@IsEnum(ProviderMethod)
|
||||
provider?: ProviderMethod;
|
||||
|
||||
@ApiPropertyOptional({ default: 10000, description: "Amount in minor units." })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
amountMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: "ETB" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Only used for payment.succeeded." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
providerTxnId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Only used for payment.failed." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
failureCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Only used for payment.failed." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
failureMessage?: string;
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import { OutboxRepository } from "./outbox.repository";
|
||||
import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher";
|
||||
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
|
||||
import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-payment-event-publisher";
|
||||
import { TestEventsController } from "./test-events.controller";
|
||||
|
||||
// Dev-only harness to publish a synthetic payment event straight to the broker.
|
||||
// Never registered in production, so the endpoint cannot exist there.
|
||||
const testControllers =
|
||||
process.env.NODE_ENV !== "production" ? [TestEventsController] : [];
|
||||
|
||||
const rabbitImports = isRabbitPublisher()
|
||||
? [
|
||||
@@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher()
|
||||
HttpModule,
|
||||
...rabbitImports,
|
||||
],
|
||||
controllers: testControllers,
|
||||
providers: [
|
||||
OutboxRepository,
|
||||
OutboxRelayService,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Body, Controller, Inject, Logger, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
PaymentEvent,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
paymentRoutingKey,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
PAYMENT_EVENT_PUBLISHER,
|
||||
PaymentEventPublisher,
|
||||
} from "./publisher/payment-event-publisher";
|
||||
import { TestPaymentEventDto } from "./dto/test-payment-event.dto";
|
||||
|
||||
/**
|
||||
* DEV-ONLY test harness. Publishes a synthetic payment event through the real
|
||||
* PaymentEventPublisher (RabbitMQ in dev), so the passenger/freight consumer receives it
|
||||
* exactly as in production — without creating an intent or going through a booking + provider
|
||||
* flow. Registered only when NODE_ENV !== "production" (see OutboxModule); never reachable in prod.
|
||||
*
|
||||
* Quick check (no body): POST /test/payment-event -> publishes payment.passenger.succeeded.
|
||||
* Real side effects: pass a real bookingId as `referenceId`.
|
||||
*/
|
||||
@ApiTags("Dev test (non-production)")
|
||||
@Controller("test")
|
||||
export class TestEventsController {
|
||||
private readonly logger = new Logger(TestEventsController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PAYMENT_EVENT_PUBLISHER)
|
||||
private readonly publisher: PaymentEventPublisher,
|
||||
) {}
|
||||
|
||||
@Post("payment-event")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"DEV ONLY: publish a synthetic payment event to the broker (passenger/freight consumes it)",
|
||||
description:
|
||||
"Bypasses intents/booking. Empty body publishes a payment.succeeded for PASSENGER. " +
|
||||
"Set referenceId to a real bookingId to trigger the consumer's seat/ticket side effects.",
|
||||
})
|
||||
async publishTestEvent(
|
||||
@Body() dto: TestPaymentEventDto,
|
||||
): Promise<{ published: true; routingKey: string; event: PaymentEvent }> {
|
||||
const eventType = dto.eventType ?? "payment.succeeded";
|
||||
const service = dto.service ?? PaymentService.PASSENGER;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const base = {
|
||||
version: 1 as const,
|
||||
eventId: randomUUID(),
|
||||
occurredAt: now,
|
||||
service,
|
||||
intentId: dto.intentId ?? randomUUID(),
|
||||
referenceType: dto.referenceType ?? PaymentReferenceType.BOOKING,
|
||||
referenceId: dto.referenceId ?? randomUUID(),
|
||||
merchantOrderId: dto.merchantOrderId ?? `test-${randomUUID().slice(0, 8)}`,
|
||||
provider: dto.provider ?? ProviderMethod.WAAFI,
|
||||
amountMinor: dto.amountMinor ?? 10_000,
|
||||
currency: dto.currency ?? "ETB",
|
||||
};
|
||||
|
||||
const event: PaymentEvent =
|
||||
eventType === "payment.failed"
|
||||
? {
|
||||
...base,
|
||||
eventType: "payment.failed",
|
||||
failureCode: dto.failureCode ?? "TEST_DECLINED",
|
||||
failureMessage: dto.failureMessage ?? "Synthetic test failure",
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
eventType: "payment.succeeded",
|
||||
providerTxnId: dto.providerTxnId ?? `TEST-${randomUUID().slice(0, 8)}`,
|
||||
paidAt: now,
|
||||
};
|
||||
|
||||
await this.publisher.publish(event);
|
||||
|
||||
const routingKey = paymentRoutingKey(event.service, event.eventType);
|
||||
this.logger.log(
|
||||
`published TEST ${event.eventType} (${event.eventId}) ref=${event.referenceId} -> ${routingKey}`,
|
||||
);
|
||||
return { published: true, routingKey, event };
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,12 @@ export class WebhooksController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" })
|
||||
async receiveDMoney(@Body() payload: DMoneyWebhookPayload) {
|
||||
this.logger.log(
|
||||
`D-Money webhook hit: merchOrderId=${payload?.merch_order_id ?? "n/a"} ` +
|
||||
`paymentOrderId=${payload?.payment_order_id ?? "n/a"} ` +
|
||||
`tradeStatus=${payload?.trade_status ?? "n/a"}`,
|
||||
);
|
||||
this.logger.log(`D-Money webhook payload: ${JSON.stringify(payload)}`);
|
||||
try {
|
||||
await this.dMoney.handle(payload);
|
||||
} catch (err) {
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
- `apps/edr-passenger-api/docker-entrypoint.sh`
|
||||
- `apps/edr-passenger-api/prisma/seed.ts`
|
||||
- `infrastructure/docker/Dockerfile.passenger-web` (NEW)
|
||||
- `apps/edr-freight-api/Dockerfile` (MODIFIED — pnpm store cache)
|
||||
- `apps/edr-passenger-api/Dockerfile` (MODIFIED — prisma generate in /deploy + pnpm store cache)
|
||||
- `apps/edr-payment-api/Dockerfile` (MODIFIED — pnpm store cache)
|
||||
- `infrastructure/docker/Dockerfile.web` (MODIFIED — pnpm store cache)
|
||||
- `apps/edr-passenger-web/portal/next.config.js` (MODIFIED)
|
||||
- `apps/edr-passenger-web/backoffice/next.config.js` (MODIFIED)
|
||||
- `DEPLOYMENT.md` (MODIFIED)
|
||||
@@ -85,6 +89,22 @@
|
||||
- `live/page.tsx` — replaced stub with real LiveTrackingPage using `liveApi` (trips, crowd signals, delay/status stats)
|
||||
- `notifications/page.tsx` — replaced hardcoded mock + broken `Table` import with real page using `notificationsApi` (templates list, send form, notification history tab)
|
||||
|
||||
## Prisma Client Missing After `pnpm deploy` (Latest)
|
||||
|
||||
20. Fixed passenger API crash in Docker (`TypeError: Cannot convert undefined or null to object` at `class-validator` `IsEnum`, triggered by `dist/modules/fare-engine/currency.dto.js`):
|
||||
- Root cause: `currency.dto.ts` imports the `Currency` enum (a runtime value) from `@prisma/client`. The generated Prisma client is an output of `prisma generate`, not a package in the pnpm store, so `pnpm deploy` did not copy it into `/deploy`. At runtime `Currency` resolved to `undefined` → `@IsEnum(undefined)` → `Object.entries(undefined)` throws at module load.
|
||||
- Prisma 6 + pnpm writes the client to `node_modules/.pnpm/@prisma+client@.../node_modules/.prisma/client`, **not** root `node_modules/.prisma`. The old `cp` rescue in the Dockerfile guarded on `[ -d node_modules/.prisma ]` (root) which never existed → silently skipped.
|
||||
- Fix in `apps/edr-passenger-api/Dockerfile`: replaced the broken `cp` with `RUN cd /deploy && npm run prisma:generate` after `pnpm deploy`, regenerating the client into the exact runtime-resolve path (`/deploy/node_modules/.prisma/client`). Safe because deploy has no `--prod` flag (so the `prisma` CLI ships) and `package.json` declares the schema path.
|
||||
- Affected 16 passenger-api files importing from `@prisma/client`; `currency.dto.js` just loaded first. payment-api unaffected (TypeORM, no Prisma).
|
||||
|
||||
## pnpm Store Build Cache Fix (Latest)
|
||||
|
||||
21. Fixed Docker builds re-downloading all dependencies every pipeline run:
|
||||
- Root cause: install steps used `--mount=type=cache,id=pnpm,target=/pnpm/store`, but nothing set pnpm's store-dir to `/pnpm/store`. Default store (`~/.local/share/pnpm/store`) was never under the mount → BuildKit cached an empty dir → full re-download each build. The two web Dockerfiles had the mount but it was dead; the two API Dockerfiles (freight, payment) had no mount at all.
|
||||
- Fix: added `ENV PNPM_HOME="/pnpm"` (+ PATH) to the `base` stage of all 5 Dockerfiles so the store resolves to `/pnpm/store`, matching the mount. Added the cache mount to every `pnpm install` and `pnpm deploy` step that lacked it.
|
||||
- Files: `apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`, `apps/edr-payment-api/Dockerfile`, `infrastructure/docker/Dockerfile.web`, `infrastructure/docker/Dockerfile.passenger-web`.
|
||||
- Caveat: BuildKit cache mounts live on the runner host; persists only while the same self-hosted runner/builder is reused and not pruned (`docker builder prune` wipes it).
|
||||
|
||||
## Next Actions
|
||||
|
||||
1. Run full CI on all target branches (`main`, `dev`, `staging`) and verify matrix job behavior.
|
||||
|
||||
@@ -20,7 +20,6 @@ services:
|
||||
- apps/edr-freight-api/.env
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
|
||||
passenger-api:
|
||||
build:
|
||||
context: .
|
||||
@@ -31,7 +30,6 @@ services:
|
||||
- apps/edr-passenger-api/.env
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
|
||||
freight-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -39,14 +37,13 @@ services:
|
||||
args:
|
||||
TURBO_FILTER: "@edr/freight-portal"
|
||||
APP_PATH: apps/edr-freight-web/portal
|
||||
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
|
||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
|
||||
VITE_API_URL: ${VITE_API_URL:-}
|
||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
||||
|
||||
freight-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -54,14 +51,13 @@ services:
|
||||
args:
|
||||
TURBO_FILTER: "@edr/freight-backoffice"
|
||||
APP_PATH: apps/edr-freight-web/backoffice
|
||||
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
|
||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
|
||||
VITE_API_URL: ${VITE_API_URL:-}
|
||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
||||
|
||||
passenger-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -69,14 +65,13 @@ services:
|
||||
args:
|
||||
APP_PACKAGE: "@edr/passenger-portal"
|
||||
APP_PATH: apps/edr-passenger-web/portal
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
|
||||
env_file:
|
||||
- apps/edr-passenger-web/portal/.env
|
||||
|
||||
passenger-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -84,14 +79,14 @@ services:
|
||||
args:
|
||||
APP_PACKAGE: "@edr/passenger-backoffice"
|
||||
APP_PATH: apps/edr-passenger-web/backoffice
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
||||
secrets:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
|
||||
env_file:
|
||||
- apps/edr-passenger-web/backoffice/.env
|
||||
|
||||
|
||||
payment-api:
|
||||
build:
|
||||
context: .
|
||||
@@ -105,7 +100,6 @@ services:
|
||||
- "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}"
|
||||
env_file:
|
||||
- apps/edr-payment-api/.env
|
||||
|
||||
secrets:
|
||||
npmrc:
|
||||
file: .npmrc
|
||||
|
||||
@@ -10,43 +10,47 @@
|
||||
# --build-arg PORT=5174 \
|
||||
# -f infrastructure/docker/Dockerfile.passenger-web .
|
||||
#
|
||||
|
||||
ARG APP_PACKAGE=@edr/passenger-portal
|
||||
ARG APP_PATH=apps/edr-passenger-web/portal
|
||||
ARG PORT=5174
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
|
||||
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
ARG APP_PACKAGE
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
ARG APP_PACKAGE
|
||||
ARG APP_PATH
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \
|
||||
echo "ERROR: NEXT_PUBLIC_API_URL must be set" && \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="${APP_PACKAGE}..."
|
||||
|
||||
FROM base AS deployer
|
||||
ARG APP_PACKAGE
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
ARG APP_PATH
|
||||
ARG PORT=5174
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
ARG TURBO_FILTER=@edr/freight-portal
|
||||
ARG APP_PATH=apps/edr-freight-web/portal
|
||||
ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api
|
||||
ARG VITE_BASE_API_URL=https://edrfreightapi.triaplc.com
|
||||
ARG VITE_USER_MANAGEMENT_BASE=/_um
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -35,6 +31,12 @@ ENV VITE_API_URL=${VITE_API_URL}
|
||||
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
|
||||
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \
|
||||
echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
|
||||
@@ -131,7 +131,11 @@ export enum PaymentStatus {
|
||||
|
||||
export enum InvoiceStatus {
|
||||
Draft = "DRAFT",
|
||||
/** Issued and awaiting payment (alias of PENDING for fee invoices). */
|
||||
Issued = "ISSUED",
|
||||
Pending = "PENDING",
|
||||
/** Some, but not all, of the balance has been settled. */
|
||||
PartiallyPaid = "PARTIALLY_PAID",
|
||||
Paid = "PAID",
|
||||
Overdue = "OVERDUE",
|
||||
Cancelled = "CANCELLED",
|
||||
|
||||
74
scripts/deploy/sync-env-from-server-jenkins.sh
Normal file
74
scripts/deploy/sync-env-from-server-jenkins.sh
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync .env files from the self-hosted runner filesystem into the repo.
|
||||
# Jenkins variant — exports variables as KEY=VALUE lines into $CI_ENV_FILE,
|
||||
# which the Jenkinsfile loads with readProperties + withEnv. Jenkins has no
|
||||
# equivalent of GitHub Actions' $GITHUB_ENV, and each `sh` step runs in its
|
||||
# own process, so this file is the hand-off point between stages.
|
||||
#
|
||||
# Usage:
|
||||
# PROJECT=edr-freight BRANCH=main CI_ENV_FILE=/tmp/passenger-api.env \
|
||||
# ./scripts/deploy/sync-env-from-server-jenkins.sh passenger-api
|
||||
#
|
||||
# Server layout (one file per service):
|
||||
# /home/user/environmen/<project>/<branch-slug>/freight-api.env
|
||||
# /home/user/environmen/<project>/<branch-slug>/freight-portal.env
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_USER="${DEPLOY_USER:-tria}"
|
||||
BRANCH="${BRANCH:?BRANCH is required}"
|
||||
BRANCH_SLUG="${BRANCH_SLUG:-$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")}"
|
||||
ENV_ROOT="${ENV_ROOT:-/home/${DEPLOY_USER}/environment/edr/${BRANCH_SLUG}/${PROJECT:?PROJECT is required}}"
|
||||
CI_ENV_FILE="${CI_ENV_FILE:?CI_ENV_FILE is required (e.g. \${WORKSPACE}/.ci-env/<service>.env)}"
|
||||
|
||||
if [[ ! -d "${ENV_ROOT}" ]]; then
|
||||
echo "Environment directory not found: ${ENV_ROOT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Using environment directory: ${ENV_ROOT}"
|
||||
|
||||
mkdir -p "$(dirname "${CI_ENV_FILE}")"
|
||||
: > "${CI_ENV_FILE}"
|
||||
|
||||
declare -A SERVICE_ENV_TARGET=(
|
||||
["freight-api"]="apps/edr-freight-api/.env"
|
||||
["freight-portal"]="apps/edr-freight-web/portal/.env"
|
||||
["freight-backoffice"]="apps/edr-freight-web/backoffice/.env"
|
||||
["passenger-api"]="apps/edr-passenger-api/.env"
|
||||
["passenger-portal"]="apps/edr-passenger-web/portal/.env"
|
||||
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
|
||||
["payment-api"]="apps/edr-payment-api/.env"
|
||||
)
|
||||
|
||||
for service in "$@"; do
|
||||
src="${ENV_ROOT}/${service}.env"
|
||||
dest="${SERVICE_ENV_TARGET[${service}]:-}"
|
||||
|
||||
if [[ -z "${dest}" ]]; then
|
||||
echo "Unknown service: ${service}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${src}" ]]; then
|
||||
echo "Missing env file: ${src}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${dest}")"
|
||||
cp "${src}" "${dest}"
|
||||
echo "Synced ${src} -> ${dest}"
|
||||
|
||||
port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${src}" | head -n1 | tr -d '[:space:]')
|
||||
if [[ -z "${port_value}" ]]; then
|
||||
echo "Missing required PORT in env file: ${src}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_')
|
||||
echo "${service_var}_PORT=${port_value}" >> "${CI_ENV_FILE}"
|
||||
echo "Exported ${service_var}_PORT from ${src}"
|
||||
|
||||
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
|
||||
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \
|
||||
| sed -E 's/^[[:space:]]*//' >> "${CI_ENV_FILE}" || true
|
||||
done
|
||||
@@ -7,7 +7,6 @@
|
||||
# Server layout (one file per service):
|
||||
# /home/user/environmen/<project>/<branch-slug>/freight-api.env
|
||||
# /home/user/environmen/<project>/<branch-slug>/freight-portal.env
|
||||
# /home/user/environmen/<project>/<branch-slug>/freight-web.build.env (optional, exports VITE_API_URL etc.)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -62,26 +61,8 @@ for service in "$@"; do
|
||||
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
|
||||
echo "Exported ${service_var}_PORT from ${src}"
|
||||
|
||||
# Forward NEXT_PUBLIC_* vars so docker compose build can inject them as build args.
|
||||
grep -E '^[[:space:]]*NEXT_PUBLIC_[A-Za-z0-9_]+=' "${src}" \
|
||||
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
|
||||
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \
|
||||
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Optional build-time variables (VITE_API_URL, etc.)
|
||||
# Set BUILD_ENV_FILE=freight-web.build.env or passenger-web.build.env per workflow.
|
||||
build_env_file="${BUILD_ENV_FILE:-web.build.env}"
|
||||
build_env="${ENV_ROOT}/${build_env_file}"
|
||||
if [[ -f "${build_env}" ]]; then
|
||||
echo "Loading build variables from ${build_env}"
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${build_env}"
|
||||
set +a
|
||||
|
||||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||
grep -E '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=' "${build_env}" \
|
||||
| sed -E 's/^[[:space:]]*export[[:space:]]+//' >> "${GITHUB_ENV}"
|
||||
echo "Wrote build variables to GITHUB_ENV"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user