From 35c53e3f36ea47beb70d58410e56e7adf96bd841 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 16 Jul 2026 20:03:35 +0000 Subject: [PATCH] changes --- .../src/modules/billing/billing.service.ts | 28 ++++----- .../bookings/booking-invoice.service.ts | 15 ++++- .../contracts/clearance-fee.service.ts | 15 +++++ .../contracts/contract-transition.service.ts | 8 +++ .../src/modules/intents/intents.service.ts | 63 +++++++++++++++---- 5 files changed, 99 insertions(+), 30 deletions(-) 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 0f60876d4..774234000 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1016,7 +1016,7 @@ export class BillingService { // 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.replace("-", "_"), + orderRef: invoice.invoiceNumber.replace(/-/g, "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, @@ -1032,20 +1032,18 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); - // DEMO: manually fire the gateway `payment.succeeded` callback here, without - // waiting for real gateway settlement. Runs AFTER the paymentId link above so - // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: - // remove — real settlement flips this via the `${source}.invoice.paid` handler. - if (!result.immediateSuccess) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); + // billing must not simulate it. Kept commented for local demos only. + // if (!result.immediateSuccess) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index b0a3ad766..3d8222d63 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -16,6 +16,7 @@ import { InvoiceLineInput, } from "../billing/billing.service"; import { Invoice } from "../billing/entities/invoice.entity"; +import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service"; import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; @@ -120,17 +121,27 @@ export class BookingInvoiceService { } /** - * Expire the booking's currently-open prepaid invoice when the booking is + * Expire the booking's currently-open invoices (freight PREPAID and the + * per-shipment clearance fee) when the booking is * cancelled or rejected — the counterpart to the pay-window-expiry path * (which also calls {@link BillingService.expirePayable}). Stops a terminated * booking from leaving a payable invoice open. No-op when the booking has no * open invoice (never invoiced, already paid/cancelled/expired). Pass a * caller `manager` to enlist in its transaction. */ - expireOpenInvoices( + async expireOpenInvoices( bookingId: string, manager?: EntityManager, ): Promise { + // The per-shipment clearance fee (GENERAL contracts) bills this same booking + // id under its own source/type — retire it alongside the freight invoice, or + // a cancelled shipment keeps a payable clearance invoice open. + await this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + bookingId, + CLEARANCE_BOOKING_INVOICE_TYPE, + manager, + ); return this.billing.expirePayable( Freight.InvoiceSource.Booking, bookingId, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts index 43ab9d22a..8de7aed87 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts @@ -161,6 +161,21 @@ export class ClearanceFeeService { return invoice; } + /** + * Retire (idempotently) the unpaid contract-level fee invoice when the + * contract reaches a terminal state — a dead contract must not leave a + * payable clearance invoice open for the customer to settle. No-op when the + * fee was already paid or never invoiced (mirrors the booking cancel path, + * {@link BillingService.expirePayable}). + */ + async expireForContract(contractId: string): Promise { + return this.billing.expirePayable( + Freight.InvoiceSource.Clearance, + contractId, + CLEARANCE_CONTRACT_INVOICE_TYPE, + ); + } + /** * Settlement branch point for `clearance`-source invoices: unlock the * document-upload step the fee was gating. Idempotent — a replayed event on diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 60db03ff1..4eede9352 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -419,6 +419,10 @@ export class ContractTransitionService { actorId, 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -457,6 +461,10 @@ export class ContractTransitionService { 'STAFF', ); + // Stop the open-invoice leak: a rejected contract must not leave a payable + // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). + await this.clearanceFeeService.expireForContract(contractId); + await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index a14469dd1..c601395e8 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -96,9 +96,20 @@ export class IntentsService { `intent ${existing.id} retired (METHOD_CHANGED ${existing.provider} → ${request.provider}) for ` + `${request.service}/${request.referenceType}/${request.referenceId}`, ); + } else if ( + existing.status === ProviderPaymentStatus.REQUIRES_ACTION + ) { + // Same provider, payer re-initiated while a session is open (back button, + // abandoned checkout). Provider sessions are single-use, so re-serving the + // old clientAction hands the payer a dead checkout. Verify at the provider, + // then supersede: paid/processing intents are adopted, unpaid ones retired + // so a fresh session opens below. + const settled = await this.verifyThenSupersede(existing); + if (settled) return this.toSnapshot(settled); } else { - const reusable = await this.reuseOrRetire(existing); - if (reusable) return this.toSnapshot(reusable); + // PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen — + // return the existing intent so the caller adopts its outcome. + return this.toSnapshot(existing); } } @@ -261,24 +272,50 @@ export class IntentsService { } /** - * Decide whether an existing active intent can be returned as-is. An expired - * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) - * so a fresh provider session can be opened. + * Re-initiate guard for an open REQUIRES_ACTION intent on the same provider. + * Queries the provider first — the payer may have paid on the old session with + * the webhook still in flight. Paid/processing answers are applied through the + * state machine and the intent is returned for reuse. Anything still unpaid is + * retired (CANCELLED, no notification — nothing was paid; a payment.failed here + * would wrongly fail the domain order mid-retry) and null is returned so the + * caller opens a fresh provider session. When the status query itself errors, + * the existing intent is reused unchanged: superseding blind could leave two + * live sessions and a double charge. */ - private async reuseOrRetire( + private async verifyThenSupersede( intent: PaymentIntent, ): Promise { - const expired = - intent.status === ProviderPaymentStatus.REQUIRES_ACTION && - intent.expiresAt != null && - intent.expiresAt.getTime() < Date.now(); - if (!expired) return intent; + let status: ProviderStatus; + try { + status = await this.queryProviderStatus(intent); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `verify-before-supersede: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`, + ); + return intent; + } + if ( + status.status === ProviderPaymentStatus.SUCCEEDED || + status.status === ProviderPaymentStatus.PROCESSING + ) { + await this.applyProviderResult(intent.id, this.fromProviderStatus(status)); + return (await this.intentsRepository.findById(intent.id)) ?? intent; + } + + const expired = + intent.expiresAt != null && intent.expiresAt.getTime() < Date.now(); await this.intentsRepository.update(intent.id, { status: ProviderPaymentStatus.CANCELLED, - failureCode: "EXPIRED", - failureMessage: "Provider session expired before the payer acted", + failureCode: expired ? "EXPIRED" : "SUPERSEDED", + failureMessage: expired + ? "Provider session expired before the payer acted" + : "Payer re-initiated; previous provider session superseded", }); + this.logger.log( + `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`, + ); return null; }