From 6dde17fa4dc1cf53a7d5d1c6086cea0b91a4ccc0 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 08:38:49 +0000 Subject: [PATCH 01/16] fix: premature payable invoice --- .../modules/billing/billing.service.spec.ts | 93 +++++++++++++++++++ .../src/modules/billing/billing.service.ts | 59 +++++++++--- .../bookings/booking-transition.service.ts | 23 +++-- .../booking-batch.service.spec.ts | 8 +- .../train-scheduling/booking-batch.service.ts | 10 +- 5 files changed, 167 insertions(+), 26 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 037957367..e7682879b 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 @@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction", expect(result).toBeNull(); expect(transaction).not.toHaveBeenCalled(); }); + + it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => { + const { service, defaultManager } = build({ + ...openInvoice, + status: Freight.InvoiceStatus.Draft, + }); + + await service.expirePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "prepaid", + ); + + const { where } = defaultManager.findOne.mock.calls[0][1]; + expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); + }); +}); + +describe("BillingService.issuePayable", () => { + const dueAt = new Date("2026-01-02T00:00:00.000Z"); + + const build = (found: Record | null) => { + const manager = { + findOne: jest.fn().mockResolvedValue(found), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { manager, transaction: jest.fn() } as never, + {} as never, + {} as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + ); + return { service, manager }; + }; + + const issue = (service: BillingService) => + service.issuePayable( + Freight.InvoiceSource.Booking, + "booking-1", + dueAt, + "PREPAID", + ); + + it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => { + const { service, manager } = build({ + id: "inv-1", + invoiceNumber: "INV-20260101-00001", + status: Freight.InvoiceStatus.Draft, + issuedAt: null, + }); + + const result = await issue(service); + + const patch = manager.update.mock.calls[0][2]; + expect(patch.status).toBe(Freight.InvoiceStatus.Pending); + expect(patch.dueAt).toBe(dueAt); + expect(patch.issuedAt).toBeInstanceOf(Date); + expect(result?.status).toBe(Freight.InvoiceStatus.Pending); + }); + + it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => { + const { service, manager } = build(null); + + await issue(service); + + const { where } = manager.findOne.mock.calls[0][1]; + expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); + }); + + it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => { + const issuedAt = new Date("2026-01-01T00:00:00.000Z"); + const { service, manager } = build({ + id: "inv-1", + invoiceNumber: "INV-20260101-00001", + status: Freight.InvoiceStatus.Pending, + issuedAt, + }); + + const result = await issue(service); + + expect(manager.update.mock.calls[0][2]).toEqual({ dueAt }); + expect(result?.issuedAt).toBe(issuedAt); + }); + + it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => { + const { service, manager } = build(null); + + await expect(issue(service)).resolves.toBeNull(); + expect(manager.update).not.toHaveBeenCalled(); + }); }); 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 3b14d6f4c..4d647e3ef 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -826,8 +826,15 @@ export class BillingService { * Expire a source's currently-open invoice (its pay window closed before * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice * and transitions it to EXPIRED — a terminal, non-payable status (kept out of - * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice - * (already paid/cancelled/expired). + * `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to + * retire (already paid/cancelled/expired). + * + * DRAFT invoices are matched too, even though they were never issued: this is + * also the "retire the invoice this source no longer needs" path (a cancelled + * booking, or a full-amount invoice superseded by a partial-offer one). Skipping + * drafts would leave the stale one behind for `findPayable` to hand back — the + * superseding invoice would then never be minted, and a cancelled booking would + * keep a draft that a later `issuePayable` could still make payable. * * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in * the batch engine) to enlist in its DB transaction. @@ -850,7 +857,7 @@ export class BillingService { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, @@ -867,30 +874,58 @@ export class BillingService { } /** - * Sync a source's open invoice `dueAt` to its real pay-window deadline. The - * booking invoice is generated before the pay window opens (at booking - * creation/approval), so its printed due date is refreshed when the batch engine - * sets `paymentDeadline`. No-op when the source has no open invoice. + * Issue a source's invoice and stamp its real pay-window deadline — the single + * transition that makes a source payable. + * + * A source's invoice is minted DRAFT, before any pay window exists (e.g. a + * booking invoice is generated at creation / operation-accept, long before the + * batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`, + * so such an invoice is not settleable and the portal renders no pay button. + * The domain calls this at the moment the pay window actually opens (booking → + * `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues + * the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`. + * + * Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so + * a re-reserve never re-issues. No-op (returns null) when the source has no + * draft-or-open invoice (already paid/cancelled/expired). */ - async syncPayableDueDate( + async issuePayable( source: Freight.InvoiceSource, sourceId: string, dueAt: Date, type?: string, manager?: EntityManager, - ): Promise { + ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); - if (!invoice) return; - await mg.update(Invoice, { id: invoice.id }, { dueAt }); + if (!invoice) return null; + + const issuing = invoice.status === Freight.InvoiceStatus.Draft; + const patch = { + dueAt, + ...(issuing + ? { + status: Freight.InvoiceStatus.Pending, + issuedAt: invoice.issuedAt ?? new Date(), + } + : {}), + }; + await mg.update(Invoice, { id: invoice.id }, patch); + + if (issuing) { + this.logger.log( + `Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`, + ); + } + return { ...invoice, ...patch } as Invoice; } /** diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 69d505c3b..2aed86027 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -33,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; - -import { Freight } from "@edr/types"; import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() @@ -1112,14 +1110,25 @@ export class BookingTransitionService { await this.bookingBatchService.pickExportSchedule(booking); } + // Mint the booking's invoice (DRAFT) so the priced order carries its billing + // record from accept onward. It is deliberately NOT issued here: accepting an + // operation only puts the booking in the batch holding pool — no slot has been + // offered and no pay window exists yet. Issuing at this point made the invoice + // payable straight away (portal invoice list/detail gate on invoice status + // alone), letting a customer pay before being selected for a batch, while the + // booking page correctly still showed it as not payable. The batch engine + // issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window + // and the real deadline are created — matching the portal's `canPay` gate. const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( - `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, - ); - await this.invoiceService.updateStatus( - invoice.id, - Freight.InvoiceStatus.Pending, + `Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`, ); + // TODO: road (truck) orders are an incomplete feature — they stop at the + // dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no + // per-km pricing wired via roadKmPrice, no pay surface in the portal). They + // skip the train batch, so they never reach `reserve` and their invoice stays + // DRAFT / unpayable. When the road flow is built, issue its invoice + // (billing.issuePayable) at whatever transition opens the road pay window. if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { status: "ROAD_DISPATCH_PENDING", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index c37430121..8c3c92193 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => { { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { - syncPayableDueDate: jest.fn().mockResolvedValue(undefined), + issuePayable: jest.fn().mockResolvedValue(null), expirePayable: jest.fn().mockResolvedValue(undefined), } as never, { emitPhase: jest.fn() } as never, @@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 18e1c0fc7..ef39ab138 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -2317,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit { paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; - // The invoice was generated at booking creation/approval, before this pay - // window opened — refresh its printed due date to the real deadline. - await this.billing.syncPayableDueDate( + // The invoice was generated DRAFT at booking creation / operation-accept, + // before this pay window existed. Reserving is the moment the booking becomes + // payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and + // print the deadline as its due date — never earlier, or the customer could + // settle an invoice for a slot they have not been offered yet. Idempotent: a + // re-reserve only refreshes `dueAt`. + await this.billing.issuePayable( Freight.InvoiceSource.Booking, booking.id, deadline, From a852b4e619b1ab3d673e02e292c00fb37522d8c2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 11:43:04 +0300 Subject: [PATCH 02/16] Seats report, supplementary change for bookings added --- .../migration.sql | 33 ++ apps/edr-passenger-api/prisma/schema.prisma | 23 + .../modules/payments/payments.controller.ts | 110 +++- .../src/modules/payments/payments.module.ts | 7 +- .../src/modules/payments/payments.service.ts | 35 +- .../payments/supplementary-charges.service.ts | 193 +++++++ .../payments/SupplementaryChargesModal.tsx | 290 +++++++++++ .../backoffice/src/app/payments/page.tsx | 11 +- .../app/payments/useSupplementaryCharges.ts | 47 ++ .../backoffice/src/app/reports/page.tsx | 469 +++++++++++------- .../src/app/reports/seats/layout.tsx | 3 + .../backoffice/src/app/reports/seats/page.tsx | 324 ++++++++++++ .../src/components/layout/Sidebar.tsx | 3 +- .../backoffice/src/lib/api/index.ts | 19 + packages/types/src/common/payments.ts | 1 + 15 files changed, 1392 insertions(+), 176 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/seats/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql new file mode 100644 index 000000000..fce916917 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "SupplementaryCharge" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "paymentToken" TEXT NOT NULL, + "providerTxnId" TEXT, + "notes" TEXT, + "createdBy" TEXT NOT NULL, + "paidAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SupplementaryCharge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SupplementaryCharge_paymentToken_key" ON "SupplementaryCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_bookingId_idx" ON "SupplementaryCharge"("bookingId"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_paymentToken_idx" ON "SupplementaryCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_status_idx" ON "SupplementaryCharge"("status"); + +-- AddForeignKey +ALTER TABLE "SupplementaryCharge" ADD CONSTRAINT "SupplementaryCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 2721bb156..cef7d0033 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -567,6 +567,7 @@ model Booking { cancellation BookingCancellation? baggage BaggageBooking[] excessBaggageCharges ExcessBaggageCharge[] + supplementaryCharges SupplementaryCharge[] journey Journey? @@index([passengerId, status]) @@ -1234,6 +1235,28 @@ model BaggageBooking { @@schema("passenger") } +model SupplementaryCharge { + id String @id @default(uuid()) + bookingId String + reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION" + amountMinor Int + currency String @default("ETB") + status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED + paymentToken String @unique @default(uuid()) + providerTxnId String? + notes String? + createdBy String + paidAt DateTime? + expiresAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId]) + @@index([paymentToken]) + @@index([status]) + @@schema("passenger") +} + model ExcessBaggageCharge { id String @id @default(uuid()) bookingId String diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 50cd99ec4..9a0288656 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -39,12 +39,34 @@ import { import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; +import { SupplementaryChargesService } from "./supplementary-charges.service"; +import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +class CreateSupplementaryChargeDto { + @ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string; + @ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number; + @ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string; + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} + +class WaiveSupplementaryChargeDto { + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} + +class PaySupplementaryChargeDto { + @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; + @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile'; +} @ApiTags("Payment") @Controller("payments") // @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PaymentsController { - constructor(private service: PaymentsService) {} + constructor( + private service: PaymentsService, + private supplementaryService: SupplementaryChargesService, + ) {} @Delete(":id") @PassengerStaff([PASSENGER_PERMS.admin]) @@ -315,6 +337,92 @@ export class PaymentsController { } } + // ── Supplementary Charges ────────────────────────────────────────────────── + + @Post('supplementary') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) + createSupplementaryCharge( + @Body() dto: CreateSupplementaryChargeDto, + @Headers('x-iam-user-id') iamUserId?: string, + ) { + return this.supplementaryService.create({ + ...dto, + createdBy: iamUserId ?? 'staff', + }); + } + + @Get('supplementary') + @PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List supplementary charges (staff only)' }) + @ApiQuery({ name: 'bookingRef', required: false }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + listSupplementaryCharges( + @Query('bookingRef') bookingRef?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.supplementaryService.getAll({ + bookingRef, + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, + }); + } + + @Get('supplementary/by-token/:token') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' }) + getSupplementaryByToken(@Param('token') token: string) { + return this.supplementaryService.getByToken(token); + } + + @Post('supplementary/by-token/:token/pay') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' }) + paySupplementaryCharge( + @Param('token') token: string, + @Body() dto: PaySupplementaryChargeDto, + ) { + return this.supplementaryService.pay(token, dto.method, dto.platform); + } + + @Post('supplementary/:id/mark-paid') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' }) + markSupplementaryPaid( + @Param('id') id: string, + @Body() body: { providerTxnId?: string }, + ) { + return this.supplementaryService.markPaid(id, body.providerTxnId); + } + + @Post('supplementary/:id/waive') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Waive a supplementary charge (staff only)' }) + waiveSupplementaryCharge( + @Param('id') id: string, + @Body() dto: WaiveSupplementaryChargeDto, + @Headers('x-iam-user-id') iamUserId?: string, + ) { + return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff'); + } + + @Post('supplementary/:id/resend') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' }) + resendSupplementaryLink(@Param('id') id: string) { + return this.supplementaryService.resendLink(id); + } + private buildRedirectHtml(url: string): string { const escaped = url.replace(/\"/g, """); return ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 3c08eb3cd..6e4be1aa0 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -12,6 +12,7 @@ import { } from "@edr/types"; import { PaymentsController } from "./payments.controller"; import { PaymentsService } from "./payments.service"; +import { SupplementaryChargesService } from "./supplementary-charges.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; import { PaymentEventsConsumer } from "./payment-events.consumer"; @@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module"; import { CurrencyModule } from "../currency/currency.module"; import { AuditModule } from "../../common/audit.module"; +import { NotificationsModule } from "../notifications/notifications.module"; + const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; function rabbitMQImport(): DynamicModule[] { @@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] { TicketsModule, CurrencyModule, AuditModule, - // The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an - // OTP and can take tens of seconds). Keep this hop generous; overridable via env. + NotificationsModule, HttpModule.register({ timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, }), @@ -65,6 +67,7 @@ function rabbitMQImport(): DynamicModule[] { controllers: [PaymentsController, InternalPaymentsController], providers: [ PaymentsService, + SupplementaryChargesService, PaymentClientService, PaymentEventsConsumer, ServiceAuthGuard, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5bd068255..78856ccd5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -833,19 +833,46 @@ export class PaymentsService { return { alreadyFinalized: false }; } + private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise { + if (event.eventType === 'payment.failed') { + this.logger.warn(`supplementary charge ${event.referenceId} payment failed`); + return { processed: true }; + } + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } }); + if (!charge) { + this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`); + return { processed: false, reason: 'charge-not-found' }; + } + if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true }; + await this.prisma.supplementaryCharge.update({ + where: { id: charge.id }, + data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } }); + return { processed: true }; + } + async handlePaymentEvent( event: PaymentEventDto, ): Promise { - if ( - event.service !== PaymentServiceEnum.PASSENGER || - event.referenceType !== PaymentReferenceType.BOOKING - ) { + if (event.service !== PaymentServiceEnum.PASSENGER) { this.logger.warn( `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`, ); return { processed: false, reason: "foreign-reference" }; } + if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) { + return this.handleSupplementaryChargeEvent(event); + } + + if (event.referenceType !== PaymentReferenceType.BOOKING) { + this.logger.warn( + `mark-paid: ignoring unknown referenceType ${event.referenceType}`, + ); + return { processed: false, reason: "foreign-reference" }; + } + if (event.eventType === "payment.failed") { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: event.referenceId }, diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts new file mode 100644 index 000000000..8b604d905 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -0,0 +1,193 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; +import { EmailClientService } from '../notifications/email-client.service'; +import { PaymentClientService } from './payment-client.service'; +import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; + +const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours + +@Injectable() +export class SupplementaryChargesService { + private readonly logger = new Logger(SupplementaryChargesService.name); + + constructor( + private prisma: PrismaService, + private auditService: AuditService, + private smsClient: SmsClientService, + private emailClient: EmailClientService, + private paymentClient: PaymentClientService, + ) {} + + async create(dto: { + bookingRef: string; + amountMinor: number; + reason: string; + notes?: string; + createdBy: string; + }) { + const booking = await this.prisma.booking.findUnique({ + where: { bookingRef: dto.bookingRef }, + include: { passenger: { include: { user: true } } }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { + throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge'); + } + if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive'); + + const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); + const charge = await this.prisma.supplementaryCharge.create({ + data: { + bookingId: booking.id, + reason: dto.reason, + amountMinor: dto.amountMinor, + notes: dto.notes ?? null, + createdBy: dto.createdBy, + expiresAt, + }, + }); + + const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; + const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + await this.sendLink(charge, booking.bookingRef, phone, email); + + await this.auditService.log({ + action: 'CREATE', + entityType: 'SupplementaryCharge', + entityId: charge.id, + newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason }, + }); + return charge; + } + + async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) { + const { bookingRef, status, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + const where: any = {}; + if (status) where.status = status; + if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } }; + + await this.prisma.supplementaryCharge.updateMany({ + where: { status: 'PENDING', expiresAt: { lt: new Date() } }, + data: { status: 'EXPIRED' }, + }); + + const [items, total] = await Promise.all([ + this.prisma.supplementaryCharge.findMany({ + where, + include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.supplementaryCharge.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async getByToken(token: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { paymentToken: token }, + include: { booking: { select: { bookingRef: true } } }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid'); + if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived'); + if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) { + if (charge.status === 'PENDING') { + await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } }); + } + throw new BadRequestException('This payment link has expired'); + } + return charge; + } + + async markPaid(id: string, providerTxnId?: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') return charge; + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } }); + return updated; + } + + async pay(token: string, method: string, platform?: 'web' | 'mobile') { + const charge = await this.getByToken(token); // validates status/expiry + + const paymentMethod = method as ProviderMethod; + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const returnUrl = `${portalUrl}/pay-balance/${token}/success`; + const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; + + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE, + referenceId: charge.id, + orderRef: `SC-${charge.id.substring(0, 8)}`, + amountMinor: charge.amountMinor, + currency: charge.currency, + provider: paymentMethod, + platform, + returnUrl, + failureUrl, + }); + + return snapshot; + } + + async waive(id: string, notes: string, waivedBy: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge'); + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { status: 'WAIVED', notes }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } }); + return updated; + } + + async resendLink(id: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { id }, + include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } }, + }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges'); + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, + }); + await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail); + return { sent: true }; + } + + private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) { + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`; + const amount = (charge.amountMinor / 100).toFixed(2); + const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`; + + if (phone) { + try { await this.smsClient.sendSms({ to: phone, message: msg }); } + catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); } + } + if (email) { + try { + await this.emailClient.sendEmail({ + to: email, + subject: `EDR — Outstanding balance for booking ${bookingRef}`, + text: msg, + }); + } catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); } + } + if (!phone && !email) { + this.logger.warn(`No contact info for supplementary charge ${charge.id}`); + } + } +} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx new file mode 100644 index 000000000..9fb0de6bb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState } from 'react'; +import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; +import Badge from '@/components/ui/Badge'; +import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { + useSupplementaryCharges, + useCreateSupplementaryCharge, + useMarkSupplementaryPaid, + useWaiveSupplementaryCharge, + useResendSupplementaryLink, +} from './useSupplementaryCharges'; + +type Tab = 'create' | 'list'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; + +const STATUS_COLORS: Record = { + PENDING: 'warning', + PAID: 'success', + WAIVED: 'info', + EXPIRED: 'error', +}; + +export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { + const [tab, setTab] = useState('create'); + const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' }); + + // Create form state + const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + const [formError, setFormError] = useState(null); + const [createSuccess, setCreateSuccess] = useState(null); + + const { data: chargesData, isLoading } = useSupplementaryCharges(listFilters); + const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []); + + const createMutation = useCreateSupplementaryCharge(() => { + setCreateSuccess(`Charge created and payment link sent.`); + setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + setFormError(null); + setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000); + }); + + const markPaidMutation = useMarkSupplementaryPaid(); + const waiveMutation = useWaiveSupplementaryCharge(); + const resendMutation = useResendSupplementaryLink(); + + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + + const flash = (msg: string) => { + setActionSuccess(msg); + setTimeout(() => setActionSuccess(null), 3000); + }; + + const handleCreate = async () => { + setFormError(null); + const amountMinor = Math.round(parseFloat(form.amountEtb) * 100); + if (!form.bookingRef.trim()) return setFormError('Booking reference is required'); + if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount'); + try { + await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined }); + } catch (e: any) { + setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge'); + } + }; + + const handleMarkPaid = async (id: string) => { + setActionError(null); + try { + await markPaidMutation.mutateAsync({ id }); + flash('Marked as paid'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleWaive = async (id: string) => { + setActionError(null); + try { + await waiveMutation.mutateAsync({ id }); + flash('Charge waived'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleResend = async (id: string) => { + setActionError(null); + try { + await resendMutation.mutateAsync(id); + flash('Payment link resent'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + return ( + + {/* Tabs */} +
+ {(['create', 'list'] as Tab[]).map((t) => ( + + ))} +
+ + {/* ── CREATE TAB ── */} + {tab === 'create' && ( +
+ {createSuccess && ( +
✓ {createSuccess}
+ )} + {formError && ( +
{formError}
+ )} + +
+
+ + setForm({ ...form, bookingRef: e.target.value })} + /> +
+
+ + setForm({ ...form, amountEtb: e.target.value })} + /> +
+
+ + +
+
+ +