mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user