From b7dcc1bf0aed33cf7e0a9b9b5d006f68c2e1f4fb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 5 Aug 2026 12:59:37 +0000 Subject: [PATCH] feat(billing): add PAYMENT_PROCESSING invoice status on payment success redirect (all except CBE bill) --- ...00000000-InvoicePaymentProcessingStatus.ts | 22 ++++++++ .../src/modules/billing/billing.service.ts | 56 +++++++++++++++++++ .../src/modules/payment/payment.controller.ts | 10 ++++ .../src/modules/payment/payment.service.ts | 39 ++++++++++++- .../src/components/customers/badges.tsx | 1 + .../components/warehouses/FeePreviewModal.tsx | 1 + .../src/pages/invoices/InvoicesPage.tsx | 1 + .../src/pages/operations/FirstMilePage.tsx | 1 + .../src/pages/operations/LastMilePage.tsx | 1 + .../src/pages/reports/reportConfigs.ts | 1 + .../warehouses/WarehouseInvoicesPage.tsx | 1 + .../backoffice/src/types/warehouse.ts | 1 + .../portal/src/constants/URLS.ts | 2 + .../portal/src/pages/billing/invoice-ui.tsx | 1 + .../BookingClearanceWorkflowBanner.tsx | 1 + .../src/pages/bookings/booking-display.tsx | 2 + .../src/pages/payments/PaymentSuccessPage.tsx | 14 ++++- .../portal/src/services/payments.service.ts | 5 ++ .../src/modules/cbe-bill/cbe-bill.service.ts | 1 + packages/types/src/freight/index.ts | 2 + 20 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts diff --git a/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts b/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts new file mode 100644 index 000000000..76fee7d22 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3260000000000-InvoicePaymentProcessingStatus.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Adds PAYMENT_PROCESSING to the invoice status enum: the customer completed + * provider checkout (success redirect) and settlement is awaiting the + * provider webhook. + */ +export class InvoicePaymentProcessingStatus3260000000000 + implements MigrationInterface +{ + name = "InvoicePaymentProcessingStatus3260000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PAYMENT_PROCESSING' AFTER 'PENDING'`, + ); + } + + public async down(): Promise { + // Postgres cannot drop an enum value; PAYMENT_PROCESSING stays. Harmless. + } +} 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 bd2355df5..60f173596 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -52,6 +52,8 @@ const DEFAULT_DUE_DAYS = 14; const OPEN_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, + // Success-redirect ack; still unsettled, so it must stay payable/settleable. + Freight.InvoiceStatus.PaymentProcessing, Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; @@ -924,6 +926,26 @@ export class BillingService { }); if (!invoice) return null; + // Reconcile-before-expire, caller-proof: an invoice with a payment intent may + // have settled at the gateway without the webhook landing yet. `paid` — leave + // it open, the (re-emitted) payment.succeeded settles it. `unverifiable` — + // never expire on unknown; the caller's next sweep retries. Invoices with no + // intent (`paymentId` null) were never payable at a gateway and expire directly. + if (invoice.paymentId) { + const { paid, unverifiable } = await this.reconcilePayable( + invoice.sourceId, + ); + if (paid || unverifiable) { + this.logger.warn( + `expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` + + (paid + ? "gateway reconcile found a settled payment" + : "settlement unverifiable at the gateway"), + ); + return null; + } + } + return this.transition( invoice.id, Freight.InvoiceStatus.Expired, @@ -1028,6 +1050,40 @@ export class BillingService { ); } + /** + * Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move + * the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from + * ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and + * is idempotent. Balance untouched: this is a display state, not a + * settlement; settleByPaymentId still performs the real transition. + */ + async markInvoicePaymentProcessing(paymentId: string): Promise { + await this.dataSource.getRepository(Invoice).update( + { + paymentId, + status: In([ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + ]), + }, + { status: Freight.InvoiceStatus.PaymentProcessing }, + ); + } + + /** + * Counterpart of {@link markInvoicePaymentProcessing} for a failed intent: + * PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a + * retry. No-op from any other status. + */ + async revertInvoicePaymentProcessing(paymentId: string): Promise { + await this.dataSource + .getRepository(Invoice) + .update( + { paymentId, status: Freight.InvoiceStatus.PaymentProcessing }, + { status: Freight.InvoiceStatus.Pending }, + ); + } + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** 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 3602657de..b1dafbc85 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -4,6 +4,7 @@ import { HttpStatus, Param, ParseUUIDPipe, + Post, Query, Res, } from "@nestjs/common"; @@ -84,6 +85,15 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } + @Post("redirect-success/:bookingId") + @ApiOperation({ + summary: + "Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", + }) + acknowledgeSuccessRedirect(@Param("bookingId") bookingId: string) { + return this.paymentService.acknowledgeSuccessRedirect(bookingId); + } + @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 99c4aca46..afe6009ef 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -253,8 +253,10 @@ export class PaymentService { payerAccount: input.payerAccount, payerName: input.payerName, expiresAt: input.expiresAt, + // bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING). returnUrl: - input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + input.returnUrl ?? + `https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`, failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", }); @@ -492,6 +494,37 @@ export class PaymentService { return { alreadyFinalized: false }; } + /** + * Success-redirect ack from the portal: the customer finished provider + * checkout, settlement webhook not (necessarily) in yet. Optimistic + * intermediate only — the webhook stays the source of truth. Never + * downgrades: only action-required → processing, and the invoice moves to + * PAYMENT_PROCESSING only from an open unpaid status. CBE_BILL is excluded + * (bank-counter flow, it has no redirect). + */ + async acknowledgeSuccessRedirect( + referenceId: string, + ): Promise<{ acknowledged: boolean }> { + const intent = await this.paymentRepo.findOneBy({ refId: referenceId }); + if (!intent || intent.method === "cbe-bill") { + return { acknowledged: false }; + } + + if (intent.status === "action-required") { + await this.paymentRepo.update( + { id: intent.id, status: "action-required" }, + { status: "processing" }, + ); + } + // Even if the intent already advanced (e.g. webhook raced the redirect to + // "processing"), the invoice ack is idempotent and status-guarded. + if (intent.status === "action-required" || intent.status === "processing") { + await this.billing.markInvoicePaymentProcessing(intent.id); + return { acknowledged: true }; + } + return { acknowledged: false }; + } + async markPaymentFailed(input: { intentId: string; failureCode?: string; @@ -510,7 +543,9 @@ export class PaymentService { }, ); - // Invoice stays open for retry — nothing to settle. Logged only. + // Invoice stays open for retry — nothing to settle. A redirect-acked + // PAYMENT_PROCESSING invoice is put back to PENDING so it reads payable. + await this.billing.revertInvoicePaymentProcessing(intent.id); this.logger.warn( `Payment ${intent.id} failed for ${intent.refId}` + (input.failureMessage ? `: ${input.failureMessage}` : ""), diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 112026ca6..046493b0f 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -249,6 +249,7 @@ const INVOICE_STATUS_COLOR: Record = { DRAFT: "gray", ISSUED: "cyan", PENDING: "yellow", + PAYMENT_PROCESSING: "indigo", PARTIALLY_PAID: "orange", PAID: "edr-green", OVERDUE: "red", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index d3c8e1897..cb2cbc658 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -14,6 +14,7 @@ import { openPdfBlob } from './pdf'; const INVOICE_STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', + PAYMENT_PROCESSING: 'indigo', PARTIALLY_PAID: 'yellow', PAID: 'edr-green', CANCELLED: 'gray', diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 7eca77ddf..fd839de16 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -183,6 +183,7 @@ export default function InvoicesPage() { data={[ { label: "All", value: "all" }, { label: "Pending", value: "PENDING" }, + { label: "Payment processing", value: "PAYMENT_PROCESSING" }, { label: "Paid", value: "PAID" }, { label: "Overdue", value: "OVERDUE" }, ]} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 835d1a82f..192a748c3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -79,6 +79,7 @@ const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, PENDING: { label: "Pending", color: "yellow" }, + PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" }, UNPAID: { label: "Unpaid", color: "yellow" }, OPEN: { label: "Open", color: "yellow" }, ISSUED: { label: "Issued", color: "blue" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 33e9bbed8..f402f1792 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -86,6 +86,7 @@ const INVOICE_STATUS_META: Record = { PAID: { label: "Paid", color: "green" }, PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, PENDING: { label: "Pending", color: "yellow" }, + PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" }, UNPAID: { label: "Unpaid", color: "yellow" }, OPEN: { label: "Open", color: "yellow" }, ISSUED: { label: "Issued", color: "blue" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts index 9aec4fec0..ced916fbb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts +++ b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts @@ -75,6 +75,7 @@ const CONTRACT_STATUSES = [ const INVOICE_STATUSES = [ "ISSUED", "PENDING", + "PAYMENT_PROCESSING", "PARTIALLY_PAID", "PAID", "OVERDUE", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 92f807363..c8e3d111d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -42,6 +42,7 @@ import { extractErrorMessage } from '@/components/warehouses/options'; const STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', + PAYMENT_PROCESSING: 'indigo', PARTIALLY_PAID: 'yellow', PAID: 'edr-green', CANCELLED: 'gray', diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 8ff87c251..6b11d4dcd 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -907,6 +907,7 @@ export interface AllocationCriteria { export const WAREHOUSE_INVOICE_STATUSES = [ 'DRAFT', 'ISSUED', + 'PAYMENT_PROCESSING', 'PARTIALLY_PAID', 'PAID', 'CANCELLED', diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 22421400f..f050818ed 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -194,6 +194,8 @@ export const URL_CONSTANTS = { PAYMENTS: { INITIATE: "/api/payments/initiate", INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`, + REDIRECT_SUCCESS: (bookingId: string) => + `/api/payments/redirect-success/${bookingId}`, CHECKOUT: "/api/payments/checkout", }, diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx index dac9cb18c..39cdc5333 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx @@ -21,6 +21,7 @@ const STATUS_STYLE: Record< [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" }, [Freight.InvoiceStatus.Issued]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" }, [Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" }, + [Freight.InvoiceStatus.PaymentProcessing]: { label: "Payment processing", bg: "#EAF1FB", fg: "#2563EB" }, [Freight.InvoiceStatus.PartiallyPaid]: { label: "Partially paid", bg: "#FEF9E7", fg: "#A16207" }, [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" }, [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx index 6e8546dd8..ad188a206 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx @@ -41,6 +41,7 @@ const INVOICE_STATUS_LABELS: Record = { DRAFT: "Draft", ISSUED: "Issued", PENDING: "Due", + PAYMENT_PROCESSING: "Payment processing", PARTIALLY_PAID: "Partially paid", PAID: "Paid", OVERDUE: "Overdue", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index 7dc1109eb..134eae3b5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -120,6 +120,7 @@ const PAYMENT_COLORS: Record = { // Backend emits the long form on some flows; keep the short alias too. VERIFICATION_IN_PROGRESS: "yellow", PAYMENT_VERIFICATION_IN_PROGRESS: "yellow", + PAYMENT_PROCESSING: "yellow", OVERDUE: "red", REFUNDED: "blue", CANCELLED: "gray", @@ -132,6 +133,7 @@ const PAYMENT_LABELS: Record = { PNR_GENERATED: "PNR generated", VERIFICATION_IN_PROGRESS: "Verifying", PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying", + PAYMENT_PROCESSING: "Payment processing", OVERDUE: "Overdue", REFUNDED: "Refunded", CANCELLED: "Cancelled", diff --git a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx index 93a040191..d0bb9c348 100644 --- a/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/payments/PaymentSuccessPage.tsx @@ -8,10 +8,22 @@ import { ThemeIcon, } from "@mantine/core"; import { CheckCircle2, FileText, Home } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { paymentsService } from "@/services/payments.service"; export default function PaymentSuccessPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const bookingId = searchParams.get("bookingId"); + + // Fire-and-forget ack: payment → processing, invoice → PAYMENT_PROCESSING. + // The provider webhook remains the source of truth for the final PAID state. + useEffect(() => { + if (bookingId) { + paymentsService.acknowledgeSuccessRedirect(bookingId).catch(() => {}); + } + }, [bookingId]); return ( => { + await client.post(P.REDIRECT_SUCCESS(bookingId)); + }, + checkoutUrl: buildCheckoutUrl, checkoutUrlForInvoice: buildCheckoutUrlForInvoice, }; diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 5b1a6cb28..9f60ab910 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -107,6 +107,7 @@ export class CbeBillService { consented_on: Math.floor(Date.now() / 1000), }; } + /* ------------------------------------------------------------------ query */ diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 37997ea18..d99dc07f8 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -161,6 +161,8 @@ export enum InvoiceStatus { /** Issued and awaiting payment (alias of PENDING for fee invoices). */ Issued = "ISSUED", Pending = "PENDING", + /** Customer completed provider checkout (success redirect); awaiting webhook confirmation. */ + PaymentProcessing = "PAYMENT_PROCESSING", /** Some, but not all, of the balance has been settled. */ PartiallyPaid = "PARTIALLY_PAID", Paid = "PAID",