Merge pull request #1144 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-06 21:18:59 +03:00
committed by GitHub
5 changed files with 36 additions and 62 deletions

View File

@@ -48,35 +48,23 @@ export function defaultPaymentReason(
: "Freight invoice"; : "Freight invoice";
} }
/** /** Short descriptions per CBE integration request — CBE's channel renders them as-is. */
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it export function reasonToDescription(reason: string | null | undefined): string {
* has to name the thing they are actually holding — a passenger booking or a freight invoice —
* rather than our internal "bill" abstraction (plan §6.6).
*/
function subjectOf(referenceType: PaymentReferenceType): string {
return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice";
}
export function reasonToDescription(
reason: string | null | undefined,
referenceType: PaymentReferenceType,
): string {
const subject = subjectOf(referenceType);
switch (reason) { switch (reason) {
case "ALREADY_PAID": case "ALREADY_PAID":
return `This ${subject} has already been paid.`; return "Already paid";
case "CANCELLED": case "CANCELLED":
return `This ${subject} has been cancelled.`; return "Cancelled";
case "REFUNDED": case "REFUNDED":
return `This ${subject} has been refunded.`; return "Refunded";
case "EXPIRED": case "EXPIRED":
return `This ${subject} has expired and can no longer be paid.`; return "Expired";
// A bill reference we issued whose order has since vanished from the domain app. Same // A bill reference we issued whose order has since vanished from the domain app. Same
// wording as an unknown Bill_Id — from the teller's side it is the same situation. // wording as an unknown Bill_Id — from the teller's side it is the same situation.
case "NOT_FOUND": case "NOT_FOUND":
return "Bill not found."; return "Bill not found";
default: default:
return `This ${subject} is no longer payable.`; return "Not payable";
} }
} }
@@ -135,7 +123,7 @@ export class BillResolverService {
}`, }`,
); );
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5). // TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT"); throw new CbeBillError("Service unavailable", "TRANSIENT");
} }
} }
} }

View File

@@ -130,7 +130,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent); const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) { if (!billQuery.stillPayable) {
throw new CbeBillError( throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType), reasonToDescription(billQuery.reason),
"BUSINESS", "BUSINESS",
); );
} }
@@ -184,27 +184,15 @@ export class CbeBillService {
); );
if (prior) { if (prior) {
if (prior.tradeStatus === "SUCCESS") { if (prior.tradeStatus === "SUCCESS") {
// A replay must be the SAME attempt. A reused id with different money details is // Per CBE integration request: a settled End_To_End_Txn_Id never replays the stored
// not a retry — echoing the stored success would fake a settlement that never ran. // success — every repeat answers "Already paid". Money moved exactly once (the first
const orig = prior.requestPayload as unknown as // call); this only changes what a duplicate hears back. NOTE this diverges from the
| CbePaymentRequestDto // original §6.5 replay design: if CBE retries because our SUCCESS response was lost
| undefined; // in transit, it now sees FAILED for a debit we kept — reconcile such cases manually.
if ( return mapPaymentFailure(dto, "Already paid");
orig &&
(orig.Bill_Id !== dto.Bill_Id ||
orig.Cbe_Txn_Ref !== dto.Cbe_Txn_Ref ||
Number(orig.Amount) !== Number(dto.Amount))
) {
return mapPaymentFailure(
dto,
`End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already used by a different payment.`,
);
}
// Replay the stored body verbatim. Never re-settle.
return prior.responsePayload as unknown as CbePaymentResponseDto;
} }
if (prior.tradeStatus === "PENDING") { if (prior.tradeStatus === "PENDING") {
return mapPaymentFailure(dto, "Payment is being processed."); return mapPaymentFailure(dto, "Payment in progress");
} }
if (prior.failureClass === "BUSINESS") { if (prior.failureClass === "BUSINESS") {
// Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already // Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already
@@ -212,7 +200,7 @@ export class CbeBillService {
// echoing the original reason, which no longer describes this request. // echoing the original reason, which no longer describes this request.
return mapPaymentFailure( return mapPaymentFailure(
dto, dto,
`End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already processed and failed: ${prior.responseDescription ?? "unknown reason"}.`, `Already processed: ${prior.responseDescription ?? "failed"}`,
); );
} }
// FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement. // FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement.
@@ -225,7 +213,7 @@ export class CbeBillService {
if (settled) { if (settled) {
return mapPaymentFailure( return mapPaymentFailure(
dto, dto,
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`, "Duplicate transaction ref",
); );
} }
@@ -253,7 +241,7 @@ export class CbeBillService {
} catch (err) { } catch (err) {
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) { if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
// Concurrent duplicate of the same attempt lost the insert race. // Concurrent duplicate of the same attempt lost the insert race.
return mapPaymentFailure(dto, "Payment is being processed."); return mapPaymentFailure(dto, "Payment in progress");
} }
throw err; throw err;
} }
@@ -264,7 +252,7 @@ export class CbeBillService {
intent = await this.resolveIntent(dto.Bill_Id); intent = await this.resolveIntent(dto.Bill_Id);
if (dto.Currency && dto.Currency !== intent.currency) { if (dto.Currency && dto.Currency !== intent.currency) {
throw new CbeBillError("Payment currency does not match.", "BUSINESS"); throw new CbeBillError("Currency mismatch", "BUSINESS");
} }
// Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous // Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous
@@ -272,7 +260,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent); const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) { if (!billQuery.stillPayable) {
throw new CbeBillError( throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType), reasonToDescription(billQuery.reason),
"BUSINESS", "BUSINESS",
); );
} }
@@ -283,7 +271,7 @@ export class CbeBillService {
Math.abs(amount - intent.amountMinor) > Math.abs(amount - intent.amountMinor) >
intent.amountMinor * AMOUNT_TOLERANCE intent.amountMinor * AMOUNT_TOLERANCE
) { ) {
throw new CbeBillError("Payment amount does not match.", "BUSINESS"); throw new CbeBillError("Amount mismatch", "BUSINESS");
} }
const paidAt = new Date(dto.Timestamp); const paidAt = new Date(dto.Timestamp);
@@ -341,10 +329,10 @@ export class CbeBillService {
private assertIntentPayable(intent: PaymentIntent): void { private assertIntentPayable(intent: PaymentIntent): void {
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return; if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
if (intent.status === ProviderPaymentStatus.PROCESSING) { if (intent.status === ProviderPaymentStatus.PROCESSING) {
throw new CbeBillError("Payment is being processed.", "BUSINESS"); throw new CbeBillError("Payment in progress", "BUSINESS");
} }
throw new CbeBillError( throw new CbeBillError(
reasonToDescription(localReason(intent), intent.referenceType), reasonToDescription(localReason(intent)),
"BUSINESS", "BUSINESS",
); );
} }
@@ -352,11 +340,11 @@ export class CbeBillService {
/** Check digit first (cheap reject), then the unique bill_reference lookup. */ /** Check digit first (cheap reject), then the unique bill_reference lookup. */
private async resolveIntent(billId: string): Promise<PaymentIntent> { private async resolveIntent(billId: string): Promise<PaymentIntent> {
if (!this.billReferenceService.isValid(billId)) { if (!this.billReferenceService.isValid(billId)) {
throw new CbeBillError("Bill not found.", "BUSINESS"); throw new CbeBillError("Bill not found", "BUSINESS");
} }
const intent = await this.intentsRepository.findByBillReference(billId); const intent = await this.intentsRepository.findByBillReference(billId);
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) { if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
throw new CbeBillError("Bill not found.", "BUSINESS"); throw new CbeBillError("Bill not found", "BUSINESS");
} }
return intent; return intent;
} }

View File

@@ -40,7 +40,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({ response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
Status: "FAILED", Status: "FAILED",
Response_Code: "9", Response_Code: "9",
Response_Description: "Service temporarily unavailable.", Response_Description: "Service unavailable",
}); });
return; return;
} }
@@ -66,7 +66,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
response.status(HttpStatus.OK).json({ response.status(HttpStatus.OK).json({
Status: "FAILED", Status: "FAILED",
Response_Code: "2", Response_Code: "2",
Response_Description: "Internal server error.", Response_Description: "Internal error",
}); });
} }
} }

View File

@@ -26,5 +26,5 @@ export function toCbeFailure(err: unknown): {
if (err instanceof CbeBillError) { if (err instanceof CbeBillError) {
return { description: err.message, failureClass: err.failureClass }; return { description: err.message, failureClass: err.failureClass };
} }
return { description: "Internal server error.", failureClass: "TRANSIENT" }; return { description: "Internal error", failureClass: "TRANSIENT" };
} }

View File

@@ -129,7 +129,6 @@ describe("CBE Unified Bill (payment service as biller)", () => {
}); });
let settleBody: Record<string, string>; let settleBody: Record<string, string>;
let settledTxnRef: string;
it("settles the freight invoice when CBE reports the debit", async () => { it("settles the freight invoice when CBE reports the debit", async () => {
const invoice = await currentInvoice(invoiceId); const invoice = await currentInvoice(invoiceId);
@@ -147,7 +146,6 @@ describe("CBE Unified Bill (payment service as biller)", () => {
const res = await cbe(token, "/cbe/payment", settleBody); const res = await cbe(token, "/cbe/payment", settleBody);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("0"); expect(res.body.Response_Code).toBe("0");
settledTxnRef = res.body.Destination_Txn_Ref;
const paid = await poll<{ status: string }>( const paid = await poll<{ status: string }>(
"invoice PAID via CBE bill", "invoice PAID via CBE bill",
@@ -159,22 +157,22 @@ describe("CBE Unified Bill (payment service as biller)", () => {
expect(paid.status).toBe("PAID"); expect(paid.status).toBe("PAID");
}); });
it("replays the stored success when CBE retries the same attempt verbatim", async () => { it("answers 'Already paid' when the settled attempt is sent again verbatim", async () => {
const res = await cbe(token, "/cbe/payment", settleBody); const res = await cbe(token, "/cbe/payment", settleBody);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("0"); expect(res.body.Status).toBe("FAILED");
// The stored body, not a re-settlement — same order id as the first answer. expect(res.body.Response_Code).toBe("2");
expect(res.body.Destination_Txn_Ref).toBe(settledTxnRef); expect(res.body.Response_Description).toBe("Already paid");
}); });
it("rejects a settled End_To_End_Txn_Id reused with a different amount", async () => { it("answers 'Already paid' when the settled End_To_End_Txn_Id is reused with a different amount", async () => {
const res = await cbe(token, "/cbe/payment", { const res = await cbe(token, "/cbe/payment", {
...settleBody, ...settleBody,
Amount: "1.00", Amount: "1.00",
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("2"); expect(res.body.Response_Code).toBe("2");
expect(res.body.Response_Description).toContain("already used by a different payment"); expect(res.body.Response_Description).toBe("Already paid");
}); });
it("rejects a second debit on the same bill", async () => { it("rejects a second debit on the same bill", async () => {