From 936bf5843ede747a795f11fdac53097731d550b4 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 30 Jun 2026 07:19:18 +0300 Subject: [PATCH 01/26] fix(passenger-api): allow gender column migration to run --- .../scripts/resolve-migrations.sh | Bin 938 -> 1216 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/edr-passenger-api/scripts/resolve-migrations.sh b/apps/edr-passenger-api/scripts/resolve-migrations.sh index 96cba674adc6713a09f9a30344e4341571a1939d..0905ff28b9b864747f67e90403ec278072be3b18 100644 GIT binary patch literal 1216 zcmc(fOHRWu5Qb-s#2rGqE>MKH0PCK?N}Cca5;w{NQN-c-etQwpmL*hHJf4SV{&_eH zyRkdln707!?bR~7wku9M8#phaNs{)RZL!%S%k7zy__uhwLiU2Di?l#iqb=bY&K2?k zj~}~#U^Vxm zygF|5Agb-1ld@f&c+2ghjpXz-NAE-@|Hpo6>?=FE}9hjB_aR-+62(bgZdO1@4fUX3y zx-ueDKL1(uRh*jRs%zdFFlySSc}Q>Kl68Kfy25&lx1fe4NLAMmC%vMYddVAVsvJ~b pWu9%xLL53w(6`C%?;?J)i-{E72X75uAE)zKI16qi_CCxsk zvRbVcRdoaRn)VQ|)#mu@FX#fg?}L|`SKMJi(1hS9N6-m!uux2gQ3H3H!DcM!B}3F~ zIxqtfCWcQRUW>rzQ}vTDd_f^uEWS-bpUi2n-Z{CJq8U3P6|pH|qSWO<1a}B!@B1FD zP~V5}-0lvAgV5B?w%*p8^}9{8exrQy0(`6{EIEv#Dd8lOX@ew5l_^9OZqxO&X33Tws%JUjr?fd2-b(Gtk| z(c3`>#op|Zi%MCJ5x?eiExb9~R+e%mlMxcZMuq?Y From 756aa046465bef3dda0dec61d6fa84986ed88c21 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 30 Jun 2026 07:33:59 +0300 Subject: [PATCH 02/26] fix(passenger-api): recreate script with LF line endings --- .../scripts/resolve-migrations.sh | Bin 1216 -> 591 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/edr-passenger-api/scripts/resolve-migrations.sh b/apps/edr-passenger-api/scripts/resolve-migrations.sh index 0905ff28b9b864747f67e90403ec278072be3b18..cc34c747e2a81148f4d859d044d3d89042bf4734 100644 GIT binary patch literal 591 zcmbu7O-{ow5QX{udkH%`D2FvJ126t70| z=)I@!&E{rzB)ba(3mBnB#!wCps`uCDC%7L_j%0i2oQF=3Mg@j?FCsY`w%cvR*ai;5 zkD##^^8D);L;x{Onou!2onZ15j3^5T9)r^}LS2_7sM@BzZT3xhyrUCeJo&dL4^c#{ z;BtZJ6aG!ONOQ7^W>QIL9!ZWE1Gg@Z1|OWgR=izebLHJsWVv)Y8Os%RBIy{eiM?-E zBHW{KGky`s^#7ChE^DqZgi<-D?Wj*)95rU-KAW6FMDr849L3wqBOKQLmkQJ8DS&il I(wLk10Cnumm;e9( literal 1216 zcmc(fOHRWu5Qb-s#2rGqE>MKH0PCK?N}Cca5;w{NQN-c-etQwpmL*hHJf4SV{&_eH zyRkdln707!?bR~7wku9M8#phaNs{)RZL!%S%k7zy__uhwLiU2Di?l#iqb=bY&K2?k zj~}~#U^Vxm zygF|5Agb-1ld@f&c+2ghjpXz-NAE-@|Hpo6>?=FE}9hjB_aR-+62(bgZdO1@4fUX3y zx-ueDKL1(uRh*jRs%zdFFlySSc}Q>Kl68Kfy25&lx1fe4NLAMmC%vMYddVAVsvJ~b pWu Date: Tue, 30 Jun 2026 08:28:44 +0300 Subject: [PATCH 03/26] Routes management issue fix --- .../src/modules/schedules/routes.dto.ts | 6 +++--- .../src/modules/schedules/routes.service.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index 21768ab8b..8339bf477 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index c7998e9cd..d8df838ca 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -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, + }, }); } From 468ac8b28a83c2681062293b24d9985184e29072 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 30 Jun 2026 08:31:26 +0300 Subject: [PATCH 04/26] fix --- .../src/migrations/1821000000002-CreateInvoices.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 5c42cad65..6ed649d5a 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -27,7 +27,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query(` - CREATE TABLE freight.invoices ( + CREATE IF NOT EXISTS TABLE freight.invoices ( id uuid NOT NULL DEFAULT uuid_generate_v4(), invoice_number varchar(64) NOT NULL, company_id uuid NOT NULL, @@ -96,6 +96,8 @@ export class CreateInvoices1821000000002 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { 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;`, + ); } } From c299973bb0e7adaf61d6155121395173bbb773aa Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 30 Jun 2026 08:38:27 +0300 Subject: [PATCH 05/26] fix --- .../src/migrations/1821000000002-CreateInvoices.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 6ed649d5a..5fd7cd0ad 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -27,7 +27,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query(` - CREATE IF NOT EXISTS 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, From 8d7ded1726c414440c013131204a421015cd6043 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 09:12:45 +0300 Subject: [PATCH 06/26] Update webhooks.controller.ts --- .../src/modules/webhooks/webhooks.controller.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index d367971d0..66232dfcc 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -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) { From 004c40059f3df994d8a5fe9a63a2e6dbc8334d88 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 30 Jun 2026 09:13:48 +0300 Subject: [PATCH 07/26] fix: api url --- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index a60559d5d..f4b707fbc 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -//export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = "https://edrfreightapi.triaplc.com"; -export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From 0f762172bf775ab1d2f15dad6caf6531835cba8a Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 30 Jun 2026 09:19:32 +0300 Subject: [PATCH 08/26] fix: api url backoffice --- apps/edr-freight-web/portal/src/constants/apiConfig.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index b2d93ca3a..40c6c0041 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -//export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = "https://edrfreightapi.triaplc.com"; +// export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the @@ -12,4 +12,3 @@ export function fileViewUrl(fileId: string, download = false): string { const base = `${API_BASE_URL}/api/files/${fileId}`; return download ? `${base}?download=1` : base; } - From 5e43820efa7bff1955a0d341be6371399895f990 Mon Sep 17 00:00:00 2001 From: SennayT Date: Tue, 30 Jun 2026 10:38:53 +0300 Subject: [PATCH 09/26] revert changes to dockerfile --- apps/edr-passenger-api/Dockerfile | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 4941ceb46..2b0ee8041 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -23,10 +23,10 @@ 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 if [ -d node_modules/.prisma ]; then \ + mkdir -p /deploy/node_modules && \ + cp -r node_modules/.prisma /deploy/node_modules/.prisma; \ + fi # --- 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 +37,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 From 35a2a200d63062f9cbe3e810467a7568d15562ff Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 11:11:50 +0300 Subject: [PATCH 10/26] fix: ( migration ) clean up migrations --- .../migration.sql | 10 - .../migration.sql | 77 ---- .../migration.sql | 3 - .../migration.sql | 2 - .../migration.sql | 2 - .../migration.sql | 127 ------ .../migration.sql | 14 - .../migration.sql | 28 -- .../migration.sql | 54 --- .../migration.sql | 13 - .../migration.sql | 10 - .../migration.sql | 5 - .../migration.sql | 2 - .../migration.sql | 275 ------------- .../migration.sql | 2 - .../migration.sql | 9 - .../migration.sql | 164 -------- .../migration.sql | 82 ---- .../migration.sql | 5 - .../migration.sql | 46 --- .../20260623073543_config/migration.sql | 290 -------------- .../migration.sql | 18 - .../migration.sql | 70 ---- .../migration.sql | 21 - .../migration.sql | 2 - .../migration.sql | 153 -------- .../migration.sql | 12 - .../migration.sql | 42 -- .../migration.sql | 1 - .../migration.sql | 368 +++++++++++++++--- 30 files changed, 320 insertions(+), 1587 deletions(-) delete mode 100644 apps/edr-passenger-api/prisma/migrations/20240100000000_fix_failed_migration_state/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql rename apps/edr-passenger-api/prisma/migrations/{20260605195213_init => 20260630074725_init}/migration.sql (79%) diff --git a/apps/edr-passenger-api/prisma/migrations/20240100000000_fix_failed_migration_state/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240100000000_fix_failed_migration_state/migration.sql deleted file mode 100644 index a72a795c1..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20240100000000_fix_failed_migration_state/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql deleted file mode 100644 index 44d778635..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql +++ /dev/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 $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql deleted file mode 100644 index 0e9961c0a..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql b/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql deleted file mode 100644 index 028b08500..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Remove timezone column if it still exists -ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone"; \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql b/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql deleted file mode 100644 index e9ba7761b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql deleted file mode 100644 index 4b1fbd19c..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ /dev/null @@ -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); diff --git a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql deleted file mode 100644 index 9ccd3d52e..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql +++ /dev/null @@ -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"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql deleted file mode 100644 index 525b6572e..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql deleted file mode 100644 index f2250e52b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql +++ /dev/null @@ -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 $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql deleted file mode 100644 index ec1cfd078..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql +++ /dev/null @@ -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"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql deleted file mode 100644 index 52914b220..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql +++ /dev/null @@ -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"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql deleted file mode 100644 index 125074c12..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3); - --- RenameIndex -ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql deleted file mode 100644 index 9b9228768..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterEnum -ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY'; diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql deleted file mode 100644 index 577312395..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql deleted file mode 100644 index 7e7d9bd58..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Empty placeholder migration -SELECT 1; diff --git a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql deleted file mode 100644 index 1673a795b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql +++ /dev/null @@ -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"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql deleted file mode 100644 index 9f70a96b1..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql deleted file mode 100644 index 582db9567..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql +++ /dev/null @@ -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 $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql deleted file mode 100644 index 33f793aa3..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql deleted file mode 100644 index fb2e47592..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql +++ /dev/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"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql deleted file mode 100644 index a20893705..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql deleted file mode 100644 index bc6e6c2a2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql deleted file mode 100644 index b0a5bc0b4..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql +++ /dev/null @@ -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"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql deleted file mode 100644 index 12f4a0eb7..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql deleted file mode 100644 index 435d95829..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration already applied directly to the database. --- This file exists only to satisfy Prisma's migration directory check (P3015). diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql deleted file mode 100644 index 2dff947d2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql +++ /dev/null @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql deleted file mode 100644 index 545b6a5b4..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql +++ /dev/null @@ -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"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql deleted file mode 100644 index 04e2a7610..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql +++ /dev/null @@ -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" = ''; diff --git a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql deleted file mode 100644 index 34a2e2caa..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql similarity index 79% rename from apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql index 4ae48ea16..d4d7cbe42 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql @@ -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; From e7e523b3b2ff8ce73c299f242221e053a3a5a641 Mon Sep 17 00:00:00 2001 From: SennayT Date: Tue, 30 Jun 2026 08:39:38 +0000 Subject: [PATCH 11/26] geerate prisma client after deploying --- apps/edr-passenger-api/Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 2b0ee8041..a8dfcddfd 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -23,10 +23,12 @@ 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 -RUN if [ -d node_modules/.prisma ]; then \ - mkdir -p /deploy/node_modules && \ - cp -r node_modules/.prisma /deploy/node_modules/.prisma; \ - fi +# 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. From 3c5f5b586183f4cc1e1354353109f7c4dcf73468 Mon Sep 17 00:00:00 2001 From: SennayT Date: Tue, 30 Jun 2026 09:54:07 +0000 Subject: [PATCH 12/26] fix: add docker cache for pnpm installations --- apps/edr-freight-api/Dockerfile | 8 +++++++- apps/edr-passenger-api/Dockerfile | 9 ++++++++- apps/edr-payment-api/Dockerfile | 8 +++++++- checkpoint.md | 20 +++++++++++++++++++ .../docker/Dockerfile.passenger-web | 7 ++++++- infrastructure/docker/Dockerfile.web | 4 ++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b0850737b..f9107ed23 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -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 diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index a8dfcddfd..45b365496 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -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,7 +28,8 @@ 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 +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, …) diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index 5cddf5232..76d211a20 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -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. diff --git a/checkpoint.md b/checkpoint.md index d9665e1fc..4f357ab6c 100644 --- a/checkpoint.md +++ b/checkpoint.md @@ -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. diff --git a/infrastructure/docker/Dockerfile.passenger-web b/infrastructure/docker/Dockerfile.passenger-web index 735a19e8e..9d9adac08 100644 --- a/infrastructure/docker/Dockerfile.passenger-web +++ b/infrastructure/docker/Dockerfile.passenger-web @@ -18,6 +18,10 @@ 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 @@ -45,7 +49,8 @@ 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 diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 1ccdeb81f..22ec91563 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -9,6 +9,10 @@ ARG NEXT_PUBLIC_API_URL=http://localhost:4000 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 fdb77a703652c50d56905a3b0bfe7c701f94ff24 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 30 Jun 2026 13:49:52 +0300 Subject: [PATCH 13/26] fix: testing the env --- apps/edr-freight-web/portal/src/constants/apiConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 40c6c0041..59e5297df 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,4 +1,4 @@ -export const API_BASE_URL = "https://edrfreightapi.triaplc.com"; +export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; // export const API_BASE_URL = 'http://localhost:3001'; /** From 21cf24950de5ed7af87032b12c28835254a361ac Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 10:50:43 +0000 Subject: [PATCH 14/26] feat: add partial payment to match the warehouse invoice before migration --- ...0000000-ExtendInvoicesForPartialPayment.ts | 71 ++++++++++ .../modules/billing/billing.service.spec.ts | 82 ++++++++++++ .../src/modules/billing/billing.service.ts | 124 +++++++++++++++++- .../billing/entities/invoice.entity.ts | 33 +++++ packages/types/src/freight/index.ts | 4 + 5 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts diff --git a/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts new file mode 100644 index 000000000..55239c13f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts @@ -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 { + // 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 { + 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). + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 0e6d97de0..5d407d3cd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -180,6 +180,88 @@ describe("BillingService.markInvoiceAsPaid", () => { }); }); +describe("BillingService.recordPayment", () => { + function serviceFor(invoice: Record | 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 + ); + return { service, mg, events }; + } + + const openInvoice = (overrides: Record = {}) => ({ + 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 = { diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 01b057a76..ce4341486 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,9 +1,16 @@ -import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { Freight, PaymentReferenceType } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice } from "./entities/invoice.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; import { InvoiceLineRepository } from "./invoice-line.repository"; @@ -20,16 +27,32 @@ 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 | 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, ]; +/** Round to 2 decimals, avoiding binary float drift. */ +const round2 = (n: number): number => Math.round(n * 100) / 100; + /** A single line to bill on a generated invoice. */ export interface InvoiceLineInput { chargeType: string; @@ -56,7 +79,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; @@ -230,8 +257,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 +281,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 +329,84 @@ 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 { + 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 total = Number(invoice.totalAmount); + const paidAmount = round2(Number(invoice.paidAmount) + input.amount); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + const fullyPaid = paidAmount >= total; + 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. diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 61bc9c16b..23c332f80 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -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 | 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; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dbfcf655d..171a294fb 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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", From 9619fbd9b0d7c580c6b4953ed306f705103cab84 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 30 Jun 2026 13:58:57 +0300 Subject: [PATCH 15/26] fix: the hardcoded api url --- apps/edr-freight-web/portal/src/constants/apiConfig.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 59e5297df..a285a8fd2 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,4 @@ export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; -// export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From 48340f5e0a25ca66a1cd32702766a4d14e665f4e Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 30 Jun 2026 14:05:28 +0300 Subject: [PATCH 16/26] fix: the hardcoded api url --- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index f4b707fbc..842276815 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,4 +1,4 @@ -export const API_BASE_URL = "https://edrfreightapi.triaplc.com"; +export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL; // export const API_BASE_URL = 'http://localhost:3001'; From 3eb4a24199cceef5c4a4e68027fbbb2b56a5518f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 11:29:28 +0000 Subject: [PATCH 17/26] fix: terminal clearing int vite --- apps/edr-freight-web/backoffice/package.json | 2 +- apps/edr-freight-web/portal/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 93caae0a4..48b9bc0a9 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -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", diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 9f866d756..960531fda 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -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", From 7fa18b8ee726ea41420afad945c73e48ba15a87a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 11:54:43 +0000 Subject: [PATCH 18/26] fix: reference type in payment service --- .../src/modules/payment/payment.service.ts | 988 ++++++++++-------- 1 file changed, 527 insertions(+), 461 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..738a6d118 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,11 +1,11 @@ import { - BadRequestException, - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, } from "@nestjs/common"; import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; @@ -18,73 +18,70 @@ import * as path from "path"; import * as Handlebars from "handlebars"; import { Booking } from "../bookings/entities/booking.entity"; +import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { - ClientAction, - ProviderPaymentStatus, -} from "@edr/payment-providers"; -import { - PaymentService as PaymentServiceEnum, - PaymentReferenceType, - PaymentIntentSnapshot, - ProviderMethod, + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, } from "@edr/types"; import { - InitiateResponseDto, - IntentStatusDto, - PaymentPlatformDto, - RefundDto, + InitiateResponseDto, + IntentStatusDto, + PaymentPlatformDto, + RefundDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by * the caller (billing) — this service never derives them from a domain record. */ export interface InitiateIntentInput { - /** Opaque domain reference (booking id, …). */ - referenceId: string; - /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ - source: string; - /** Gateway reference type the intent is opened with (caller's domain decides it). */ - referenceType: PaymentReferenceType; - /** Human-readable order ref shown on provider pages. */ - orderRef: string; - /** Authoritative amount in minor units, computed by the caller. */ - amountMinor: number; - currency: string; - /** Stored on the intent projection for receipts/dashboards. */ - reason?: string; - /** Provider/method selector. */ - method: ProviderMethod | string; - platform?: PaymentPlatformDto; - payerAccount?: string; - returnUrl?: string; - failureUrl?: string; + /** Opaque domain reference (booking id, …). */ + referenceId: string; + /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ + source: string; + /** Gateway reference type the intent is opened with (caller's domain decides it). */ + referenceType: PaymentReferenceType; + /** Human-readable order ref shown on provider pages. */ + orderRef: string; + /** Authoritative amount in minor units, computed by the caller. */ + amountMinor: number; + currency: string; + /** Stored on the intent projection for receipts/dashboards. */ + reason?: string; + /** Provider/method selector. */ + method: ProviderMethod | string; + platform?: PaymentPlatformDto; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; } export interface InitiateIntentResult { - intentId: string; - response: InitiateResponseDto; - /** True when the provider settled the charge synchronously during initiate. */ - immediateSuccess: boolean; - providerTxnId?: string; - paidAt?: Date; + intentId: string; + response: InitiateResponseDto; + /** True when the provider settled the charge synchronously during initiate. */ + immediateSuccess: boolean; + providerTxnId?: string; + paidAt?: Date; } const STATUS_MAP: Record = { - "action-required": ProviderPaymentStatus.REQUIRES_ACTION, - "processing": ProviderPaymentStatus.PROCESSING, - "success": ProviderPaymentStatus.SUCCEEDED, - "failed": ProviderPaymentStatus.FAILED, - "canceled": ProviderPaymentStatus.CANCELLED, - "refunded": ProviderPaymentStatus.CANCELLED, + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + processing: ProviderPaymentStatus.PROCESSING, + success: ProviderPaymentStatus.SUCCEEDED, + failed: ProviderPaymentStatus.FAILED, + canceled: ProviderPaymentStatus.CANCELLED, + refunded: ProviderPaymentStatus.CANCELLED, }; const PROVIDER_TO_METHOD: Record = { - TELEBIRR: "telebirr", - CBE_BIRR: "cbe-birr", - EBIRR: "ebirr", - WAAFI: "waafi", - CARD: "card", - DMONEY: "dmoney", - CAC_BANK: "cac-bank", + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; /** @@ -96,426 +93,495 @@ const PROVIDER_TO_METHOD: Record = { */ @Injectable() export class PaymentService { - private readonly logger = new Logger(PaymentService.name); + private readonly logger = new Logger(PaymentService.name); - constructor( - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BillingService)) - private readonly billing: BillingService, - ) { } + constructor( + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly paymentClient: PaymentClientService, + @Inject(forwardRef(() => BillingService)) + private readonly billing: BillingService, + ) { } - async getAll(filters: { - search?: string; - status?: string; - method?: string; - page?: number; - pageSize?: number; - }) { - const { search, status, method, page = 1, pageSize = 10 } = filters; - const skip = (page - 1) * pageSize; + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { + const { search, status, method, page = 1, pageSize = 10 } = filters; + const skip = (page - 1) * pageSize; - const qb = this.paymentRepo.createQueryBuilder("payment"); + const qb = this.paymentRepo.createQueryBuilder("payment"); - if (search) { - qb.andWhere( - "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", - { search: `%${search}%` }, - ); - } - if (status) { - qb.andWhere("payment.status = :status", { status }); - } - if (method) { - qb.andWhere("payment.method = :method", { method }); - } + if (search) { + qb.andWhere( + "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", + { search: `%${search}%` }, + ); + } + if (status) { + qb.andWhere("payment.status = :status", { status }); + } + if (method) { + qb.andWhere("payment.method = :method", { method }); + } - const [items, total] = await qb - .orderBy("payment.createdAt", "DESC") - .skip(skip) - .take(pageSize) - .getManyAndCount(); + const [items, total] = await qb + .orderBy("payment.createdAt", "DESC") + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { + items: items.map((p) => ({ + id: p.id, + bookingId: p.refId, + amount: p.amount, + currency: p.currency, + method: p.method, + status: p.status, + merchantOrderId: p.merchantOrderId, + paidAt: p.paidAt, + createdAt: p.createdAt, + })), + total, + page, + pageSize, + }; + } + + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); + + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; + } + + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + + /** + * Open a gateway intent for a caller-supplied amount/reference and project it + * locally. Returns the intent id (so billing can correlate the invoice) plus + * the client action. When the provider settles synchronously, the intent is + * marked paid WITHOUT emitting — the caller (billing) settles inline after it + * has stored the intent id, avoiding a settle-before-correlation race. + */ + async initiate(input: InitiateIntentInput): Promise { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: + input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); + + const immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + + const intent = await this.upsertIntent(input, snapshot); + + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { + providerTxnId: snapshot.providerTxnId, + paidAt, + notify: false, + }); + } + + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, + providerTxnId: snapshot.providerTxnId, + paidAt, + }; + } + + /** Create or update the local intent projection from a provider snapshot. */ + private async upsertIntent( + input: InitiateIntentInput, + snapshot: PaymentIntentSnapshot, + ): Promise { + const existing = await this.paymentRepo.findOneBy({ + refId: input.referenceId, + }); + + const method: PaymentEntity["method"] = + PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); + + const clientAction = (snapshot.clientAction ?? undefined) as + | Record + | undefined; + const data = { + status, + method, + merchantOrderId: + snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", + transactionId: snapshot.providerTxnId ?? existing?.transactionId, + expiresAt: snapshot.expiresAt + ? new Date(snapshot.expiresAt) + : existing?.expiresAt, + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }; + + if (existing) { + await this.paymentRepo.update({ id: existing.id }, { + ...data, + clientAction, + } as any); + return { ...existing, ...data, clientAction } as PaymentEntity; + } + + return this.paymentRepo.create({ + refId: input.referenceId, + type: input.source, + referenceType: input.referenceType, + amount: input.amountMinor, + currency: input.currency as PaymentEntity["currency"], + reason: input.reason ?? `Payment for ${input.orderRef}`, + rawInitiation: snapshot as unknown as Record, + clientAction: clientAction ?? {}, + ...data, + } as any); + } + + /** + * Reconcile an intent's status with the gateway by reference. Read-only on the + * domain side: it syncs the local projection and, when the provider reports a + * newly-observed success, notifies billing to settle. `referenceId` is opaque + * (the booking id, but this service does not load it). + */ + async getIntentByBookingId(referenceId: string): Promise { + const local = await this.paymentRepo.findOneBy({ refId: referenceId }); + + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + (local?.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + referenceId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, + ); + } + + if (!snapshot) { + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + if (!local) throw new NotFoundException("PaymentIntent not found"); + + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && + local.status !== "success"; + + if (becameSuccess) { + await this.markIntentSucceeded(local.id, { + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + notify: true, + }); + } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(snapshot.status), + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent — no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success") return { alreadyFinalized: true }; + + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { + status: "success", + paidAt, + transactionId: opts.providerTxnId ?? intent.transactionId, + }, + ); + + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId, + paidAt, + ); + } + + return { alreadyFinalized: false }; + } + + async markPaymentFailed(input: { + intentId: string; + failureCode?: string; + failureMessage?: string; + }): Promise { + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success" || intent.status === "canceled") return; + + await this.paymentRepo.update( + { id: intent.id }, + { + status: "failed", + failerCode: input.failureCode, + failureMessage: input.failureMessage, + }, + ); + + // Invoice stays open for retry — nothing to settle. Logged only. + this.logger.warn( + `Payment ${intent.id} failed for ${intent.refId}` + + (input.failureMessage ? `: ${input.failureMessage}` : ""), + ); + } + + async refund(dto: RefundDto) { + const intent = await this.paymentRepo.findOneBy({ + refId: dto.bookingId, + type: "booking", + }); + if (!intent || intent.status !== "success") { + throw new BadRequestException("No successful payment to refund"); + } + + // NOTE: refunding still mutates the booking directly — left intact pending + // the refund redesign. TODO: route refunds through billing.refundPayable + + // a `${source}.invoice.refunded` reaction, like settlement. + await this.datasource.transaction(async (mg) => { + await mg.update( + PaymentEntity, + { id: intent.id }, + { status: "refunded", refundedAt: new Date() }, + ); + await mg.update( + Booking, + { id: dto.bookingId }, + { paymentStatus: "FAILED", status: "CANCELLED" }, + ); + }); + + return { refunded: true, bookingId: dto.bookingId }; + } + + async getActivePaymentByOrderIdAndMethod( + orderId: string, + method: PaymentEntity["method"], + ): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success", + }); + if (!payment) + throw new BadRequestException( + "No successful payment found for this order", + ); + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); + + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + return template({ + vendorName: "Ethio Djibouti Railway Freight Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment.method, + subtotal: payment.amount.toString(), + total: payment.amount.toString(), + currency: payment.currency, + reason: payment.reason, + }); + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id }); + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + return { + intentId: intent.id, + status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } + + private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { + return { + ...this.formatIntentResponse(intent), + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failerCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + }; + } + + async handlePaymentEvent(event: { + eventType: string; + eventId: string; + referenceId: string; + intentId: string; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + }): Promise<{ + processed: boolean; + alreadyFinalized?: boolean; + reason?: string; + }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - items: items.map((p) => ({ - id: p.id, - bookingId: p.refId, - amount: p.amount, - currency: p.currency, - method: p.method, - status: p.status, - merchantOrderId: p.merchantOrderId, - paidAt: p.paidAt, - createdAt: p.createdAt, - })), - total, - page, - pageSize, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; - } + } + console.log(`Processing payment succeeded event for intent: }`, intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, + }); + console.log( + `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, + ); - /** Aggregate counts across ALL payments for the dashboard summary cards. */ - async getSummary() { - const rows = await this.paymentRepo - .createQueryBuilder("payment") - .select("payment.status", "status") - .addSelect("COUNT(*)::int", "count") - .groupBy("payment.status") - .getRawMany<{ status: string; count: number }>(); - - const byStatus: Record = {}; - let total = 0; - for (const row of rows) { - byStatus[row.status] = row.count; - total += row.count; - } - - const paidAgg = await this.paymentRepo - .createQueryBuilder("payment") - .select("COALESCE(SUM(payment.amount), 0)", "sum") - .where("payment.status = :status", { status: "success" }) - .getRawOne<{ sum: string }>(); - - return { - total, - success: byStatus["success"] ?? 0, - processing: - (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), - failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), - refunded: byStatus["refunded"] ?? 0, - paidAmount: Number(paidAgg?.sum ?? 0), - }; - } - - /** - * Open a gateway intent for a caller-supplied amount/reference and project it - * locally. Returns the intent id (so billing can correlate the invoice) plus - * the client action. When the provider settles synchronously, the intent is - * marked paid WITHOUT emitting — the caller (billing) settles inline after it - * has stored the intent id, avoiding a settle-before-correlation race. - */ - async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: input.referenceType, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", - failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); - - const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - - const intent = await this.upsertIntent(input, snapshot); - - if (immediateSuccess) { - // Settle the projection but DO NOT notify billing — billing settles - // inline once it has stored intentId on the invoice (see payInvoice), - // avoiding a settle-before-correlation race. - await this.markIntentSucceeded(intent.id, { - providerTxnId: snapshot.providerTxnId, - paidAt, - notify: false, - }); - } - - return { - intentId: intent.id, - // `intent` still reflects the projection status ("processing" on immediate - // success — settlement is applied by the caller, not shown synchronously). - response: this.formatIntentResponse(intent), - immediateSuccess, - providerTxnId: snapshot.providerTxnId, - paidAt, - }; - } - - /** Create or update the local intent projection from a provider snapshot. */ - private async upsertIntent( - input: InitiateIntentInput, - snapshot: PaymentIntentSnapshot, - ): Promise { - const existing = await this.paymentRepo.findOneBy({ - refId: input.referenceId, - }); - - const method: PaymentEntity["method"] = - PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; - const status = - snapshot.status === ProviderPaymentStatus.SUCCEEDED - ? "processing" - : this.toLocalStatus(snapshot.status); - - const clientAction = (snapshot.clientAction ?? undefined) as - | Record - | undefined; - const data = { - status, - method, - merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", - transactionId: snapshot.providerTxnId ?? existing?.transactionId, - expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt, - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }; - - if (existing) { - await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any); - return { ...existing, ...data, clientAction } as PaymentEntity; - } - - return this.paymentRepo.create({ - refId: input.referenceId, - type: input.source, - referenceType: input.referenceType, - amount: input.amountMinor, - currency: input.currency as PaymentEntity["currency"], - reason: input.reason ?? `Payment for ${input.orderRef}`, - rawInitiation: snapshot as unknown as Record, - clientAction: clientAction ?? {}, - ...data, - } as any); - } - - /** - * Reconcile an intent's status with the gateway by reference. Read-only on the - * domain side: it syncs the local projection and, when the provider reports a - * newly-observed success, notifies billing to settle. `referenceId` is opaque - * (the booking id, but this service does not load it). - */ - async getIntentByBookingId(referenceId: string): Promise { - const local = await this.paymentRepo.findOneBy({ refId: referenceId }); - - let snapshot: PaymentIntentSnapshot | null = null; - try { - snapshot = await this.paymentClient.getIntentByReference( - (local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT, - referenceId, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.warn( - `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, - ); - } - - if (!snapshot) { - if (!local) throw new NotFoundException("PaymentIntent not found"); - return this.formatIntentStatus(local); - } - if (!local) throw new NotFoundException("PaymentIntent not found"); - - // Sync local projection with provider-reported status. - const becameSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - - if (becameSuccess) { - await this.markIntentSucceeded(local.id, { - providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, - notify: true, - }); - } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { - await this.paymentRepo.update( - { id: local.id }, - { - status: this.toLocalStatus(snapshot.status), - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }, - ); - } - - const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); - return this.formatIntentStatus(refreshed ?? local); - } - - /** - * Mark a gateway intent paid and (by default) notify billing to settle the - * linked invoice. Idempotent — no-op when already success. Pass `notify: false` - * when the caller settles inline and will trigger settlement itself. - */ - async markIntentSucceeded( - intentId: string, - opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, - ): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; - - const paidAt = opts.paidAt ?? new Date(); - await this.paymentRepo.update( - { id: intent.id }, - { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, + // When the intent references a booking, flip the booking itself paid. + // refId holds the booking id (the domain reference the intent opened with). + if (intent.referenceType === PaymentReferenceType.BOOKING) { + await this.datasource.manager.update( + Booking, + { id: intent.refId }, + { status: "PAID", paymentStatus: "PAID" }, ); - - if (opts.notify !== false) { - await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); - } - - return { alreadyFinalized: false }; + } + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + return { processed: true, alreadyFinalized }; } - async markPaymentFailed(input: { - intentId: string; - failureCode?: string; - failureMessage?: string; - }): Promise { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success" || intent.status === "canceled") return; - - await this.paymentRepo.update( - { id: intent.id }, - { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, - ); - - // Invoice stays open for retry — nothing to settle. Logged only. - this.logger.warn( - `Payment ${intent.id} failed for ${intent.refId}` + - (input.failureMessage ? `: ${input.failureMessage}` : ""), - ); - } - - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { - return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" }); - if (!payment) throw new BadRequestException("No successful payment found for this order"); - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); - - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - return template({ - vendorName: "Ethio Djibouti Railway Freight Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment.method, - subtotal: payment.amount.toString(), - total: payment.amount.toString(), - currency: payment.currency, - reason: payment.reason, - }); - } - - findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id }); - } - - formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { - const clientAction = - intent.clientAction && typeof intent.clientAction === "object" - ? (intent.clientAction as unknown as ClientAction) - : undefined; + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - intentId: intent.id, - status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, - clientAction, - merchantOrderId: intent.merchantOrderId ?? undefined, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; } - private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { - return { - ...this.formatIntentResponse(intent), - paidAt: intent.paidAt?.toISOString(), - failureCode: intent.failerCode ?? undefined, - failureMessage: intent.failureMessage ?? undefined, - }; + return { + processed: false, + reason: `Unknown event type: ${event.eventType}`, + }; + } + + private toLocalStatus( + status: ProviderPaymentStatus, + ): PaymentEntity["status"] { + switch (status) { + case ProviderPaymentStatus.SUCCEEDED: + return "success"; + case ProviderPaymentStatus.FAILED: + return "failed"; + case ProviderPaymentStatus.CANCELLED: + return "canceled"; + case ProviderPaymentStatus.PROCESSING: + return "processing"; + default: + return "action-required"; } + } - async handlePaymentEvent(event: { - eventType: string; - eventId: string; - referenceId: string; - intentId: string; - providerTxnId?: string; - paidAt?: string; - failureCode?: string; - failureMessage?: string; - }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); - if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - console.log(`Processing payment succeeded event for intent: }`,intent); - const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { - providerTxnId: event.providerTxnId, - paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - notify: true, - }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - return { processed: true, alreadyFinalized }; - } - - if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - await this.markPaymentFailed({ - intentId: intent.id, - failureCode: event.failureCode, - failureMessage: event.failureMessage, - }); - return { processed: true }; - } - - return { processed: false, reason: `Unknown event type: ${event.eventType}` }; - } - - private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { - switch (status) { - case ProviderPaymentStatus.SUCCEEDED: return "success"; - case ProviderPaymentStatus.FAILED: return "failed"; - case ProviderPaymentStatus.CANCELLED: return "canceled"; - case ProviderPaymentStatus.PROCESSING: return "processing"; - default: return "action-required"; - } - } - - async findByCompanyId(companyId: string) { - return this.paymentRepo.findByCompanyId(companyId); - } + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } } From 5ad4efd7eb7bd1b5d1d60779ed67df4bbfbe1137 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 11:58:28 +0000 Subject: [PATCH 19/26] feat: add pdf to the central invoice system --- .../src/modules/billing/billing.module.ts | 2 + .../modules/billing/billing.service.spec.ts | 6 + .../src/modules/billing/billing.service.ts | 95 +++++-- .../billing/documents/documents.module.ts | 16 ++ .../documents/invoice-document.service.ts | 179 +++++++++++++ .../billing/documents/pdf-render.service.ts | 160 ++++++++++++ .../modules/billing/invoice-numbering.util.ts | 44 ++++ .../billing/invoice-settlement.util.ts | 36 +++ .../warehouses/warehouse-invoice.service.ts | 240 ++++++------------ .../warehouse-release-document.service.ts | 104 +------- .../modules/warehouses/warehouses.module.ts | 2 + 11 files changed, 618 insertions(+), 266 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/documents/documents.module.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts create mode 100644 apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts create mode 100644 apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 551fae6bf..dc78cd6e9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 5d407d3cd..e52dfafa1 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); }); @@ -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); @@ -194,6 +197,7 @@ describe("BillingService.recordPayment", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); return { service, mg, events }; } @@ -282,6 +286,7 @@ describe("BillingService.settlePayable", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); const settled = await service.settlePayable( @@ -316,6 +321,7 @@ describe("BillingService.settlePayable", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); const settled = await service.settlePayable( diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ce4341486..edacc2c79 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -14,6 +14,12 @@ import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from "./documents/invoice-document.service"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto } from "../payment/payments.dto"; import { CompaniesService } from "../companies/companies.service"; @@ -50,9 +56,6 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Overdue, ]; -/** Round to 2 decimals, avoiding binary float drift. */ -const round2 = (n: number): number => Math.round(n * 100) / 100; - /** A single line to bill on a generated invoice. */ export interface InvoiceLineInput { chargeType: string; @@ -122,6 +125,7 @@ export class BillingService { @Inject(forwardRef(() => PaymentService)) private readonly payment: PaymentService, private readonly companies: CompaniesService, + private readonly invoiceDocuments: InvoiceDocumentService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -142,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). */ @@ -203,17 +270,8 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ - private async nextInvoiceNumber(mg: EntityManager): Promise { - 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")}`; + private nextInvoiceNumber(mg: EntityManager): Promise { + return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" }); } /** @@ -365,10 +423,11 @@ export class BillingService { } const at = input.paidAt ?? new Date(); - const total = Number(invoice.totalAmount); - const paidAmount = round2(Number(invoice.paidAmount) + input.amount); - const balanceAmount = Math.max(0, round2(total - paidAmount)); - const fullyPaid = paidAmount >= total; + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); const status = fullyPaid ? Freight.InvoiceStatus.Paid : Freight.InvoiceStatus.PartiallyPaid; diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts new file mode 100644 index 000000000..c320a5d44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts new file mode 100644 index 000000000..a07087f8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -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, "'"); + 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) => `
${esc(row.label)}${esc(row.value)}
`) + .join(""); + + const itemRows = model.lines + .map( + (item) => ` + ${esc(item.description)} + ${showCategory ? `${esc((item.category ?? "").replace(/_/g, " "))}` : ""} + ${esc(item.quantity ?? 0)} + ${esc(money(item.unitRate, item.currency ?? model.currency))} + ${esc(money(item.amount, item.currency ?? model.currency))} + `, + ) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount))}
`, + ) + .join(""); + + return ` + + + + ${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

+
+
+ Document no. + ${esc(model.documentNumber)} + Issued: ${esc(date(model.issuedAt))} +
+
+
${esc(sealText)}
+
${summaryRows}
+ + + + + ${showCategory ? `` : ""} + + + + + + + ${itemRows} + +
Description${esc(model.categoryHeader)}QtyRateAmount
+
${totalRows}
+ +
+ +`; + } + + safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts new file mode 100644 index 000000000..447bc2516 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -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 = ` +`; + +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 { + 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("")) { + return html.replace("", `${PDF_PRINT_STYLES}`); + } + 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(//gi, "") + .replace(//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"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts new file mode 100644 index 000000000..d36788600 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -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 `-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>; +} + +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 { + 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")}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts new file mode 100644 index 000000000..ab1e27b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -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 }; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 1fe184662..904728251 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,6 +1,12 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from '../billing/documents/invoice-document.service'; +import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util'; +import { applySettlement } from '../billing/invoice-settlement.util'; import { NotificationsService } from '../notifications/notifications.service'; import { WarehouseFeeInvoice, @@ -11,7 +17,6 @@ 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'; interface GenerateOptions { confirmZero?: boolean; @@ -56,7 +61,7 @@ export class WarehouseInvoiceService { private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, - private readonly documents: WarehouseReleaseDocumentService, + private readonly invoiceDocuments: InvoiceDocumentService, private readonly notifications: NotificationsService, ) {} @@ -161,18 +166,12 @@ export class WarehouseInvoiceService { return saved; } - /** WHF-YYYYMMDD-00001 — sequential per day. */ - private async nextInvoiceNumber(): Promise { - 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')}`; + /** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */ + private nextInvoiceNumber(): Promise { + return nextDailyInvoiceNumber(this.dataSource, { + table: 'freight.warehouse_fee_invoices', + code: 'WHF', + }); } // ── Reads ──────────────────────────────────────────────────────────────── @@ -186,12 +185,7 @@ export class WarehouseInvoiceService { 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,11 +193,69 @@ 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 this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); + } + + /** Map a warehouse fee invoice (with display details + items) onto the shared document model. */ + private toDocumentModel( + invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, + kind: 'INVOICE' | 'RECEIPT', + ): InvoiceDocumentModel { + const items = invoice.items as Array<{ + description?: string; + feeType?: string; + quantity?: number; + unitRate?: number; + amount?: number; + currency?: string; + chargeableDays?: number | null; + }>; + const lastPayment = [...(invoice.payments ?? [])].pop(); + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; + return { - filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), + 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: 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) }, + ], }; } @@ -237,10 +289,11 @@ export class WarehouseInvoiceService { 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 { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + dto.amount, + ); const payments = [ ...(invoice.payments ?? []), @@ -248,8 +301,8 @@ export class WarehouseInvoiceService { ]; const updated = await this.invoiceRepository.update(id, { - paidAmount: Math.round(paidAmount * 100) / 100, - balanceAmount: balance, + paidAmount, + balanceAmount, status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, payments, @@ -460,131 +513,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, '''); - 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 ` - - - - Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} - - - -
-
-
-
Ethio-Djibouti Railway S.C.
-

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

-
-
- Document no. - ${esc(invoice.invoiceNumber)} - Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} -
-
-
${esc(sealText)}
-
-
Status${esc(invoice.status.replace(/_/g, ' '))}
-
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
-
Booking reference${esc(details.bookingReference)}
-
Customer${esc(details.customerName)}
-
Inventory reference${esc(details.inventoryReference)}
-
Inventory info${esc(details.inventoryInfo)}
-
Clearance${esc(details.clearanceStatus)}
-
Warehouse${esc(details.warehouseName)}
-
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
-
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
-
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
-
- - - - - - - - - - - - ${items - .map( - (item) => ` - - - - - - `, - ) - .join('')} - -
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
-
-
Subtotal${esc(money(invoice.subtotalAmount))}
-
Tax${esc(money(invoice.taxAmount))}
-
Total${esc(money(invoice.totalAmount))}
-
Paid${esc(money(invoice.paidAmount))}
-
Balance${esc(money(invoice.balanceAmount))}
-
- -
- -`; - } - - private safeFilename(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); - } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index a77a46c29..f8c0dd355 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -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 = ` -`; - @Injectable() export class WarehouseReleaseDocumentService { - private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + constructor(private readonly pdf: PdfRenderService) {} - async htmlToPdfBuffer(html: string): Promise { - 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('')) { - return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); - } - 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 { + return this.pdf.htmlToPdfBuffer(html, { + label: 'Warehouse release', + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml), + }); } private htmlToBasicPdfBuffer(html: string): Buffer { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d880a3554..a7ce68319 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +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'; @@ -70,6 +71,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseFeeInvoice, WarehouseFeeInvoiceItem, ]), + DocumentsModule, FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), From 2bbb37207e5313a67006407d19db962a9936a1c8 Mon Sep 17 00:00:00 2001 From: yonastewabe Date: Tue, 30 Jun 2026 15:16:47 +0300 Subject: [PATCH 20/26] feat: implement automated environment synchronization and conditional CI/CD deployment workflows --- .github/workflows/deploy.yml | 2 +- docker-compose.yaml | 24 +++--- infrastructure/docker/Dockerfile.web | 4 - .../deploy/sync-env-from-server-jenkins.sh | 74 +++++++++++++++++++ scripts/deploy/sync-env-from-server.sh | 25 +------ 5 files changed, 90 insertions(+), 39 deletions(-) create mode 100644 scripts/deploy/sync-env-from-server-jenkins.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78c15cba1..fcd560a95 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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$" diff --git a/docker-compose.yaml b/docker-compose.yaml index a045125bb..5ea74843b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -39,14 +39,14 @@ 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_API_URL must be set} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} secrets: - npmrc ports: - "${FREIGHT_PORTAL_PORT:-5173}:80" - + freight-backoffice: build: context: . @@ -54,14 +54,14 @@ 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_API_URL must be set} + VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} + VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} secrets: - npmrc ports: - "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - + passenger-portal: build: context: . @@ -69,14 +69,14 @@ 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:?NEXT_PUBLIC_API_URL must be set} 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 +84,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:?NEXT_PUBLIC_API_URL must be set} secrets: - npmrc ports: - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" env_file: - apps/edr-passenger-web/backoffice/.env - + payment-api: build: context: . diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 1ccdeb81f..65c26a694 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -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 diff --git a/scripts/deploy/sync-env-from-server-jenkins.sh b/scripts/deploy/sync-env-from-server-jenkins.sh new file mode 100644 index 000000000..74b9a3f13 --- /dev/null +++ b/scripts/deploy/sync-env-from-server-jenkins.sh @@ -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///freight-api.env +# /home/user/environmen///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/.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 \ No newline at end of file diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 795ab25b2..025c518b5 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -7,7 +7,6 @@ # Server layout (one file per service): # /home/user/environmen///freight-api.env # /home/user/environmen///freight-portal.env -# /home/user/environmen///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 \ No newline at end of file From 18e18bd15e0a1354b31c9f69c1123e8cbacf54d6 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:24:09 +0300 Subject: [PATCH 21/26] Update Dockerfile.web --- infrastructure/docker/Dockerfile.web | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index fc5e9ab7e..d5f77061e 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -5,10 +5,6 @@ ARG APP_PATH=apps/edr-freight-web/portal 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 @@ -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/ . From 90f200fdc10b82ac4c445248fd5f8a98a0da271a Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:26:23 +0300 Subject: [PATCH 22/26] Update Dockerfile.passenger-web --- infrastructure/docker/Dockerfile.passenger-web | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/infrastructure/docker/Dockerfile.passenger-web b/infrastructure/docker/Dockerfile.passenger-web index 9d9adac08..61b58736b 100644 --- a/infrastructure/docker/Dockerfile.passenger-web +++ b/infrastructure/docker/Dockerfile.passenger-web @@ -10,12 +10,10 @@ # --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 @@ -24,34 +22,35 @@ 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 --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 From 9f2f1b5138a910e1331b81037cb91de7d5da2ba7 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 30 Jun 2026 15:28:39 +0300 Subject: [PATCH 23/26] Update docker-compose.yaml --- docker-compose.yaml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 5ea74843b..db3da060a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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:?VITE_API_URL must be set} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} + 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:?VITE_API_URL must be set} - VITE_BASE_API_URL: ${VITE_BASE_API_URL:?VITE_BASE_API_URL must be set} - VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:?VITE_USER_MANAGEMENT_BASE must be set} + 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:?NEXT_PUBLIC_API_URL must be set} + 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,7 +79,7 @@ services: args: APP_PACKAGE: "@edr/passenger-backoffice" APP_PATH: apps/edr-passenger-web/backoffice - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?NEXT_PUBLIC_API_URL must be set} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} secrets: - npmrc ports: @@ -105,7 +100,6 @@ services: - "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}" env_file: - apps/edr-payment-api/.env - secrets: npmrc: file: .npmrc From a667f5b2df910706de6558b7bf96c522720fb495 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 15:51:47 +0300 Subject: [PATCH 24/26] add test payment event --- .../payments/payment-events.consumer.ts | 5 + .../outbox/dto/test-payment-event.dto.ts | 92 +++++++++++++++++++ .../src/modules/outbox/outbox.module.ts | 7 ++ .../modules/outbox/test-events.controller.ts | 88 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/test-events.controller.ts diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index 291402240..f13191c48 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -29,6 +29,11 @@ export class PaymentEventsConsumer { }, }) async handle(event: PaymentEvent): Promise { + // 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, diff --git a/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts new file mode 100644 index 000000000..700d64717 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/dto/test-payment-event.dto.ts @@ -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-." }) + @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; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts index eae5515e8..5d8d0cc8a 100644 --- a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -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, diff --git a/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts new file mode 100644 index 000000000..d4396a754 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/test-events.controller.ts @@ -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 }; + } +} From fa3138f2aca8fd6a906ced36b0e4d06b8e065473 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 12:54:04 +0000 Subject: [PATCH 25/26] refactor: migrate the warehouse invoice to use the central one --- apps/edr-freight-api/package.json | 2 +- ...29000000000-CentralizeWarehouseInvoices.ts | 222 +++++++ .../src/modules/billing/billing.service.ts | 29 +- .../warehouse-fee-invoice-item.entity.ts | 50 -- .../entities/warehouse-fee-invoice.entity.ts | 107 ---- .../warehouse-fee-invoice-item.repository.ts | 13 - .../warehouse-fee-invoice.repository.ts | 13 - .../warehouses/warehouse-invoice.service.ts | 600 ++++++++++++------ .../warehouses/warehouse-invoice.types.ts | 88 +++ .../modules/warehouses/warehouses.module.ts | 10 +- apps/edr-freight-api/tsconfig.json | 1 + 11 files changed, 744 insertions(+), 391 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 71e2fd60a..9edf388b9 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts new file mode 100644 index 000000000..dd246cb7d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -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 { + // 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 { + // 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';`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index edacc2c79..e4389e7cf 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,3 +1,4 @@ +import { Freight, PaymentReferenceType } from "@edr/types"; import { BadRequestException, forwardRef, @@ -7,22 +8,21 @@ import { NotFoundException, } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { Freight, PaymentReferenceType } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice, InvoicePayment } from "./entities/invoice.entity"; -import { InvoiceLine } from "./entities/invoice-line.entity"; -import { InvoiceRepository } from "./invoice.repository"; -import { InvoiceLineRepository } from "./invoice-line.repository"; -import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; -import { applySettlement, round2 } from "./invoice-settlement.util"; +import { CompaniesService } from "../companies/companies.service"; +import { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, } from "./documents/invoice-document.service"; -import { PaymentService } from "../payment/payment.service"; -import { InitiateResponseDto } from "../payment/payments.dto"; -import { CompaniesService } from "../companies/companies.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 { @@ -96,6 +96,11 @@ export interface GenerateInvoiceInput { * (default PENDING) stamps `issuedAt`. */ status?: Freight.InvoiceStatus; + /** + * Document number prefix for this source (e.g. `WHF` for warehouse fees); + * defaults to `FRT`. The daily sequence is allocated per prefix. + */ + numberCode?: string; } /** Payload broadcast on `${source}.invoice.`. */ @@ -269,9 +274,9 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ + /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ private nextInvoiceNumber(mg: EntityManager): Promise { - return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" }); + return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); } /** diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts deleted file mode 100644 index 8b14dcea3..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts deleted file mode 100644 index e57d626d5..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts +++ /dev/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; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts deleted file mode 100644 index 5b5df396e..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts +++ /dev/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 { - constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts deleted file mode 100644 index 97328f46d..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts +++ /dev/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 { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; - -@Injectable() -export class WarehouseFeeInvoiceRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 904728251..9b349181d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,22 +1,23 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; +import { BillingService, InvoiceLineInput } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { InvoiceDocumentModel, InvoiceDocumentService, } from '../billing/documents/invoice-document.service'; -import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util'; -import { applySettlement } from '../billing/invoice-settlement.util'; import { NotificationsService } from '../notifications/notifications.service'; +import { WarehouseFeeService } from './warehouse-fee.service'; import { - WarehouseFeeInvoice, + 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'; +} from './warehouse-invoice.types'; interface GenerateOptions { confirmZero?: boolean; @@ -32,9 +33,20 @@ 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; +/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */ +const NUMBER_CODE = 'WHF'; + +/** 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; @@ -50,28 +62,75 @@ export interface InvoiceDocumentDetails { zoneName: string | null; } -export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial; +export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView & + Partial & { 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 feeService: WarehouseFeeService, + private readonly billing: BillingService, private readonly invoiceDocuments: InvoiceDocumentService, + private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, ) {} // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { 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 @@ -80,9 +139,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.', ); @@ -117,9 +183,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.'); } @@ -129,58 +193,77 @@ 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, + numberCode: NUMBER_CODE, }); - 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 (shared billing numbering). */ - private nextInvoiceNumber(): Promise { - return nextDailyInvoiceNumber(this.dataSource, { - table: 'freight.warehouse_fee_invoices', - code: 'WHF', - }); + const detail = await this.findById(invoice.id); + await this.notifyWarehouseFeeIssued(detail); + return detail; } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { - 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 { + 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 { + return this.queryViews('AND i.source_id = $1', [inventoryId]); + } + + listForBooking(bookingId: string): Promise { + return this.queryViews('AND inv.booking_id = $1', [bookingId]); + } + + async findAll( + filter: Partial< + Pick< + WarehouseFeeInvoiceView, + 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + > + >, + ): Promise { + 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 }> { @@ -196,20 +279,219 @@ export class WarehouseInvoiceService { return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); } - /** Map a warehouse fee invoice (with display details + items) onto the shared document model. */ + // ── State changes ──────────────────────────────────────────────────────── + async cancel(id: string): Promise { + 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 (delegates settlement to billing). */ + async pay(id: string, dto: PayInvoiceDto): Promise { + // 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 detail = await this.findById(id); + await this.notifyWarehouseFeePayment(detail, dto); + return detail; + } + + // ── Release blocking ────────────────────────────────────────────────────── + /** Returns the first unpaid invoice that blocks terminal release, or null. */ + async findBlockingInvoice(inventoryId: string): Promise { + 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 { + 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.`, + ); + } + + if (invoices.some((inv) => inv.status === 'PAID')) return; + + const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); + const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + if (payableAmount > 0) { + throw new BadRequestException( + 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + ); + } + } + + // ── Internal: loading & projection ───────────────────────────────────────── + + /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ + private async loadWarehouseInvoice(id: string): Promise { + 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 { + 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 { + 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).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: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, + invoice: WarehouseFeeInvoiceDetail, kind: 'INVOICE' | 'RECEIPT', ): InvoiceDocumentModel { - const items = invoice.items as Array<{ - description?: string; - feeType?: string; - quantity?: number; - unitRate?: number; - amount?: number; - currency?: string; - chargeableDays?: number | null; - }>; const lastPayment = [...(invoice.payments ?? [])].pop(); const date = (value: unknown) => value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; @@ -241,7 +523,7 @@ export class WarehouseInvoiceService { }, ], categoryHeader: 'Fee type', - lines: items.map((item) => ({ + lines: invoice.items.map((item) => ({ description: item.description ?? null, category: item.feeType ?? null, quantity: item.quantity ?? item.chargeableDays ?? 0, @@ -259,92 +541,14 @@ export class WarehouseInvoiceService { }; } - listForInventory(inventoryId: string): Promise { - return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); - } - - listForBooking(bookingId: string): Promise { - return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); - } - - findAll(filter: Partial>): Promise { - const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); - return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); - } - - // ── State changes ──────────────────────────────────────────────────────── - async cancel(id: string): Promise { - 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; - } - - /** Record a payment against the invoice and sync status (links to existing payment flow). */ - async pay(id: string, dto: PayInvoiceDto): Promise { - 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, balanceAmount, fullyPaid } = applySettlement( - invoice.totalAmount, - invoice.paidAmount, - dto.amount, - ); - - 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, - balanceAmount, - status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', - paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, - payments, - }); - const paidInvoice = updated as WarehouseFeeInvoice; - await this.notifyWarehouseFeePayment(paidInvoice, dto); - return paidInvoice; - } - - // ── Release blocking ────────────────────────────────────────────────────── - /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; - } - - async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)); - if (blocking) { - throw new BadRequestException( - `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, - ); - } - - if (invoices.some((inv) => inv.status === 'PAID')) return; - - const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); - const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); - if (payableAmount > 0) { - throw new BadRequestException( - 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', - ); - } - } - - private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise { + /** Warehouse-specific display details, derived from the linked inventory item. */ + private async getInvoiceDocumentDetails(invoice: ViewSource): Promise { 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( @@ -355,16 +559,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 @@ -372,14 +570,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, @@ -391,11 +596,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 { + 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; @@ -417,10 +644,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 @@ -446,9 +672,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 { @@ -472,8 +698,8 @@ export class WarehouseInvoiceService { } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise { + 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; @@ -486,8 +712,8 @@ export class WarehouseInvoiceService { await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); const customerName = contacts.customerName?.trim() || 'Customer'; const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; const statusText = diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts new file mode 100644 index 000000000..e201241ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index a7ce68319..b871d2a36 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,7 @@ 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'; @@ -11,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'; @@ -39,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'; @@ -68,9 +65,8 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, - WarehouseFeeInvoice, - WarehouseFeeInvoiceItem, ]), + BillingModule, DocumentsModule, FilesModule, InterchangeDocumentsModule, @@ -104,8 +100,6 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionRepository, WarehouseAllocationRuleRepository, WarehouseFeeRuleRepository, - WarehouseFeeInvoiceRepository, - WarehouseFeeInvoiceItemRepository, WarehousesService, WarehouseYardsService, WarehouseZonesService, diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index 467c474ee..52598cb95 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, "module": "node16", "moduleResolution": "node16" }, From 0056dec9248f73ce820b1de3cf54d74ec817a549 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 30 Jun 2026 13:28:14 +0000 Subject: [PATCH 26/26] style: clean up the invoice and setup event for warehouse. --- .../modules/billing/billing.service.spec.ts | 2 +- .../src/modules/billing/billing.service.ts | 16 +++---- .../src/modules/payment/payment.controller.ts | 13 +----- .../src/modules/payment/payment.service.ts | 46 ++----------------- .../warehouses/warehouse-invoice.service.ts | 21 +++++++-- 5 files changed, 30 insertions(+), 68 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index e52dfafa1..61597264b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -89,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); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index e4389e7cf..f1b58ad5e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -96,11 +96,6 @@ export interface GenerateInvoiceInput { * (default PENDING) stamps `issuedAt`. */ status?: Freight.InvoiceStatus; - /** - * Document number prefix for this source (e.g. `WHF` for warehouse fees); - * defaults to `FRT`. The daily sequence is allocated per prefix. - */ - numberCode?: string; } /** Payload broadcast on `${source}.invoice.`. */ @@ -665,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, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index b1b269665..50856c3d7 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -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" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 738a6d118..d92af7a3e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,7 +7,6 @@ import { Logger, NotFoundException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -16,7 +15,6 @@ import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { Booking } from "../bookings/entities/booking.entity"; import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { @@ -29,7 +27,6 @@ import { InitiateResponseDto, IntentStatusDto, PaymentPlatformDto, - RefundDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by @@ -96,7 +93,6 @@ export class PaymentService { private readonly logger = new Logger(PaymentService.name); constructor( - private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BillingService)) @@ -404,34 +400,6 @@ export class PaymentService { ); } - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ - refId: dto.bookingId, - type: "booking", - }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "refunded", refundedAt: new Date() }, - ); - await mg.update( - Booking, - { id: dto.bookingId }, - { paymentStatus: "FAILED", status: "CANCELLED" }, - ); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - async getActivePaymentByOrderIdAndMethod( orderId: string, method: PaymentEntity["method"], @@ -527,16 +495,10 @@ export class PaymentService { `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + // The payment service stays domain-agnostic: it settles the intent and + // lets billing settle the invoice (markIntentSucceeded → settleByPaymentId), + // which emits `${source}.invoice.paid`. Per-source advances (booking → PAID, + // warehouse → release, …) live in the domain services that listen for it. return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9b349181d..6f7219781 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,8 +1,9 @@ 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 { BillingService, InvoiceLineInput } from '../billing/billing.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 { @@ -35,8 +36,6 @@ export interface PayInvoiceDto { /** Warehouse fee invoices live in the global billing system under this source. */ const SOURCE = Freight.InvoiceSource.Warehouse; -/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */ -const NUMBER_CODE = 'WHF'; /** Global statuses that still owe money and therefore block terminal release. */ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ @@ -216,7 +215,6 @@ export class WarehouseInvoiceService { currency: billingCurrency, lines, status: Freight.InvoiceStatus.Issued, - numberCode: NUMBER_CODE, }); const detail = await this.findById(invoice.id); @@ -307,6 +305,21 @@ export class WarehouseInvoiceService { 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 { + 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 {