Merge pull request #1042 from Tria-plc/freight/chore/payment-test

CAC Integration to the freight api, tests fix
This commit is contained in:
Nathnael Wondisha
2026-07-31 14:10:04 +03:00
committed by GitHub
50 changed files with 1706 additions and 665 deletions

View File

@@ -201,6 +201,8 @@ export const URL_CONSTANTS = {
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
CONFIRM_INVOICE_OTP: (id: string) =>
`/api/billing/my-invoices/${id}/confirm`,
},
WAREHOUSE_INVOICES: {

View File

@@ -0,0 +1,115 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useState } from "react";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type InitiateResponse,
type PaymentMethod,
} from "@/services/payments.service";
/** How the invoice is charged — overridable for warehouse fee invoices. */
type InitiateFn = (
invoiceId: string,
method: PaymentMethod,
payerAccount?: string,
) => Promise<InitiateResponse>;
const payViaBilling: InitiateFn = (invoiceId, method, payerAccount) =>
invoicesService.pay(invoiceId, { method, platform: "web", payerAccount });
/** The server's message (`{ message }` / `{ message: [] }`), or a fallback. */
function apiMessage(err: unknown, fallback: string): string {
const message = (err as AxiosError<{ message?: string | string[] }>)?.response
?.data?.message;
const first = Array.isArray(message) ? message[0] : message;
return first || fallback;
}
/**
* One payment flow for every "pay this invoice" entry point: initiate, then
* either redirect to the provider or — for CAC Bank, an OTP debit with no
* redirect — collect the SMS'd code and confirm it in-app. Pass `initiate` to
* charge through a different endpoint (warehouse fee invoices); OTP
* confirmation always goes through billing, which owns the intent either way.
*/
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
const [otpMessage, setOtpMessage] = useState<string | undefined>();
const payMutation = useMutation({
mutationFn: (vars: {
invoiceId: string;
method: PaymentMethod;
payerAccount?: string;
}) => initiate(vars.invoiceId, vars.method, vars.payerAccount),
onSuccess: (data, vars) => {
if (data?.clientAction?.type === "COLLECT_OTP") {
setOtpMessage(
data.clientAction.message ?? "Enter the OTP sent to your phone",
);
setOtpInvoiceId(vars.invoiceId);
return;
}
window.location.href =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: vars.invoiceId,
method: vars.method,
});
},
});
const otpMutation = useMutation({
mutationFn: (otp: string) =>
invoicesService.confirmOtp(otpInvoiceId as string, otp),
// Settled — reload so the invoice/booking re-reads its now-paid state.
onSuccess: () => {
setOtpInvoiceId(null);
window.location.reload();
},
});
const reset = () => {
payMutation.reset();
otpMutation.reset();
setOtpInvoiceId(null);
};
return {
processing: payMutation.isPending,
error: payMutation.isError
? apiMessage(
payMutation.error,
payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again.",
)
: null,
pay: (invoiceId: string, method: PaymentMethod, payerAccount?: string) =>
payMutation.mutate({ invoiceId, method, payerAccount }),
reset,
/** Drives the modal's OTP step; `open` only for CAC Bank. */
otp: {
open: otpInvoiceId !== null,
message: otpMessage,
submitting: otpMutation.isPending,
// A wrong/expired OTP is a 400 — keep the step open so the payer retries.
error: otpMutation.isError
? apiMessage(
otpMutation.error,
"Invalid or expired OTP. Please try again.",
)
: null,
submit: (otp: string) => otpMutation.mutate(otp),
cancel: () => {
otpMutation.reset();
setOtpInvoiceId(null);
},
},
};
}
export type InvoicePaymentFlow = ReturnType<typeof useInvoicePayment>;

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
@@ -10,9 +10,8 @@ import {
PackagePlus,
} from "lucide-react";
import { api } from "@/services/api";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
isPayable(inv.status),
)?.id;
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const url =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = url;
},
});
const pay = useInvoicePayment();
if (items.length === 0) return null;
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
<PaymentMethodModal
opened={payItem !== null}
onClose={() => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayItem(null);
payMutation.reset();
pay.reset();
}
}}
currency={undefined}
processing={payMutation.isPending}
processing={pay.processing}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
pay.error ??
(payItemInvoices.length > 0 && !payableInvoiceId
? "No payable invoice found for this booking yet. Please refresh or contact support."
: null)
}
otp={pay.otp}
onConfirm={(method, payerAccount) =>
payableInvoiceId &&
pay.pay(payableInvoiceId, method, payerAccount)
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</ModalSafeWrapper>
</Card>

View File

@@ -287,9 +287,11 @@ export default function SettingsPage() {
icon={<Clock size={18} />}
title="Changes submitted for review"
>
Your recent changes are awaiting administrator approval. Editing is
disabled until the review is complete you'll be notified once it's
approved or if any changes are requested.
Your recent changes are awaiting administrator approval. Company
details and documents can't be edited until the review is complete —
you'll be notified once it's approved or if any changes are
requested. Your contact person, general manager and Power of
Attorney stay editable.
</Alert>
)}
{reviewStatus === "rejected" && (
@@ -358,9 +360,17 @@ export default function SettingsPage() {
)}
</Tabs.Panel>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}
{/* While a change request is pending, the reviewed panels' inputs +
submit buttons are disabled via the native fieldset; tab switching
stays enabled so the customer can still review what they
submitted.
Personnel panels below (contact person, general manager, Power of
Attorney) are deliberately outside the lock: the API applies those
edits live rather than staging them, so locking them here would
re-impose the approval wait the API no longer does. The PoA's
delegation letter is still reviewed — that lock lives on the file
itself, not the panel. */}
<Tabs.Panel value="company">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
@@ -368,19 +378,13 @@ export default function SettingsPage() {
<OperationalServicesCard profile={profile} />
</Tabs.Panel>
<Tabs.Panel value="contact">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabContactPerson profile={profile} mode="edit" />
</Fieldset>
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabGeneralManager profile={profile} mode="edit" />
</Fieldset>
<TabGeneralManager profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Fieldset>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="documents">
<Fieldset disabled={locked} variant="unstyled" p={0}>

View File

@@ -33,7 +33,6 @@ import {
buildOnboardingSchema,
type CompanyStep,
type FormData,
hasPoaDetails,
POA_DELEGATION_FILE_KEY,
stepFields,
} from "./companyProfileForm/schema";
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(
buildOnboardingSchema(
requirePoa,
verifiedIdentity,
identity?.passportRequired === true,
),
buildOnboardingSchema(identity?.passportRequired === true),
),
defaultValues: {
companyName: "",
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
// them and re-enables editing.
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
const [contactSameAsGm, setContactSameAsGm] = useState(false);
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
// General Manager source. The company step's email/phone are seeded from
// eTrade (and the account email) but stay editable, so the link reads the
@@ -367,9 +361,6 @@ export default function CompanyProfileForm({
const gmName = watch("generalManagerName");
const gmEmail = watch("generalManagerEmail");
const gmPhone = watch("generalManagerPhone");
const contactName = watch("contactPersonName");
const contactEmail = watch("contactPersonEmail");
const contactPhone = watch("contactPersonPhone");
// While linked, mirror the source values into the (disabled) target fields so
// the copy stays current even if the user goes back and edits the source.
@@ -381,26 +372,6 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
// The contact-person step has no address of its own, so the linked PoA takes
// the company's composed address. poaLocation (the city) stays typed on the
// PoA step — the company step no longer has a location field to mirror.
const companyAddress = watch("companyAddress");
useEffect(() => {
if (!poaSameAsContact) return;
setValue("poaName", contactName ?? "");
setValue("poaEmail", contactEmail ?? "");
setValue("poaPhone", contactPhone ?? "");
setValue("poaAddress", companyAddress ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
poaSameAsContact,
contactName,
contactEmail,
contactPhone,
companyAddress,
]);
const toggleContactSameAsGm = (checked: boolean) => {
setContactSameAsGm(checked);
// Checked → the mirror effect fills the fields; unchecked → reset them.
@@ -411,17 +382,6 @@ export default function CompanyProfileForm({
}
};
const togglePoaSameAsContact = (checked: boolean) => {
setPoaSameAsContact(checked);
if (!checked) {
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
setValue("poaLocation", "");
setValue("poaAddress", "");
}
};
// The DARS delegation paper ships in the same nationality document set as the
// rest (the API guarantees it is there), but belongs on the PoA step next to
// the details it evidences — so it's split out here and the Documents step
@@ -551,7 +511,9 @@ export default function CompanyProfileForm({
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
// the same rule on save, so skipping it here only costs the customer a
// round-trip.
const poaProvided = hasPoaDetails(watch());
// A PoA exists exactly when one has been verified — the details are the
// verification's output, so there is nothing else that could stand for one.
const poaProvided = identity?.poa.verified ?? false;
const delegationRequired = requirePoa || poaProvided;
const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
@@ -638,12 +600,7 @@ export default function CompanyProfileForm({
setSaveError("Verify the company owner's identity with Fayda before continuing.");
return;
}
if (
step === "poa" &&
verifiedIdentity &&
requirePoa &&
!identity?.poa.verified
) {
if (step === "poa" && requirePoa && !identity?.poa.verified) {
setSaveError(
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
);
@@ -876,71 +833,27 @@ export default function CompanyProfileForm({
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
</Text>
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. */}
{identity && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
required={requirePoa}
onVerified={() => onIdentityChange?.()}
/>
)}
{!verifiedIdentity && watch("contactPersonName") && (
<LinkCheckboxCard
checked={poaSameAsContact}
onToggle={togglePoaSameAsContact}
title="Same as contact person"
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
/>
)}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{/* The city is the one field the Fayda address claim does not
reliably decompose into, so it stays typed either way. */}
{verifiedIdentity && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
reliably decompose into, so it stays typed. */}
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
{poaDocumentSetting && (
<>

View File

@@ -37,10 +37,8 @@ export function buildPayload(
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
// The representative's own details are written by their Fayda
// verification, so the city is all the form has to send.
poaLocation: data.poaLocation || undefined,
},
};
@@ -88,13 +86,7 @@ export function stepPayload(
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
return { poaLocation: d.poaLocation || undefined };
default:
return {};
}

View File

@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
export const POA_FIELDS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const satisfies readonly (keyof FormData)[];
/** True once the customer has entered any Power of Attorney detail. */
export const hasPoaDetails = (d: Partial<FormData>) =>
POA_FIELDS.some((f) => d[f]?.trim());
/**
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
* rather than optional. Everyone else keeps the optional PoA — but once they
* start filling it in, the identifying fields have to be complete (the
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
* since files live outside the form state).
* The PoA's identifying fields are never typed — they come from the Fayda
* verification, whatever the company's nationality — so nothing here requires
* them. A freight forwarder's mandatory PoA is gated on the verification
* itself, and its delegation letter alongside it, both in CompanyProfileForm
* (files live outside form state).
*
* That leaves the owner's passport number as the only conditional field.
*/
export function buildOnboardingSchema(
requirePoa: boolean,
/**
* True when the PoA's identity fields come from a Fayda verification rather
* than the form (Ethiopian companies). Requiring them here would fail
* validation against inputs the step no longer renders — the verification
* itself is what the step gates on instead.
*/
faydaOwnedPoa = false,
/** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false,
) {
const poaRequired = requirePoa && !faydaOwnedPoa;
if (!poaRequired && !passportRequired) return onboardingSchema;
if (!passportRequired) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
const required: [keyof FormData, string][] = [];
if (poaRequired) {
required.push(
["poaName", "PoA name is required for freight forwarders"],
["poaEmail", "PoA email is required for freight forwarders"],
["poaPhone", "PoA phone is required for freight forwarders"],
);
}
if (passportRequired) {
required.push([
"ownerPassportNumber",
"The owner's passport number is required",
]);
}
for (const [path, message] of required) {
if (!d[path]?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
}
if (!d.ownerPassportNumber?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ownerPassportNumber"],
message: "The owner's passport number is required",
});
}
});
}
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
poa: [...POA_FIELDS],
poa: ["poaLocation"],
documents: [],
additional: [],
};

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
@@ -27,10 +27,7 @@ import toast from "react-hot-toast";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { saveBlob } from "@/utils/download";
@@ -76,17 +73,7 @@ export default function InvoiceDetailPage() {
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// one of the signed-in customer's own invoices (unlike the admin-facing
// /payments/initiate, which takes any invoiceId with no ownership check).
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({ invoiceId: id, method });
window.location.href = redirectUrl;
},
});
const pay = useInvoicePayment();
if (isLoading) {
return (
@@ -249,7 +236,7 @@ export default function InvoiceDetailPage() {
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
loading={pay.processing}
onClick={handlePay}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
@@ -376,22 +363,19 @@ export default function InvoiceDetailPage() {
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayModalOpen(false);
payMutation.reset();
pay.reset();
}
}}
amountLabel={formatCurrency(amountDue, invoice.currency)}
currency={invoice.currency}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={(method, payerAccount) =>
pay.pay(id, method, payerAccount)
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</Stack>
</Box>

View File

@@ -1,14 +1,8 @@
import { Group, Tabs } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { useFileViewer } from "@/hooks/useFileViewer";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
export function ReadonlyBookingView({
booking,
@@ -53,7 +48,6 @@ export function ReadonlyBookingView({
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash();
const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
const { viewer } = useFileViewer();
// Re-book opens the New Shipment Booking form for the same contract, not the
@@ -63,44 +57,11 @@ export function ReadonlyBookingView({
: "/contracts/new";
const onRebook = () => navigate(rebookTo);
// Billing is invoice-centric — resolve the booking's currently payable
// invoice (same query/key BookingPaymentPanel uses, so this shares its
// cache) and pay it through the ownership-checked portal route.
const { data: bookingInvoices = [] } = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const payableInvoiceId = bookingInvoices.find((inv) =>
isPayable(inv.status),
)?.id;
// POST /billing/my-invoices/:id/pay creates the intent and returns the
// provider's redirect URL (clientAction.url). Send the browser straight
// there; fall back to the public /payments/checkout page if no redirect
// URL came back.
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});
// Billing is invoice-centric — the shared hook resolves the booking's
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
// shares that cache), charges it through the ownership-checked portal route,
// and handles redirect vs CAC Bank OTP.
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
@@ -171,7 +132,7 @@ export function ReadonlyBookingView({
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
onClick={pay.open}
/>
)}
</Group>
@@ -278,8 +239,8 @@ export function ReadonlyBookingView({
<BookingPaymentPanel
booking={booking}
pricing={pricing}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
onPay={pay.open}
paying={pay.processing}
showCountdown={showCountdown}
/>
<ScheduleCard
@@ -301,24 +262,14 @@ export function ReadonlyBookingView({
</Tabs>
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
setPayModalOpen(false);
payMutation.reset();
}
}}
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
}
onConfirm={(method) => payMutation.mutate(method)}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={pay.confirm}
/>
{viewer}
</PageShell>

View File

@@ -1,20 +1,32 @@
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
import { Check, ShieldCheck } from "lucide-react";
import {
Box,
Button,
Group,
Image,
Modal,
PinInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Check, Landmark, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
import type { PaymentMethod } from "@/services/payments.service";
interface ProviderOption {
method: PaymentMethod;
label: string;
description: string;
logo: string;
/** Logo asset; falls back to a bank glyph when the provider has none. */
logo?: string;
/** Currencies this provider settles in. */
currencies: string[];
accent: string;
}
// Only Telebirr and Waafi are enabled for now.
// Only Telebirr, Waafi and CAC Bank are enabled for now.
const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
@@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [
currencies: ["USD"],
accent: "#2E5B96",
},
{
method: "CAC_BANK",
label: "CAC Bank",
description: "Djibouti bank debit · confirmed by SMS OTP",
currencies: ["USD"],
accent: "#8A5A17",
},
];
/** Providers that debit against an SMS OTP instead of redirecting to a page. */
const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK";
/**
* Pick the provider that settles in the booking's currency. USD → Waafi,
* ETB → Telebirr. Falls back to the first provider when unknown.
@@ -91,13 +113,27 @@ function ProviderRow({
backgroundColor: "#fff",
}}
>
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
{option.logo ? (
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
) : (
<Box
style={{
width: 52,
height: 52,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Landmark size={24} color={option.accent} />
</Box>
)}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
@@ -135,6 +171,7 @@ export function PaymentMethodModal({
onConfirm,
processing,
error,
otp,
}: {
opened: boolean;
onClose: () => void;
@@ -142,12 +179,19 @@ export function PaymentMethodModal({
amountLabel?: string;
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
currency?: string | null;
onConfirm: (method: PaymentMethod) => void;
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
processing?: boolean;
error?: string | null;
/** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */
otp?: InvoicePaymentFlow["otp"];
}) {
const providers = useMemo(() => providersForCurrency(currency), [currency]);
const providers = useMemo(
() => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)),
[currency, otp],
);
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
const [mobile, setMobile] = useState("");
const [code, setCode] = useState("");
// Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => {
@@ -156,6 +200,89 @@ export function PaymentMethodModal({
}
}, [providers, method]);
// A fresh OTP round always starts empty.
useEffect(() => {
if (otp?.open) setCode("");
}, [otp?.open]);
// CAC Bank debits the account behind this number and SMSes the OTP to it.
const needsMobile = isOtpMethod(method);
const canSubmit = !needsMobile || mobile.trim().length > 0;
if (otp?.open) {
return (
<Modal
opened={opened}
onClose={otp.cancel}
centered
radius={18}
size={420}
padding={0}
withCloseButton={false}
// A stray click must not drop the payer out of a live OTP window —
// Cancel is the only way back.
closeOnClickOutside={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Box px={24} py={24}>
<Text fw={800} fz="18px" c="#10202F">
Enter OTP
</Text>
<Text mt={4} fz="13px" c="#7A8794">
{otp.message}
</Text>
<Box mt={18}>
<PinInput
length={6}
type="number"
inputMode="numeric"
oneTimeCode
value={code}
onChange={setCode}
onComplete={(value) => otp.submit(value)}
aria-label="One-time password"
/>
</Box>
{otp.error && (
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
{otp.error}
</Text>
)}
<Group gap={10} wrap="nowrap" mt={20}>
<Button
variant="default"
radius={12}
onClick={otp.cancel}
disabled={otp.submitting}
styles={{
root: { height: 46, flex: "0 0 38%" },
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
}}
>
Cancel
</Button>
<Button
radius={12}
color="edr-green"
loading={otp.submitting}
disabled={otp.submitting || code.trim().length === 0}
onClick={() => otp.submit(code.trim())}
styles={{
root: { height: 46, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
Confirm payment
</Button>
</Group>
</Box>
</Modal>
);
}
return (
<Modal
opened={opened}
@@ -215,6 +342,22 @@ export function PaymentMethodModal({
/>
))}
</Stack>
{needsMobile && (
<TextInput
mt={12}
label="Mobile number"
description="CAC Bank sends a one-time password to this number to authorise the debit."
placeholder="77xxxxxx"
value={mobile}
onChange={(e) => setMobile(e.currentTarget.value)}
disabled={processing}
styles={{
label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" },
description: { fontSize: 11.5 },
}}
/>
)}
</Box>
{/* Footer */}
@@ -228,7 +371,9 @@ export function PaymentMethodModal({
<Group gap={6} align="center" justify="center" mb={12}>
<ShieldCheck size={14} color="#0A8A5F" />
<Text fz="11.5px" c="#7A8794">
Secured · you'll be redirected to your provider to pay
{needsMobile
? "Secured · you'll confirm with the OTP sent to your phone"
: "Secured · you'll be redirected to your provider to pay"}
</Text>
</Group>
@@ -248,15 +393,21 @@ export function PaymentMethodModal({
<Button
radius={12}
color="edr-green"
disabled={processing}
disabled={processing || !canSubmit}
loading={processing}
onClick={() => onConfirm(method)}
onClick={() =>
onConfirm(method, needsMobile ? mobile.trim() : undefined)
}
styles={{
root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
{processing
? needsMobile
? "Sending OTP"
: "Redirecting"
: "Continue to payment"}
</Button>
</Group>
</Box>

View File

@@ -1,10 +1,10 @@
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import {
warehouseInvoicesService,
type PortalWarehouseInvoice,
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payInvoice) throw new Error("No invoice selected for payment.");
return warehouseInvoicesService.payOnline(payInvoice.id, {
method,
platform: "web",
});
},
onSuccess: (data, method) => {
if (!payInvoice) return;
// Redirect to the provider (or the fallback checkout page) — same as the
// booking "Pay now" flow, so behaviour is identical everywhere.
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
window.location.href = redirectUrl;
},
});
const payError = payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null;
// Warehouse fees are charged through the warehouse route, but they are the
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
warehouseInvoicesService.payOnline(invoiceId, {
method,
platform: "web",
payerAccount,
}),
);
const closePayModal = () => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayInvoice(null);
payMutation.reset();
pay.reset();
}
};
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
}
currency={payInvoice?.currency}
onConfirm={(method) => payMutation.mutate(method)}
processing={payMutation.isPending}
error={payError}
onConfirm={(method, payerAccount) =>
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
/>
</SectionCard>
);

View File

@@ -55,6 +55,7 @@ export function PayNowButton({
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>

View File

@@ -1,23 +1,22 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { type PaymentMethod } from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or the fallback
* checkout page). Reused by the booking detail page, the booking list, and
* the home page so "Pay now" behaves identically everywhere.
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
const [modalOpen, setModalOpen] = useState(false);
const [noInvoice, setNoInvoice] = useState(false);
const { data: invoices = [] } = useQuery({
queryKey: ["booking-invoices", bookingId],
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
});
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
const mutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});
const flow = useInvoicePayment();
const open = () => setModalOpen(true);
const close = () => {
if (!mutation.isPending) {
if (!flow.processing) {
setModalOpen(false);
mutation.reset();
setNoInvoice(false);
flow.reset();
}
};
const error = mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null;
return {
modalOpen,
open,
close,
processing: mutation.isPending,
error,
confirm: (method: PaymentMethod) => mutation.mutate(method),
processing: flow.processing,
error: noInvoice
? "No payable invoice found for this booking yet. Please refresh or contact support."
: flow.error,
otp: flow.otp,
confirm: (method: PaymentMethod, payerAccount?: string) => {
if (!payableInvoiceId) {
setNoInvoice(true);
return;
}
setNoInvoice(false);
flow.pay(payableInvoiceId, method, payerAccount);
},
};
}

View File

@@ -39,20 +39,15 @@ import {
type LicenseFile,
type LicenseFileStatus,
} from "@/services/companies.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
// is the only detail this form owns.
const schema = z.object({
poaName: z.string().optional(),
poaEmail: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaLocation: z.string().optional(),
poaAddress: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
const { view, viewer } = useFileViewer();
const uploadInputRef = useRef<HTMLInputElement>(null);
const defaultValues = useMemo((): FormData => {
return {
poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "",
poaPhone: profile.poaPhone ?? "",
poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "",
};
}, [profile]);
const defaultValues = useMemo(
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
[profile],
);
const {
register,
control,
handleSubmit,
reset,
watch,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
// The letter is staged locally, not uploaded on pick. Uploading immediately
// would open a change request, which locks the whole settings page (see
// SettingsPage's `locked` fieldset) before the text fields could be saved.
// Save submits the file and the fields together, into one change request.
// The letter is staged locally, not uploaded on pick: the paper is the one
// thing here that still goes to a reviewer, so picking it must not open a
// change request before the customer has committed to the save. Save submits
// the file and the fields together.
const [pickedFile, setPickedFile] = useState<File | null>(null);
const [removeIds, setRemoveIds] = useState<string[]>([]);
const [saveBlocked, setSaveBlocked] = useState(false);
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
const requirePoa = profile.companyProfiles.some(
(p) => p.type === "freight_forwarder",
);
// An Ethiopian company does not type its representative's details — they
// come from the Fayda verification. A foreign company keeps the typed form:
// its representative may hold no Fayda ID.
// No company types its representative's details — they come from the Fayda
// verification whatever the nationality, since a representative acts for the
// company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified.
const identity = profile.identity;
const verifiedIdentity = identity?.faydaRequired === true;
const poaValues = watch([
"poaName",
"poaEmail",
"poaPhone",
"poaLocation",
"poaAddress",
]);
const poaProvided = verifiedIdentity
? (identity?.poa.verified ?? false)
: poaValues.some((v) => v?.trim());
const poaProvided = identity?.poa.verified ?? false;
const letterRequired = requirePoa || poaProvided;
const letterMissing = letterRequired && !hasLetterAfterSave;
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
const mutation = useMutation({
mutationFn: async (data: FormData) => {
// Every identity field except the city is written by the verification, so
// an Ethiopian company only ever saves the paper and the location here.
const fields = verifiedIdentity
? { poaLocation: data.poaLocation || undefined }
: {
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
poaAddress: data.poaAddress || undefined,
};
// only the paper and the location are ever saved here.
const fields = { poaLocation: data.poaLocation || undefined };
// A fresh upload already stages the removal of every paper on file, so
// the explicit removals only need applying when no replacement was
// picked. Saving the details after it means the API sees the new paper.
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
required={requirePoa}
disabled={mutation.isPending}
pendingReview={Boolean(
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
?.faydaIdentity?.poaFaydaSub,
)}
onVerified={() => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
{/* Name, email, phone and address are written by the Fayda
verification for an Ethiopian company, so only the city — which
the address claim does not reliably decompose into — is typed. */}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Full Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</Grid.Col>
</Grid>
</>
)}
{/* Name, email, phone and address are all written by the Fayda
verification, so only the city — which the address claim does
not reliably decompose into — is typed. */}
<Grid>
<Grid.Col span={6}>
<TextInput
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
{...register("poaLocation")}
/>
</Grid.Col>
{!verifiedIdentity && (
<Grid.Col span={6}>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</Grid.Col>
)}
</Grid>
</Stack>
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
</Stack>
)}
{profile.reviewStatus === "pending" && (
{/* Keyed on the paper's own staged status, not the company's
review state: the details on this tab now apply live, so a
pending review is just as likely to be about something else. */}
{letters.some((f) => f.status !== "live") && (
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
</Group>
<Group gap="md">
{mode === "edit" &&
verifiedIdentity &&
identity?.poa.verified &&
!requirePoa && (
<Button

View File

@@ -73,4 +73,10 @@ export const invoicesService = {
});
return data.data ?? data;
},
/** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */
confirmOtp: async (id: string, otp: string): Promise<InitiateResponse> => {
const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp });
return data.data ?? data;
},
};