Merge pull request #1080 from Tria-plc/fixes

fix:cbe and cac
This commit is contained in:
Nathnael Wondisha
2026-08-02 23:39:08 +03:00
committed by GitHub
4 changed files with 65 additions and 7 deletions

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN paid_at TYPE timestamp
USING paid_at::timestamp;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN paid_at TYPE date
USING paid_at::date;
`);
}
}

View File

@@ -49,7 +49,7 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" }) @Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
status!: PaymentStatus status!: PaymentStatus
@Column({ type: "date", nullable: true, name: "paid_at" }) @Column({ type: "timestamp", nullable: true, name: "paid_at" })
paidAt?: Date paidAt?: Date
@Column({ type: "timestamp", nullable: true, name: "refunded_at" }) @Column({ type: "timestamp", nullable: true, name: "refunded_at" })

View File

@@ -1,5 +1,6 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Freight } from "@edr/types";
import { useState } from "react"; import { useState } from "react";
import { invoicesService } from "@/services/invoices.service"; 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. */ /** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */
interface BillAction { interface BillAction {
invoiceId: string;
billReference: string; billReference: string;
instructions?: string; instructions?: string;
expiresAt?: string; expiresAt?: string;
@@ -64,6 +66,7 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
// bill reference instead of redirecting to a (nonexistent) checkout page. // bill reference instead of redirecting to a (nonexistent) checkout page.
if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") { if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") {
setBillAction({ setBillAction({
invoiceId: vars.invoiceId,
billReference: data.clientAction.billReference ?? "", billReference: data.clientAction.billReference ?? "",
instructions: data.clientAction.instructions, instructions: data.clientAction.instructions,
expiresAt: data.clientAction.expiresAt, 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 = () => { const reset = () => {
payMutation.reset(); payMutation.reset();
otpMutation.reset(); otpMutation.reset();

View File

@@ -63,6 +63,8 @@ const PROVIDERS: ProviderOption[] = [
/** Providers that debit against an SMS OTP instead of redirecting to a page. */ /** Providers that debit against an SMS OTP instead of redirecting to a page. */
const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; 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. */ /** Providers that settle asynchronously via a bill reference instead of a redirect. */
const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL"; const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL";
@@ -345,18 +347,23 @@ export function PaymentMethodModal({
{otp.message} {otp.message}
</Text> </Text>
<Box mt={18}> <Stack mt={18} gap={6} align="center">
<Text size="sm" fw={500} c="edr-text">
Verification code
</Text>
<PinInput <PinInput
length={6} length={OTP_LENGTH}
type="number" type="number"
inputMode="numeric"
oneTimeCode oneTimeCode
value={code} value={code}
placeholder="0"
disabled={otp.submitting}
styles={{ input: { textAlign: "center" } }}
onChange={setCode} onChange={setCode}
onComplete={(value) => otp.submit(value)} onComplete={(value) => otp.submit(value)}
aria-label="One-time password" aria-label="One-time password"
/> />
</Box> </Stack>
{otp.error && ( {otp.error && (
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}> <Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
@@ -381,7 +388,7 @@ export function PaymentMethodModal({
radius={12} radius={12}
color="edr-green" color="edr-green"
loading={otp.submitting} loading={otp.submitting}
disabled={otp.submitting || code.trim().length === 0} disabled={otp.submitting || code.trim().length !== OTP_LENGTH}
onClick={() => otp.submit(code.trim())} onClick={() => otp.submit(code.trim())}
styles={{ styles={{
root: { height: 46, flex: 1 }, root: { height: 46, flex: 1 },