mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
Merge pull request #1042 from Tria-plc/freight/chore/payment-test
CAC Integration to the freight api, tests fix
This commit is contained in:
@@ -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: {
|
||||
|
||||
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal file
115
apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts
Normal 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>;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user