mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1047 from Tria-plc/cbe-integration
feat: ( payment ) specific CBE query descriptions + Payment_Reason
This commit is contained in:
@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
|||||||
Freight.InvoiceStatus.Overdue,
|
Freight.InvoiceStatus.Overdue,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
|
||||||
|
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
|
||||||
|
* so "cancelled" must not stand in for "already paid" or "refunded".
|
||||||
|
*/
|
||||||
|
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case Freight.InvoiceStatus.Paid:
|
||||||
|
return "ALREADY_PAID";
|
||||||
|
case Freight.InvoiceStatus.Refunded:
|
||||||
|
return "REFUNDED";
|
||||||
|
case Freight.InvoiceStatus.Cancelled:
|
||||||
|
return "CANCELLED";
|
||||||
|
case Freight.InvoiceStatus.Expired:
|
||||||
|
return "EXPIRED";
|
||||||
|
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
|
||||||
|
default:
|
||||||
|
return "NOT_PAYABLE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A single line to bill on a generated invoice. */
|
/** A single line to bill on a generated invoice. */
|
||||||
export interface InvoiceLineInput {
|
export interface InvoiceLineInput {
|
||||||
chargeType: string;
|
chargeType: string;
|
||||||
@@ -1097,6 +1118,7 @@ export class BillingService {
|
|||||||
currentAmountMinor?: number | null;
|
currentAmountMinor?: number | null;
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
|
paymentReason?: string | null;
|
||||||
}> {
|
}> {
|
||||||
const repo = this.dataSource.getRepository(Invoice);
|
const repo = this.dataSource.getRepository(Invoice);
|
||||||
const open = await repo.findOne({
|
const open = await repo.findOne({
|
||||||
@@ -1113,7 +1135,12 @@ export class BillingService {
|
|||||||
payerName: open.company?.name ?? null,
|
payerName: open.company?.name ?? null,
|
||||||
currentAmountMinor: balance,
|
currentAmountMinor: balance,
|
||||||
currency: open.currency,
|
currency: open.currency,
|
||||||
reason: expired ? "EXPIRED" : balance > 0 ? null : "ALREADY_PAID",
|
// CBE shows this beside the amount on the confirmation screen — the invoice number
|
||||||
|
// the payer is holding, not our internal reference.
|
||||||
|
paymentReason: `Freight invoice ${open.invoiceNumber}`,
|
||||||
|
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
|
||||||
|
// expired, and that is what the payer at the CBE counter must be told.
|
||||||
|
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1122,15 +1149,24 @@ export class BillingService {
|
|||||||
relations: { company: true },
|
relations: { company: true },
|
||||||
order: { createdAt: "DESC" },
|
order: { createdAt: "DESC" },
|
||||||
});
|
});
|
||||||
|
// A bill reference whose invoice no longer exists at all — a data problem, not a
|
||||||
|
// cancellation the payer did anything to cause.
|
||||||
|
if (!latest) {
|
||||||
|
return {
|
||||||
|
stillPayable: false,
|
||||||
|
payerName: null,
|
||||||
|
currentAmountMinor: null,
|
||||||
|
currency: null,
|
||||||
|
reason: "NOT_FOUND",
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
stillPayable: false,
|
stillPayable: false,
|
||||||
payerName: latest?.company?.name ?? null,
|
payerName: latest.company?.name ?? null,
|
||||||
currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null,
|
currentAmountMinor: Math.round(Number(latest.totalAmount)),
|
||||||
currency: latest?.currency ?? null,
|
currency: latest.currency,
|
||||||
reason:
|
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||||
latest?.status === Freight.InvoiceStatus.Paid
|
reason: closedInvoiceReason(latest.status),
|
||||||
? "ALREADY_PAID"
|
|
||||||
: "CANCELLED",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,13 @@ export class MarkPaidResponseDto {
|
|||||||
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
|
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
|
||||||
*/
|
*/
|
||||||
export class BillQueryRequestDto {
|
export class BillQueryRequestDto {
|
||||||
|
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
|
||||||
|
// property to a relative require() into packages/types, which does not exist inside the
|
||||||
|
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
|
||||||
|
// decorators below still give us enum docs + runtime validation.
|
||||||
@ApiProperty({ enum: PaymentReferenceType })
|
@ApiProperty({ enum: PaymentReferenceType })
|
||||||
@IsEnum(PaymentReferenceType)
|
@IsEnum(PaymentReferenceType)
|
||||||
referenceType!: PaymentReferenceType;
|
referenceType!: string;
|
||||||
|
|
||||||
@ApiProperty() @IsString() referenceId!: string;
|
@ApiProperty() @IsString() referenceId!: string;
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,8 @@ export class BillQueryResponseDto {
|
|||||||
@ApiPropertyOptional() payerName?: string | null;
|
@ApiPropertyOptional() payerName?: string | null;
|
||||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||||
@ApiPropertyOptional() currency?: string | null;
|
@ApiPropertyOptional() currency?: string | null;
|
||||||
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
|
||||||
@ApiPropertyOptional() reason?: string | null;
|
@ApiPropertyOptional() reason?: string | null;
|
||||||
|
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
|
||||||
|
@ApiPropertyOptional() paymentReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,13 @@ export class MarkPaidResponseDto {
|
|||||||
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
|
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
|
||||||
*/
|
*/
|
||||||
export class BillQueryRequestDto {
|
export class BillQueryRequestDto {
|
||||||
|
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
|
||||||
|
// property to a relative require() into packages/types, which does not exist inside the
|
||||||
|
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
|
||||||
|
// decorators below still give us enum docs + runtime validation.
|
||||||
@ApiProperty({ enum: PaymentReferenceType })
|
@ApiProperty({ enum: PaymentReferenceType })
|
||||||
@IsEnum(PaymentReferenceType)
|
@IsEnum(PaymentReferenceType)
|
||||||
referenceType!: PaymentReferenceType;
|
referenceType!: string;
|
||||||
|
|
||||||
@ApiProperty() @IsString() referenceId!: string;
|
@ApiProperty() @IsString() referenceId!: string;
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,8 @@ export class BillQueryResponseDto {
|
|||||||
@ApiPropertyOptional() payerName?: string | null;
|
@ApiPropertyOptional() payerName?: string | null;
|
||||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||||
@ApiPropertyOptional() currency?: string | null;
|
@ApiPropertyOptional() currency?: string | null;
|
||||||
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
|
||||||
@ApiPropertyOptional() reason?: string | null;
|
@ApiPropertyOptional() reason?: string | null;
|
||||||
|
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
|
||||||
|
@ApiPropertyOptional() paymentReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,7 +400,9 @@ export class PaymentsService {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
include: { seats: true, passenger: { include: { user: true } } },
|
include: { seats: true, passenger: { include: { user: true } } },
|
||||||
});
|
});
|
||||||
if (!booking) return { stillPayable: false, reason: "CANCELLED" };
|
// Distinct from CANCELLED: the payment service issued a bill reference for a booking that
|
||||||
|
// no longer exists at all, which is a data problem, not a customer-facing cancellation.
|
||||||
|
if (!booking) return { stillPayable: false, reason: "NOT_FOUND" };
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
|
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
|
||||||
@@ -414,14 +416,26 @@ export class PaymentsService {
|
|||||||
"ETB",
|
"ETB",
|
||||||
),
|
),
|
||||||
currency: "ETB",
|
currency: "ETB",
|
||||||
|
// CBE shows this beside the amount on the confirmation screen. bookingRef is the same
|
||||||
|
// code on the customer's ticket, so they can match the two before confirming.
|
||||||
|
paymentReason: `Train ticket booking ${booking.bookingRef}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Paid first: a booking that was paid and then boarded/refunded must never be reported as
|
||||||
|
// merely "not payable" — the payer needs to hear that their money already went through.
|
||||||
if (booking.status === "CONFIRMED" || booking.paidAt) {
|
if (booking.status === "CONFIRMED" || booking.paidAt) {
|
||||||
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
|
||||||
}
|
}
|
||||||
if (booking.status !== "PENDING_PAYMENT") {
|
if (booking.status === "REFUNDED") {
|
||||||
|
return { ...base, stillPayable: false, reason: "REFUNDED" };
|
||||||
|
}
|
||||||
|
if (booking.status === "CANCELLED") {
|
||||||
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
return { ...base, stillPayable: false, reason: "CANCELLED" };
|
||||||
}
|
}
|
||||||
|
// DRAFT / BOARDED / NO_SHOW without a payment: no honest specific wording exists.
|
||||||
|
if (booking.status !== "PENDING_PAYMENT") {
|
||||||
|
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
|
||||||
|
}
|
||||||
const deadline = await this.computeBookingPaymentDeadline(booking.id);
|
const deadline = await this.computeBookingPaymentDeadline(booking.id);
|
||||||
if (deadline && deadline.getTime() < Date.now()) {
|
if (deadline && deadline.getTime() < Date.now()) {
|
||||||
return { ...base, stillPayable: false, reason: "EXPIRED" };
|
return { ...base, stillPayable: false, reason: "EXPIRED" };
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export default function PaymentMethodsPage() {
|
|||||||
const paymentTypes = [
|
const paymentTypes = [
|
||||||
{ value: 'TELEBIRR', label: 'Telebirr' },
|
{ value: 'TELEBIRR', label: 'Telebirr' },
|
||||||
{ value: 'CBE_BIRR', label: 'CBE Birr' },
|
{ value: 'CBE_BIRR', label: 'CBE Birr' },
|
||||||
|
{ value: 'CBE_BILL', label: 'CBE Bill Payment' },
|
||||||
{ value: 'EBIRR', label: 'eBirr' },
|
{ value: 'EBIRR', label: 'eBirr' },
|
||||||
{ value: 'WAAFI', label: 'Waafi' },
|
{ value: 'WAAFI', label: 'Waafi' },
|
||||||
{ value: 'DMONEY', label: 'dMoney' },
|
{ value: 'DMONEY', label: 'dMoney' },
|
||||||
|
|||||||
@@ -2,30 +2,82 @@ import { Injectable, Logger } from "@nestjs/common";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from "@nestjs/axios";
|
import { HttpService } from "@nestjs/axios";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
import { PaymentService } from "@edr/types";
|
import { PaymentReferenceType, PaymentService } from "@edr/types";
|
||||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||||
import { CbeBillError } from "./mappers/cbe-error.mapper";
|
import { CbeBillError } from "./mappers/cbe-error.mapper";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a bill is no longer payable. Shared vocabulary between both domain apps and the local
|
||||||
|
* intent check, so one mapper produces every Response_Description CBE sees.
|
||||||
|
* `NOT_PAYABLE` is the catch-all for domain states with no better name (booking DRAFT/BOARDED,
|
||||||
|
* invoice DRAFT) — it must stay last-resort, never a substitute for a specific reason.
|
||||||
|
*/
|
||||||
|
export type BillNotPayableReason =
|
||||||
|
| "ALREADY_PAID"
|
||||||
|
| "CANCELLED"
|
||||||
|
| "REFUNDED"
|
||||||
|
| "EXPIRED"
|
||||||
|
| "NOT_FOUND"
|
||||||
|
| "NOT_PAYABLE";
|
||||||
|
|
||||||
/** Contract of POST /internal/payments/bill-query on the domain apps (plan Phase 4). */
|
/** Contract of POST /internal/payments/bill-query on the domain apps (plan Phase 4). */
|
||||||
export interface BillQueryResult {
|
export interface BillQueryResult {
|
||||||
stillPayable: boolean;
|
stillPayable: boolean;
|
||||||
payerName?: string | null;
|
payerName?: string | null;
|
||||||
currentAmountMinor?: number | null;
|
currentAmountMinor?: number | null;
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
/** When stillPayable=false — see {@link BillNotPayableReason}. */
|
||||||
reason?: string | null;
|
reason?: BillNotPayableReason | string | null;
|
||||||
|
/**
|
||||||
|
* What the payer is paying for, shown on CBE's confirmation screen next to the amount —
|
||||||
|
* the domain's own human reference (booking ref / invoice number), not our internal ids.
|
||||||
|
*/
|
||||||
|
paymentReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REASON_DESCRIPTIONS: Record<string, string> = {
|
/**
|
||||||
CANCELLED: "Bill has been cancelled.",
|
* Payment_Reason when the domain app sends none (older build, or an order with no human
|
||||||
ALREADY_PAID: "Bill already paid.",
|
* reference). Generic but never blank: CBE renders this field to the payer, and a bill with
|
||||||
EXPIRED: "Bill has expired.",
|
* an amount and no stated purpose is what a customer refuses to confirm.
|
||||||
};
|
*/
|
||||||
|
export function defaultPaymentReason(
|
||||||
|
referenceType: PaymentReferenceType,
|
||||||
|
): string {
|
||||||
|
return referenceType === PaymentReferenceType.BOOKING
|
||||||
|
? "Train ticket booking"
|
||||||
|
: "Freight invoice";
|
||||||
|
}
|
||||||
|
|
||||||
export function reasonToDescription(reason?: string | null): string {
|
/**
|
||||||
return (
|
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
|
||||||
(reason && REASON_DESCRIPTIONS[reason]) || "Bill is not payable."
|
* 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) {
|
||||||
|
case "ALREADY_PAID":
|
||||||
|
return `This ${subject} has already been paid.`;
|
||||||
|
case "CANCELLED":
|
||||||
|
return `This ${subject} has been cancelled.`;
|
||||||
|
case "REFUNDED":
|
||||||
|
return `This ${subject} has been refunded.`;
|
||||||
|
case "EXPIRED":
|
||||||
|
return `This ${subject} has expired and can no longer be paid.`;
|
||||||
|
// 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.";
|
||||||
|
default:
|
||||||
|
return `This ${subject} is no longer payable.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { IntentsRepository } from "../intents/intents.repository";
|
|||||||
import { IntentsService } from "../intents/intents.service";
|
import { IntentsService } from "../intents/intents.service";
|
||||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||||
import {
|
import {
|
||||||
|
BillNotPayableReason,
|
||||||
BillResolverService,
|
BillResolverService,
|
||||||
|
defaultPaymentReason,
|
||||||
reasonToDescription,
|
reasonToDescription,
|
||||||
} from "./bill-resolver.service";
|
} from "./bill-resolver.service";
|
||||||
import { CbeBillRepository } from "./cbe-bill.repository";
|
import { CbeBillRepository } from "./cbe-bill.repository";
|
||||||
@@ -39,6 +41,22 @@ const PG_UNIQUE_VIOLATION = "23505";
|
|||||||
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
|
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
|
||||||
const AMOUNT_TOLERANCE = 0.01;
|
const AMOUNT_TOLERANCE = 0.01;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
|
||||||
|
* speak, so both paths flow through one description mapper.
|
||||||
|
*/
|
||||||
|
function localReason(intent: PaymentIntent): BillNotPayableReason {
|
||||||
|
switch (intent.status) {
|
||||||
|
case ProviderPaymentStatus.SUCCEEDED:
|
||||||
|
return "ALREADY_PAID";
|
||||||
|
case ProviderPaymentStatus.CANCELLED:
|
||||||
|
// expireIntent() cancels with failureCode EXPIRED — an abandoned bill, not a cancellation.
|
||||||
|
return intent.failureCode === "EXPIRED" ? "EXPIRED" : "CANCELLED";
|
||||||
|
default:
|
||||||
|
return "NOT_PAYABLE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
|
* 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 "3" envelopes (never throw past the
|
||||||
@@ -105,20 +123,13 @@ export class CbeBillService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const intent = await this.resolveIntent(dto.Bill_Id);
|
const intent = await this.resolveIntent(dto.Bill_Id);
|
||||||
if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) {
|
this.assertIntentPayable(intent);
|
||||||
throw new CbeBillError(
|
|
||||||
intent.status === ProviderPaymentStatus.SUCCEEDED
|
|
||||||
? "Bill already paid."
|
|
||||||
: "Bill is not payable.",
|
|
||||||
"BUSINESS",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The live domain hop — the double-payment guard (§6.3). Not optional.
|
// The live domain hop — the double-payment guard (§6.3). Not optional.
|
||||||
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),
|
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||||
"BUSINESS",
|
"BUSINESS",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,6 +137,8 @@ export class CbeBillService {
|
|||||||
const response = mapQuerySuccess(dto, {
|
const response = mapQuerySuccess(dto, {
|
||||||
amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor,
|
amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor,
|
||||||
fullName: billQuery.payerName || intent.payerName || "",
|
fullName: billQuery.payerName || intent.payerName || "",
|
||||||
|
paymentReason:
|
||||||
|
billQuery.paymentReason || defaultPaymentReason(intent.referenceType),
|
||||||
});
|
});
|
||||||
await this.finishAudit(audit, {
|
await this.finishAudit(audit, {
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
@@ -235,7 +248,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),
|
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||||
"BUSINESS",
|
"BUSINESS",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -295,6 +308,23 @@ export class CbeBillService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cheap local gate before the domain hop: what OUR record of this attempt says. Only
|
||||||
|
* REQUIRES_ACTION is payable; every other status gets a description naming the actual reason,
|
||||||
|
* because CBE reads it back to the payer standing at the counter. All BUSINESS — none of these
|
||||||
|
* states can change back, so a same-End_To_End_Txn_Id retry cannot produce a different answer.
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
reasonToDescription(localReason(intent), intent.referenceType),
|
||||||
|
"BUSINESS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 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)) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CbeQueryResponseDto } from "../dto/cbe-query-response.dto";
|
|||||||
|
|
||||||
export function mapQuerySuccess(
|
export function mapQuerySuccess(
|
||||||
request: CbeQueryRequestDto,
|
request: CbeQueryRequestDto,
|
||||||
input: { amountMajor: number; fullName: string },
|
input: { amountMajor: number; fullName: string; paymentReason: string },
|
||||||
): CbeQueryResponseDto {
|
): CbeQueryResponseDto {
|
||||||
const amount = input.amountMajor.toFixed(2);
|
const amount = input.amountMajor.toFixed(2);
|
||||||
return {
|
return {
|
||||||
@@ -16,7 +16,7 @@ export function mapQuerySuccess(
|
|||||||
First_Name: "",
|
First_Name: "",
|
||||||
Last_Name: "",
|
Last_Name: "",
|
||||||
Full_Name: input.fullName,
|
Full_Name: input.fullName,
|
||||||
Payment_Reason: "",
|
Payment_Reason: input.paymentReason,
|
||||||
Tin_Number: "",
|
Tin_Number: "",
|
||||||
Credit_Acct_Number: "",
|
Credit_Acct_Number: "",
|
||||||
Transaction_Type: "",
|
Transaction_Type: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user