mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user