mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1144 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,27 +184,15 @@ export class CbeBillService {
|
||||
);
|
||||
if (prior) {
|
||||
if (prior.tradeStatus === "SUCCESS") {
|
||||
// A replay must be the SAME attempt. A reused id with different money details is
|
||||
// not a retry — echoing the stored success would fake a settlement that never ran.
|
||||
const orig = prior.requestPayload as unknown as
|
||||
| CbePaymentRequestDto
|
||||
| undefined;
|
||||
if (
|
||||
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;
|
||||
// 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
|
||||
@@ -212,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.
|
||||
@@ -225,7 +213,7 @@ export class CbeBillService {
|
||||
if (settled) {
|
||||
return mapPaymentFailure(
|
||||
dto,
|
||||
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
|
||||
"Duplicate transaction ref",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,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;
|
||||
}
|
||||
@@ -264,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
|
||||
@@ -272,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",
|
||||
);
|
||||
}
|
||||
@@ -283,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);
|
||||
@@ -341,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",
|
||||
);
|
||||
}
|
||||
@@ -352,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
|
||||
response.status(HttpStatus.OK).json({
|
||||
Status: "FAILED",
|
||||
Response_Code: "2",
|
||||
Response_Description: "Internal server error.",
|
||||
Response_Description: "Internal error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user