This commit is contained in:
Roba Boru
2026-07-16 23:22:55 +03:00
5 changed files with 89 additions and 20 deletions

View File

@@ -1016,7 +1016,7 @@ export class BillingService {
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment // in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type. // service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT, referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace("-", "_"), orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)), amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency, currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`, reason: `Payment for invoice ${invoice.invoiceNumber}`,
@@ -1032,10 +1032,8 @@ export class BillingService {
.getRepository(Invoice) .getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId }); .update({ id: invoice.id }, { paymentId: result.intentId });
// DEMO: manually fire the gateway `payment.succeeded` callback here, without // Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// waiting for real gateway settlement. Runs AFTER the paymentId link above so // billing must not simulate it. Kept commented for local demos only.
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
if (!result.immediateSuccess) { if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({ await this.payment.handlePaymentEvent({
eventType: "payment.succeeded", eventType: "payment.succeeded",

View File

@@ -16,6 +16,7 @@ import {
InvoiceLineInput, InvoiceLineInput,
} from "../billing/billing.service"; } from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity"; 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 { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto"; 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 * cancelled or rejected — the counterpart to the pay-window-expiry path
* (which also calls {@link BillingService.expirePayable}). Stops a terminated * (which also calls {@link BillingService.expirePayable}). Stops a terminated
* booking from leaving a payable invoice open. No-op when the booking has no * booking from leaving a payable invoice open. No-op when the booking has no
* open invoice (never invoiced, already paid/cancelled/expired). Pass a * open invoice (never invoiced, already paid/cancelled/expired). Pass a
* caller `manager` to enlist in its transaction. * caller `manager` to enlist in its transaction.
*/ */
expireOpenInvoices( async expireOpenInvoices(
bookingId: string, bookingId: string,
manager?: EntityManager, manager?: EntityManager,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
// 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( return this.billing.expirePayable(
Freight.InvoiceSource.Booking, Freight.InvoiceSource.Booking,
bookingId, bookingId,

View File

@@ -161,6 +161,21 @@ export class ClearanceFeeService {
return invoice; 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<Invoice | null> {
return this.billing.expirePayable(
Freight.InvoiceSource.Clearance,
contractId,
CLEARANCE_CONTRACT_INVOICE_TYPE,
);
}
/** /**
* Settlement branch point for `clearance`-source invoices: unlock the * Settlement branch point for `clearance`-source invoices: unlock the
* document-upload step the fee was gating. Idempotent — a replayed event on * document-upload step the fee was gating. Idempotent — a replayed event on

View File

@@ -419,6 +419,10 @@ export class ContractTransitionService {
actorId, actorId,
'STAFF', '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, { await this.contractsRepository.update(contractId, {
status: 'REJECTED', status: 'REJECTED',
} as never); } as never);
@@ -457,6 +461,10 @@ export class ContractTransitionService {
'STAFF', '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, { await this.contractsRepository.update(contractId, {
status: 'REJECTED', status: 'REJECTED',
} as never); } as never);

View File

@@ -96,9 +96,20 @@ export class IntentsService {
`intent ${existing.id} retired (METHOD_CHANGED ${existing.provider}${request.provider}) for ` + `intent ${existing.id} retired (METHOD_CHANGED ${existing.provider}${request.provider}) for ` +
`${request.service}/${request.referenceType}/${request.referenceId}`, `${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 { } else {
const reusable = await this.reuseOrRetire(existing); // PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen —
if (reusable) return this.toSnapshot(reusable); // 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 * Re-initiate guard for an open REQUIRES_ACTION intent on the same provider.
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) * Queries the provider first — the payer may have paid on the old session with
* so a fresh provider session can be opened. * 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, intent: PaymentIntent,
): Promise<PaymentIntent | null> { ): Promise<PaymentIntent | null> {
const expired = let status: ProviderStatus;
intent.status === ProviderPaymentStatus.REQUIRES_ACTION && try {
intent.expiresAt != null && status = await this.queryProviderStatus(intent);
intent.expiresAt.getTime() < Date.now(); } catch (err) {
if (!expired) return intent; 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, { await this.intentsRepository.update(intent.id, {
status: ProviderPaymentStatus.CANCELLED, status: ProviderPaymentStatus.CANCELLED,
failureCode: "EXPIRED", failureCode: expired ? "EXPIRED" : "SUPERSEDED",
failureMessage: "Provider session expired before the payer acted", 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; return null;
} }