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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-07 01:35:39 +03:00
committed by GitHub
44 changed files with 1824 additions and 202 deletions

View File

@@ -48,35 +48,23 @@ export function defaultPaymentReason(
: "Freight invoice";
}
/**
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
* 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);
/** Short descriptions per CBE integration request — CBE's channel renders them as-is. */
export function reasonToDescription(reason: string | null | undefined): string {
switch (reason) {
case "ALREADY_PAID":
return `This ${subject} has already been paid.`;
return "Already paid";
case "CANCELLED":
return `This ${subject} has been cancelled.`;
return "Cancelled";
case "REFUNDED":
return `This ${subject} has been refunded.`;
return "Refunded";
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
// wording as an unknown Bill_Id — from the teller's side it is the same situation.
case "NOT_FOUND":
return "Bill not found.";
return "Bill not found";
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).
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
throw new CbeBillError("Service unavailable", "TRANSIENT");
}
}
}

View File

@@ -22,7 +22,7 @@ import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
* CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are
* the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then
* presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200
* with Response_Code "3"; only authentication answers 401 (plan D6/D7).
* with Response_Code "2"; only authentication answers 401 (plan D6/D7).
*/
@ApiTags("CBE Unified Bill (inbound)")
@Controller("cbe")

View File

@@ -59,7 +59,7 @@ function localReason(intent: PaymentIntent): BillNotPayableReason {
/**
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
* Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
* Business failures return HTTP 200 + Response_Code "2" envelopes (never throw past the
* controller); the exception filter only catches auth, validation, and the unexpected.
*/
@Injectable()
@@ -130,7 +130,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) {
throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType),
reasonToDescription(billQuery.reason),
"BUSINESS",
);
}
@@ -184,11 +184,15 @@ export class CbeBillService {
);
if (prior) {
if (prior.tradeStatus === "SUCCESS") {
// Replay the stored body verbatim. Never re-settle.
return prior.responsePayload as unknown as CbePaymentResponseDto;
// Per CBE integration request: a settled End_To_End_Txn_Id never replays the stored
// success — every repeat answers "Already paid". Money moved exactly once (the first
// call); this only changes what a duplicate hears back. NOTE this diverges from the
// original §6.5 replay design: if CBE retries because our SUCCESS response was lost
// in transit, it now sees FAILED for a debit we kept — reconcile such cases manually.
return mapPaymentFailure(dto, "Already paid");
}
if (prior.tradeStatus === "PENDING") {
return mapPaymentFailure(dto, "Payment is being processed.");
return mapPaymentFailure(dto, "Payment in progress");
}
if (prior.failureClass === "BUSINESS") {
// Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already
@@ -196,7 +200,7 @@ export class CbeBillService {
// echoing the original reason, which no longer describes this request.
return mapPaymentFailure(
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.
@@ -209,7 +213,7 @@ export class CbeBillService {
if (settled) {
return mapPaymentFailure(
dto,
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
"Duplicate transaction ref",
);
}
@@ -237,7 +241,7 @@ export class CbeBillService {
} catch (err) {
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
// 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;
}
@@ -248,7 +252,7 @@ export class CbeBillService {
intent = await this.resolveIntent(dto.Bill_Id);
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
@@ -256,7 +260,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) {
throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType),
reasonToDescription(billQuery.reason),
"BUSINESS",
);
}
@@ -267,7 +271,7 @@ export class CbeBillService {
Math.abs(amount - intent.amountMinor) >
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);
@@ -325,10 +329,10 @@ export class CbeBillService {
private assertIntentPayable(intent: PaymentIntent): void {
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
if (intent.status === ProviderPaymentStatus.PROCESSING) {
throw new CbeBillError("Payment is being processed.", "BUSINESS");
throw new CbeBillError("Payment in progress", "BUSINESS");
}
throw new CbeBillError(
reasonToDescription(localReason(intent), intent.referenceType),
reasonToDescription(localReason(intent)),
"BUSINESS",
);
}
@@ -336,11 +340,11 @@ export class CbeBillService {
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
private async resolveIntent(billId: string): Promise<PaymentIntent> {
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);
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
throw new CbeBillError("Bill not found.", "BUSINESS");
throw new CbeBillError("Bill not found", "BUSINESS");
}
return intent;
}

View File

@@ -40,7 +40,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
Status: "FAILED",
Response_Code: "9",
Response_Description: "Service temporarily unavailable.",
Response_Description: "Service unavailable",
});
return;
}
@@ -54,7 +54,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
: exception.message;
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: message || "Invalid request",
});
return;
@@ -65,8 +65,8 @@ export class CbeExceptionFilter implements ExceptionFilter {
);
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Description: "Internal server error.",
Response_Code: "2",
Response_Description: "Internal error",
});
}
}

View File

@@ -16,8 +16,8 @@ export class CbeBillError extends Error {
}
/**
* Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only
* 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6).
* Every failure maps to Response_Code "2" (per current CBE integration requirement; the
* original AAFDA plan used 3); only the description is specific (plan §6.6).
*/
export function toCbeFailure(err: unknown): {
description: string;
@@ -26,5 +26,5 @@ export function toCbeFailure(err: unknown): {
if (err instanceof CbeBillError) {
return { description: err.message, failureClass: err.failureClass };
}
return { description: "Internal server error.", failureClass: "TRANSIENT" };
return { description: "Internal error", failureClass: "TRANSIENT" };
}

View File

@@ -27,7 +27,7 @@ export function mapPaymentFailure(
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
Destination_Txn_Ref: "",
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: description,
Additional_Fields: [],
};

View File

@@ -48,7 +48,7 @@ export function mapQueryFailure(
Transaction_Type: "",
Timestamp: new Date().toISOString(),
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: description,
Additional_Fields: [],
};