mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update(
|
||||
{ paymentId, status: Freight.InvoiceStatus.PaymentProcessing },
|
||||
{ status: Freight.InvoiceStatus.Pending },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,6 +87,33 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||||
});
|
||||
|
||||
it('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => {
|
||||
exchangeService.getRate.mockResolvedValue(162.2132);
|
||||
const booking = {
|
||||
id: 'b-1-frac',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
// 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr
|
||||
// rounding here billed with the integer part of the rate, in effect).
|
||||
expect(result.lineItems[0].amount).toBe(681295.44);
|
||||
});
|
||||
|
||||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||||
const booking = {
|
||||
id: 'b-1-usd',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { round2 } from '../billing/invoice-settlement.util';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
@@ -205,14 +206,14 @@ export class BookingPricingService {
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
? Math.round(unitUsd * usdToEtb)
|
||||
? round2(unitUsd * usdToEtb)
|
||||
: unitUsd;
|
||||
const convertedAmount = frozen
|
||||
? isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
? round2(unitAmount * quantity)
|
||||
: unitAmount * quantity
|
||||
: isEtbBooking
|
||||
? Math.round(usdAmount * usdToEtb)
|
||||
? round2(usdAmount * usdToEtb)
|
||||
: usdAmount;
|
||||
|
||||
const item: PriceLineItemDto = {
|
||||
@@ -583,8 +584,8 @@ export class BookingPricingService {
|
||||
} else {
|
||||
const unitUsd = Number(rate!.rateValue);
|
||||
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
@@ -652,8 +653,8 @@ export class BookingPricingService {
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
lines.push({
|
||||
code: rateType,
|
||||
@@ -763,12 +764,12 @@ export class BookingPricingService {
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
? round2(unitAmount * quantity)
|
||||
: unitAmount * quantity;
|
||||
} else {
|
||||
const usdAmount = value * quantity;
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
|
||||
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? round2(value * usdToEtb) : value;
|
||||
}
|
||||
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||
if (!(amount > 0)) continue;
|
||||
@@ -971,7 +972,7 @@ export class BookingPricingService {
|
||||
if (!(usdToEtb > 0)) return null;
|
||||
const converted =
|
||||
snap.currency === 'USD' && bookingCurrency === 'ETB'
|
||||
? Math.round(unitPrice * usdToEtb)
|
||||
? round2(unitPrice * usdToEtb)
|
||||
: snap.currency === 'ETB' && bookingCurrency === 'USD'
|
||||
? unitPrice / usdToEtb
|
||||
: null;
|
||||
@@ -1027,7 +1028,7 @@ export class BookingPricingService {
|
||||
const currency = booking.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { round2 } from '../billing/invoice-settlement.util';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
@@ -79,7 +80,7 @@ export class ContractPricingService {
|
||||
const currency = contract.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||
|
||||
const lineItems: ContractUnitRateLineItem[] = [];
|
||||
const baseType = this.baseRateType(contract);
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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}` : ""),
|
||||
|
||||
Reference in New Issue
Block a user