diff --git a/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts new file mode 100644 index 000000000..449f3cebe --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface { + name = "AddCbeBillPaymentMethod3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen + // per the local convention (see 2460000000000-AddCacBankPaymentMethod). + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 525ccfefc..8ede56f69 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ 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. */ export interface InvoiceLineInput { chargeType: string; @@ -1034,6 +1055,7 @@ export class BillingService { ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: invoiceId, status: In(OPEN_STATUSES) }, + relations: { company: true }, }); if (!invoice) { throw new NotFoundException( @@ -1090,6 +1112,10 @@ export class BillingService { method: opts.method ?? "TELEBIRR", platform: opts.platform, payerAccount: opts.payerAccount, + // CBE_BILL: payer identity + the invoice's own due date as the bill expiry + // (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4). + payerName: invoice.company?.name, + expiresAt: invoice.dueAt?.toISOString(), returnUrl: opts.returnUrl, failureUrl: opts.failureUrl, }); @@ -1100,12 +1126,14 @@ export class BillingService { .update({ id: invoice.id }, { paymentId: result.intentId }); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); - // billing must not simulate it. Kept commented for local demos only. + // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the - // code — so the demo shortcut must never fire for it. + // code — so the demo shortcut must never fire for it. Same for CBE_BILL: its + // bill must stay open until CBE actually settles it via /cbe/payment. if ( !result.immediateSuccess && - result.response.clientAction?.type !== "COLLECT_OTP" + result.response.clientAction?.type !== "COLLECT_OTP" && + opts.method !== "CBE_BILL" ) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", @@ -1151,4 +1179,67 @@ export class BillingService { paidAt, }); } + + /** + * CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for + * the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId, + * i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting. + */ + async billQuery(referenceId: string): Promise<{ + stillPayable: boolean; + payerName?: string | null; + currentAmountMinor?: number | null; + currency?: string | null; + reason?: string | null; + paymentReason?: string | null; + }> { + const repo = this.dataSource.getRepository(Invoice); + const open = await repo.findOne({ + where: { sourceId: referenceId, status: In(OPEN_STATUSES) }, + relations: { company: true }, + order: { issuedAt: "DESC" }, + }); + + if (open) { + const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount)); + const expired = open.dueAt && open.dueAt.getTime() < Date.now(); + return { + stillPayable: balance > 0 && !expired, + payerName: open.company?.name ?? null, + currentAmountMinor: balance, + currency: open.currency, + // 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", + }; + } + + const latest = await repo.findOne({ + where: { sourceId: referenceId }, + relations: { company: true }, + 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 { + stillPayable: false, + payerName: latest.company?.name ?? null, + currentAmountMinor: Math.round(Number(latest.totalAmount)), + currency: latest.currency, + paymentReason: `Freight invoice ${latest.invoiceNumber}`, + reason: closedInvoiceReason(latest.status), + }; + } } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 5c4a4f7f7..ce072beca 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ type PaymentType = string -type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" +type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" }) referenceType?: string; - @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] }) method!: PaymentMethod @Column({ type: "enum", enum: ["ETB", "USD"] }) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 0fc5a6ba5..b5ff51c48 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,30 +1,42 @@ import { Body, Controller, + forwardRef, HttpCode, HttpStatus, + Inject, Logger, Post, + UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { Public } from "@edr/api-common"; -import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; +import { + PaymentEventDto, + MarkPaidResponseDto, + BillQueryRequestDto, + BillQueryResponseDto, +} from "./internal-payment.dto"; import { PaymentService } from "./payment.service"; +import { BillingService } from "../billing/billing.service"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; /** - * Consumer side of the payment microservice's outbox relay. - * WARNING: currently unauthenticated — anyone who can reach the API can mark - * payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network. + * Consumer side of the payment microservice's outbox relay. Only the payment service may + * call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8). * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") -@Public() +@UseGuards(ServiceAuthGuard) @Controller("internal/payments") export class InternalPaymentController { private readonly logger = new Logger(InternalPaymentController.name); - constructor(private readonly paymentService: PaymentService) { } + constructor( + private readonly paymentService: PaymentService, + @Inject(forwardRef(() => BillingService)) + private readonly billingService: BillingService, + ) { } @Post("mark-paid") @HttpCode(HttpStatus.OK) @@ -36,4 +48,16 @@ export class InternalPaymentController { this.logger.log(`Marking payment ${event} as PAID`); return this.paymentService.handlePaymentEvent(event); } + + @Post("bill-query") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", + }) + async billQuery( + @Body() request: BillQueryRequestDto, + ): Promise { + return this.billingService.billQuery(request.referenceId); + } } diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index 1bf8c3f82..e69a0e15a 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -51,3 +51,30 @@ export class MarkPaidResponseDto { @ApiPropertyOptional() alreadyFinalized?: boolean; @ApiPropertyOptional() reason?: string; } + +/** + * CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks + * "is this invoice still payable, by whom, for how much" while a CBE channel is on the line. + */ +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 }) + @IsEnum(PaymentReferenceType) + referenceType!: string; + + @ApiProperty() @IsString() referenceId!: string; +} + +export class BillQueryResponseDto { + @ApiProperty() stillPayable!: boolean; + @ApiPropertyOptional() payerName?: string | null; + @ApiPropertyOptional() currentAmountMinor?: number | null; + @ApiPropertyOptional() currency?: string | null; + /** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */ + @ApiPropertyOptional() reason?: string | null; + /** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */ + @ApiPropertyOptional() paymentReason?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 0c166d164..0f628e39c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -51,6 +51,10 @@ export interface InitiateIntentInput { payerAccount?: string; returnUrl?: string; failureUrl?: string; + /** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */ + payerName?: string; + /** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */ + expiresAt?: string; } export interface InitiateIntentResult { @@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record = { CARD: "card", DMONEY: "dmoney", CAC_BANK: "cac-bank", + CBE_BILL: "cbe-bill", }; /** @@ -214,8 +219,13 @@ export class PaymentService { async initiate(input: InitiateIntentInput): Promise { try { - - + const isCbeBill = input.method === ProviderMethod.CBE_BILL; + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). + if (isCbeBill && input.currency?.toUpperCase() !== "ETB") { + throw new BadRequestException( + "CBE bill payment is only available for ETB invoices", + ); + } const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, @@ -223,11 +233,15 @@ export class PaymentService { referenceId: input.referenceId, orderRef: input.orderRef, // amountMinor: input.amountMinor, - amountMinor:1, + // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was + // debited against the intent amount, so the 1-birr dev shortcut would break it. + amountMinor: isCbeBill ? input.amountMinor : 1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, payerAccount: input.payerAccount, + payerName: input.payerName, + expiresAt: input.expiresAt, returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", failureUrl: diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 3b86a940c..255da0ea2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,8 +60,10 @@ export class RefundDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) - type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; + @ApiProperty({ + enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + }) + type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @@ -80,6 +82,17 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) message?: string; + + @ApiPropertyOptional({ + description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", + }) + billReference?: string; + + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" }) + instructions?: string; + + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" }) + expiresAt?: string; } export class InitiateResponseDto { diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index d3e4387b0..60044e550 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [ { value: "card", label: "Card" }, { value: "dmoney", label: "D-Money" }, { value: "cac-bank", label: "CAC Bank" }, + { value: "cbe-bill", label: "CBE Bill" }, ]; const STATUS_COLORS: Record = { diff --git a/apps/edr-freight-web/backoffice/src/services/payments.service.ts b/apps/edr-freight-web/backoffice/src/services/payments.service.ts index bc4083f97..4615417ca 100644 --- a/apps/edr-freight-web/backoffice/src/services/payments.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/payments.service.ts @@ -19,7 +19,8 @@ export type PaymentMethod = | "waafi" | "card" | "dmoney" - | "cac-bank"; + | "cac-bank" + | "cbe-bill"; export interface PaymentRow { id: string; diff --git a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts index 04c91a683..ffa86d381 100644 --- a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts +++ b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts @@ -34,9 +34,17 @@ function apiMessage(err: unknown, fallback: string): string { * charge through a different endpoint (warehouse fee invoices); OTP * confirmation always goes through billing, which owns the intent either way. */ +/** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */ +interface BillAction { + billReference: string; + instructions?: string; + expiresAt?: string; +} + export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { const [otpInvoiceId, setOtpInvoiceId] = useState(null); const [otpMessage, setOtpMessage] = useState(); + const [billAction, setBillAction] = useState(null); const payMutation = useMutation({ mutationFn: (vars: { @@ -52,6 +60,16 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { setOtpInvoiceId(vars.invoiceId); return; } + // CBE_BILL settles asynchronously via CBE, not the browser — show the + // bill reference instead of redirecting to a (nonexistent) checkout page. + if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + billReference: data.clientAction.billReference ?? "", + instructions: data.clientAction.instructions, + expiresAt: data.clientAction.expiresAt, + }); + return; + } window.location.href = data?.clientAction?.type === "REDIRECT" && data.clientAction.url ? data.clientAction.url @@ -76,6 +94,7 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { payMutation.reset(); otpMutation.reset(); setOtpInvoiceId(null); + setBillAction(null); }; return { @@ -109,6 +128,14 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { setOtpInvoiceId(null); }, }, + /** Drives the modal's "pay at CBE" step; `open` only for CBE_BILL. */ + bill: { + open: billAction !== null, + billReference: billAction?.billReference, + instructions: billAction?.instructions, + expiresAt: billAction?.expiresAt, + close: () => setBillAction(null), + }, }; } diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 30cb1597d..775d9fe41 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -33,13 +33,7 @@ import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/component import { saveBlob } from "@/utils/download"; import { formatCurrency } from "@/lib/currency"; import { BORDER, INK, MUTED } from "../contracts/contract-ui"; -import { - billedTo, - fmtDate, - InvoiceStatusBadge, - isPayable, - titleCase, -} from "./invoice-ui"; +import { billedTo, fmtDate, InvoiceStatusBadge, isPayable, titleCase } from "./invoice-ui"; function MetaItem({ label, value }: { label: string; value: string }) { return ( @@ -361,7 +355,7 @@ export default function InvoiceDetailPage() { { if (!pay.processing) { setPayModalOpen(false); @@ -373,6 +367,7 @@ export default function InvoiceDetailPage() { processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={(method, payerAccount) => pay.pay(id, method, payerAccount) } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 0cc6d7bff..e0fe11ca0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -26,7 +26,7 @@ interface ProviderOption { accent: string; } -// Only Telebirr, Waafi and CAC Bank are enabled for now. +// Only Telebirr, Waafi, CAC Bank and CBE bill payment are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -51,10 +51,20 @@ const PROVIDERS: ProviderOption[] = [ currencies: ["USD"], accent: "#8A5A17", }, + { + method: "CBE_BILL", + label: "CBE bill payment", + description: "Pay at any CBE branch, app or USSD · ETB", + logo: "/assets/edr-logo.png", + currencies: ["ETB"], + accent: "#5B2D8C", + }, ]; /** Providers that debit against an SMS OTP instead of redirecting to a page. */ const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; +/** Providers that settle asynchronously via a bill reference instead of a redirect. */ +const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL"; /** * Pick the provider that settles in the booking's currency. USD → Waafi, @@ -172,6 +182,7 @@ export function PaymentMethodModal({ processing, error, otp, + bill, }: { opened: boolean; onClose: () => void; @@ -184,14 +195,21 @@ export function PaymentMethodModal({ error?: string | null; /** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */ otp?: InvoicePaymentFlow["otp"]; + /** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */ + bill?: InvoicePaymentFlow["bill"]; }) { const providers = useMemo( - () => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)), - [currency, otp], + () => + providersForCurrency(currency).filter( + (p) => + (otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)), + ), + [currency, otp, bill], ); const [method, setMethod] = useState(providers[0].method); const [mobile, setMobile] = useState(""); const [code, setCode] = useState(""); + const [copied, setCopied] = useState(false); // Keep the selection valid when the currency (and therefore provider list) changes. useEffect(() => { @@ -209,6 +227,101 @@ export function PaymentMethodModal({ const needsMobile = isOtpMethod(method); const canSubmit = !needsMobile || mobile.trim().length > 0; + if (bill?.open) { + return ( + + + + Pay at CBE + + + {bill.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} + + + + + {bill.billReference} + + + + + {amountLabel && ( + + Amount due: {amountLabel} + + )} + {bill.expiresAt && ( + + Pay before:{" "} + + {new Date(bill.expiresAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + )} + + + This page updates automatically once CBE confirms your payment. + + + + + + ); + } + if (otp?.open) { return ( { return this.paymentsService.handlePaymentEvent(event); } + + @Post("bill-query") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", + }) + async billQuery( + @Body() request: BillQueryRequestDto, + ): Promise { + return this.paymentsService.billQuery(request.referenceId); + } } diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts index 7f732ae51..d2b88372e 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -55,3 +55,30 @@ export class MarkPaidResponseDto { @ApiPropertyOptional() alreadyFinalized?: boolean; @ApiPropertyOptional() reason?: string; } + +/** + * CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks + * "is this order still payable, by whom, for how much" while a CBE teller/app is on the line. + */ +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 }) + @IsEnum(PaymentReferenceType) + referenceType!: string; + + @ApiProperty() @IsString() referenceId!: string; +} + +export class BillQueryResponseDto { + @ApiProperty() stillPayable!: boolean; + @ApiPropertyOptional() payerName?: string | null; + @ApiPropertyOptional() currentAmountMinor?: number | null; + @ApiPropertyOptional() currency?: string | null; + /** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */ + @ApiPropertyOptional() reason?: string | null; + /** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */ + @ApiPropertyOptional() paymentReason?: string | null; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index e0b594fd2..dc389e5a1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -25,6 +25,7 @@ export enum PaymentMethodTypeEnum { CAC_BANK = "CAC_BANK", // Djibouti (OTP debit) CARD = "CARD", // International WALLET = "WALLET", // Internal + CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number) } export type PaymentPlatformDto = "web" | "mobile"; @@ -115,8 +116,10 @@ export class SupportedPaymentMethodDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) - type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; + @ApiProperty({ + enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + }) + type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -135,6 +138,14 @@ export class ClientActionDto { providerOrderId?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) message?: string; + @ApiPropertyOptional({ + description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", + }) + billReference?: string; + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" }) + instructions?: string; + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" }) + expiresAt?: string; } export class InitiateResponseDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 5271acef4..8c4edf083 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -3,6 +3,7 @@ import { PaymentsService } from "./payments.service"; import { PaymentClientService } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { PrismaService } from "../../common/prisma.service"; +import { AuditService } from "../../common/audit.service"; import { SeatsService } from "../seats/seats.service"; import { TicketsService } from "../tickets/tickets.service"; import { EventEmitter2 } from "@nestjs/event-emitter"; @@ -27,6 +28,7 @@ describe("PaymentsService", () => { booking: { findUnique: jest.fn(), update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, paymentIntent: { findUnique: jest.fn(), @@ -81,6 +83,8 @@ describe("PaymentsService", () => { convertEtbMinorToChargeMajor: jest.fn((minor: number) => Promise.resolve(minor), ), + displayMinorToChargeMajor: jest.fn((minor: number) => minor / 100), + convertMinorToChargeMajor: jest.fn(async (minor: number) => minor / 100), getRateOrThrow: jest.fn(), }; @@ -109,6 +113,7 @@ describe("PaymentsService", () => { { provide: EventEmitter2, useValue: mockEventEmitter }, { provide: PaymentClientService, useValue: mockPaymentClient }, { provide: CurrencyService, useValue: mockCurrencyService }, + { provide: AuditService, useValue: { log: jest.fn() } }, ], }).compile(); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 6fc91556a..14e48dce5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -24,7 +24,12 @@ import { PaymentRegionEnum, ForceConfirmDto, } from "./payments.dto"; -import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; +import { + PaymentEventDto, + MarkPaidResponseDto, + BillQueryResponseDto, +} from "./internal-payments.dto"; +import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils"; import { PaymentClientService, PaymentDiagnostic, @@ -222,6 +227,17 @@ export class PaymentsService { ); } + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT + // required — CBE identifies the payer at its own channel. + if ( + method === PaymentMethodType.CBE_BILL && + (booking.currency ?? "ETB").toUpperCase() !== "ETB" + ) { + throw new BadRequestException( + "CBE bill payment is only available for bookings charged in ETB", + ); + } + const correctTotalMinor = await this.resolveBookingTotal(booking as any); // Patch the DB if the stored total is wrong (single-leg for a round-trip package booking) @@ -259,15 +275,22 @@ export class PaymentsService { const paymentMethod = await this.prisma.paymentMethod.findUnique({ where: { type: method }, }); - const chargeCurrency = ( - paymentMethod?.currency ?? booking.currency - ).toUpperCase(); + const chargeCurrency = + method === PaymentMethodType.CBE_BILL + ? "ETB" + : (paymentMethod?.currency ?? booking.currency).toUpperCase(); const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase(); const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null; let chargeAmount: number; - if ( + if (method === PaymentMethodType.CBE_BILL) { + // Force ETB, no conversion (D8) — eligibility was already checked above. + chargeAmount = this.currencyService.displayMinorToChargeMajor( + booking.totalMinor, + "ETB", + ); + } else if ( chargeCurrency === bookingDisplayCurrency && chargeCurrency !== 'ETB' && bookingDisplayTotalMinor != null @@ -285,6 +308,20 @@ export class PaymentsService { ); } + // CBE_BILL: the bill lives in CBE's system for as long as the booking is payable, so the + // intent expiry is the booking's own payment deadline — never a provider-session TTL + // (plan §6.4); payerName feeds the mandatory Full_Name of CBE's query response. + let payerName: string | undefined; + let expiresAt: string | undefined; + if (method === PaymentMethodType.CBE_BILL) { + payerName = + booking.seats.find((s) => s.leg === 1)?.passengerName ?? + booking.seats[0]?.passengerName; + expiresAt = ( + await this.computeBookingPaymentDeadline(booking.id) + )?.toISOString(); + } + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.BOOKING, @@ -297,6 +334,8 @@ export class PaymentsService { payerAccount: dto.payerAccount, returnUrl, failureUrl, + payerName, + expiresAt, }); let intent = await this.syncIntentProjection(booking.id, snapshot); @@ -350,6 +389,111 @@ export class PaymentsService { return this.formatIntentStatus(intent); } + /** + * CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check + + * payer identity for a booking. Called by the payment service while a CBE teller/app is + * waiting — read-only and fast. This is the double-payment guard: once the booking is + * confirmed by ANY method, stillPayable=false and CBE refuses the bill (§6.3). + */ + async billQuery(bookingId: string): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { seats: true, passenger: { include: { user: true } } }, + }); + // 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 = { + // Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder. + payerName: + booking.seats.find((s) => s.leg === 1)?.passengerName ?? + booking.seats[0]?.passengerName ?? + booking.passenger?.user?.fullName ?? + null, + currentAmountMinor: this.currencyService.displayMinorToChargeMajor( + booking.totalMinor, + "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) { + return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; + } + if (booking.status === "REFUNDED") { + return { ...base, stillPayable: false, reason: "REFUNDED" }; + } + if (booking.status === "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); + if (deadline && deadline.getTime() < Date.now()) { + return { ...base, stillPayable: false, reason: "EXPIRED" }; + } + return { ...base, stillPayable: true, reason: null }; + } + + /** + * The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's + * origin-segment time and that stop's own check-in window, falling back to the route default. + */ + private async computeBookingPaymentDeadline( + bookingId: string, + ): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { + createdAt: true, + originStationId: true, + schedule: { + select: { + departureAt: true, + stopTimes: { + select: { + stationId: true, + plannedArrivalAt: true, + plannedDepartureAt: true, + }, + }, + route: { + select: { + checkinMinutesBefore: true, + stops: { + select: { stationId: true, checkinMinutesBefore: true }, + }, + }, + }, + }, + }, + }, + }); + if (!booking?.schedule) return null; + const originStop = booking.schedule.stopTimes?.find( + (s) => s.stationId === booking.originStationId, + ); + const dep = (originStop?.plannedArrivalAt ?? + originStop?.plannedDepartureAt ?? + booking.schedule.departureAt) as Date; + const originRouteStop = booking.schedule.route?.stops?.find( + (s) => s.stationId === booking.originStationId, + ); + const checkinMinutes = + originRouteStop?.checkinMinutesBefore ?? + booking.schedule.route?.checkinMinutesBefore ?? + undefined; + return computePaymentDeadline(booking.createdAt, dep, checkinMinutes); + } + private resolveReturnUrls( method: PaymentMethodType, requestOrigin?: string | null, @@ -1117,15 +1261,17 @@ export class PaymentsService { return { processed: false, reason: "booking-not-found" }; } - // C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled - // amount against the booking's display-currency total (the amount the customer agreed to pay); - // a short payment must NOT confirm the booking. Amount-only — the display↔charge currency - // divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding. - const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor; - const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01)); - if (event.amountMinor < expectedMinor - shortPayTolerance) { + // C-4 guard: a settlement must cover what the passenger was quoted. `event.amountMinor` + // carries the charge amount in MAJOR units (the intent's "real/major price" — what + // initiate sent, e.g. 1500.00 ETB), while booking totals are stored in minor units, so + // normalize before comparing; a short payment must NOT confirm the booking. Amount-only — + // the display↔charge currency divergence is tracked separately under the USD/DJF + // findings. The 1% tolerance absorbs rounding. + const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100; + const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01); + if (event.amountMinor < expectedMajor - shortPayTolerance) { this.logger.error( - `mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`, + `mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMajor} ${booking.displayCurrency}; not confirming`, ); return { processed: false, reason: "amount-mismatch" }; } diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts index 4313d5426..2c5b780fc 100644 --- a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -69,4 +69,9 @@ export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [ name: { en: 'EDR Passenger Finance Manager' }, permissionKeys: [...ROLE_PERMISSION_PRESETS.financeManager], }, + { + key: 'edr_passenger_director', + name: { en: 'EDR Passenger Director' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + }, ]; diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index 05235e3a8..82cc62ef6 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -166,9 +166,18 @@ export const PASSENGER_PERMS = { admin: 'edr_passenger_app:admin', } as const; +// Every view-only permission (`:view` and `:view_all`), derived from the registry +// so newly-added view permissions are automatically included. +export const PASSENGER_VIEW_PERMISSION_KEYS = PASSENGER_PERMISSION_KEYS.filter( + (key) => key.endsWith(':view') || key.endsWith(':view_all'), +); + export const ROLE_PERMISSION_PRESETS = { backofficeAdmin: [...PASSENGER_PERMISSION_KEYS], + // Director: read-only across the whole app — can view every resource, cannot manage/mutate anything. + director: [...PASSENGER_VIEW_PERMISSION_KEYS], + stationMaster: [ PASSENGER_PERMS.bookings.view, PASSENGER_PERMS.bookings.manage, diff --git a/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts index 9e5ef560e..03b7cf6a4 100644 --- a/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts +++ b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts @@ -20,6 +20,8 @@ const STAFF_USERS = [ { email: 'passenger.staff@edr.local', username: 'passenger_staff', roleKey: 'edr_passenger_backoffice_staff' }, { email: 'passenger.agent@edr.local', username: 'passenger_agent', roleKey: 'edr_passenger_agent' }, { email: 'passenger.finance@edr.local', username: 'passenger_finance', roleKey: 'edr_passenger_finance' }, + { email: 'passenger.director@edr.local', username: 'passenger_director', roleKey: 'edr_passenger_director' }, + { email: 'passenger.ticketofficer@edr.local', username: 'passenger_ticket_officer', roleKey: 'edr_passenger_ticket_officer' }, ] as const; @Injectable() diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx index 4e6a71950..c2965ea5c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx @@ -200,6 +200,7 @@ export default function PaymentMethodsPage() { const paymentTypes = [ { value: 'TELEBIRR', label: 'Telebirr' }, { value: 'CBE_BIRR', label: 'CBE Birr' }, + { value: 'CBE_BILL', label: 'CBE Bill Payment' }, { value: 'EBIRR', label: 'eBirr' }, { value: 'WAAFI', label: 'Waafi' }, { value: 'DMONEY', label: 'dMoney' }, diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index d27a505fa..685ba5ee2 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -20,6 +20,8 @@ import { ChevronLeft, KeyRound, Landmark, + Copy, + Check, } from "lucide-react"; const getIconForMethod = (methodId: string) => { @@ -45,6 +47,13 @@ export default function PaymentPage() { const [otpCode, setOtpCode] = useState(""); const [otpMessage, setOtpMessage] = useState(null); const [otpError, setOtpError] = useState(null); + // CBE bill payment: the bill reference the customer pays at any CBE channel. + const [billAction, setBillAction] = useState<{ + billReference: string; + instructions?: string; + expiresAt?: string; + } | null>(null); + const [billCopied, setBillCopied] = useState(false); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; @@ -144,6 +153,16 @@ export default function PaymentPage() { return; } + // CBE bill: no redirect — show the bill reference and wait for the customer to pay + // at a CBE channel. Confirmation only ever comes from polling the intent status. + if (data?.clientAction?.type === 'SHOW_BILL_REFERENCE') { + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + setBillAction(data.clientAction); + setIsProcessing(false); + return; + } + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); @@ -190,6 +209,34 @@ export default function PaymentPage() { + // While the CBE bill dialog is open, poll the intent; the booking confirms server-side + // once CBE settles the bill. Never claim success on any client-side signal. + const { data: billIntentStatus } = useQuery({ + queryKey: ["cbe-bill-intent-status", bookingId], + queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), + enabled: !!billAction && !!bookingId, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (billAction && billIntentStatus?.status === "SUCCEEDED") { + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [billAction, billIntentStatus?.status]); + + const copyBillReference = async () => { + if (!billAction) return; + try { + await navigator.clipboard.writeText(billAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } catch { + /* clipboard unavailable — the number is still shown on screen */ + } + }; + // Fire the actual initiate. `mobile` is only used for CAC (OTP debit). const startPayment = (mobile?: string) => { if (!selectedMethod || !bookingId || !selectedPaymentMethod) return; @@ -569,6 +616,62 @@ export default function PaymentPage() { )} + {/* CBE bill payment — show the bill reference; confirmation comes from polling */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount:{" "} + + ETB {(totalAmountDisplay ?? 0).toFixed(2)} + +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {format(new Date(billAction.expiresAt), "MMM d, yyyy HH:mm")} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + {/* Two-column grid */}
diff --git a/apps/edr-payment-api/package.json b/apps/edr-payment-api/package.json index bdcda721b..5b306c839 100644 --- a/apps/edr-payment-api/package.json +++ b/apps/edr-payment-api/package.json @@ -25,6 +25,7 @@ "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.0", "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.4.2", diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts index 410a8146b..c3c1f88f7 100644 --- a/apps/edr-payment-api/src/app.module.ts +++ b/apps/edr-payment-api/src/app.module.ts @@ -13,6 +13,8 @@ import ebirrConfig from "./config/ebirr.config"; import cardConfig from "./config/card.config"; import dmoneyConfig from "./config/dmoney.config"; import cacConfig from "./config/cac.config"; +import cbeBillConfig from "./config/cbe-bill.config"; +import { CbeBillModule } from "./modules/cbe-bill/cbe-bill.module"; import { HealthModule } from "./modules/health/health.module"; import { IntentsModule } from "./modules/intents/intents.module"; import { OutboxModule } from "./modules/outbox/outbox.module"; @@ -36,6 +38,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module"; cardConfig, dmoneyConfig, cacConfig, + cbeBillConfig, ], }), TypeOrmModule.forRootAsync({ @@ -47,6 +50,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module"; HealthModule, ProvidersModule, IntentsModule, + CbeBillModule, WebhooksModule, OutboxModule, ReconciliationModule, diff --git a/apps/edr-payment-api/src/config/cbe-bill.config.ts b/apps/edr-payment-api/src/config/cbe-bill.config.ts new file mode 100644 index 000000000..7fa528464 --- /dev/null +++ b/apps/edr-payment-api/src/config/cbe-bill.config.ts @@ -0,0 +1,26 @@ +import { registerAs } from "@nestjs/config"; + +/** + * CBE Unified Bill Payment — the INBOUND biller integration (docs/cbe/). Deliberately separate + * from cbe.config.ts, which belongs to the outbound CBE_BIRR wallet gateway: two disjoint + * credential/auth domains that rotate independently (plan D7). + */ +export default registerAs("cbeBill", () => ({ + /** Kill switch — all /cbe/* endpoints answer 503 while false. */ + enabled: process.env.CBE_BILL_ENABLED === "true", + /** Credentials CBE presents to /cbe/oauth/token. */ + clientId: process.env.CBE_BILL_CLIENT_ID || "", + clientSecret: process.env.CBE_BILL_CLIENT_SECRET || "", + /** Signs/verifies the bearer tokens WE issue to CBE — never shared with ServiceAuthGuard. */ + jwtSecret: process.env.CBE_BILL_JWT_SECRET || "", + tokenExpiresIn: Number(process.env.CBE_BILL_TOKEN_EXPIRES_IN || 3600), + scope: process.env.CBE_BILL_SCOPE || "Unified_Outgoing", + /** bill-query hop to the owning domain app. Short — CBE holds its own timeout over ours. */ + domainTimeoutMs: Number(process.env.CBE_BILL_DOMAIN_TIMEOUT_MS || 3000), + passengerApiBaseUrl: ( + process.env.PASSENGER_API_BASE_URL || "http://localhost:3002" + ).replace(/\/$/, ""), + freightApiBaseUrl: ( + process.env.FREIGHT_API_BASE_URL || "http://localhost:3001" + ).replace(/\/$/, ""), +})); diff --git a/apps/edr-payment-api/src/migrations/1782300000000-AddCbeBillReference.ts b/apps/edr-payment-api/src/migrations/1782300000000-AddCbeBillReference.ts new file mode 100644 index 000000000..9cef39533 --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1782300000000-AddCbeBillReference.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.1, §5): + * - `bill_reference` — the short numeric Bill_Id CBE presents back to us; unique, null for + * every non-CBE intent. + * - `payer_name` — payer snapshot captured at initiate; fallback for /cbe/query Full_Name. + * - `cbe_bill_reference_seq` — backs the 11-digit sequence body of the bill reference. + * + * DATA SAFETY: purely additive — new nullable columns and a new sequence; no existing rows + * or values are touched. + */ +export class AddCbeBillReference1782300000000 implements MigrationInterface { + name = "AddCbeBillReference1782300000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "bill_reference" varchar(32)`, + ); + await queryRunner.query( + `ALTER TABLE "edr_payment"."payment_intent" ADD COLUMN IF NOT EXISTS "payer_name" varchar(128)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "uq_payment_intent_bill_reference" ON "edr_payment"."payment_intent" ("bill_reference") WHERE "bill_reference" IS NOT NULL`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS "edr_payment"."cbe_bill_reference_seq" START 10000001`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP SEQUENCE IF EXISTS "edr_payment"."cbe_bill_reference_seq"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "edr_payment"."uq_payment_intent_bill_reference"`, + ); + await queryRunner.query( + `ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "payer_name"`, + ); + await queryRunner.query( + `ALTER TABLE "edr_payment"."payment_intent" DROP COLUMN IF EXISTS "bill_reference"`, + ); + } +} diff --git a/apps/edr-payment-api/src/migrations/1782400000000-CreateCbeBillOperation.ts b/apps/edr-payment-api/src/migrations/1782400000000-CreateCbeBillOperation.ts new file mode 100644 index 000000000..075fbdf5c --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1782400000000-CreateCbeBillOperation.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * CBE-protocol audit/idempotency ledger for the Unified Bill Payment integration + * (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2). + * + * - UNIQUE (end_to_end_txn_id, operation): DB-level backstop for the application idempotency + * checks — a concurrent duplicate of the same CBE attempt cannot create two rows. + * - Partial UNIQUE (cbe_txn_ref) on settled PAYMENTs: blocks Cbe_Txn_Ref replay across bills. + * + * DATA SAFETY: new table only; nothing existing is touched. + */ +export class CreateCbeBillOperation1782400000000 implements MigrationInterface { + name = "CreateCbeBillOperation1782400000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "edr_payment"."cbe_bill_operation" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "operation" varchar(16) NOT NULL, + "bill_id" varchar(32) NOT NULL, + "end_to_end_txn_id" varchar(128) NOT NULL, + "cbe_txn_ref" varchar(128), + "destination_api_name" varchar(64), + "intent_id" uuid, + "trade_status" varchar(16) NOT NULL, + "failure_class" varchar(16), + "response_code" varchar(8), + "response_description" text, + "request_payload" jsonb NOT NULL, + "response_payload" jsonb, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_e2e" ON "edr_payment"."cbe_bill_operation" ("end_to_end_txn_id", "operation")`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "uq_cbe_bill_operation_txn_ref" ON "edr_payment"."cbe_bill_operation" ("cbe_txn_ref") WHERE "operation" = 'PAYMENT' AND "trade_status" = 'SUCCESS'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_cbe_bill_operation_bill" ON "edr_payment"."cbe_bill_operation" ("bill_id", "operation")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS "edr_payment"."cbe_bill_operation"`, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts new file mode 100644 index 000000000..f46571c6b --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts @@ -0,0 +1,141 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { firstValueFrom } from "rxjs"; +import { PaymentReferenceType, PaymentService } from "@edr/types"; +import { PaymentIntent } from "../intents/entities/payment-intent.entity"; +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). */ +export interface BillQueryResult { + stillPayable: boolean; + payerName?: string | null; + currentAmountMinor?: number | null; + currency?: string | null; + /** When stillPayable=false — see {@link BillNotPayableReason}. */ + 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; +} + +/** + * Payment_Reason when the domain app sends none (older build, or an order with no human + * reference). Generic but never blank: CBE renders this field to the payer, and a bill with + * 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"; +} + +/** + * 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); + 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.`; + } +} + +/** + * The live "still payable?" hop to the owning domain app — routing comes from + * `intent.service` (plan D3). This hop is the double-payment guard (§6.3) and the source of + * the mandatory Full_Name: NOT optional, and on the /cbe/payment path never served from cache. + * Short timeout, no retries — CBE holds its own timeout over ours. + */ +@Injectable() +export class BillResolverService { + private readonly logger = new Logger(BillResolverService.name); + private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + ) {} + + async billQuery(intent: PaymentIntent): Promise { + const base = + intent.service === PaymentService.PASSENGER + ? this.config.get("cbeBill.passengerApiBaseUrl") + : this.config.get("cbeBill.freightApiBaseUrl"); + const url = `${base}/internal/payments/bill-query`; + + try { + const response = await firstValueFrom( + this.http.post( + url, + { + referenceType: intent.referenceType, + referenceId: intent.referenceId, + }, + { + timeout: this.config.get("cbeBill.domainTimeoutMs") ?? 3000, + headers: this.serviceToken + ? { "x-service-token": this.serviceToken } + : {}, + }, + ), + ); + // The passenger API wraps every response in a { success, data } envelope + // (global transform interceptor); freight returns the body bare. Accept both. + const body = response.data as unknown as { + success?: boolean; + data?: BillQueryResult; + }; + return body && typeof body === "object" && "success" in body && body.data + ? body.data + : (response.data as BillQueryResult); + } catch (err) { + this.logger.warn( + `bill-query ${intent.service}/${intent.referenceId} unreachable: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + // TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5). + throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT"); + } + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts new file mode 100644 index 000000000..bb4319cf8 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-auth.guard.ts @@ -0,0 +1,39 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; +import { Request } from "express"; + +/** + * Verifies the bearer tokens WE minted for CBE at /cbe/oauth/token (plan D7). Its secret + * (CBE_BILL_JWT_SECRET) is disjoint from ServiceAuthGuard's shared token: a CBE token must + * never authenticate a call to /payments/*, and vice versa. + */ +@Injectable() +export class CbeAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly config: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const auth = request.headers.authorization; + if (!auth?.startsWith("Bearer ")) { + throw new UnauthorizedException("Missing bearer token"); + } + + try { + await this.jwtService.verifyAsync(auth.substring(7), { + secret: this.config.get("cbeBill.jwtSecret"), + }); + return true; + } catch { + throw new UnauthorizedException("Invalid or expired token"); + } + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts new file mode 100644 index 000000000..46b8f9009 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts @@ -0,0 +1,57 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseFilters, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CbeAuthGuard } from "./cbe-auth.guard"; +import { CbeExceptionFilter } from "./cbe-exception.filter"; +import { CbeBillService } from "./cbe-bill.service"; +import { TokenRequestDto } from "./dto/token-request.dto"; +import { TokenResponseDto } from "./dto/token-response.dto"; +import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto"; +import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto"; +import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto"; +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). + */ +@ApiTags("CBE Unified Bill (inbound)") +@Controller("cbe") +@UseFilters(CbeExceptionFilter) +export class CbeBillController { + constructor(private readonly cbeBillService: CbeBillService) {} + + @Post("oauth/token") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "OAuth client_credentials token for CBE (we are the auth server)" }) + async token(@Body() dto: TokenRequestDto): Promise { + return this.cbeBillService.generateToken(dto); + } + + @Post("query") + @HttpCode(HttpStatus.OK) + @UseGuards(CbeAuthGuard) + @ApiOperation({ summary: "Bill lookup — amount due + payer name for a Bill_Id" }) + async query(@Body() dto: CbeQueryRequestDto): Promise { + return this.cbeBillService.query(dto); + } + + @Post("payment") + @HttpCode(HttpStatus.OK) + @UseGuards(CbeAuthGuard) + @ApiOperation({ summary: "Settle a bill — customer already debited by CBE" }) + async payment( + @Body() dto: CbePaymentRequestDto, + ): Promise { + return this.cbeBillService.pay(dto); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts new file mode 100644 index 000000000..8efe8399f --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.module.ts @@ -0,0 +1,28 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { JwtModule } from "@nestjs/jwt"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { IntentsModule } from "../intents/intents.module"; +import { CbeBillOperation } from "./entities/cbe-bill-operation.entity"; +import { BillResolverService } from "./bill-resolver.service"; +import { CbeAuthGuard } from "./cbe-auth.guard"; +import { CbeBillController } from "./cbe-bill.controller"; +import { CbeBillRepository } from "./cbe-bill.repository"; +import { CbeBillService } from "./cbe-bill.service"; + +/** + * Inbound CBE Unified Bill Payment module (docs/cbe/). Its auth domain is disjoint from the + * rest of the app: tokens are minted and verified with CBE_BILL_JWT_SECRET only (plan D7) — + * JwtModule is registered bare and the secret passed explicitly at sign/verify time. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([CbeBillOperation]), + HttpModule, + JwtModule.register({}), + IntentsModule, + ], + controllers: [CbeBillController], + providers: [CbeBillService, CbeBillRepository, BillResolverService, CbeAuthGuard], +}) +export class CbeBillModule {} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.repository.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.repository.ts new file mode 100644 index 000000000..d71c69ff5 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.repository.ts @@ -0,0 +1,35 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { + CbeBillOperation, + CbeOperation, +} from "./entities/cbe-bill-operation.entity"; + +@Injectable() +export class CbeBillRepository extends BaseRepository { + constructor( + @InjectRepository(CbeBillOperation) + repository: Repository, + ) { + super(repository); + } + + /** The prior attempt for CBE's per-attempt id — the §6.5 idempotency lookup. */ + async findByEndToEndTxnId( + endToEndTxnId: string, + operation: CbeOperation, + ): Promise { + return this.repository.findOne({ where: { endToEndTxnId, operation } }); + } + + /** A SUCCESSful settlement already carrying this Cbe_Txn_Ref — blocks replay across bills. */ + async findSettledByCbeTxnRef( + cbeTxnRef: string, + ): Promise { + return this.repository.findOne({ + where: { cbeTxnRef, operation: "PAYMENT", tradeStatus: "SUCCESS" }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts new file mode 100644 index 000000000..5b1a6cb28 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -0,0 +1,387 @@ +import { + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; +import { ProviderMethod, ProviderPaymentStatus } from "@edr/types"; +import { BillReferenceService } from "../intents/bill-reference.service"; +import { IntentsRepository } from "../intents/intents.repository"; +import { IntentsService } from "../intents/intents.service"; +import { PaymentIntent } from "../intents/entities/payment-intent.entity"; +import { + BillNotPayableReason, + BillResolverService, + defaultPaymentReason, + reasonToDescription, +} from "./bill-resolver.service"; +import { CbeBillRepository } from "./cbe-bill.repository"; +import { CbeBillOperation } from "./entities/cbe-bill-operation.entity"; +import { TokenRequestDto } from "./dto/token-request.dto"; +import { TokenResponseDto } from "./dto/token-response.dto"; +import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto"; +import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto"; +import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto"; +import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto"; +import { CbeBillError, toCbeFailure } from "./mappers/cbe-error.mapper"; +import { + mapQueryFailure, + mapQuerySuccess, +} from "./mappers/cbe-query.mapper"; +import { + mapPaymentFailure, + mapPaymentSuccess, +} from "./mappers/cbe-payment.mapper"; + +/** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */ +const PG_UNIQUE_VIOLATION = "23505"; + +/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */ +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). + * Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the + * controller); the exception filter only catches auth, validation, and the unexpected. + */ +@Injectable() +export class CbeBillService { + private readonly logger = new Logger(CbeBillService.name); + + constructor( + private readonly config: ConfigService, + private readonly jwtService: JwtService, + private readonly cbeBillRepository: CbeBillRepository, + private readonly intentsRepository: IntentsRepository, + private readonly intentsService: IntentsService, + private readonly billReferenceService: BillReferenceService, + private readonly billResolver: BillResolverService, + ) {} + + /* ------------------------------------------------------------------ token */ + + async generateToken(dto: TokenRequestDto): Promise { + this.assertEnabled(); + + const clientId = this.config.get("cbeBill.clientId"); + const clientSecret = this.config.get("cbeBill.clientSecret"); + // Unset credentials must fail closed — never let "" === "" mint a token. + const valid = + !!clientId && + !!clientSecret && + dto.client_id === clientId && + dto.client_secret === clientSecret && + dto.grant_type === "client_credentials" && + dto.scope === this.config.get("cbeBill.scope"); + if (!valid) throw new UnauthorizedException("Invalid credentials"); + + const expiresIn = this.config.get("cbeBill.tokenExpiresIn") ?? 3600; + const accessToken = await this.jwtService.signAsync( + { iss: "edr-payment-api", clientId: dto.client_id, scope: dto.scope }, + { secret: this.config.get("cbeBill.jwtSecret"), expiresIn }, + ); + + return { + token_type: "Bearer", + access_token: accessToken, + expires_in: expiresIn, + scope: dto.scope, + consented_on: Math.floor(Date.now() / 1000), + }; + } + + /* ------------------------------------------------------------------ query */ + + async query(dto: CbeQueryRequestDto): Promise { + this.assertEnabled(); + + // Audit first — unlike the reference (which left this commented out), every query attempt + // is persisted; counter disputes are exactly what this row is for (plan Phase 3.2). + const audit = await this.upsertAudit("QUERY", dto.End_To_End_Txn_Id, { + billId: dto.Bill_Id, + destinationApiName: dto.Destination_Api_Name, + requestPayload: dto as unknown as Record, + }); + + try { + const intent = await this.resolveIntent(dto.Bill_Id); + this.assertIntentPayable(intent); + + // The live domain hop — the double-payment guard (§6.3). Not optional. + const billQuery = await this.billResolver.billQuery(intent); + if (!billQuery.stillPayable) { + throw new CbeBillError( + reasonToDescription(billQuery.reason, intent.referenceType), + "BUSINESS", + ); + } + + const response = mapQuerySuccess(dto, { + amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor, + fullName: billQuery.payerName || intent.payerName || "", + paymentReason: + billQuery.paymentReason || defaultPaymentReason(intent.referenceType), + }); + await this.finishAudit(audit, { + intentId: intent.id, + tradeStatus: "SUCCESS", + response, + }); + return response; + } catch (err) { + const failure = toCbeFailure(err); + if (!(err instanceof CbeBillError)) { + this.logger.error( + `/cbe/query ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`, + ); + } + const response = mapQueryFailure(dto, failure.description); + await this.finishAudit(audit, { + tradeStatus: "FAILED", + failureClass: failure.failureClass, + response, + }); + return response; + } + } + + /* ------------------------------------------------------------------ payment */ + + async pay(dto: CbePaymentRequestDto): Promise { + this.assertEnabled(); + + // §6.5 idempotency on CBE's per-attempt id, in order. + const prior = await this.cbeBillRepository.findByEndToEndTxnId( + dto.End_To_End_Txn_Id, + "PAYMENT", + ); + if (prior) { + if (prior.tradeStatus === "SUCCESS") { + // Replay the stored body verbatim. Never re-settle. + return prior.responsePayload as unknown as CbePaymentResponseDto; + } + if (prior.tradeStatus === "PENDING") { + return mapPaymentFailure(dto, "Payment is being processed."); + } + if (prior.failureClass === "BUSINESS") { + // Final — retrying cannot change the answer. Replay what we told CBE last time. + return ( + (prior.responsePayload as unknown as CbePaymentResponseDto) ?? + mapPaymentFailure( + dto, + prior.responseDescription ?? "Payment already failed.", + ) + ); + } + // FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement. + } + + // Cbe_Txn_Ref replay across different bills/attempts (partial-unique backstop in the DB). + const settled = await this.cbeBillRepository.findSettledByCbeTxnRef( + dto.Cbe_Txn_Ref, + ); + if (settled) { + return mapPaymentFailure( + dto, + `Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`, + ); + } + + let audit: CbeBillOperation; + if (prior) { + // Transient retry reuses the row — UNIQUE (end_to_end_txn_id, operation) forbids a second. + audit = + (await this.cbeBillRepository.update(prior.id, { + tradeStatus: "PENDING", + failureClass: null, + cbeTxnRef: dto.Cbe_Txn_Ref, + requestPayload: dto as unknown as Record, + })) ?? prior; + } else { + try { + audit = await this.cbeBillRepository.create({ + operation: "PAYMENT", + billId: dto.Bill_Id, + endToEndTxnId: dto.End_To_End_Txn_Id, + cbeTxnRef: dto.Cbe_Txn_Ref, + destinationApiName: dto.Destination_Api_Name, + tradeStatus: "PENDING", + requestPayload: dto as unknown as Record, + }); + } 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."); + } + throw err; + } + } + + let intent: PaymentIntent | undefined; + try { + intent = await this.resolveIntent(dto.Bill_Id); + + if (dto.Currency && dto.Currency !== intent.currency) { + throw new CbeBillError("Payment currency does not match.", "BUSINESS"); + } + + // Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous + // failure (§6.1): after this we settle and reconcile downstream. + const billQuery = await this.billResolver.billQuery(intent); + if (!billQuery.stillPayable) { + throw new CbeBillError( + reasonToDescription(billQuery.reason, intent.referenceType), + "BUSINESS", + ); + } + + const amount = Number(dto.Amount); + if ( + !Number.isFinite(amount) || + Math.abs(amount - intent.amountMinor) > + intent.amountMinor * AMOUNT_TOLERANCE + ) { + throw new CbeBillError("Payment amount does not match.", "BUSINESS"); + } + + const paidAt = new Date(dto.Timestamp); + // Existing state machine, unmodified — intent + outbox commit in one transaction. + await this.intentsService.applyProviderResult(intent.id, { + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: dto.Cbe_Txn_Ref, + paidAt: Number.isNaN(paidAt.getTime()) ? new Date() : paidAt, + confirmedAmountMinor: amount, + }); + + const response = mapPaymentSuccess(dto, intent.merchantOrderId); + await this.finishAudit(audit, { + intentId: intent.id, + tradeStatus: "SUCCESS", + response, + }); + this.logger.log( + `bill ${dto.Bill_Id} settled by CBE txn ${dto.Cbe_Txn_Ref} (intent ${intent.id})`, + ); + return response; + } catch (err) { + const failure = toCbeFailure(err); + if (!(err instanceof CbeBillError)) { + this.logger.error( + `/cbe/payment ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`, + ); + } + const response = mapPaymentFailure(dto, failure.description); + await this.finishAudit(audit, { + intentId: intent?.id, + tradeStatus: "FAILED", + failureClass: failure.failureClass, + response, + }); + return response; + } + } + + /* ------------------------------------------------------------------ helpers */ + + /** Kill switch (CBE_BILL_ENABLED) — 503 so CBE classes it as transport error and retries. */ + assertEnabled(): void { + if (!this.config.get("cbeBill.enabled")) { + throw new ServiceUnavailableException("CBE bill payment is disabled"); + } + } + + /** + * 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. */ + private async resolveIntent(billId: string): Promise { + if (!this.billReferenceService.isValid(billId)) { + 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"); + } + return intent; + } + + /** Insert the audit row; a CBE retry of the same QUERY attempt reuses (and refreshes) its row. */ + private async upsertAudit( + operation: "QUERY", + endToEndTxnId: string, + data: { + billId: string; + destinationApiName: string; + requestPayload: Record; + }, + ): Promise { + try { + return await this.cbeBillRepository.create({ + operation, + endToEndTxnId, + tradeStatus: "PENDING", + ...data, + }); + } catch (err) { + if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) { + const existing = await this.cbeBillRepository.findByEndToEndTxnId( + endToEndTxnId, + operation, + ); + if (existing) return existing; + } + throw err; + } + } + + private async finishAudit( + audit: CbeBillOperation, + outcome: { + intentId?: string; + tradeStatus: "SUCCESS" | "FAILED"; + failureClass?: "BUSINESS" | "TRANSIENT"; + response: { Response_Code: string; Response_Description: string }; + }, + ): Promise { + await this.cbeBillRepository.update(audit.id, { + intentId: outcome.intentId ?? audit.intentId, + tradeStatus: outcome.tradeStatus, + failureClass: outcome.failureClass ?? null, + responseCode: outcome.response.Response_Code, + responseDescription: outcome.response.Response_Description, + responsePayload: outcome.response as unknown as Record, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts new file mode 100644 index 000000000..dcbb5d82a --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts @@ -0,0 +1,72 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from "@nestjs/common"; +import { Response } from "express"; + +/** + * Controller-scoped safety net for anything that escapes CbeBillService's own error handling + * (auth failures, DTO validation, unhandled throws). CBE's contract (plan D6): HTTP 200 for + * every business outcome, 401 only for authentication. + * + * Exception: the CBE_BILL_ENABLED kill switch throws ServiceUnavailableException and stays + * HTTP 503 with a non-0/1/3 code — the spec classes "any other code" as a transport error + * ("retry, contact admin"), which is exactly what a kill switch should signal; a 200/code-3 + * would tell CBE the failure is final. + */ +@Catch() +export class CbeExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(CbeExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + + if (exception instanceof UnauthorizedException) { + response.status(HttpStatus.UNAUTHORIZED).json({ + Status: "FAILED", + Response_Code: "1", + Response_Description: exception.message || "Unauthorized", + }); + return; + } + + if (exception instanceof ServiceUnavailableException) { + response.status(HttpStatus.SERVICE_UNAVAILABLE).json({ + Status: "FAILED", + Response_Code: "9", + Response_Description: "Service temporarily unavailable.", + }); + return; + } + + if (exception instanceof HttpException) { + // class-validator errors arrive as BadRequestException with message: string[]. + const body = exception.getResponse(); + const message = + typeof body === "object" && body !== null && "message" in body + ? ([] as string[]).concat((body as { message: string }).message).join("; ") + : exception.message; + response.status(HttpStatus.OK).json({ + Status: "FAILED", + Response_Code: "3", + Response_Description: message || "Invalid request", + }); + return; + } + + this.logger.error( + `unhandled /cbe/* error: ${exception instanceof Error ? exception.stack : String(exception)}`, + ); + response.status(HttpStatus.OK).json({ + Status: "FAILED", + Response_Code: "3", + Response_Description: "Internal server error.", + }); + } +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-request.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-request.dto.ts new file mode 100644 index 000000000..04b6a054d --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-request.dto.ts @@ -0,0 +1,102 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; +import { AdditionalFieldDto } from "./cbe-query-request.dto"; + +/** + * CBE → us, POST /cbe/payment (AAFDA spec §3.5). Mandatory fields per spec; the optional tail + * (payer identity, channel) mirrors the reference implementation pending the Q1 sample files. + */ +export class CbePaymentRequestDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + Destination_Api_Name!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + End_To_End_Txn_Id!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + Cbe_Txn_Ref!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + Timestamp!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + Bill_Id!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + Amount!: string; + + @ApiProperty() + @IsString() + Currency!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Phone_No?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Credit_Acct_Number?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + First_Name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Last_Name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Full_Name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Tin_Number?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Cheque_No?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Bank_Code?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + Payment_Method?: string; + + @ApiPropertyOptional({ type: [AdditionalFieldDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdditionalFieldDto) + Additional_Fields?: AdditionalFieldDto[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-response.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-response.dto.ts new file mode 100644 index 000000000..3dbffab2c --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-payment-response.dto.ts @@ -0,0 +1,11 @@ +/** Us → CBE, POST /cbe/payment response (AAFDA spec §3.8). */ +export class CbePaymentResponseDto { + Destination_Api_Name!: string; + End_To_End_Txn_Id!: string; + Cbe_Txn_Ref!: string; + Destination_Txn_Ref!: string; + Status!: string; + Response_Code!: string; + Response_Description!: string; + Additional_Fields!: { Key: string; Value: string }[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-request.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-request.dto.ts new file mode 100644 index 000000000..f44f9ea9b --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-request.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; + +export class AdditionalFieldDto { + @IsString() + Key!: string; + + @IsString() + Value!: string; +} + +/** CBE → us, POST /cbe/query (AAFDA spec §2.5). Field names are CBE's, Pascal_Snake. */ +export class CbeQueryRequestDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + Destination_Api_Name!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + End_To_End_Txn_Id!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + Bill_Id!: string; + + @ApiPropertyOptional({ type: [AdditionalFieldDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdditionalFieldDto) + Additional_Fields?: AdditionalFieldDto[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-response.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-response.dto.ts new file mode 100644 index 000000000..443ccde0a --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/cbe-query-response.dto.ts @@ -0,0 +1,25 @@ +/** + * Us → CBE, POST /cbe/query response (AAFDA spec §2.8, shape mirrored from the reference + * implementation pending the Q1 sample files). Empty-string fields are deliberate — the + * reference sends the full envelope with blanks rather than omitting keys. + */ +export class CbeQueryResponseDto { + Destination_Api_Name!: string; + End_To_End_Txn_Id!: string; + Bill_Id!: string; + Total_Amount!: string; + Penalty_Amount!: string; + Bill_Amount!: string; + First_Name!: string; + Last_Name!: string; + Full_Name!: string; + Payment_Reason!: string; + Tin_Number!: string; + Credit_Acct_Number!: string; + Transaction_Type!: string; + Timestamp!: string; + Status!: string; + Response_Code!: string; + Response_Description!: string; + Additional_Fields!: { Key: string; Value: string }[]; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/token-request.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-request.dto.ts new file mode 100644 index 000000000..15dccb2aa --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-request.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +/** CBE → us, POST /cbe/oauth/token (AAFDA spec §1.5). Field names are CBE's, snake_case. */ +export class TokenRequestDto { + @ApiProperty({ example: "client_credentials" }) + @IsString() + @IsNotEmpty() + grant_type!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + client_id!: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + client_secret!: string; + + @ApiProperty({ example: "Unified_Outgoing" }) + @IsString() + @IsNotEmpty() + scope!: string; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/dto/token-response.dto.ts b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-response.dto.ts new file mode 100644 index 000000000..9fc183bb8 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/dto/token-response.dto.ts @@ -0,0 +1,9 @@ +/** Us → CBE, POST /cbe/oauth/token response (AAFDA spec §1.7). */ +export class TokenResponseDto { + token_type!: string; + access_token!: string; + expires_in!: number; + scope!: string; + /** Unix seconds at issue time. */ + consented_on!: number; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/entities/cbe-bill-operation.entity.ts b/apps/edr-payment-api/src/modules/cbe-bill/entities/cbe-bill-operation.entity.ts new file mode 100644 index 000000000..2182c9cb1 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/entities/cbe-bill-operation.entity.ts @@ -0,0 +1,72 @@ +import { Column, Entity, Index } from "typeorm"; +import { BaseEntity } from "@edr/api-common"; + +export type CbeOperation = "QUERY" | "PAYMENT"; +export type CbeTradeStatus = "PENDING" | "SUCCESS" | "FAILED"; +/** Drives the same-End_To_End_Txn_Id retry policy (plan §6.5): BUSINESS is final, TRANSIENT retryable. */ +export type CbeFailureClass = "BUSINESS" | "TRANSIENT"; + +/** + * CBE-protocol-level audit and idempotency ledger (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.2). + * Separate from payment_intent because it tracks CBE's transaction identity — not ours — and + * must retain the exact response body we returned so a retry replays it byte-for-byte. + * + * The UNIQUE (end_to_end_txn_id, operation) and partial-unique cbe_txn_ref indexes live in the + * CreateCbeBillOperation migration. + */ +@Entity({ name: "cbe_bill_operation" }) +@Index("idx_cbe_bill_operation_bill", ["billId", "operation"]) +export class CbeBillOperation extends BaseEntity { + @Column({ name: "operation", type: "varchar", length: 16 }) + operation!: CbeOperation; + + /** Bill_Id exactly as received from CBE. */ + @Column({ name: "bill_id", type: "varchar", length: 32 }) + billId!: string; + + /** CBE's per-attempt id — the idempotency key of the protocol. */ + @Column({ name: "end_to_end_txn_id", type: "varchar", length: 128 }) + endToEndTxnId!: string; + + /** CBE core-banking reference; set on PAYMENT. */ + @Column({ name: "cbe_txn_ref", type: "varchar", length: 128, nullable: true }) + cbeTxnRef?: string | null; + + /** Echoed back in every response. */ + @Column({ + name: "destination_api_name", + type: "varchar", + length: 64, + nullable: true, + }) + destinationApiName?: string | null; + + /** Our payment_intent.id once the bill resolved to an intent. Soft reference, no FK. */ + @Column({ name: "intent_id", type: "uuid", nullable: true }) + intentId?: string | null; + + @Column({ name: "trade_status", type: "varchar", length: 16 }) + tradeStatus!: CbeTradeStatus; + + @Column({ + name: "failure_class", + type: "varchar", + length: 16, + nullable: true, + }) + failureClass?: CbeFailureClass | null; + + @Column({ name: "response_code", type: "varchar", length: 8, nullable: true }) + responseCode?: string | null; + + @Column({ name: "response_description", type: "text", nullable: true }) + responseDescription?: string | null; + + /** Raw inbound body, verbatim. */ + @Column({ name: "request_payload", type: "jsonb" }) + requestPayload!: Record; + + /** EXACT body we returned — replayed verbatim when CBE retries a settled End_To_End_Txn_Id. */ + @Column({ name: "response_payload", type: "jsonb", nullable: true }) + responsePayload?: Record | null; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts new file mode 100644 index 000000000..7cda33117 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts @@ -0,0 +1,30 @@ +import { CbeFailureClass } from "../entities/cbe-bill-operation.entity"; + +/** + * A CBE business/transport outcome we detected ourselves. `failureClass` drives the §6.5 + * same-End_To_End_Txn_Id retry policy: BUSINESS outcomes are final (retrying cannot change + * the answer), TRANSIENT ones (our 5xx, domain app unreachable) may be retried by CBE. + */ +export class CbeBillError extends Error { + constructor( + message: string, + readonly failureClass: CbeFailureClass, + ) { + super(message); + this.name = "CbeBillError"; + } +} + +/** + * 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). + */ +export function toCbeFailure(err: unknown): { + description: string; + failureClass: CbeFailureClass; +} { + if (err instanceof CbeBillError) { + return { description: err.message, failureClass: err.failureClass }; + } + return { description: "Internal server error.", failureClass: "TRANSIENT" }; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts new file mode 100644 index 000000000..d90d94c91 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts @@ -0,0 +1,34 @@ +import { CbePaymentRequestDto } from "../dto/cbe-payment-request.dto"; +import { CbePaymentResponseDto } from "../dto/cbe-payment-response.dto"; + +export function mapPaymentSuccess( + request: CbePaymentRequestDto, + destinationTxnRef: string, +): CbePaymentResponseDto { + return { + Destination_Api_Name: request.Destination_Api_Name, + End_To_End_Txn_Id: request.End_To_End_Txn_Id, + Cbe_Txn_Ref: request.Cbe_Txn_Ref, + Destination_Txn_Ref: destinationTxnRef, + Status: "SUCCESS", + Response_Code: "0", + Response_Description: "Success", + Additional_Fields: [], + }; +} + +export function mapPaymentFailure( + request: CbePaymentRequestDto, + description: string, +): CbePaymentResponseDto { + return { + Destination_Api_Name: request.Destination_Api_Name, + End_To_End_Txn_Id: request.End_To_End_Txn_Id, + Cbe_Txn_Ref: request.Cbe_Txn_Ref, + Destination_Txn_Ref: "", + Status: "FAILED", + Response_Code: "3", + Response_Description: description, + Additional_Fields: [], + }; +} diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts new file mode 100644 index 000000000..95caadc75 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts @@ -0,0 +1,55 @@ +import { CbeQueryRequestDto } from "../dto/cbe-query-request.dto"; +import { CbeQueryResponseDto } from "../dto/cbe-query-response.dto"; + +export function mapQuerySuccess( + request: CbeQueryRequestDto, + input: { amountMajor: number; fullName: string; paymentReason: string }, +): CbeQueryResponseDto { + const amount = input.amountMajor.toFixed(2); + return { + Destination_Api_Name: request.Destination_Api_Name, + End_To_End_Txn_Id: request.End_To_End_Txn_Id, + Bill_Id: request.Bill_Id, + Total_Amount: amount, + Penalty_Amount: "0.00", + Bill_Amount: amount, + First_Name: "", + Last_Name: "", + Full_Name: input.fullName, + Payment_Reason: input.paymentReason, + Tin_Number: "", + Credit_Acct_Number: "", + Transaction_Type: "", + Timestamp: new Date().toISOString(), + Status: "SUCCESS", + Response_Code: "0", + Response_Description: "Success", + Additional_Fields: [], + }; +} + +export function mapQueryFailure( + request: CbeQueryRequestDto, + description: string, +): CbeQueryResponseDto { + return { + Destination_Api_Name: request.Destination_Api_Name, + End_To_End_Txn_Id: request.End_To_End_Txn_Id, + Bill_Id: request.Bill_Id, + Total_Amount: "", + Penalty_Amount: "", + Bill_Amount: "", + First_Name: "", + Last_Name: "", + Full_Name: "", + Payment_Reason: "", + Tin_Number: "", + Credit_Acct_Number: "", + Transaction_Type: "", + Timestamp: new Date().toISOString(), + Status: "FAILED", + Response_Code: "3", + Response_Description: description, + Additional_Fields: [], + }; +} diff --git a/apps/edr-payment-api/src/modules/intents/bill-reference.service.spec.ts b/apps/edr-payment-api/src/modules/intents/bill-reference.service.spec.ts new file mode 100644 index 000000000..2956d5e45 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/bill-reference.service.spec.ts @@ -0,0 +1,38 @@ +import { DataSource } from "typeorm"; +import { BillReferenceService } from "./bill-reference.service"; + +describe("BillReferenceService", () => { + const dataSource = { + query: jest.fn().mockResolvedValue([{ nextval: "10000001" }]), + } as unknown as DataSource; + const service = new BillReferenceService(dataSource); + + it("generates a 12-digit numeric reference that validates", async () => { + const ref = await service.generate(); + expect(ref).toMatch(/^\d{12}$/); + expect(ref.startsWith("00010000001")).toBe(true); + expect(service.isValid(ref)).toBe(true); + }); + + it("rejects a single mistyped digit", async () => { + const ref = await service.generate(); + const flipped = + ref.slice(0, 5) + ((Number(ref[5]) + 1) % 10) + ref.slice(6); + expect(service.isValid(flipped)).toBe(false); + }); + + it("rejects adjacent transpositions", async () => { + const ref = await service.generate(); + // Transpose the last two differing adjacent body digits. + const digits = ref.split(""); + const i = digits.findIndex((d, idx) => idx < 11 && d !== digits[idx + 1]); + [digits[i], digits[i + 1]] = [digits[i + 1], digits[i]]; + expect(service.isValid(digits.join(""))).toBe(false); + }); + + it("rejects wrong length and non-numeric input", () => { + expect(service.isValid("12345")).toBe(false); + expect(service.isValid("00045123389A")).toBe(false); + expect(service.isValid("")).toBe(false); + }); +}); diff --git a/apps/edr-payment-api/src/modules/intents/bill-reference.service.ts b/apps/edr-payment-api/src/modules/intents/bill-reference.service.ts new file mode 100644 index 000000000..e383d6352 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/bill-reference.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +/** + * CBE_BILL bill reference numbers (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5). + * + * 12 numeric digits: an 11-digit Postgres-sequence value, zero-padded, plus a trailing Luhn + * check digit. Numeric-only so it is typeable on any USSD keypad; the check digit rejects + * most single-digit typos and adjacent transpositions before any DB lookup; sequence-backed + * so uniqueness is guaranteed without a collision-retry loop. + * + * Subject to Phase 0-Q2 — if CBE imposes their own length/charset constraint, theirs wins. + */ +const SEQUENCE = "edr_payment.cbe_bill_reference_seq"; +const TOTAL_LENGTH = 12; + +@Injectable() +export class BillReferenceService { + constructor(private readonly dataSource: DataSource) {} + + async generate(): Promise { + const rows: [{ nextval: string }] = await this.dataSource.query( + `SELECT nextval('${SEQUENCE}')`, + ); + const body = rows[0].nextval.padStart(TOTAL_LENGTH - 1, "0"); + return body + luhnCheckDigit(body); + } + + /** Format + check-digit validation — the cheap reject before any DB hit. */ + isValid(billReference: string): boolean { + if (!/^\d+$/.test(billReference) || billReference.length !== TOTAL_LENGTH) { + return false; + } + const body = billReference.slice(0, -1); + return luhnCheckDigit(body) === billReference.slice(-1); + } +} + +/** Standard Luhn check digit over a numeric string. */ +function luhnCheckDigit(digits: string): string { + let sum = 0; + // Rightmost body digit is doubled (it sits next to the check digit position). + for (let i = 0; i < digits.length; i++) { + let d = Number(digits[digits.length - 1 - i]); + if (i % 2 === 0) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + } + return String((10 - (sum % 10)) % 10); +} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index a9c8edfea..6d602b28a 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -1,6 +1,7 @@ import { IsEnum, IsIn, + IsISO8601, IsNumber, IsOptional, IsPositive, @@ -101,6 +102,25 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @IsString() @MaxLength(128) idempotencyKey?: string; + + @ApiPropertyOptional({ + description: + "Payer full name snapshot (CBE_BILL: fallback Full_Name for /cbe/query when the " + + "domain app is unreachable)", + }) + @IsOptional() + @IsString() + @MaxLength(128) + payerName?: string; + + @ApiPropertyOptional({ + description: + "Intent expiry, ISO-8601 (CBE_BILL: the booking's own payment deadline — never a " + + "provider session TTL)", + }) + @IsOptional() + @IsISO8601() + expiresAt?: string; } export class IntentReferenceQueryDto { diff --git a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts index 12fa3c402..4025cfc30 100644 --- a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts +++ b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts @@ -109,6 +109,23 @@ export class PaymentIntent extends BaseEntity { }) idempotencyKey?: string | null; + /** + * CBE_BILL only: the short numeric Bill_Id the customer types at a CBE channel + * (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5). Null for every other provider. + */ + @Column({ + name: "bill_reference", + type: "varchar", + length: 32, + nullable: true, + unique: true, + }) + billReference?: string | null; + + /** Payer full name snapshot — fallback for CBE /cbe/query Full_Name when bill-query is down. */ + @Column({ name: "payer_name", type: "varchar", length: 128, nullable: true }) + payerName?: string | null; + @Column({ name: "expires_at", type: "timestamptz", nullable: true }) expiresAt?: Date | null; diff --git a/apps/edr-payment-api/src/modules/intents/intents.module.ts b/apps/edr-payment-api/src/modules/intents/intents.module.ts index 9e9774ad9..f968726b0 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.module.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { ProvidersModule } from "../providers/providers.module"; import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity"; import { PaymentIntent } from "./entities/payment-intent.entity"; +import { BillReferenceService } from "./bill-reference.service"; import { IntentsController } from "./intents.controller"; import { IntentsRepository } from "./intents.repository"; import { IntentsService } from "./intents.service"; @@ -15,7 +16,7 @@ import { IntentsService } from "./intents.service"; ProvidersModule, ], controllers: [IntentsController], - providers: [IntentsService, IntentsRepository], - exports: [IntentsService, IntentsRepository], + providers: [IntentsService, IntentsRepository, BillReferenceService], + exports: [IntentsService, IntentsRepository, BillReferenceService], }) export class IntentsModule {} diff --git a/apps/edr-payment-api/src/modules/intents/intents.repository.ts b/apps/edr-payment-api/src/modules/intents/intents.repository.ts index dcda00222..a2ff075dc 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.repository.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.repository.ts @@ -70,6 +70,13 @@ export class IntentsRepository extends BaseRepository { }); } + /** CBE_BILL: resolve the intent behind a Bill_Id presented by CBE. */ + async findByBillReference( + billReference: string, + ): Promise { + return this.repository.findOne({ where: { billReference } }); + } + async findByMerchantOrderId( merchantOrderId: string, ): Promise { diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts new file mode 100644 index 000000000..715184424 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -0,0 +1,111 @@ +import { BadRequestException } from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { + InitiatePaymentRequest, + PaymentReferenceType, + PaymentService, + ProviderMethod, + ProviderPaymentStatus, +} from "@edr/types"; +import { CacBankProvider } from "@edr/payment-providers"; +import { IntentsService } from "./intents.service"; +import { IntentsRepository } from "./intents.repository"; +import { BillReferenceService } from "./bill-reference.service"; +import { PaymentIntent } from "./entities/payment-intent.entity"; + +/** + * CBE_BILL regression tests for plan D5 (docs/cbe/CBE_IMPLEMENTATION_PLAN.md): the provider is + * deliberately absent from PAYMENT_PROVIDER_MAP, so the pull-side refresh must return the + * cached intent untouched instead of calling a provider. This is load-bearing — a stub + * provider entry would make the reconciliation sweep expire live CBE bills. + */ +describe("IntentsService CBE_BILL", () => { + const providers = new Map(); + let repository: jest.Mocked< + Pick< + IntentsRepository, + "create" | "findById" | "findByIdempotencyKey" | "update" + > + >; + let billReferenceService: { generate: jest.Mock }; + let service: IntentsService; + + const request: InitiatePaymentRequest = { + service: PaymentService.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + amountMinor: 1500, + currency: "ETB", + provider: ProviderMethod.CBE_BILL, + payerName: "Abebe Kebede", + expiresAt: "2026-08-01T12:00:00.000Z", + }; + + beforeEach(() => { + repository = { + create: jest.fn(async (data) => ({ id: "intent-1", ...data })), + findById: jest.fn(), + findByIdempotencyKey: jest.fn().mockResolvedValue(null), + update: jest.fn(), + } as never; + billReferenceService = { + generate: jest.fn().mockResolvedValue("000100000015"), + }; + service = new IntentsService( + repository as unknown as IntentsRepository, + {} as DataSource, + providers as never, + {} as CacBankProvider, + billReferenceService as unknown as BillReferenceService, + ); + }); + + it("initiates without a provider session: REQUIRES_ACTION + SHOW_BILL_REFERENCE", async () => { + const snapshot = await service.initiate(request); + + expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); + expect(snapshot.billReference).toBe("000100000015"); + expect(snapshot.clientAction).toMatchObject({ + type: "SHOW_BILL_REFERENCE", + billReference: "000100000015", + }); + // The booking's own deadline, not a provider-session TTL (plan §6.4). + expect(snapshot.expiresAt).toBe("2026-08-01T12:00:00.000Z"); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + billReference: "000100000015", + payerName: "Abebe Kebede", + }), + ); + }); + + it("rejects non-ETB currency (plan D8)", async () => { + await expect( + service.initiate({ ...request, currency: "DJF" }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("getIntent leaves a stale CBE_BILL intent untouched (no provider in map — plan D5)", async () => { + const intent = { + id: "intent-1", + service: PaymentService.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + merchantOrderId: "PSG-x", + provider: ProviderMethod.CBE_BILL, + status: ProviderPaymentStatus.REQUIRES_ACTION, + amountMinor: 1500, + currency: "ETB", + billReference: "000100000015", + // Stale enough that a mapped provider WOULD be queried. + updatedAt: new Date(Date.now() - 60_000), + } as unknown as PaymentIntent; + repository.findById.mockResolvedValue(intent); + const applySpy = jest.spyOn(service, "applyProviderResult"); + + const snapshot = await service.getIntent("intent-1"); + + expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); + expect(applySpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 7b389af78..c0b246b83 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -28,6 +28,7 @@ import { TERMINAL_INTENT_STATUSES, } from "./entities/payment-intent.entity"; import { IntentsRepository } from "./intents.repository"; +import { BillReferenceService } from "./bill-reference.service"; /** Don't hit the provider again if the intent was refreshed this recently. */ const REFRESH_MIN_AGE_MS = 5_000; @@ -69,6 +70,7 @@ export class IntentsService { @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, private readonly cacBankProvider: CacBankProvider, + private readonly billReferenceService: BillReferenceService, ) {} /* ------------------------------------------------------------------ initiate */ @@ -85,6 +87,12 @@ export class IntentsService { if (byKey) return this.toSnapshot(byKey); } + // CBE_BILL is inbound-only: there is no provider session to open and deliberately no entry + // in PAYMENT_PROVIDER_MAP (plan D5 — the sweep and refreshIfStale must no-op on it). + if (request.provider === ProviderMethod.CBE_BILL) { + return this.initiateCbeBill(request); + } + // Free method changes: no reuse/supersede. Every initiate opens a fresh intent, so a booking // may accumulate many intents (each method attempt is its own row). The `idempotencyKey` check // above still collapses exact duplicate submissions (e.g. a double-click). Confirm-once is @@ -140,6 +148,54 @@ export class IntentsService { return this.toSnapshot(intent); } + /** + * CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md). Intent-first: the bill + * reference is created here, before CBE ever sees the bill; settlement arrives later through + * the inbound /cbe/payment endpoint and the unchanged applyProviderResult() state machine. + */ + private async initiateCbeBill( + request: InitiatePaymentRequest, + ): Promise { + // D8: CBE settles ETB only. The domain app must price/charge the order in ETB. + if (request.currency !== "ETB") { + throw new BadRequestException( + `CBE_BILL supports ETB only (got ${request.currency})`, + ); + } + + const merchantOrderId = createMerchantOrderId(); + const billReference = await this.billReferenceService.generate(); + // expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider + // session TTL (plan §6.4: a short TTL would make the sweep cancel the booking within the hour). + const expiresAt = request.expiresAt ? new Date(request.expiresAt) : null; + + const intent = await this.intentsRepository.create({ + service: request.service, + referenceType: request.referenceType, + referenceId: request.referenceId, + merchantOrderId, + provider: request.provider, + amountMinor: request.amountMinor, + currency: request.currency, + status: ProviderPaymentStatus.REQUIRES_ACTION, + clientAction: { + type: "SHOW_BILL_REFERENCE", + billReference, + instructions: + "Pay this bill at any CBE branch, CBE Birr app, mobile banking or USSD.", + expiresAt: expiresAt?.toISOString(), + }, + idempotencyKey: request.idempotencyKey ?? null, + expiresAt, + billReference, + payerName: request.payerName ?? null, + }); + this.logger.log( + `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${billReference})`, + ); + return this.toSnapshot(intent); + } + /* ------------------------------------------------------------------ confirm (OTP providers) */ async confirm( @@ -626,6 +682,7 @@ export class IntentsService { failureCode: intent.failureCode ?? undefined, failureMessage: intent.failureMessage ?? undefined, expiresAt: intent.expiresAt?.toISOString(), + billReference: intent.billReference ?? undefined, providerResponse: intent.rawInitiation ?? undefined, }; } diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index f5d8c7e4c..2731aa794 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -24,6 +24,12 @@ export enum ProviderMethod { CARD = "CARD", DMONEY = "DMONEY", CAC_BANK = "CAC_BANK", + /** + * CBE Unified Bill Payment — inbound biller integration. We never call CBE: the customer + * takes the bill reference to any CBE channel and CBE calls payment-api's /cbe/* endpoints. + * No entry in PAYMENT_PROVIDER_MAP by design (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D5). + */ + CBE_BILL = "CBE_BILL", } export type PaymentPlatform = "web" | "mobile"; @@ -40,6 +46,13 @@ export type ClientAction = type: "COLLECT_OTP"; providerOrderId: string; message?: string; + } + | { + /** CBE_BILL: show the bill reference the customer pays at any CBE channel. */ + type: "SHOW_BILL_REFERENCE"; + billReference: string; + instructions?: string; + expiresAt?: string; }; export interface ProviderInitiationInput { @@ -130,6 +143,16 @@ export interface InitiatePaymentRequest { failureUrl?: string; /** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */ idempotencyKey?: string; + /** + * Payer full name snapshot (CBE_BILL: fallback for the mandatory Full_Name in /cbe/query + * responses when the domain app's bill-query is unreachable). + */ + payerName?: string; + /** + * Intent expiry, ISO-8601. CBE_BILL: the booking's own payment deadline — NOT a provider + * session TTL (the reconciliation sweep cancels the intent when this passes). + */ + expiresAt?: string; } /** Body of `POST /payments/intents/:id/confirm` (OTP-based providers such as CAC Bank). */ @@ -154,6 +177,8 @@ export type PaymentIntentSnapshot ={ failureCode?: string; failureMessage?: string; expiresAt?: string; + /** CBE_BILL only: the short numeric Bill_Id the customer pays at a CBE channel. */ + billReference?: string; /** * Raw provider payload for inspection/debugging — the audit copy of the provider * initiation response merged with the latest status-query response (secrets redacted diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2631397e..a3871acd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,10 +98,10 @@ importers: version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(3400edbb67a81ad1009ef960f3b47a6d) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.15.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(578386f46cf99fd4720e3e99f196f69e) + version: file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -816,10 +816,10 @@ importers: version: 8.1.6 '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991) + version: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -1135,6 +1135,9 @@ importers: '@nestjs/core': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/platform-express': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) @@ -2830,10 +2833,10 @@ packages: '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 - '@nestjs/jwt@10.2.0': - resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} peerDependencies: - '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 '@nestjs/mapped-types@2.0.5': resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==} @@ -4760,8 +4763,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/jsonwebtoken@9.0.5': - resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} @@ -4775,6 +4778,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.1.0': resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} @@ -8565,10 +8571,6 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -8595,15 +8597,9 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jws@3.2.3: - resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} - jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -13863,11 +13859,11 @@ snapshots: '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) eventemitter2: 6.4.9 - '@nestjs/jwt@10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@types/jsonwebtoken': 9.0.5 - jsonwebtoken: 9.0.2 + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 '@nestjs/mapped-types@2.0.5(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: @@ -16291,18 +16287,18 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(3400edbb67a81ad1009ef960f3b47a6d)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(578386f46cf99fd4720e3e99f196f69e) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f) argon2: 0.43.1 axios: 1.17.0 change-case: 5.4.4 @@ -16335,18 +16331,18 @@ snapshots: - debug - supports-color - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999) argon2: 0.43.1 axios: 1.17.0 change-case: 5.4.4 @@ -16379,18 +16375,18 @@ snapshots: - debug - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(578386f46cf99fd4720e3e99f196f69e)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.15.tgz(cb6b1db7b4758cd12009f4c537b4221f)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(3400edbb67a81ad1009ef960f3b47a6d) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(56f7abaee02dde7d68b15d9ab04572e5) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -16414,18 +16410,18 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(c97ba831ddde82920910406ab5262991)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(59a15a37c5b1c12685ed78e172f27e65) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -16889,8 +16885,9 @@ snapshots: '@types/json5@0.0.29': {} - '@types/jsonwebtoken@9.0.5': + '@types/jsonwebtoken@9.0.10': dependencies: + '@types/ms': 2.1.0 '@types/node': 20.19.42 '@types/lodash@4.17.24': {} @@ -16901,6 +16898,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/multer@2.1.0': dependencies: '@types/express': 5.0.6 @@ -21319,19 +21318,6 @@ snapshots: jsonparse@1.3.1: {} - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.3 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.8.2 - jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -21392,23 +21378,12 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jws@3.2.3: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - jws@4.0.1: dependencies: jwa: 2.0.1