From 6f137a11c3620b5fbd40d452d3987231196f8ddc Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 20:37:16 +0000 Subject: [PATCH] fix:cbe and cac --- .../3160000000000-FixPaymentPaidAtType.ts | 32 +++++++++++++++++++ .../payment/entities/payment.entity.ts | 2 +- .../portal/src/hooks/useInvoicePayment.ts | 21 +++++++++++- .../components/PaymentMethodModal.tsx | 17 +++++++--- 4 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts diff --git a/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts b/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts new file mode 100644 index 000000000..35ccdc658 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `freight.payments.paid_at` was created as `date` (CreatePaymentTable) and never + * migrated to `timestamp` alongside its siblings `refunded_at`/`expires_at` + * (UpdatePaymentTimestamp). TypeORM's postgres driver hydrates `date` columns as a + * plain "YYYY-MM-DD" string, not a `Date` — so `PaymentEntity.paidAt` (typed `Date`) + * was actually a string once read back from the DB, and + * `intent.paidAt?.toISOString()` in PaymentService.formatIntentStatus threw + * `TypeError: intent.paidAt.toISOString is not a function`. This hit every + * OTP-confirm response (CAC Bank) because confirmOtp always re-reads the intent + * before formatting the response. + */ +export class FixPaymentPaidAtType3160000000000 implements MigrationInterface { + name = "FixPaymentPaidAtType3160000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN paid_at TYPE timestamp + USING paid_at::timestamp; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN paid_at TYPE date + USING paid_at::date; + `); + } +} 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 ce072beca..0cf3b886c 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 @@ -49,7 +49,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" }) status!: PaymentStatus - @Column({ type: "date", nullable: true, name: "paid_at" }) + @Column({ type: "timestamp", nullable: true, name: "paid_at" }) paidAt?: Date @Column({ type: "timestamp", nullable: true, name: "refunded_at" }) diff --git a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts index ffa86d381..85e951dac 100644 --- a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts +++ b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts @@ -1,5 +1,6 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; +import { Freight } from "@edr/types"; import { useState } from "react"; import { invoicesService } from "@/services/invoices.service"; @@ -36,6 +37,7 @@ function apiMessage(err: unknown, fallback: string): string { */ /** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */ interface BillAction { + invoiceId: string; billReference: string; instructions?: string; expiresAt?: string; @@ -64,6 +66,7 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { // bill reference instead of redirecting to a (nonexistent) checkout page. if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") { setBillAction({ + invoiceId: vars.invoiceId, billReference: data.clientAction.billReference ?? "", instructions: data.clientAction.instructions, expiresAt: data.clientAction.expiresAt, @@ -90,6 +93,22 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { }, }); + // Poll the invoice while the CBE bill dialog is open — CBE settles out of + // band (branch/app/USSD), so this is the only way the browser learns it paid. + useQuery({ + queryKey: ["invoice-bill-poll", billAction?.invoiceId], + queryFn: async () => { + const invoice = await invoicesService.get(billAction!.invoiceId); + if (invoice.status === Freight.InvoiceStatus.Paid) { + setBillAction(null); + window.location.reload(); + } + return invoice; + }, + enabled: billAction !== null, + refetchInterval: 5000, + }); + const reset = () => { payMutation.reset(); otpMutation.reset(); 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 e0fe11ca0..a57dd0a9d 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 @@ -63,6 +63,8 @@ const PROVIDERS: ProviderOption[] = [ /** Providers that debit against an SMS OTP instead of redirecting to a page. */ const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; +/** CAC Bank SMS codes are 4 digits. */ +const OTP_LENGTH = 4; /** Providers that settle asynchronously via a bill reference instead of a redirect. */ const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL"; @@ -345,18 +347,23 @@ export function PaymentMethodModal({ {otp.message} - + + + Verification code + otp.submit(value)} aria-label="One-time password" /> - + {otp.error && ( @@ -381,7 +388,7 @@ export function PaymentMethodModal({ radius={12} color="edr-green" loading={otp.submitting} - disabled={otp.submitting || code.trim().length === 0} + disabled={otp.submitting || code.trim().length !== OTP_LENGTH} onClick={() => otp.submit(code.trim())} styles={{ root: { height: 46, flex: 1 },