Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-12 06:44:30 +00:00
94 changed files with 5399 additions and 3583 deletions

View File

@@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -117,6 +119,20 @@ import {
findActiveSidebarLabel,
} from "@/components/layout/sidebar-sections";
/**
* The per-shipment clearance detail page is the shared destination of three
* hubs (Operations → Clearance, Clearance Documents, Self-Clearance Review),
* none of which are gated on `bookings:clearance_view`. Gating the detail on
* that key alone bounced reviewers back to their landing page (Bookings) the
* moment they opened a row, so accept any key that can reach a hub.
*/
const CLEARANCE_DETAIL_PERMS = [
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.opsClearanceReview,
];
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -286,9 +302,7 @@ const App = () => {
<Route
path="clearance/:id"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -322,9 +336,7 @@ const App = () => {
<Route
path="bookings/:bookingId/clearance"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -774,6 +786,24 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="stamp-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
<StampSettings />
</RequirePermission>
}
/>
<Route
path="invoice-stamp-settings"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
>
<InvoiceStampSettingsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

View File

@@ -170,12 +170,13 @@ export function ContractCustomerCard({
/>
</SectionCard>
<SectionCard icon={User} title="General manager" accent="grape">
{/* Whoever the eTrade licence names as the business's manager. */}
<SectionCard icon={User} title="Owner" accent="grape">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.generalManagerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone },
{ icon: User, label: "Name", value: company.ownerName },
{ icon: Mail, label: "Email", value: company.ownerEmail },
{ icon: Phone, label: "Phone", value: company.ownerPhone },
]}
/>
</SectionCard>

View File

@@ -43,9 +43,17 @@ export const FIELD_LABELS: Record<string, string> = {
contactPersonPosition: "Contact position",
contactPersonEmail: "Contact email",
contactPersonPhone: "Contact phone",
generalManagerName: "General manager",
generalManagerEmail: "GM email",
generalManagerPhone: "GM phone",
ownerName: "Owner name",
ownerEmail: "Owner email",
ownerPhone: "Owner phone",
poaDeclared: "Has a Power of Attorney",
poaPassportNumber: "PoA passport number",
// Nothing writes these any more — the general manager was removed — but
// change requests filed before that still carry them, and without a label
// the reviewer sees a raw attribute key.
generalManagerName: "General manager (retired)",
generalManagerEmail: "GM email (retired)",
generalManagerPhone: "GM phone (retired)",
poaName: "PoA name",
poaPhone: "PoA phone",
poaEmail: "PoA email",
@@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string {
nationality: c.nationality,
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
ownerName: c.ownerName ?? attrs.ownerName,
ownerEmail: c.ownerEmail ?? attrs.ownerEmail,
ownerPhone: c.ownerPhone ?? attrs.ownerPhone,
};
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
return v === null || v === undefined || v === "" ? "—" : String(v);

View File

@@ -485,6 +485,18 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
icon: <Settings />,
permission: FREIGHT_PERMS.settings.dropdown.view,
},
{
label: "Stamp settings",
href: "/dashboard/stamp-settings",
icon: <FileSignature />,
permission: FREIGHT_PERMS.settings.stamp.view,
},
{
label: "Invoice stamp",
href: "/dashboard/invoice-stamp-settings",
icon: <Receipt />,
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",

View File

@@ -1,11 +1,10 @@
import { useState } from "react";
import { FileSignature, Loader2, Stamp } from "lucide-react";
import { FileSignature, Loader2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import {
Card,
CardContent,
@@ -27,9 +26,13 @@ import {
} from "@edr/ui-common";
/**
* Lets the signed-in user view and update the reusable signature and company
* stamp stored on their profile — managed independently of each other. Both
* are offered when signing a booking contract.
* Lets the signed-in user view and update the reusable signature stored on
* their profile, offered for approval when signing a contract.
*
* Signature only — there is no per-employee stamp. EDR seals with ONE global
* company stamp, managed under Settings and applied server-side, so a staff
* member never uploads or picks a stamp. (Customers do upload their own, in
* the portal — that is a different card.)
*/
export function MySignatureCard() {
const { user } = useAuth();
@@ -39,10 +42,8 @@ export function MySignatureCard() {
const saveMutation = useMutation(api.signatures.save.mutationOptions());
const [signatureOpen, setSignatureOpen] = useState(false);
const [stampOpen, setStampOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const defaultName =
user?.name?.en || user?.username || user?.email || "";
@@ -60,7 +61,6 @@ export function MySignatureCard() {
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
// Stamp untouched — it is managed by its own dialog.
},
{
onSuccess: () => {
@@ -72,38 +72,16 @@ export function MySignatureCard() {
);
};
const openStampDialog = () => {
setStampData(saved?.stampImageUrl ?? null);
setStampOpen(true);
};
const saveStamp = () => {
if (!stampData) return;
saveMutation.mutate(
{
signerDisplayName: savedName || defaultName,
// Signature untouched — stamp-only update.
stampImageBase64: stampData,
},
{
onSuccess: () => {
toast.success("Stamp saved");
setStampOpen(false);
},
onError: () => toast.error("Failed to save stamp"),
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-4" />
Signature &amp; Stamp
Signature
</CardTitle>
<CardDescription>
This signature can be reused to sign booking contracts.
This signature can be reused to sign booking contracts. The EDR
company stamp is applied automatically you do not upload one.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -112,54 +90,29 @@ export function MySignatureCard() {
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
<div className="space-y-2">
{saved?.stampImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not uploaded a company stamp yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openStampDialog}>
<Stamp className="size-4" />
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
</Button>
</div>
</>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
)}
</CardContent>
@@ -203,39 +156,6 @@ export function MySignatureCard() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Company stamp</DialogTitle>
<DialogDescription>
Upload your official company stamp or seal as an image. It is
stored on your profile and applied next to your signature on
contracts.
</DialogDescription>
</DialogHeader>
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
<DialogFooter>
<Button variant="outline" onClick={() => setStampOpen(false)}>
Cancel
</Button>
<Button
disabled={saveMutation.isPending || !stampData}
onClick={saveStamp}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save stamp"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { stampSettingsService } from "@/services/stampSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["stampSettings"];
export const useStampSettingsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => stampSettingsService.get(),
});
export const useSetStamp = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (stampImageBase64: string) =>
stampSettingsService.set(stampImageBase64),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("stampSettings.updated", "Company stamp updated"));
},
onError: handleError,
});
};
export const useClearStamp = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: () => stampSettingsService.clear(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("stampSettings.cleared", "Company stamp removed"));
},
onError: handleError,
});
};

View File

@@ -328,6 +328,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
stamp: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",

View File

@@ -12,7 +12,6 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
@@ -41,9 +40,6 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Company stamp: prefilled from the profile, or uploaded here when none is
// saved yet.
const [stampData, setStampData] = useState<string | null>(null);
// When the user has a saved signature we offer it for approval first; they
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
@@ -59,7 +55,6 @@ export default function BookingContractPage() {
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const savedStampImage = savedSignature?.stampImageUrl ?? null;
// Show the approval view only while a saved signature exists and the user
// hasn't opted to draw a new one.
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
@@ -103,8 +98,6 @@ export default function BookingContractPage() {
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(savedStampImage);
setDrawNew(false);
setSignOpen(true);
};
@@ -113,12 +106,12 @@ export default function BookingContractPage() {
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
// The API rejects a STAFF signature without a stamp.
if (!image || !stampData) return;
if (!image) return;
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
// server-side at render time (see StampSettingsService).
signMutation.mutate({
role: "STAFF",
signatureImageBase64: image,
stampImageBase64: stampData,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -243,15 +236,6 @@ export default function BookingContractPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description={
savedStampImage
? "Your saved company stamp — replace it for this contract if needed."
: "Required. Attach your official company stamp or seal."
}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -261,7 +245,6 @@ export default function BookingContractPage() {
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!stampData ||
!signerName.trim()
}
onClick={confirmSign}

View File

@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { extractApiError } from "@/utils/result";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -40,7 +39,6 @@ export default function ContractViewPage() {
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
@@ -56,10 +54,11 @@ export default function ContractViewPage() {
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
// server-side from StampSettingsService when the signature is stored.
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
stampImageBase64: stampData ?? "",
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -98,14 +97,12 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(data?.savedSignature?.stampImageUrl ?? null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim() || !stampData) return;
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
@@ -243,13 +240,6 @@ export default function ContractViewPage() {
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
label="EDR company stamp"
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -260,8 +250,7 @@ export default function ContractViewPage() {
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>

View File

@@ -17,6 +17,7 @@ import {
Text,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Banknote,
@@ -638,8 +639,9 @@ export default function CustomerDetailPage() {
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces.
const ownerIdentity = company?.identity?.owner;
const poaIdentity = company?.identity?.poa;
const identityState = company?.identity;
const ownerIdentity = identityState?.owner;
const poaIdentity = identityState?.poa;
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional.
@@ -647,7 +649,7 @@ export default function CustomerDetailPage() {
(p) => p.type === "freight_forwarder",
);
const delegationMissing =
(hasPoaDetails || poaMandatory) && poaLive.length === 0;
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
if (isLoading) {
return (
@@ -820,6 +822,16 @@ export default function CustomerDetailPage() {
: undefined
}
/>
{/* Why this company's registration was typed rather than
fetched, and why it carries no business licence. */}
<InfoField
label="Registration"
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} />
@@ -834,18 +846,9 @@ export default function CustomerDetailPage() {
value={company.contactPersonPhone}
/>
<Box />
<InfoField
label="General manager"
value={company.generalManagerName}
/>
<InfoField
label="GM email"
value={company.generalManagerEmail}
/>
<InfoField
label="GM phone"
value={company.generalManagerPhone}
/>
<InfoField label="Owner" value={company.ownerName} />
<InfoField label="Owner email" value={company.ownerEmail} />
<InfoField label="Owner phone" value={company.ownerPhone} />
</SimpleGrid>
</Stack>
</Card>
@@ -912,6 +915,11 @@ export default function CustomerDetailPage() {
<Text fw={600} c="edr-text">
Owner identity
</Text>
{identityState?.subject === "owner" && (
<Badge size="sm" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{ownerIdentity?.verified ? (
<Badge size="sm" color="edr-green" variant="light">
Fayda verified
@@ -922,6 +930,41 @@ export default function CustomerDetailPage() {
</Badge>
)}
</Group>
{/* THE check: is the owner the company put forward the person
the eTrade licence actually names? Advisory — eTrade and
Fayda transliterate Amharic names differently, so this is a
prompt to look, not a verdict. */}
{identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>, but this
company recorded <strong>{company.ownerName}</strong>.
</Alert>
) : identityState?.ownerMatchesEtrade === true ? (
<Badge
size="sm"
color="edr-green"
variant="light"
style={{ alignSelf: "flex-start" }}
>
Matches the eTrade licence
</Badge>
) : company.cooperative ? (
<Text size="xs" c="dimmed">
A co-operative union or farm holds no trade licence, so
there is no eTrade record to check the owner against.
</Text>
) : (
<Text size="xs" c="dimmed">
No eTrade manager name on file to compare against.
</Text>
)}
{ownerIdentity?.verified ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="Name" value={ownerIdentity.name} />

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Save, Trash2 } from "lucide-react";
import { StampUpload } from "@/components/contracts/StampUpload";
import {
useClearStamp,
useSetStamp,
useStampSettingsQuery,
} from "@/hooks/useStampSettings";
/**
* The one company stamp/seal stamped onto every generated invoice/receipt
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
*/
export default function InvoiceStampSettingsPage() {
const { data, isLoading } = useStampSettingsQuery();
const setStamp = useSetStamp();
const clearStamp = useClearStamp();
const [draft, setDraft] = useState<string | null>(null);
useEffect(() => {
setDraft(null);
}, [data?.stampImageUrl]);
const value = draft !== null ? draft : (data?.stampImageUrl ?? null);
const dirty = draft !== null && draft !== data?.stampImageUrl;
const handleSave = async () => {
if (!draft) return;
await setStamp.mutateAsync(draft);
};
const handleClear = async () => {
if (!data?.stampImageUrl) return;
await clearStamp.mutateAsync();
};
return (
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Invoice stamp</CardTitle>
<CardDescription>
Stamped onto every generated invoice and receipt PDF. Replacing it
here changes it everywhere at once there is no per-invoice or
per-user choice.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<StampUpload
value={isLoading ? null : value}
onChange={setDraft}
label="Company stamp"
description="Shown on every invoice/receipt PDF in place of the plain seal."
/>
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={!dirty || setStamp.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{data?.stampImageUrl && !dirty && (
<Button
variant="outline"
onClick={handleClear}
disabled={clearStamp.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => {
)}
</TabsContent>
{/* Teeter Tab */}
{/* Teeter Tab — single active stamp only: remove the current one to upload a replacement. */}
<TabsContent value="teeter" className="p-4 space-y-6">
{teeters.length > 0 && (
<div className="space-y-6">
{teeters.length > 1 && (
<p className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300">
{t(
"signatureUpload.multipleStampsWarning",
"Only one stamp is allowed. Remove the extras below to keep a single active stamp.",
)}
</p>
)}
{teeters.map(({ id, url }) => (
<div key={id} className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300">
@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {
</div>
)}
{teeters.length === 0 && (
<div className="border-2 border-dashed border-primary-300 dark:border-primary-600 rounded-lg p-6 text-center space-y-4">
{!stampBlocks && !showLanguagePicker && (
<Button
@@ -792,6 +801,7 @@ const UploadTeeterAndSignature = () => {
</>
)}
</div>
)}
</TabsContent>
</Tabs>

View File

@@ -111,11 +111,14 @@ export interface ConsolidationDetails {
splitBilling: { bookingShare: number; partnerShare: number } | null;
}
/**
* No stamp field: the backoffice only ever signs as STAFF, and EDR's seal is
* the ONE global company stamp applied server-side. Customer stamps are posted
* from the portal, not here.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -118,11 +118,14 @@ export interface ContractView {
} | null;
}
/**
* No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's
* seal is the ONE global company stamp, snapshotted server-side from
* StampSettingsService when the signature is stored.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; required to sign a contract. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -24,7 +24,7 @@ const cleanParams = (params: object) =>
),
);
/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */
/** Lift attributes JSONB into the flat contact/owner fields the UI reads. */
function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return {
@@ -32,9 +32,9 @@ function mapCompany(dto: Record<string, unknown>): Company {
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null,
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null,
ownerName: (attrs.ownerName as string | null) ?? null,
ownerEmail: (attrs.ownerEmail as string | null) ?? null,
ownerPhone: (attrs.ownerPhone as string | null) ?? null,
poaName: (attrs.poaName as string | null) ?? null,
poaEmail: (attrs.poaEmail as string | null) ?? null,
poaPhone: (attrs.poaPhone as string | null) ?? null,

View File

@@ -9,12 +9,16 @@ export interface SavedSignature {
stampImageUrl?: string | null;
}
/**
* Signature only. A backoffice employee has no personal stamp — EDR seals with
* the one global company stamp managed under Settings — so the stamp half of
* PUT /me/signature is deliberately not exposed here, even though the shared
* endpoint still accepts it for portal (customer) users.
*/
export interface SaveSignaturePayload {
signerDisplayName: string;
/** Omit to keep the existing saved signature (stamp-only update). */
/** Omit to keep the existing saved signature. */
signatureImageBase64?: string;
/** Omit to keep the existing saved stamp. */
stampImageBase64?: string;
}
export const signaturesService = {

View File

@@ -0,0 +1,31 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = "/stamp-settings";
/** Company stamp/seal used on generated invoice/receipt PDFs. */
export interface StampSettings {
stampImageUrl: string | null;
updatedById: string | null;
updatedAt: string | null;
}
export const stampSettingsService = {
get: async (): Promise<StampSettings> => {
const response = await client.get<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
set: async (stampImageBase64: string): Promise<StampSettings> => {
const response = await client.put<ApiResponse<StampSettings>>(BASE, {
stampImageBase64,
});
return unwrap(response.data);
},
clear: async (): Promise<StampSettings> => {
const response = await client.delete<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
};

View File

@@ -64,9 +64,9 @@ export interface BookingCompany {
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
ownerName?: string | null;
ownerEmail?: string | null;
ownerPhone?: string | null;
website?: string | null;
}

View File

@@ -179,23 +179,34 @@ export interface IdentityVerificationState {
verifiedAt: string | null;
birthdate: string | null;
gender: string | null;
}
/** Mirrors `OwnerIdentityStateDto`. */
export interface OwnerIdentityState extends IdentityVerificationState {
/** Typed passport number — the foreign-company alternative to Fayda. */
passportNumber: string | null;
}
/**
* Owner/PoA Fayda verification, shared with the portal's derivation
* The company's single identity verification, shared with the portal's derivation
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces.
*/
export interface CompanyIdentityState {
faydaRequired: boolean;
passportRequired: boolean;
owner: OwnerIdentityState;
/** Foreign company: a passport number proves the person as Fayda would. */
passportAccepted: boolean;
/** Whether the company named a representative. Null = never answered. */
poaDeclared: "yes" | "no" | null;
/** Whose verification the company is gated on — PoA if declared, else owner. */
subject: "owner" | "poa" | null;
owner: IdentityVerificationState;
poa: IdentityVerificationState;
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* Does the owner the company put forward match the eTrade licence?
* THE reviewer check. Null when there is nothing to compare. Advisory —
* eTrade and Fayda transliterate Amharic names differently, so a `false` is
* "look at this", not "reject this".
*/
ownerMatchesEtrade: boolean | null;
complete: boolean;
}
@@ -211,14 +222,21 @@ export interface Company {
fanNumber?: string | null;
country: string;
nationality?: CompanyNationality | null;
/**
* A co-operative union or farm: a TIN but no trade licence, so its
* registration was typed rather than fetched from eTrade, there is no eTrade
* manager to check the owner against, and it holds no freight-forwarder role.
*/
cooperative?: boolean;
address?: string | null;
phone?: string | null;
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
/** The owner — whoever the eTrade licence names as the business's manager. */
ownerName?: string | null;
ownerEmail?: string | null;
ownerPhone?: string | null;
poaName?: string | null;
poaEmail?: string | null;
poaPhone?: string | null;

View File

@@ -11,7 +11,7 @@ import {
} from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { AlertCircle, Building2, Download } from "lucide-react";
import { AlertCircle, Building2, Download, Info } from "lucide-react";
import { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types";
@@ -54,6 +54,14 @@ interface ETradeInfoProps {
* than silently snapping to eTrade's first one.
*/
selectedLicenceNumber?: string;
/**
* A record is worth having but not required — a co-operative union or farm
* registers on a TIN alone, so eTrade may legitimately hold nothing for it.
* The lookup still runs (plenty of co-operatives DO have a record, and it
* beats typing), but "not found" stops being a red dead end and becomes the
* expected outcome, with the form below to fill in by hand.
*/
registrationOptional?: boolean;
}
// Digits, not just length: a 10-character non-numeric TIN used to fire a lookup
@@ -70,6 +78,7 @@ export default function ETradeInfo({
onReset,
alreadyVerified,
selectedLicenceNumber,
registrationOptional = false,
}: ETradeInfoProps) {
const mutation = useETradeData();
const isLoading = mutation.isPending;
@@ -327,16 +336,26 @@ export default function ETradeInfo({
</Stack>
)}
{notFound && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="No matching business record"
>
This TIN isn't registered with eTrade. Check the number — we can't
continue without a matching business record.
</Alert>
)}
{notFound &&
(registrationOptional ? (
<Alert
icon={<Info size={16} />}
color="blue"
title="Nothing on file at eTrade for this TIN"
>
That's expected without a trade licence. Fill in your registration
details below and we'll take them as you give them.
</Alert>
) : (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="No matching business record"
>
This TIN isn't registered with eTrade. Check the number — we can't
continue without a matching business record.
</Alert>
))}
{errorMessage && (
<Alert

View File

@@ -1,6 +1,7 @@
import {
Box,
Button,
Checkbox,
Group,
Modal,
ScrollArea,
@@ -41,12 +42,17 @@ import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
type FormStep =
| "company"
| "owner"
| "representation"
| "contact"
| "documents";
const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"owner",
"representation",
"contact",
"poa",
"documents",
];
@@ -68,23 +74,25 @@ const STEP_META: Record<
icon: <Building2 size={20} />,
title: "Company Information",
description:
"Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.",
"Confirm your VAT number and we'll pull your registration straight from eTrade.",
},
personnel: {
owner: {
icon: <User size={20} />,
title: "General Manager",
description: "Who is the general manager of the company?",
title: "Company Owner",
description:
"The person registered on your eTrade licence. We fill in what eTrade and Fayda gave us.",
},
representation: {
icon: <FileText size={20} />,
title: "Who Acts For You",
description:
"Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.",
},
contact: {
icon: <UserCheck size={20} />,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
description: "Optionally add a representative with power of attorney.",
},
documents: {
icon: <UploadCloud size={20} />,
title: "Upload Documents",
@@ -157,6 +165,15 @@ export default function OnboardingWizardDialog({
const [roles, setRoles] = useState<string[]>(
existingProfiles.map((p) => p.type),
);
const [cooperative, setCooperative] = useState<boolean>(
company?.company?.attributes?.cooperative === true,
);
// Ticking the box drops a role the company can no longer hold, rather than
// letting Continue fail on a selection the API refuses.
const handleCooperativeChange = useCallback((checked: boolean) => {
setCooperative(checked);
if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
}, []);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
@@ -207,10 +224,11 @@ export default function OnboardingWizardDialog({
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
cooperative?: boolean;
}) => api.companies.startOnboarding.call(vars),
onSuccess: async () => {
// Nationality drives the server-resolved identity requirements (Fayda vs
// passport), the document set and the GM/PoA copy — all read from
// passport), the document set and the PoA copy — all read from
// onboardingRequirements/profile. Re-entering role selection can change
// it, so both must be refetched alongside getInfo or the form step would
// keep rendering the previous nationality's requirements.
@@ -289,6 +307,7 @@ export default function OnboardingWizardDialog({
resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality);
setCooperative(company?.company?.attributes?.cooperative === true);
// Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
@@ -303,8 +322,9 @@ export default function OnboardingWizardDialog({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined,
cooperative,
});
}, [roles, nationality, startMutation]);
}, [roles, nationality, cooperative, startMutation]);
// Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
@@ -414,15 +434,21 @@ export default function OnboardingWizardDialog({
const requiredDocsMissing = requirementDocuments.some(
(d) => d.isRequired && !d.uploaded,
);
// The PoA gets the same treatment: a resumed draft that predates the
// delegation-letter requirement (or a forwarder whose PoA is blank) must land
// back on the PoA step, where both the details and the letter are entered.
const poaIncomplete = requirementsQuery.data?.poa?.complete === false;
// The representation step gets the same treatment. An unanswered
// power-of-attorney question, or a declared representative still missing
// details or the DARS paper, must land the customer back on the step where
// all of that is entered — including a draft that predates the question
// existing at all, whose `declared` comes back null.
const representationIncomplete =
requirementsQuery.data?.poa?.declared == null ||
requirementsQuery.data?.poa?.complete === false ||
requirementsQuery.data?.identity?.identityProven === false;
// Each unmet requirement lowers the ceiling; resume never moves forward.
let ceiling = FORM_STEPS.length - 1;
if (requiredDocsMissing)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
if (representationIncomplete)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("representation"));
const effectiveResumeStep: FormStep =
FORM_STEPS[
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
@@ -446,10 +472,19 @@ export default function OnboardingWizardDialog({
onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// Fayda verification state for the owner and the PoA — the general manager
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
// a foreign one requires a typed passport number for the owner instead.
// The company's single identity verification, and whose it is. Fayda is
// mandatory for an Ethiopian company; a foreign one may instead type a
// passport number for the same person.
identity: requirementsQuery.data?.identity,
// Server-confirmed, not the local checkbox: the flag is only real once
// startOnboarding has persisted it, and the form's whole company step
// branches on it.
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
extraDocumentSettingCode:
requirementsQuery.data?.cooperativeDocumentSettingCode ?? null,
// A freight forwarder cannot answer the power-of-attorney question — the
// API forces "yes" — so the step offers no way to change it.
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
onIdentityChange: () => {
void profileQuery.refetch();
void requirementsQuery.refetch();
@@ -517,6 +552,16 @@ export default function OnboardingWizardDialog({
onChange={setNationality}
embedded
/>
{/* A co-operative union or farm registers on a TIN alone. It
changes what the next step asks for (typed registration, no
eTrade lookup), which documents apply, and which roles are on
offer — so it is answered here, alongside the other two. */}
<Checkbox
checked={cooperative}
onChange={(e) => handleCooperativeChange(e.currentTarget.checked)}
label="We're a co-operative union or farm"
description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence."
/>
<Text fw={600} size="lg" c="edr-text">
What does your company do?(multiple)
</Text>
@@ -524,6 +569,9 @@ export default function OnboardingWizardDialog({
value={roles}
onChange={setRoles}
embedded
// Forwarding is licensed work — a co-op holds no licence, so
// the role is not offered rather than refused later.
excludeTypes={cooperative ? ["freight_forwarder"] : undefined}
/>
{startError && (
<Text size="sm" c="red">

View File

@@ -49,49 +49,46 @@ import TabAccount from "./settings/TabAccount";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager";
import TabOwner from "./settings/TabOwner";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab =
| "account"
| "company"
| "contact"
| "gm"
| "owner"
| "poa"
| "documents";
/** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
switch (tabId) {
case "company": {
// Identity proof lives here: the owner's Fayda verification for an
// Ethiopian company, or the owner's typed passport number for a foreign
// one.
const identity = profile.identity;
const identityIncomplete = identity
? (identity.faydaRequired && !identity.owner.verified) ||
(identity.passportRequired && !identity.owner.passportNumber)
: false;
return !profile.companyAddress || identityIncomplete;
}
case "company":
return !profile.companyAddress;
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm":
// The GM is established through Fayda — verified in their own right or
// declared the same person as the owner — so the identity answers this,
// not the typed columns. A company that may still type them (foreign,
// whose manager may hold no Fayda ID) is judged on those instead.
if (profile.identity?.gm.verified) return false;
if (profile.identity?.faydaRequired) return true;
case "owner":
// The owner is whoever the eTrade licence names. All three details are
// required whatever supplied them, and the identity verification lives
// on whichever person the PoA declaration points at — flagged here when
// it is the owner and still unproven.
return (
!profile.generalManagerName ||
!profile.generalManagerEmail ||
!profile.generalManagerPhone
!profile.ownerName ||
!profile.ownerEmail ||
!profile.ownerPhone ||
(profile.identity?.subject === "owner" &&
!profile.identity.identityProven)
);
case "poa":
// Unanswered is itself incomplete — the answer decides whose identity is
// verified — as is a declared representative who has not proved theirs.
if (profile.identity?.poaDeclared == null) return true;
return (
profile.identity.subject === "poa" && !profile.identity.identityProven
);
case "account":
// Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here.
case "poa":
case "documents":
return false;
}
@@ -101,7 +98,7 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "account", label: "Account", icon: <UserCog size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
{ id: "owner", label: "Owner", icon: <Briefcase size={16} /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
];
@@ -405,8 +402,8 @@ export default function SettingsPage() {
<Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<TabGeneralManager profile={profile} mode="edit" />
<Tabs.Panel value="owner">
<TabOwner profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" />

View File

@@ -0,0 +1,61 @@
import { Badge, Group, Stack, Text } from "@mantine/core";
import type { ReactNode } from "react";
/** Where a locked value came from, shown as a badge next to it. */
export type FieldSource = "eTrade" | "Fayda";
const SOURCE_NOTE: Record<FieldSource, string> = {
eTrade: "From your eTrade licence",
Fayda: "From the Fayda verification",
};
/**
* One person-detail field that an outside source may have taken ownership of.
*
* `source` — not "does a value exist" — decides which side renders. That
* distinction is the whole point: `ownerName` holds a value the moment the
* customer types it and the step saves, and keying off presence meant the input
* they had just filled in turned into a read-only badge as soon as they
* navigated away and back, with no way to correct it. Only a real source owns a
* field: a Fayda verification (the API refuses to overwrite those) or the
* eTrade licence (the record the backoffice checks the company against).
*
* It pairs with `requiredKeys` in CompanyProfileForm, which requires exactly
* the fields that fall through to `children`: **a field is required if and only
* if there is an input on screen to satisfy it.**
*/
export default function SourcedField({
label,
value,
source,
children,
}: {
label: string;
/** The value to display when a source owns this field. */
value?: string | null;
/** The owning source, or null while the field is still the customer's. */
source: FieldSource | null;
/** The input rendered whenever the field is still the customer's to fill. */
children: ReactNode;
}) {
if (!source || !value?.trim()) return <>{children}</>;
return (
<Stack gap={2}>
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{label}
</Text>
<Badge size="xs" variant="light" color="edr-green">
{source}
</Badge>
</Group>
<Text className="wrap-break-word" size="sm" c="edr-text" fw={500}>
{value}
</Text>
<Text size="xs" c="edr-muted">
{SOURCE_NOTE[source]}
</Text>
</Stack>
);
}

View File

@@ -4,6 +4,8 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
import type { FieldSource } from "./SourcedField";
import {
ETRADE_BUNDLE_FIELDS,
type CompanyStep,
@@ -69,7 +71,6 @@ export function normalizeIdentityPhones(
...identity,
owner: fix(identity.owner),
poa: fix(identity.poa),
gm: fix(identity.gm),
};
}
@@ -82,6 +83,73 @@ export const samePhone = (a?: string | null, b?: string | null) => {
return da.length === 9 && da === phoneDigits(b);
};
/** The owner details an outside source can take ownership of. */
export type OwnerField = "name" | "email" | "phone";
export interface OwnerSources {
/** Which source owns each field, or null where it is still the customer's. */
source: Record<OwnerField, FieldSource | null>;
/** What to display for an owned field — normalized as the payload will be. */
sourced: Record<OwnerField, string>;
}
/**
* Who owns each of the owner's fields, and with what value.
*
* Two sources can own a field, and they rank. A Fayda verification owns
* whatever its claims filled (the API refuses to overwrite those), and the
* eTrade licence owns the manager's name and phone: that record is the thing
* the backoffice checks the company against, so it is reported, not proposed.
* Neither is typeable. Everything left over is the customer's — an editable
* input, required precisely because there is an input for it. Fayda outranks
* eTrade on the same person: the stronger claim, and the one the API keeps.
*
* Two things deliberately do NOT take ownership.
*
* A value merely being present. It exists the moment the customer types one and
* the step saves — so a presence test turned the input they had just filled
* into a read-only badge on the way back through the wizard, and dropped the
* field out of `requiredKeys` at the same time.
*
* And a value that isn't usable. Both sources hold contact details as free
* text: Fayda's phone is whatever the national registry recorded, eTrade's is
* whatever was typed at the licence desk ("09 " is a real answer, and it
* normalizes to a non-empty, invalid `+2519`). Locking one of those behind a
* read-only row leaves the customer told to fix a field with no input, or the
* step saved with a value the API rejects. So a source owns an email or a phone
* only if what it supplies holds up as one; otherwise the field falls through
* to an input and is required like any other. Names have no format to fail, so
* presence is the whole test there.
*/
export function resolveOwnerSources(
identity: CompanyIdentityState | undefined,
etradeOwner: { name: string; phone: string } | null,
): OwnerSources {
const verified = identity?.owner.verified ?? false;
const faydaName = verified && Boolean(identity?.owner.name?.trim());
const faydaEmail = verified ? firstValidEmail(identity?.owner.email) : "";
const faydaPhone = verified ? firstValidPhone(identity?.owner.phone) : "";
const etradeName = etradeOwner?.name?.trim() ?? "";
const etradePhone = firstValidPhone(etradeOwner?.phone);
const source: Record<OwnerField, FieldSource | null> = {
name: faydaName ? "Fayda" : etradeName ? "eTrade" : null,
// eTrade never returns an email for the manager, so this one is Fayda's or
// it is the customer's to type.
email: faydaEmail ? "Fayda" : null,
phone: faydaPhone ? "Fayda" : etradePhone ? "eTrade" : null,
};
return {
source,
sourced: {
name: source.name === "Fayda" ? (identity?.owner.name?.trim() ?? "") : etradeName,
email: faydaEmail,
phone: source.phone === "Fayda" ? faydaPhone : etradePhone,
},
};
}
/** Mask all but the first 7 chars of an E.164 phone for display. */
export const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
@@ -97,15 +165,14 @@ export function buildPayload(
vatNumber: data.vatNumber,
attributes: {
ownerPassportNumber: data.ownerPassportNumber || undefined,
poaPassportNumber: data.poaPassportNumber || undefined,
contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
// The representative's own details are written by their Fayda
// verification, so the city is all the form has to send.
ownerName: data.ownerName,
ownerEmail: data.ownerEmail,
ownerPhone: data.ownerPhone,
poaLocation: data.poaLocation || undefined,
},
};
@@ -135,23 +202,28 @@ export function stepPayload(
}
if (dirty.tinNumber) etrade.tin = d.tinNumber;
return {
companyAddress: d.companyAddress,
// Composed from the address parts, so it is only as complete as they
// are. Sending it while they are still empty (the lookup hasn't landed,
// or eTrade left them blank) would overwrite a stored address with a
// degraded version of itself — an absent key means "untouched".
...(d.companyAddress?.trim()
? { companyAddress: d.companyAddress }
: {}),
vatNumber: d.vatNumber,
ownerPassportNumber: d.ownerPassportNumber || undefined,
...etrade,
};
}
case "personnel":
case "owner":
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and
// undefined, so an empty string is validated and 400s with
// "generalManagerEmail must be an email". An Ethiopian company never types
// these — the GM comes from the Fayda verification (or the "same as owner"
// declaration), so the form fields are legitimately blank and would fail a
// step that has no input to fix.
// "ownerEmail must be an email". A field the eTrade lookup or the Fayda
// claim already filled is legitimately blank in the form — it has no
// input — so sending "" would fail a step with nothing on screen to fix.
return {
generalManagerName: d.generalManagerName || undefined,
generalManagerEmail: d.generalManagerEmail || undefined,
generalManagerPhone: d.generalManagerPhone || undefined,
ownerName: d.ownerName || undefined,
ownerEmail: d.ownerEmail || undefined,
ownerPhone: d.ownerPhone || undefined,
ownerPassportNumber: d.ownerPassportNumber || undefined,
};
case "contact":
return {
@@ -160,12 +232,16 @@ export function stepPayload(
contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
case "representation":
return {
poaName: d.poaName || undefined,
poaEmail: d.poaEmail || undefined,
poaPhone: d.poaPhone || undefined,
poaLocation: d.poaLocation || undefined,
poaPassportNumber: d.poaPassportNumber || undefined,
// The step renders one passport input, for whichever person the
// declaration made the identity subject — so it has to save both.
ownerPassportNumber: d.ownerPassportNumber || undefined,
};
default:
return {};
@@ -183,6 +259,7 @@ export function toFormValues(p: ProfileResponse): FormData {
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
poaPassportNumber: p.identity?.poa.passportNumber ?? "",
licenceNumber: p.licenceNumber ?? "",
statusDescription: p.statusDescription ?? "",
dateRegistered: p.dateRegistered ?? "",
@@ -198,12 +275,11 @@ export function toFormValues(p: ProfileResponse): FormData {
contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
ownerName: p.ownerName ?? "",
ownerEmail: p.ownerEmail ?? "",
ownerPhone: p.ownerPhone ?? "",
poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "",
};

View File

@@ -6,6 +6,7 @@ import {
firstValidEmail,
firstValidPhone,
normalizeIdentityPhones,
resolveOwnerSources,
stepPayload,
} from "./helpers";
import type { FormData } from "./schema";
@@ -35,9 +36,9 @@ const values = (over: Partial<FormData> = {}): FormData =>
contactPersonPosition: "",
contactPersonEmail: "",
contactPersonPhone: "+251911223344",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
ownerName: "",
ownerEmail: "",
ownerPhone: "",
poaName: "",
poaPhone: "",
poaAddress: "",
@@ -65,18 +66,16 @@ describe("VAT number", () => {
).toBeUndefined();
});
it("rejects twelve digits", () => {
expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe(
"VAT number must be 10 or 11 digits",
);
});
// `.length(10)` used to pass this, so a ten-letter string reached the API.
it("rejects ten non-digits", () => {
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe(
"VAT number must be 10 or 11 digits",
);
});
// No shape rule any more: a foreign tax authority's VAT number carries
// letters and dashes, and a co-operative union's registration numbering
// follows the trade-licence pattern not at all. Length and alphabet are not
// ours to police — only presence is.
it.each(["001234567890", "GB123456789", "ET-2024/0091"])(
"accepts %s",
(vat) => {
expect(errorFor(values({ vatNumber: vat }), "vatNumber")).toBeUndefined();
},
);
it("rejects blank", () => {
expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe(
@@ -104,25 +103,88 @@ describe("stepFields", () => {
// The regression this whole change exists to prevent: a step must not gate on
// a field it renders no input for, or Continue fails with the error attached
// to nothing on screen.
it("never gates the company step on a derived or read-only field", () => {
const unreachable = [
"etradePhone",
//
// Listing a field on a step is no longer the gate — `requiredKeys` is. The
// registration fields appear on the company step because a co-operative union
// or farm types them, and a licensed company gets them read-only from eTrade;
// the base schema must accept them blank either way.
it("never gates the company step on a field with no input", () => {
const derived = ["etradePhone", "licenceNumber", "statusDescription"];
expect(stepFields.company.filter((f) => derived.includes(f))).toEqual([]);
});
it("leaves the registration fields optional in the base schema", () => {
for (const field of ["companyName", "region", "zone", "woreda", "kebele"] as const) {
expect(errorFor(values({ [field]: "" }), field)).toBeUndefined();
}
});
it("requires the registration fields once a co-operative types them", () => {
const parsed = buildOnboardingSchema([
"companyName",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
];
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
[],
]).safeParse(
values({ companyName: "", region: "", zone: "", woreda: "", kebele: "" }),
);
expect(parsed.success).toBe(false);
const paths = parsed.success
? []
: parsed.error.issues.map((i) => String(i.path[0]));
expect(paths).toEqual(
expect.arrayContaining([
"companyName",
"region",
"zone",
"woreda",
"kebele",
]),
);
});
});
describe("the identity subject's passport", () => {
// The representation step renders ONE passport input, for whoever the
// power-of-attorney answer made the identity subject. When the answer is "no
// PoA" that is the owner — so the owner's passport number is typed on the
// representation step and has to be carried by it. It used to belong to the
// owner step alone, which is already behind the customer by then: the number
// was typed, dropped, and the final submit failed `assertIdentityVerified`
// naming a field they could see was filled in.
it("is carried by the step that renders the input", () => {
expect(stepFields.representation).toContain("ownerPassportNumber");
expect(stepFields.representation).toContain("poaPassportNumber");
});
it("saves the owner's passport from the representation step", () => {
const payload = stepPayload(
"representation",
values({ ownerPassportNumber: "P1234567" }),
);
expect(payload.ownerPassportNumber).toBe("P1234567");
});
it("still omits it when there is none, rather than sending an empty string", () => {
const payload = stepPayload(
"representation",
values({ ownerPassportNumber: "" }),
);
expect(payload.ownerPassportNumber).toBeUndefined();
});
});
describe("stepPayload (representation)", () => {
// `poaAddress` is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS` server-side).
// The portal states `poaLocation` instead and must never send the other.
it("sends the company's stated location, never the Fayda address", () => {
const payload = stepPayload(
"representation",
values({ poaLocation: "Dire Dawa, Ethiopia" }),
);
expect(payload.poaLocation).toBe("Dire Dawa, Ethiopia");
expect("poaAddress" in payload).toBe(false);
});
});
@@ -131,31 +193,29 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
data: FormData,
required: (keyof FormData)[],
): (keyof FormData)[] => {
const parsed = buildOnboardingSchema(false, required).safeParse(data);
const parsed = buildOnboardingSchema(required).safeParse(data);
return parsed.success
? []
: (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]);
};
// Fayda's email/phone claims are optional: the step renders an input for what
// the verification did not supply, and requires exactly those. Nothing else —
// a field with no input on screen must never fail Continue.
// eTrade returns no email and Fayda's email/phone claims are optional: the
// step renders an input for what no source supplied, and requires exactly
// those. Nothing else — a field with no input on screen must never fail
// Continue.
it("requires only the keys it is handed", () => {
const issues = issuesFor(values(), [
"generalManagerEmail",
"generalManagerPhone",
]);
expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]);
const issues = issuesFor(values(), ["ownerEmail", "ownerPhone"]);
expect(issues).toEqual(["ownerEmail", "ownerPhone"]);
});
it("passes once those keys are filled", () => {
expect(
issuesFor(
values({
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
ownerEmail: "owner@example.com",
ownerPhone: "+251911223344",
}),
["generalManagerEmail", "generalManagerPhone"],
["ownerEmail", "ownerPhone"],
),
).toEqual([]);
});
@@ -165,9 +225,7 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
});
it("names the field in the message, so it reads under its own input", () => {
const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse(
values(),
);
const parsed = buildOnboardingSchema(["poaEmail"]).safeParse(values());
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe(
@@ -196,39 +254,43 @@ describe("stepPayload (company)", () => {
// Still only the dirty ones.
expect(payload.licenceNumber).toBeUndefined();
});
// The address is composed from the parts, so it is only ever as complete as
// they are. An absent key means "untouched" to the API; sending a blank one
// would replace a stored address with nothing.
it("omits a blank composed address rather than clearing the stored one", () => {
const payload = stepPayload("company", values({ companyAddress: "" }), {});
expect("companyAddress" in payload).toBe(false);
});
});
describe("stepPayload (personnel)", () => {
// An Ethiopian company never types the GM — Fayda (or "same as owner") owns
// those fields — so the form holds "". `@IsOptional()` on the DTO skips only
// null/undefined, so an empty string is validated and comes back as
// "generalManagerEmail must be an email", on a step that renders no input.
it("omits blank GM fields instead of sending empty strings", () => {
describe("stepPayload (owner)", () => {
// A Fayda claim owns whatever it supplied, so the form holds "" for those.
// `@IsOptional()` on the DTO skips only null/undefined, so an empty string is
// validated and comes back as "ownerEmail must be an email" — on a step that
// renders no input for it.
it("omits blank owner fields instead of sending empty strings", () => {
const payload = stepPayload(
"personnel",
values({
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
}),
"owner",
values({ ownerName: "", ownerEmail: "", ownerPhone: "" }),
);
expect(payload.generalManagerName).toBeUndefined();
expect(payload.generalManagerEmail).toBeUndefined();
expect(payload.generalManagerPhone).toBeUndefined();
expect(payload.ownerName).toBeUndefined();
expect(payload.ownerEmail).toBeUndefined();
expect(payload.ownerPhone).toBeUndefined();
});
it("still sends typed GM details (foreign company)", () => {
it("sends the owner details the customer typed or eTrade prefilled", () => {
const payload = stepPayload(
"personnel",
"owner",
values({
generalManagerName: "Abebe Bikila",
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
ownerName: "Abebe Bikila",
ownerEmail: "owner@example.com",
ownerPhone: "+251911223344",
}),
);
expect(payload.generalManagerName).toBe("Abebe Bikila");
expect(payload.generalManagerEmail).toBe("gm@example.com");
expect(payload.generalManagerPhone).toBe("+251911223344");
expect(payload.ownerName).toBe("Abebe Bikila");
expect(payload.ownerEmail).toBe("owner@example.com");
expect(payload.ownerPhone).toBe("+251911223344");
});
});
@@ -268,13 +330,117 @@ describe("firstValidEmail", () => {
});
});
describe("resolveOwnerSources", () => {
const identity = (over: {
verified?: boolean;
name?: string | null;
email?: string | null;
phone?: string | null;
}): CompanyIdentityState =>
({
passportAccepted: false,
poaDeclared: "no",
subject: "owner",
owner: {
verified: over.verified ?? true,
name: over.name ?? null,
email: over.email ?? null,
phone: over.phone ?? null,
address: null,
verifiedAt: null,
passportNumber: null,
},
poa: {
verified: false,
name: null,
email: null,
phone: null,
address: null,
verifiedAt: null,
passportNumber: null,
},
identityProven: false,
etradeManagerName: null,
etradeManagerPhone: null,
ownerMatchesEtrade: null,
complete: false,
}) as CompanyIdentityState;
it("locks what each source supplied, Fayda outranking eTrade", () => {
const { source, sourced } = resolveOwnerSources(
identity({
name: "Abebe Bikila",
email: "owner@example.com",
phone: "+251911223344",
}),
{ name: "A. Bikila", phone: "+251911999888" },
);
expect(source).toEqual({ name: "Fayda", email: "Fayda", phone: "Fayda" });
expect(sourced.phone).toBe("+251911223344");
});
// The point of the whole exercise: a field is read-only only if what the
// source gave can actually be submitted. Otherwise the customer is shown a
// badge holding a value the API will reject, with no input to fix it.
it("falls back to an input when Fayda's phone claim is unusable", () => {
const { source, sourced } = resolveOwnerSources(
identity({ name: "Abebe Bikila", phone: "09 " }),
null,
);
expect(source.phone).toBeNull();
expect(sourced.phone).toBe("");
});
it("falls back to an input when Fayda's email claim is malformed", () => {
const { source } = resolveOwnerSources(
identity({ name: "Abebe Bikila", email: "not-an-email" }),
null,
);
expect(source.email).toBeNull();
});
it("falls back to an input when eTrade's manager phone is unusable", () => {
const { source, sourced } = resolveOwnerSources(undefined, {
name: "Abebe Bikila",
phone: "09 ",
});
// The name is still eTrade's — names have no format to fail.
expect(source.name).toBe("eTrade");
expect(source.phone).toBeNull();
expect(sourced.phone).toBe("");
});
it("takes eTrade's phone where Fayda has none, normalized", () => {
const { source, sourced } = resolveOwnerSources(
identity({ verified: false }),
{ name: "Abebe Bikila", phone: "0911223344" },
);
expect(source.phone).toBe("eTrade");
expect(sourced.phone).toBe("+251911223344");
});
it("owns nothing when the verification never happened", () => {
const { source } = resolveOwnerSources(
identity({
verified: false,
name: "Abebe Bikila",
email: "owner@example.com",
phone: "+251911223344",
}),
null,
);
expect(source).toEqual({ name: null, email: null, phone: null });
});
});
describe("normalizeIdentityPhones", () => {
it("converts a local Fayda phone claim to E.164", () => {
const identity = {
faydaRequired: true,
passportRequired: false,
passportAccepted: false,
poaDeclared: "yes",
subject: "poa",
owner: {
verified: true,
verified: false,
name: "A",
phone: "0911223344",
email: null,
@@ -283,28 +449,22 @@ describe("normalizeIdentityPhones", () => {
passportNumber: null,
},
poa: {
verified: false,
name: null,
phone: null,
email: null,
address: null,
verifiedAt: null,
},
gm: {
verified: false,
name: null,
verified: true,
name: "B",
phone: "251911223344",
email: null,
address: null,
verifiedAt: null,
passportNumber: null,
},
gmSameAsOwner: false,
complete: false,
identityProven: true,
etradeManagerName: null,
ownerMatchesEtrade: null,
complete: true,
} as CompanyIdentityState;
const fixed = normalizeIdentityPhones(identity)!;
expect(fixed.owner.phone).toBe("+251911223344");
expect(fixed.gm.phone).toBe("+251911223344");
expect(fixed.poa.phone).toBeNull();
expect(fixed.poa.phone).toBe("+251911223344");
});
});

View File

@@ -5,9 +5,9 @@ import { isValidPhone } from "@/components/PhoneField";
export type CompanyStep =
| "company"
| "personnel"
| "owner"
| "representation"
| "contact"
| "poa"
| "documents"
| "additional";
@@ -22,25 +22,33 @@ export const onboardingSchema = z.object({
// can diverge without the backend's eTrade-authenticity check misfiring.
etradePhone: z.string().optional(),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
// `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits.
vatNumber: z
.string()
.min(1, "VAT number is required")
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
// The owner's passport number — the foreign-company identity credential
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
// enforced in buildOnboardingSchema since that depends on `nationality`.
// Required, but no shape check. Ethiopian VAT numbers are usually 10 or 11
// digits; a foreign company's is whatever its own tax authority issues, and a
// co-operative's registration numbering follows neither. A format rule here
// only ever rejected valid numbers we had no business judging.
vatNumber: z.string().min(1, "VAT number is required"),
// Passport numbers — the alternative identity credential for a foreign
// company (Fayda is an Ethiopian national ID). Only the one belonging to the
// declared identity subject is ever asked for, and only when that person has
// not verified with Fayda — so requiredness is decided per render and lives
// in `requiredKeys`, not here.
ownerPassportNumber: z.string().optional(),
poaPassportNumber: z.string().optional(),
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: z.string().optional(),
// The registered address comes from eTrade and nowhere else — the form
// renders these read-only, so requiring them would be a Continue button that
// fails on a field with no input to fix it. A gap in eTrade's own data stays
// a gap rather than becoming a customer-typed claim wearing eTrade's badge.
// The registered address normally comes from eTrade and nowhere else — the
// form renders these read-only, so requiring them would be a Continue button
// that fails on a field with no input to fix it. A gap in eTrade's own data
// stays a gap rather than becoming a customer-typed claim wearing eTrade's
// badge.
//
// A co-operative is the exception: it has no business licence, so there is no
// eTrade record at all and these ARE typed. Requiredness follows the same
// invariant as everywhere else — it is decided per render, in `requiredKeys`.
region: z.string().optional(),
zone: z.string().optional(),
woreda: z.string().optional(),
@@ -57,21 +65,23 @@ export const onboardingSchema = z.object({
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// Optional here, not unrequired: the GM is now established by Fayda — either
// verified in their own right or declared the same person as the owner — so
// for an Ethiopian company these fields are never typed and would fail a
// blanket `min(1)`. Presence is gated per nationality in the step's own
// check, where the identity state is available; zod only polices format for
// the foreign companies that still type them.
generalManagerName: z.string().optional(),
generalManagerEmail: z
// The owner — whoever the eTrade licence names as the business's manager.
//
// Optional here, not unrequired: the eTrade lookup fills the name and phone,
// and a Fayda verification can fill all three, so on a well-supplied company
// none of them is typed and a blanket `min(1)` would fail a step with no
// input on screen. What IS required is decided per render — a field is
// required exactly when the step renders an input for it (`requiredKeys`).
// zod only polices format here.
ownerName: z.string().optional(),
ownerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid Manager email",
"Invalid owner email",
),
generalManagerPhone: z
ownerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
@@ -80,7 +90,6 @@ export const onboardingSchema = z.object({
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z
.string()
.optional()
@@ -88,6 +97,10 @@ export const onboardingSchema = z.object({
(v) => !v || z.string().email().safeParse(v).success,
"Invalid email address",
),
// Where the representative is based, as the company states it. Deliberately
// NOT `poaAddress`: that one is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS`)
// the verification writes and the portal must never send — the two used to
// sit side by side here, with the Fayda address silently hiding this input.
poaLocation: z.string().optional(),
});
@@ -102,45 +115,45 @@ export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
* message has to be built here rather than attached to the base schema.
*/
const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
// Typed only by a co-operative — every other company gets these from eTrade.
companyName: "Company name",
region: "Region",
zone: "Zone",
woreda: "Woreda",
kebele: "Kebele",
poaName: "Representative's name",
poaEmail: "Representative's email",
poaPhone: "Representative's phone",
generalManagerName: "General manager's name",
generalManagerEmail: "General manager's email",
generalManagerPhone: "General manager's phone",
poaPassportNumber: "Representative's passport number",
ownerName: "Owner's name",
ownerEmail: "Owner's email",
ownerPhone: "Owner's phone",
ownerPassportNumber: "Owner's passport number",
};
/**
* The PoA's and GM's identifying fields normally come from their Fayda
* verification, so nothing in the base schema requires them. But Fayda's email
* and phone claims are optional and routinely come back empty, and the steps
* render an input for whatever the verification did not supply — so those
* fields become mandatory exactly then.
* The owner's and the representative's identifying fields arrive from three
* places — the eTrade lookup, a Fayda verification, or the customer typing them
* and which one supplies what varies per company. eTrade returns no email at
* all; Fayda's email and phone claims are optional and routinely come back
* empty. So nothing in the base schema requires them, and the steps render an
* input for whatever no source supplied.
*
* `requiredKeys` is that decision, made by CompanyProfileForm from the same
* state that drives the rendering: a field is required iff an input exists for
* it. Passing it in (rather than deriving it here) is what keeps the two from
* drifting into a Continue button that fails on a field nobody can see.
* state that drives the rendering: **a field is required iff an input exists
* for it**. Passing it in (rather than deriving it here) is what keeps the two
* from drifting into a Continue button that fails on a field nobody can see.
*/
export function buildOnboardingSchema(
/** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false,
/** Fields the current step renders an input for and must not leave blank. */
requiredKeys: readonly (keyof FormData)[] = [],
) {
if (!passportRequired && requiredKeys.length === 0) return onboardingSchema;
if (requiredKeys.length === 0) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
if (passportRequired && !d.ownerPassportNumber?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ownerPassportNumber"],
message: "The owner's passport number is required",
});
}
for (const key of requiredKeys) {
if (d[key]?.trim()) continue;
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
path: [key],
message: `${CONDITIONAL_LABELS[key] ?? key} is required`,
});
@@ -184,25 +197,46 @@ export const ETRADE_BUNDLE_FIELDS = [
* (`REQUIRED_COMPANY_INFO`), and reports it with a message.
*/
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
// Only the three fields this step actually renders an input for. The company
// name and the registered address are eTrade's, shown read-only.
company: ["tinNumber", "vatNumber", "ownerPassportNumber"],
personnel: [
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
// Only what this step actually renders an input for. The company name and the
// registered address are eTrade's, shown read-only — except for a
// co-operative, which types them (added per render via `requiredKeys`).
company: [
"tinNumber",
"vatNumber",
"companyName",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
],
// `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers
// an input wherever eTrade and Fayda between them left a gap.
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"],
contact: [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
],
// The API requires poaName/poaEmail/poaPhone from a freight forwarder
// (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the
// representative isn't proven by Fayda — otherwise the save is rejected
// naming fields the form never rendered.
poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"],
// The API requires poaName/poaEmail/poaPhone once a representative is
// declared (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever
// Fayda didn't supply them — otherwise the save is rejected naming fields the
// form never rendered.
// `ownerPassportNumber` belongs here as much as the PoA's: the step renders
// whichever passport input the declaration calls for, and when the answer is
// "no PoA" that is the owner's. Leaving it out meant the number was typed on
// this step, validated by nothing, and dropped by `stepPayload` — so the
// final submit failed `assertIdentityVerified` over a field two steps back
// that the customer could see was filled in.
representation: [
"poaName",
"poaEmail",
"poaPhone",
"poaLocation",
"poaPassportNumber",
"ownerPassportNumber",
],
documents: [],
additional: [],
};

View File

@@ -1,12 +1,10 @@
import { Stack, TextInput } from "@mantine/core";
import { Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import type { CompanyRegistrationData } from "@edr/types";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import ETradeCompanyCard from "../ETradeCompanyCard";
@@ -14,14 +12,16 @@ import StepSection from "../StepSection";
export interface CompanyInfoStepProps {
form: UseFormReturn<FormData>;
/** Fayda verification state, phone-normalized by the parent. */
identity?: CompanyIdentityState;
/** True when Fayda (not a passport) is what this company must prove with. */
verifiedIdentity: boolean;
tinStatus: ETradeStatus;
tinVerified: boolean;
/** Registration fields are already populated (a lookup passed, now or earlier). */
hasRegistrationDetails: boolean;
/**
* The company is a co-operative union or farm: it has a TIN but no business
* licence, so eTrade holds no record to look up and the registration is typed
* here instead.
*/
cooperative?: boolean;
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
onETradeStatusChange: (status: ETradeStatus) => void;
onETradeReset: () => void;
@@ -29,11 +29,10 @@ export interface CompanyInfoStepProps {
export default function CompanyInfoStep({
form,
identity,
verifiedIdentity,
tinStatus,
tinVerified,
hasRegistrationDetails,
cooperative = false,
onETradeDataLoaded,
onETradeStatusChange,
onETradeReset,
@@ -41,77 +40,51 @@ export default function CompanyInfoStep({
const {
register,
watch,
setValue,
formState: { errors },
} = form;
const region = watch("region") ?? "";
return (
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={
(watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber
? "done"
: "todo"
}
status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"}
>
<TextInput
aria-label="VAT Number"
placeholder="0012345678"
maxLength={11}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</StepSection>
{/* The TIN lookup runs for everyone, co-operative included. A co-op has
no trade licence, but plenty of them are on eTrade all the same — and
when the record is there it is better data than anything typed, so we
ask for it first and fall back to the form below rather than deciding
in advance that nothing will be found. What differs for a co-op is
only the consequence of finding nothing: expected, not an error. */}
<StepSection
index={2}
title="Owner identity"
subtitle={
!identity?.owner.verified && !verifiedIdentity
? "Provide the company owner's passport number."
: undefined
}
status={
verifiedIdentity
? identity?.owner.verified
? "done"
: identity?.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity?.passportRequired
? "blocked"
: "todo"
}
>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Owner"
state={identity.owner}
required={identity.faydaRequired}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
</StepSection>
<StepSection
index={3}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
subtitle={
cooperative
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
: "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
}
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
tinStatus === "taken"
? "blocked"
: cooperative
? watch("tinNumber")?.trim() && !errors.tinNumber
? "done"
: "todo"
: tinVerified
? "done"
: "todo"
}
>
<ETradeInfo
@@ -123,11 +96,87 @@ export default function CompanyInfoStep({
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
registrationOptional={cooperative}
/>
{tinVerified && (
{!cooperative && tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
{/* A co-operative keeps its typed registration section either way. When
the lookup found something these arrive prefilled — still editable,
because for a co-op they are the customer's own statement rather than
the licence's, and the API takes them as given (`applyEtradeSourcedFields`
skips co-operatives entirely). */}
{cooperative && (
<StepSection
index={3}
title="Registration details"
subtitle={
hasRegistrationDetails
? "From eTrade. Correct anything that doesn't look right — for a co-operative these are yours to state."
: "Everything we'd normally read off an eTrade licence. We need it from you instead."
}
status={
watch("companyName")?.trim() && watch("region")?.trim()
? "done"
: "todo"
}
>
<Stack gap="md">
<TextInput
label="Company Name"
placeholder="Registered name of the union or farm"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Text size="sm" c="edr-muted">
Registered address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Select
label="Region"
placeholder="Select region"
// eTrade's own spelling may not be one of ours. Carrying it in
// as an option keeps the lookup's answer visible instead of
// silently blanking the field it just filled.
data={
region &&
!(ETHIOPIAN_REGIONS as readonly string[]).includes(region)
? [...ETHIOPIAN_REGIONS, region]
: [...ETHIOPIAN_REGIONS]
}
searchable
value={region || null}
onChange={(v) =>
setValue("region", v ?? "", { shouldValidate: true })
}
error={errors.region?.message}
/>
<TextInput
label="Zone"
error={errors.zone?.message}
{...register("zone")}
/>
<TextInput
label="Woreda"
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
error={errors.kebele?.message}
{...register("kebele")}
/>
<TextInput
label="House No."
error={errors.houseNo?.message}
{...register("houseNo")}
/>
</SimpleGrid>
</Stack>
</StepSection>
)}
</Stack>
);
}

View File

@@ -9,19 +9,19 @@ import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface ContactStepProps {
form: UseFormReturn<FormData>;
/**
* The GM's name from whichever source established them (verification or form)
* — the "same as GM" card only makes sense once there is a GM.
* The owner's name from whichever source established them (eTrade, Fayda or
* typed) — the "same as owner" card only makes sense once there is one.
*/
gmName?: string;
contactSameAsGm: boolean;
onToggleContactSameAsGm: (checked: boolean) => void;
ownerName?: string;
contactSameAsOwner: boolean;
onToggleContactSameAsOwner: (checked: boolean) => void;
}
export default function ContactStep({
form,
gmName,
contactSameAsGm,
onToggleContactSameAsGm,
ownerName,
contactSameAsOwner,
onToggleContactSameAsOwner,
}: ContactStepProps) {
const {
register,
@@ -34,21 +34,26 @@ export default function ContactStep({
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
{/* `gmName`, not the raw form field: a Fayda-verified GM never
fills `generalManagerName`, so gating on it hid this card from
every Ethiopian company — the majority case. */}
{gmName && (
{/* `ownerName`, not the raw form field: the owner's name usually comes
from the eTrade lookup or a Fayda claim rather than being typed, so
gating on the form value would hide this card from most companies. */}
{ownerName && (
<LinkCheckboxCard
checked={contactSameAsGm}
onToggle={onToggleContactSameAsGm}
title="Same as General Manager"
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
checked={contactSameAsOwner}
onToggle={onToggleContactSameAsOwner}
title="Same as company owner"
description="Reuse the owner's name, email and phone. Uncheck to enter different details."
/>
)}
<SimpleGrid cols={2} spacing="md">
{/* Disabled while linked, not merely prefilled: the mirror effect
rewrites these from the owner whenever the owner changes, so an edit
made here would be silently thrown away the next time it fires.
Position is the customer's either way — the owner has no equivalent. */}
<TextInput
label="Name"
placeholder="Jane Smith"
disabled={contactSameAsOwner}
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
@@ -64,6 +69,7 @@ export default function ContactStep({
label="Email (Optional)"
type="email"
placeholder="contact@company.com"
disabled={contactSameAsOwner}
error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")}
/>
@@ -71,6 +77,7 @@ export default function ContactStep({
control={control}
name="contactPersonPhone"
label="Phone"
disabled={contactSameAsOwner}
required
/>
</SimpleGrid>

View File

@@ -0,0 +1,151 @@
import { Alert, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import { AlertTriangle, Info } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import SourcedField, { type FieldSource } from "../SourcedField";
/** The owner details this step is responsible for. */
type OwnerField = "name" | "email" | "phone";
export interface OwnerStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade's registered manager, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/**
* Which source owns each of the owner's details, or null where none does.
*
* A sourced field is shown read-only with its provenance; every other one is
* an editable input. Neither the eTrade licence nor a Fayda verification is
* the customer's to retype — the first is the record the backoffice checks
* this company against, the second is the government's. CompanyProfileForm
* computes this and requires exactly the unsourced fields, so every input on
* screen is one the customer is actually asked to fill and nothing is
* required that has none.
*/
source: Record<OwnerField, FieldSource | null>;
/** The value to display for a field its source owns. */
sourced: Record<OwnerField, string>;
/** A co-operative union or farm: no licence, so no eTrade record to match. */
cooperative?: boolean;
}
/**
* Who the company's owner is — meaning whoever the eTrade licence names as the
* business's manager. Not necessarily the legal owner, but the person the
* record has to match: the backoffice's check is precisely "is this the person
* on the licence".
*
* Nothing here falls back to the signed-in account. The person doing the
* onboarding is often not the person on the licence, and stamping their name,
* email and phone onto the owner turned three required fields into a guess
* wearing the licence's authority.
*
* A co-operative union or farm has no licence, so there is nobody named on one
* — the owner is simply the person who runs it, typed in full and compared
* against nothing.
*/
export default function OwnerStep({
form,
identity,
etradeOwner,
source,
sourced,
cooperative = false,
}: OwnerStepProps) {
const {
register,
control,
formState: { errors },
} = form;
const ownerVerified = identity?.owner.verified ?? false;
// A Fayda verification that names someone other than the person on the
// licence is the one thing this step exists to catch. Advisory here — the two
// sources transliterate Amharic names differently, so the reviewer decides —
// but the customer should see it now rather than be rejected later.
const mismatch = identity?.ownerMatchesEtrade === false;
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
{cooperative && !etradeOwner
? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you."
: "These are the details of the person registered on your eTrade licence. What eTrade and Fayda gave us is shown as they gave it; anything they left blank we need from you."}
</Text>
{/* A co-operative is not told its licence listed no manager — it has no
licence. Its own "nothing came back" case is covered by the line
above. */}
{!cooperative && !etradeOwner && !ownerVerified && (
<Alert color="blue" variant="light" icon={<Info size={18} />}>
Your eTrade licence didn't list a manager, so there's nothing for us
to prefill. Enter the details of the person registered on it.
</Alert>
)}
{mismatch && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity?.etradeManagerName}</strong>, but
the name here is <strong>{identity?.owner.name}</strong>. You can
continue, but our team will check this before approving your account
so make sure it's the person the licence actually names.
</Alert>
)}
<SourcedField
label="Owner's Name"
value={sourced.name}
source={source.name}
>
<TextInput
label="Owner's Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* eTrade never returns an email for the manager and Fayda's email
claim is optional, so this is the field most companies actually
type it is required either way (`REQUIRED_COMPANY_INFO`). */}
<SourcedField
label="Owner's Email"
value={sourced.email}
source={source.email}
>
<TextInput
label="Owner's Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
<SourcedField
label="Owner's Phone"
value={sourced.phone}
source={source.phone}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Owner's Phone"
/>
</SourcedField>
</SimpleGrid>
</Stack>
);
}

View File

@@ -1,150 +0,0 @@
import { SimpleGrid, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PersonnelStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
gmSameAsOwner: boolean;
onToggleGmSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
gmLinkPending: boolean;
gmVerified: boolean;
/**
* Which of the manager's contact details their Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema.
*/
gaps: { name: boolean; email: boolean; phone: boolean };
}
export default function PersonnelStep({
form,
identity,
etradeOwner,
gmSameAsOwner,
onToggleGmSameAsOwner,
gmLinkPending,
gmVerified,
gaps,
}: PersonnelStepProps) {
const {
register,
control,
formState: { errors },
} = form;
return (
<>
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{/* The GM is very often the owner. Where the owner is
Fayda-verified this reuses that proven identity outright
rather than making the same human verify twice; where the
owner is backed by a typed passport there is nothing proven
to copy, so it stays a local prefill. */}
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={onToggleGmSameAsOwner}
title={
identity?.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
identity?.owner.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: etradeOwner
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
/>
{/* Verifying a second person is only meaningful when the GM is
someone other than the owner. */}
{!gmSameAsOwner && identity && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={identity.gm}
required={identity.faydaRequired}
disabled={gmLinkPending}
/>
)}
{/* Fayda's name, email and phone claims are all optional, and the
manager's own verification has no account to fall back on the way the
owner's does — the person onboarding is not necessarily the manager.
Whatever the verification left empty is typed here, and required:
without it the submit fails on "Add your general manager name" with no
field anywhere to satisfy it. */}
{gaps.name && (
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gaps.email || gaps.phone) && (
<SimpleGrid cols={gaps.email && gaps.phone ? 2 : 1} spacing="md">
{gaps.email && (
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
)}
{gaps.phone && (
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
)}
</SimpleGrid>
)}
{/* Typed details survive only where Fayda cannot be required —
a foreign company's manager may hold no Fayda ID. Once
verified the API owns these fields, so they go away. */}
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
<>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
</>
);
}

View File

@@ -1,217 +0,0 @@
import { Button, Divider, Group, SimpleGrid, Text, TextInput } from "@mantine/core";
import { Trash2 } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { SmartFileInput } from "@edr/ui-common";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PoaStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
requirePoa: boolean;
/** The owner represents the company themselves. */
poaSameAsOwner: boolean;
onTogglePoaSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
poaLinkPending: boolean;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/**
* Which of the representative's details the Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema — so every input rendered below is one
* the customer is actually asked to fill.
*/
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
/** Drop a verified representative the company decided against. */
onRemovePoa: () => void;
removePending: boolean;
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
delegationRequired: boolean;
/** Single-field upload setting carrying just the delegation letter. */
poaDocumentSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
}
export default function PoaStep({
form,
identity,
requirePoa,
poaSameAsOwner,
onTogglePoaSameAsOwner,
poaLinkPending,
etradeOwner,
gaps,
onRemovePoa,
removePending,
delegationRequired,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: PoaStepProps) {
const {
register,
control,
formState: { errors },
} = form;
// Fayda's email/phone/address claims are optional and routinely come back
// empty, so a *verified* representative can still be missing the email and
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
// and the panel above renders no input for them, which dead-ends the step on
// "Add the poa email first". `gaps` is exactly what the verification did not
// supply: the API keeps those keys typeable, since a claim that returned
// nothing owns no value to protect (`faydaOwnedKeys`).
const needsEmail = gaps.email;
const needsPhone = gaps.phone;
// Fayda is mandatory for an Ethiopian company's representative, so there the
// link can only reuse a proven owner — with none there would be nothing to
// copy and the declaration could never satisfy the gate. A foreign company's
// owner is backed by a typed passport, so it prefills instead.
const linkNeedsVerifiedOwner =
(identity?.faydaRequired ?? false) && !identity?.owner.verified;
return (
<>
<Text size="sm" c="edr-muted">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
</Text>
{/* An owner who represents their own company is the ordinary
small-business case. Where the owner is Fayda-verified this reuses
that proven identity outright rather than sending the same human
through Fayda twice; where they are backed by a typed passport there
is nothing proven to copy, so it stays a local prefill. Either way it
is the declaration that waives the DARS paper. */}
{identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={onTogglePoaSameAsOwner}
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
title={
identity.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
linkNeedsVerifiedOwner
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: identity.owner.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: etradeOwner
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{/* 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. Verifying a second
person is only meaningful when the representative is not the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
disabled={poaLinkPending}
/>
)}
{/* A verification cannot be undone by clearing the form — it owns those
fields — and its mere existence makes the delegation paper due, which
then blocks the submit. So an optional representative needs a way
back out, here rather than only in settings (unreachable until
onboarding finishes). */}
{identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
<Group justify="flex-end">
<Button
type="button"
variant="subtle"
color="red"
size="xs"
loading={removePending}
leftSection={<Trash2 size={14} />}
onClick={onRemovePoa}
>
Remove this representative
</Button>
</Group>
)}
{/* Whatever the Fayda claim did carry is shown on the panel above and
is never typed here — the verification owns it. */}
{gaps.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(needsEmail || needsPhone) && (
<SimpleGrid cols={needsEmail && needsPhone ? 2 : 1} spacing="md">
{needsEmail && (
<TextInput
label="Representative's Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{needsPhone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
{gaps.address && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
{/* The paper authorises the representative, so it shows once one
exists — or straight away for a freight forwarder, who owes it
either way and must not be failed on submit for a file the
step never offered. */}
{delegationRequired && poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,363 @@
import {
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { IdCard, ShieldCheck, UserCheck, UserX } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { SmartFileInput } from "@edr/ui-common";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import RoleCard from "@/pages/settings/RoleCard";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type {
CompanyIdentityState,
PoaDeclaration,
} from "@/services/verifayda.service";
import type { FormData } from "../schema";
/** How a foreign company chose to prove the person: Fayda, or a passport. */
export type IdentityMethod = "fayda" | "passport";
export interface RepresentationStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** Answer the power-of-attorney question (persisted server-side). */
onDeclare: (declared: PoaDeclaration) => void;
/** A declaration change is in flight. */
declarePending: boolean;
/**
* The answer is not the company's to change — it operates as a freight
* forwarder, which the API forces to "yes". No alert says so: the summary
* simply offers no way back, which is the same information without the
* lecture.
*/
declarationLocked: boolean;
/**
* Which of the representative's details the Fayda verification owns. Same
* contract as OwnerStep's `locked`: a locked field is read-only, every other
* one is an input, and the schema requires exactly the unlocked ones.
*
* There is deliberately no `address` here. Fayda's address claim is stored as
* `poaAddress`, which the portal never sends; the input below writes
* `poaLocation`, a different field the company states itself. Gating one on
* the other hid the only input for `poaLocation` from every verified
* representative whose Fayda record happened to carry an address.
*/
locked: { name: boolean; email: boolean; phone: boolean };
/**
* How a foreign company is proving the subject. Null until it picks — the
* either/or is a fork, not a fallback, so nothing below it renders until one
* side is chosen. Always "fayda" for an Ethiopian company, which has no
* choice to make.
*/
method: IdentityMethod | null;
onMethodChange: (method: IdentityMethod) => void;
/** Single-field upload setting carrying just the DARS delegation letter. */
poaDocumentSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
}
/**
* Who acts for this company — and, as a direct consequence, whose identity gets
* verified.
*
* A company proves itself through exactly one person. This step asks which:
* name a Power of Attorney and it is the representative who verifies (plus the
* DARS paper evidencing the delegation); say there is none and the owner
* verifies here instead. There is no third answer — "the owner represents the
* company themselves" IS "no".
*
* A freight forwarder is never asked. It signs on other companies' behalf, so a
* representative and the paper behind them are non-negotiable; the API forces
* the answer regardless of what the portal sends.
*/
export default function RepresentationStep({
form,
identity,
onDeclare,
declarePending,
declarationLocked,
locked,
method,
onMethodChange,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: RepresentationStepProps) {
const {
register,
control,
formState: { errors },
} = form;
if (!identity) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
const declared = identity.poaDeclared;
const passportAccepted = identity.passportAccepted;
const subject = declared === "yes" ? identity.poa : identity.owner;
const verified = subject.verified;
// Nothing below the fork renders until the person is actually established:
// a Fayda claim that came back, or the passport path deliberately chosen.
// Asking for a name before the verification runs is asking for a value the
// verification is about to overwrite.
const established = verified || method === "passport";
const who = declared === "yes" ? "Representative" : "Owner";
const passportField =
declared === "yes" ? "poaPassportNumber" : "ownerPassportNumber";
return (
<Stack gap="lg">
{/* ------------------------------------------------------------------ */}
{/* The question. Once answered it collapses to its answer, so the step */}
{/* is about the person rather than re-presenting a settled choice. */}
{/* ------------------------------------------------------------------ */}
{declared === null ? (
<>
<Stack gap="xs">
<Text fw={600} size="lg" c="edr-text">
Does anyone hold power of attorney for this company?
</Text>
<Text size="sm" c="edr-muted">
Your answer decides whose identity we verify the
representative's, or the owner's.
</Text>
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Yes, we have a representative"
description="Someone other than the owner is authorised to act for the company. We'll verify their identity and ask for the DARS delegation paper."
icon={<UserCheck size={20} />}
selected={false}
onClick={declarePending ? undefined : () => onDeclare("yes")}
/>
<RoleCard
label="No, the owner acts for us"
description="Nobody holds power of attorney. We'll verify the owner instead, and no delegation paper is needed."
icon={<UserX size={20} />}
selected={false}
onClick={declarePending ? undefined : () => onDeclare("no")}
/>
</SimpleGrid>
</>
) : (
<ChoiceSummary
icon={declared === "yes" ? <UserCheck size={18} /> : <UserX size={18} />}
label={
declared === "yes"
? "A representative holds power of attorney"
: "The owner acts for the company"
}
detail={
declared === "yes"
? "We'll verify their identity and ask for the DARS delegation paper."
: "Nobody holds power of attorney, so we verify the owner."
}
onChange={
declarePending || declarationLocked
? undefined
: () => onDeclare(declared === "yes" ? "no" : "yes")
}
changeLabel={declared === "yes" ? "We have no representative" : "We have a representative"}
/>
)}
{declared !== null && (
<>
<Divider />
{/* -------------------------------------------------------------- */}
{/* How the person is proved. Ethiopian: Fayda, no choice. Foreign: */}
{/* Fayda or a passport — one or the other, picked outright. */}
{/* -------------------------------------------------------------- */}
{passportAccepted && !verified && method === null ? (
<Stack gap="xs">
<Text fw={600} c="edr-text">
How would you like to prove {who.toLowerCase()}'s identity?
</Text>
<Text size="sm" c="edr-muted">
Either one is enough — you don't need both.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mt="xs">
<RoleCard
label="Verify with Fayda"
description="Their name, phone and address come straight from the national ID — nothing to type."
icon={<ShieldCheck size={20} />}
selected={false}
onClick={() => onMethodChange("fayda")}
/>
<RoleCard
label="Use a passport instead"
description="For someone who holds no Fayda ID. You'll enter their passport number and details yourself."
icon={<IdCard size={20} />}
selected={false}
onClick={() => onMethodChange("passport")}
/>
</SimpleGrid>
</Stack>
) : (
<>
{(method === "fayda" || !passportAccepted || verified) && (
<FaydaVerifyPanel
subject={declared === "yes" ? "poa" : "owner"}
title={declared === "yes" ? "Power of Attorney" : "Company owner"}
state={subject}
required={!passportAccepted}
/>
)}
{passportAccepted && !verified && method === "passport" && (
<TextInput
label={`${who}'s Passport Number`}
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
placeholder="P1234567"
error={errors[passportField]?.message}
{...register(passportField)}
/>
)}
{passportAccepted && !verified && method !== null && (
<Button
variant="subtle"
size="compact-xs"
style={{ alignSelf: "flex-start" }}
onClick={() =>
onMethodChange(method === "fayda" ? "passport" : "fayda")
}
>
{method === "fayda"
? "Use a passport instead"
: "Verify with Fayda instead"}
</Button>
)}
</>
)}
{/* -------------------------------------------------------------- */}
{/* The representative's own details — only once the person exists, */}
{/* and only the parts the verification did not carry. What Fayda */}
{/* supplied is on the card above; echoing it back here as read-only */}
{/* rows was the same data twice with nothing to do about either. */}
{/* -------------------------------------------------------------- */}
{declared === "yes" && established && (
<>
{!locked.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(!locked.email || !locked.phone) && (
<SimpleGrid
cols={{ base: 1, sm: !locked.email && !locked.phone ? 2 : 1 }}
spacing="md"
>
{!locked.email && (
<TextInput
label="Representative's Email"
type="email"
placeholder="representative@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{!locked.phone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
<TextInput
label="Representative's Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
{poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
)}
</>
)}
</Stack>
);
}
/** A settled choice, shown as its answer with a way back to the question. */
function ChoiceSummary({
icon,
label,
detail,
onChange,
changeLabel,
}: {
icon: React.ReactNode;
label: string;
detail: string;
onChange?: () => void;
changeLabel: string;
}) {
return (
<Card padding="md" radius="md" withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<span style={{ display: "flex", marginTop: 2 }}>{icon}</span>
<Stack gap={2}>
<Text fw={600} size="sm" c="edr-text">
{label}
</Text>
<Text size="xs" c="edr-muted">
{detail}
</Text>
</Stack>
</Group>
{onChange && (
<Button variant="subtle" size="compact-xs" onClick={onChange}>
{changeLabel}
</Button>
)}
</Group>
</Card>
);
}

View File

@@ -9,6 +9,11 @@ interface OnboardingRoleSelectProps {
onChange: (next: string[]) => void;
/** Render only the option grid — the wizard supplies its own header/card. */
embedded?: boolean;
/**
* Roles this company cannot hold, hidden rather than shown-and-refused. A
* co-operative has no business licence, so it cannot freight-forward.
*/
excludeTypes?: readonly string[];
}
/**
@@ -22,8 +27,12 @@ export default function OnboardingRoleSelect({
value,
onChange,
embedded = false,
excludeTypes,
}: OnboardingRoleSelectProps) {
const selected = new Set(value);
const roles = excludeTypes?.length
? CUSTOMER_ROLES.filter((r) => !excludeTypes.includes(r.type))
: CUSTOMER_ROLES;
const toggleRole = (type: string) => {
const next = new Set(value);
@@ -34,7 +43,7 @@ export default function OnboardingRoleSelect({
const grid = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
{roles.map((role) => (
<RoleCard
key={role.type}
label={role.label}

View File

@@ -24,14 +24,12 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import type { CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
import { normalizeIdentityPhones } from "@/pages/accounts/companyProfileForm/helpers";
export const COMPANY_PROFILE_SCHEMA = z.object({
// eTrade-sourced and read-only, like the registration block below.
@@ -149,11 +147,6 @@ export default function TabCompanyProfile({
// Fayda stores the phone as the national registry holds it (often a local
// number), which neither this form's E.164 validation nor the API's
// `@IsValidPhone()` accepts. Normalize on read — same as the wizard.
const identity = useMemo(
() => normalizeIdentityPhones(profile?.identity),
[profile?.identity],
);
const verifiedIdentity = identity?.faydaRequired === true;
// companyAddress is composed from the (locked) eTrade address parts, not
// typed directly.
@@ -287,13 +280,6 @@ export default function TabCompanyProfile({
validationError ??
(mutation.isError ? extractApiError(mutation.error).message : null);
const pendingOwnerReview = Boolean(
(
profile?.pendingChanges as {
faydaIdentity?: Record<string, unknown>;
} | null
)?.faydaIdentity?.ownerFaydaSub,
);
// During onboarding the role selection gates the form: nothing else shows
// until the user picks Importer/Exporter or Freight Forwarder.
@@ -335,51 +321,9 @@ export default function TabCompanyProfile({
/>
</StepSection>
{identity && (
<StepSection
index={2}
title="Owner identity"
subtitle={
verifiedIdentity
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
: "The company owner's passport number."
}
status={
verifiedIdentity
? identity.owner.verified
? "done"
: identity.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity.passportRequired
? "blocked"
: "todo"
}
>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
disabled={mutation.isPending}
pendingReview={pendingOwnerReview}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</StepSection>
)}
<StepSection
index={3}
index={2}
title="Company TIN"
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
status={

View File

@@ -1,333 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
import {
Alert,
Card,
Group,
Stack,
Title,
Text,
TextInput,
Button,
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import { verifaydaService } from "@/services/verifayda.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: an Ethiopian company's GM is established through
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which route
// applies; zod only polices format for the companies that still type them.
const schema = z.object({
generalManagerName: z.string().optional(),
generalManagerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid GM email",
),
generalManagerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
interface TabGeneralManagerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
/**
* The general manager's identity comes from Fayda: either verified in their
* own right, or declared to be the owner — very often the same human, which is
* what "Same as owner" is for. Typed details survive only for a foreign
* company, whose manager may hold no Fayda ID at all.
*/
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
const queryClient = useQueryClient();
const owner = profile.identity?.owner;
const identity = profile.identity;
const gm = identity?.gm;
const faydaRequired = identity?.faydaRequired ?? false;
const [gmSameAsOwner, setGmSameAsOwner] = useState(
identity?.gmSameAsOwner ?? false,
);
const defaultValues = useMemo((): FormData => {
return {
generalManagerName: profile.generalManagerName ?? "",
generalManagerEmail: profile.generalManagerEmail ?? "",
generalManagerPhone: profile.generalManagerPhone ?? "",
};
}, [profile]);
const {
register,
control,
handleSubmit,
reset,
setValue,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
values: defaultValues,
});
/**
* With a Fayda-verified owner the declaration is made server-side — the API
* copies the proven identity onto the GM — so nothing is typed here. Without
* one (a foreign company, whose owner is backed by a passport) there is
* nothing proven to copy and this stays a local prefill.
*/
const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const toggleGmSameAsOwner = async (checked: boolean) => {
setGmSameAsOwner(checked);
setLinkError(null);
if (!owner?.verified) {
if (checked && owner) {
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
}
return;
}
setLinkPending(true);
try {
if (checked) await verifaydaService.setGmSameAsOwner();
else await verifaydaService.clearGmIdentity();
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
} catch (err) {
setGmSameAsOwner(!checked);
setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error ? err.message : "Could not update the general manager"),
);
} finally {
setLinkPending(false);
}
};
const mutation = useMutation({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
// and undefined, so an empty string is validated and 400s with
// "generalManagerEmail must be an email". A verified manager legitimately
// leaves the fields Fayda did supply blank here.
generalManagerName: data.generalManagerName || undefined,
generalManagerEmail: data.generalManagerEmail || undefined,
generalManagerPhone: data.generalManagerPhone || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
if (mode === "onboarding") onContinue?.();
},
});
const onSubmit = (data: FormData) => mutation.mutate(data);
// Nothing to save when Fayda owns the details: the verification and the
// "same as owner" declaration both write server-side, so the form would be
// posting empty strings over a proven identity.
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
// Except for what the verification never supplied. Fayda's name, email and
// phone claims are all optional, and a manager verified without them has no
// account to fall back on the way the owner does — so those stay typed, here
// as well as in onboarding, or a wrong value could never be corrected.
const gmGaps = {
name: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.name?.trim(),
email: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.email?.trim(),
phone: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.phone?.trim(),
};
const savable =
typedFieldsInUse || gmGaps.name || gmGaps.email || gmGaps.phone;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>General Manager</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Manage the general manager information
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={toggleGmSameAsOwner}
title={
owner?.verified ? "Same as verified owner" : "Same as business owner"
}
description={
owner?.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
}
/>
{linkError && (
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
{linkError}
</Alert>
)}
{/* Verifying a second person only means something when the manager
is someone other than the owner. */}
{!gmSameAsOwner && gm && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={gm}
required={faydaRequired}
disabled={linkPending || mutation.isPending}
/>
)}
{/* Whatever the verification did not supply is typed instead — the
API keeps exactly those keys writable. */}
{gmGaps.name && (
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gmGaps.email || gmGaps.phone) && (
<Grid>
{gmGaps.email && (
<Grid.Col span={gmGaps.phone ? 6 : 12}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
)}
{gmGaps.phone && (
<Grid.Col span={gmGaps.email ? 6 : 12}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
)}
</Grid>
)}
{/* Typed details survive only where Fayda cannot be required — a
foreign company's manager may hold no Fayda ID. Once verified the
API owns these fields and refuses edits, so they go away. */}
{!gmSameAsOwner && !gm?.verified && !faydaRequired && (
<>
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
</Grid>
</>
)}
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>Save failed</Text>
</Group>
)}
</Group>
<Group gap="md">
{mode === "edit" && typedFieldsInUse && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
)}
{/* Saving only means something while the details are typed: under
Fayda both routes write server-side, so a submit would post
empty strings at an identity the API owns and refuses to
overwrite. Onboarding still needs a way forward, so the button
becomes a plain Continue rather than disappearing. */}
{savable ? (
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
{mode === "onboarding" ? "Continue" : "Save Changes"}
</Button>
) : (
mode === "onboarding" && (
<Button type="button" onClick={() => onContinue?.()}>
Continue
</Button>
)
)}
</Group>
</Group>
</form>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { AlertTriangle, Briefcase, Save } from "lucide-react";
import {
Alert,
Button,
Card,
Grid,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
// the API and never typed here, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which fields
// are actually on screen; zod only polices format.
const schema = z.object({
ownerName: z.string().optional(),
ownerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid owner email",
),
ownerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
interface TabOwnerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
/**
* The company's owner — meaning whoever the eTrade licence names as the
* business's manager. Not necessarily the legal owner, but the person the
* record has to match: the backoffice's check is that comparison.
*
* Their identity is Fayda-verified only when the company has NO Power of
* Attorney; when it names a representative it is the representative who
* verifies, and the owner's details are simply recorded (from eTrade, or typed
* here). Either way all three are required.
*/
export default function TabOwner({
profile,
mode = "edit",
onContinue,
}: TabOwnerProps) {
const queryClient = useQueryClient();
const identity = profile.identity;
const owner = identity?.owner;
// Only the person the declaration points at carries the verification, so the
// panel is offered here only when that person is the owner.
const ownerIsSubject = identity?.subject === "owner";
// A Fayda verification owns what its claims filled — the API refuses to
// overwrite those, so they show read-only. Anything it left blank stays
// editable here, whatever value is currently stored.
const ownerVerified = owner?.verified ?? false;
const ownerLocked = {
name: ownerVerified && Boolean(owner?.name?.trim()),
email: ownerVerified && Boolean(owner?.email?.trim()),
phone: ownerVerified && Boolean(owner?.phone?.trim()),
};
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
ownerName: profile.ownerName ?? "",
ownerEmail: profile.ownerEmail ?? "",
ownerPhone: profile.ownerPhone ?? "",
},
});
const mutation = useMutation({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
// and undefined, so an empty string is validated and 400s with
// "ownerEmail must be an email". A verified owner legitimately leaves
// the fields Fayda did supply blank here.
ownerName: data.ownerName || undefined,
ownerEmail: data.ownerEmail || undefined,
ownerPhone: data.ownerPhone || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
if (mode === "onboarding") onContinue?.();
},
});
// What the verification did NOT supply. Fayda's email and phone claims are
// optional, so a verified owner can still be missing details the API demands
// — the API leaves exactly those keys typeable, and so does this form.
const gaps = {
name: !owner?.name?.trim(),
email: !owner?.email?.trim(),
phone: !owner?.phone?.trim(),
};
const savable = gaps.name || gaps.email || gaps.phone;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>Company Owner</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
The person registered on your eTrade licence.
</Text>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
<Stack gap="md">
{identity?.ownerMatchesEtrade === false && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity.etradeManagerName}</strong>.
Our team checks this before approving changes.
</Alert>
)}
{ownerIsSubject && owner && (
<FaydaVerifyPanel
subject="owner"
title="Owner identity"
state={owner}
required={!identity?.passportAccepted}
pendingReview={profile.reviewStatus === "pending"}
/>
)}
<SourcedField
label="Name"
value={owner?.name}
source={ownerLocked.name ? "Fayda" : null}
>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Email"
value={owner?.email}
source={ownerLocked.email ? "Fayda" : null}
>
<TextInput
label="Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Phone"
value={owner?.phone}
source={ownerLocked.phone ? "Fayda" : null}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Phone"
/>
</SourcedField>
</Grid.Col>
</Grid>
{savable && (
<Group justify="flex-end">
<Button
type="submit"
color="edr-green"
loading={mutation.isPending}
leftSection={<Save size={16} />}
>
{mode === "onboarding" ? "Save & continue" : "Save changes"}
</Button>
</Group>
)}
</Stack>
</form>
</Card>
);
}

View File

@@ -13,6 +13,7 @@ import {
Undo2,
UploadCloud,
UserCheck,
UserX,
XCircle,
} from "lucide-react";
import {
@@ -24,6 +25,7 @@ import {
Card,
Group,
Loader,
SimpleGrid,
Stack,
Title,
Text,
@@ -41,7 +43,7 @@ import {
} from "@/services/companies.service";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import RoleCard from "@/pages/settings/RoleCard";
import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their
@@ -136,21 +138,17 @@ export default function TabPowerOfAttorney({
// company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified.
const identity = profile.identity;
const owner = identity?.owner;
const poaProvided = identity?.poa.verified ?? false;
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
// The paper authorises the representative named above, so there is nothing
// for it to authorise until one has been verified — the upload is hidden
// until then, and requiring it while hidden would block the save on a
// control the customer cannot see. A freight forwarder is still held to
// having a PoA at all, by the verification gate on the panel and by the API.
//
// And nobody delegates to themselves: an owner representing their own company
// has no delegation to evidence, which is the same waiver the API applies in
// `assertPoaDelegationSatisfied`.
const letterRequired = poaProvided && !poaSameAsOwner;
// Whether there is a representative at all is the company's own declaration,
// held server-side — it decides whose identity the API gates on, so it is
// never local state here.
const declared = identity?.poaDeclared ?? null;
// The paper is owed exactly when the company says it has a representative —
// the same single rule `assertPoaDelegationSatisfied` enforces. Keying it on
// the verification instead would hide the upload from a foreign company whose
// representative proves themselves by passport, then fail the save for a file
// that was never offered.
const letterRequired = declared === "yes";
const letterMissing = letterRequired && !hasLetterAfterSave;
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
@@ -191,11 +189,12 @@ export default function TabPowerOfAttorney({
/**
* A verified representative cannot be removed by blanking the form — their
* fields are owned by the verification — so removal is its own action that
* clears the identity and the delegation paper together.
* fields are owned by the verification — so removal is answering the
* declaration "no", which clears the identity, the details and the
* delegation paper together. Refused by the API for a freight forwarder.
*/
const removeMutation = useMutation({
mutationFn: () => verifaydaService.removePoa(),
mutationFn: () => verifaydaService.setPoaDeclared("no"),
onSuccess: () => {
resetAll();
queryClient.invalidateQueries({
@@ -208,25 +207,20 @@ export default function TabPowerOfAttorney({
});
/**
* "Same as owner": the owner represents the company themselves. Always goes
* to the API, whichever credential backs the owner — the declaration is what
* waives the DARS paper, so it has to be recorded server-side even when there
* is no proven identity to copy.
* Answer the power-of-attorney question.
*
* Unchecking undoes the declaration only. It leaves the paper on file and is
* allowed for a freight forwarder, which is how one changes who represents
* it; "Remove representative" below is the harder action that takes the paper
* with it and is refused to a forwarder.
* "No" means the owner acts for the company themselves there is no
* delegation, so no DARS paper is owed and it is the OWNER whose identity is
* verified. The API tears the representative down when this is answered, and
* refuses "no" outright for a freight forwarder.
*/
const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const togglePoaSameAsOwner = async (checked: boolean) => {
setPoaSameAsOwner(checked);
const declare = async (next: "yes" | "no") => {
setLinkError(null);
setLinkPending(true);
try {
if (checked) await verifaydaService.setPoaSameAsOwner();
else await verifaydaService.clearPoaSameAsOwner();
await verifaydaService.setPoaDeclared(next);
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
@@ -234,7 +228,6 @@ export default function TabPowerOfAttorney({
queryKey: api.companies.poaDelegation.queryKey(),
});
} catch (err) {
setPoaSameAsOwner(!checked);
setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
@@ -295,12 +288,14 @@ export default function TabPowerOfAttorney({
)}
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
: "Power of Attorney details are optional."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If you name a representative, upload the DARS delegation paper authorising them."}
{/* The "Required for freight forwarder" badge above already says why
a forwarder has no choice here; repeating it in prose was a
lecture, not information. */}
{!requirePoa &&
"Tell us whether anyone is authorised to act for the company your answer decides whose identity we verify. "}
{declared === "no"
? "The owner acts for the company, so no delegation paper is needed."
: "A representative must be identified, and the DARS delegation paper authorising them uploaded."}
</Text>
{/* The owner representing their own company is the ordinary
@@ -309,30 +304,35 @@ export default function TabPowerOfAttorney({
mandatory it needs a verified owner first — there would be nothing
proven to copy, and a representative who could never satisfy the
gate. */}
{/* The declaration itself. "No" is not a lesser answer — it means the
owner acts for the company, so it is the OWNER who verifies and no
delegation paper is owed. A freight forwarder cannot choose it; the
API refuses and the error lands in `linkError`. */}
{identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={togglePoaSameAsOwner}
disabled={
linkPending ||
mutation.isPending ||
(!poaSameAsOwner &&
(identity.faydaRequired ?? false) &&
!owner?.verified)
}
title={
owner?.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
(identity.faydaRequired ?? false) && !owner?.verified
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: owner?.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses the owner's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
<RoleCard
label="Yes, we have a representative"
description="Someone other than the owner is authorised to act for the company. Their identity is verified and the DARS delegation paper is required."
icon={<UserCheck size={20} />}
selected={declared === "yes"}
onClick={
linkPending || declared === "yes"
? undefined
: () => void declare("yes")
}
/>
<RoleCard
label="No, the owner acts for us"
description="Nobody holds power of attorney. The owner's identity is verified instead, and no delegation paper is needed."
icon={<UserX size={20} />}
selected={declared === "no"}
onClick={
linkPending || declared === "no"
? undefined
: () => void declare("no")
}
/>
</SimpleGrid>
)}
{linkError && (
@@ -343,12 +343,12 @@ export default function TabPowerOfAttorney({
{/* Verifying a second person only means something when the
representative is someone other than the owner. */}
{identity && !poaSameAsOwner && (
{identity && declared === "yes" && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
required={!identity.passportAccepted}
disabled={mutation.isPending || linkPending}
/>
)}
@@ -359,7 +359,7 @@ export default function TabPowerOfAttorney({
verification and are shown on the panel above. Only a company
whose representative may hold no Fayda ID still types a
location. */}
{!poaProvided && !(identity?.faydaRequired ?? false) && (
{declared === "yes" && !poaProvided && (
<Grid>
<Grid.Col span={6}>
<TextInput
@@ -376,7 +376,7 @@ export default function TabPowerOfAttorney({
{/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */}
{poaProvided && !poaSameAsOwner && (
{declared === "yes" && (
<Stack gap="sm" mt="xl">
<Group justify="space-between" align="center">
<Group gap="sm">
@@ -418,7 +418,7 @@ export default function TabPowerOfAttorney({
>
{requirePoa
? "Upload the DARS delegation paper before saving — it is required for freight forwarders."
: "Upload the DARS delegation paper for the representative you named, or clear the PoA details."}
: "Upload the DARS delegation paper for the representative you named, or answer \u201cthe owner acts for us\u201d instead."}
</Alert>
)}
@@ -551,12 +551,12 @@ export default function TabPowerOfAttorney({
)}
</Group>
<Group gap="md">
{/* Not offered against a "same as owner" declaration: unchecking
the card above is the way out of that one, and it leaves the
paper alone. */}
{/* Answering "the owner acts for us" is the same teardown, so
this is only a shortcut — and it is refused to a forwarder,
which cannot be without a representative. */}
{mode === "edit" &&
identity?.poa.verified &&
!poaSameAsOwner &&
declared === "yes" &&
!requirePoa && (
<Button
type="button"

View File

@@ -238,6 +238,8 @@ export const api = {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
/** No business licence: registration typed, no eTrade lookup, no forwarding. */
cooperative?: boolean;
},
CompanyInfoResponse
>("companies", "startOnboarding", companiesService.startOnboarding),

View File

@@ -158,14 +158,17 @@ export interface OnboardingLicenseProfile {
uploaded: boolean;
}
/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */
/** Power of Attorney state, driven by the company's own declaration. */
export interface OnboardingPoaState {
required: boolean;
provided: boolean;
/**
* True when the DARS delegation paper is owed. False when the owner
* represents the company themselves — nobody delegates to themselves.
* True for a freight forwarder: it signs on other companies' behalf, so a
* representative is non-negotiable and the question is shown answered rather
* than asked.
*/
locked: boolean;
/** The company's answer. Null until it answers — itself an outstanding item. */
declared: "yes" | "no" | null;
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
delegationLetterRequired: boolean;
delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */
@@ -181,7 +184,16 @@ export interface OnboardingPoaState {
*/
export interface OnboardingRequirements {
documentSettingCode: string;
/**
* Extra document set merged on top of the nationality one for a co-operative,
* null otherwise. `documents` already carries the merged list; this is only
* so the pickers, which render from the file-settings endpoint, can fetch the
* same extra fields.
*/
cooperativeDocumentSettingCode: string | null;
nationality: string;
/** No business licence: registration typed by hand, no eTrade lookup. */
cooperative: boolean;
companyInfo: {
complete: boolean;
missingFields: { key: string; label: string }[];
@@ -189,7 +201,7 @@ export interface OnboardingRequirements {
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
poa: OnboardingPoaState;
/** Fayda verification state; `required` is false for a foreign company. */
/** The company's single identity verification, and whose it is. */
identity: CompanyIdentityState;
progress: { completed: number; total: number };
isComplete: boolean;
@@ -321,6 +333,7 @@ export const companiesService = {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
cooperative?: boolean;
}): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,

View File

@@ -3,14 +3,21 @@ import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
/**
* Which of the company's people a verification is for. The owner is who the
* company is proven through; the PoA and GM are personnel it names. The GM is
* very often the owner — "same as owner" reuses that verification rather than
* making the same human prove themselves twice.
* Which of the company's two people a verification is for.
*
* The **owner** is whoever the eTrade licence names as the business's manager
* — not necessarily the legal owner, but the person the record has to match.
* The **PoA** is who the company delegates to act for it.
*
* Exactly one of them is verified, chosen by the company's own answer to "does
* anyone hold power of attorney for you?" — see `poaDeclared`.
*/
export type IdentitySubject = "owner" | "poa" | "gm";
export type IdentitySubject = "owner" | "poa";
/** One person's Fayda verification state, as the API reports it. */
/** Whether the company named a representative. Null until it answers. */
export type PoaDeclaration = "yes" | "no";
/** One person's identity state, as the API reports it. */
export interface IdentityVerificationState {
verified: boolean;
name: string | null;
@@ -18,43 +25,49 @@ export interface IdentityVerificationState {
email: string | null;
address: string | null;
verifiedAt: string | null;
}
export interface OwnerIdentityState extends IdentityVerificationState {
/**
* Typed passport number — the foreign-company identity credential.
* Independent of Fayda: never written by a verification, and still required
* even if the owner also verifies.
* Typed passport number — the ALTERNATIVE to Fayda for a foreign company,
* never written by a verification. Only asked of whichever person carries
* the company's identity, and only when `passportAccepted`.
*/
passportNumber: string | null;
}
export interface CompanyIdentityState {
/**
* True when Fayda verification is mandatory — Ethiopian companies only.
* Doubles as "may this person be typed instead": Fayda is an Ethiopian
* national ID, so a foreign company's GM and PoA are offered the
* verification but fall back to typed details when they hold none.
* True for a foreign company: a typed passport number proves the identity
* just as a Fayda verification does. Fayda is an Ethiopian national ID, so an
* Ethiopian company has no alternative to it.
*/
faydaRequired: boolean;
/** True when the owner's passport number is mandatory — foreign companies only. */
passportRequired: boolean;
owner: OwnerIdentityState;
passportAccepted: boolean;
/**
* The company's answer to the power-of-attorney question. Null until it
* answers — which is itself outstanding, since the answer decides who
* verifies. Always "yes" for a freight forwarder, which cannot operate
* without a representative and is never asked.
*/
poaDeclared: PoaDeclaration | null;
/** Whose verification the company is gated on. Null while undeclared. */
subject: IdentitySubject | null;
owner: IdentityVerificationState;
poa: IdentityVerificationState;
/** True once `subject` is proven — Fayda-verified, or passport where accepted. */
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* True when the representative is the owner themselves, declared through
* "same as owner". Waives the DARS delegation paper — nobody delegates to
* themselves — and, where the owner is Fayda-verified, backs `poa.verified`
* with the owner's sub.
* That manager's phone (E.164), from the same lookup. Together with the name
* this is what survives a refresh: the live lookup result does not, so
* without these two a resumed wizard cannot tell an eTrade-sourced owner from
* a typed one, and offers the licence's own data back as editable inputs.
*/
poaSameAsOwner: boolean;
etradeManagerPhone: string | null;
/**
* General manager. `verified` covers both routes: the GM verifying in their
* own right, and the company declaring the GM is the owner (in which case
* `gmSameAsOwner` is set and the owner's Fayda sub backs it).
* Does the owner the company put forward match the eTrade licence? This is
* the backoffice's check. Null when there is nothing to compare. Advisory:
* eTrade's and Fayda's transliterations rarely agree exactly.
*/
gm: IdentityVerificationState;
gmSameAsOwner: boolean;
ownerMatchesEtrade: boolean | null;
complete: boolean;
}
@@ -127,65 +140,19 @@ export const verifaydaService = {
},
/**
* Declare the General Manager is the company's owner, reusing the owner's
* verified identity rather than making the same human verify twice. The copy
* happens server-side from the stored owner identity — the portal never
* supplies the values — and is refused until the owner is verified.
*/
setGmSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/gm/same-as-owner",
);
return unwrap(response.data);
},
/**
* Clear the GM's identity — the "same as owner" declaration or a verification
* of their own — leaving them open to be re-established either way.
*/
clearGmIdentity: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/gm",
);
return unwrap(response.data);
},
/**
* Declare the Power of Attorney is the company's owner. A Fayda-verified
* owner's identity is copied server-side (the portal never supplies it); a
* foreign company's owner has nothing proven to copy, so the API records the
* declaration and the form types the representative's details as usual.
* Answer whether anyone holds power of attorney for this company — the
* question that decides whose identity is verified.
*
* Either way the declaration is what waives the DARS delegation paper.
* Answering "no" tears the representative down server-side: their details,
* their verification, their passport number and the DARS delegation paper.
* Refused for a freight forwarder, which cannot operate without one.
*/
setPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Undo that declaration and the identity it copied, leaving the
* representative open to be verified in their own right. Unlike
* {@link removePoa} this is allowed for a freight forwarder — it is how they
* change who represents them — and leaves the delegation paper on file.
*/
clearPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Drop the Power of Attorney — verified identity, details and delegation
* paper together. A verified person's fields are locked, so blanking the form
* is no longer a way to remove them. Refused for a freight forwarder.
*/
removePoa: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/fayda/poa",
setPoaDeclared: async (
declared: PoaDeclaration,
): Promise<CompanyIdentityState> => {
const response = await client.patch<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa-declared",
{ declared },
);
return unwrap(response.data);
},

View File

@@ -6,6 +6,8 @@ export interface ProfileResponse {
companyName: string;
companyType: string;
nationality: string | null;
/** No business licence: the registration is typed, not fetched from eTrade. */
cooperative: boolean;
companyProfiles: CompanyProfileResponse[];
companyLocation: string;
companyAddress: string | null;
@@ -30,19 +32,15 @@ export interface ProfileResponse {
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
/** The owner — whoever the eTrade licence names as the business's manager. */
ownerName: string | null;
ownerEmail: string | null;
ownerPhone: string | null;
/**
* Fayda verification state for the owner, the PoA and the general manager.
* `identity.faydaRequired` / `identity.passportRequired` is the
* Ethiopian/foreign switch: an Ethiopian company verifies all three with
* Fayda, while a foreign one proves its owner with a typed passport number
* and may type its GM and PoA, whose holders may have no Fayda ID.
*
* The `generalManager*` fields above are the same person's details written
* flat — a verification keeps them in step, since the booking, contract and
* train-scheduling notifiers mail `generalManagerEmail` directly.
* The company's single identity verification. `identity.subject` says whose
* it is (the PoA when one is declared, otherwise the owner);
* `identity.passportAccepted` is the Ethiopian/foreign switch — a foreign
* company may prove the same person with a typed passport number instead.
*/
identity: CompanyIdentityState;
poaName: string | null;
@@ -88,14 +86,18 @@ export interface UpdateProfilePayload {
contactPersonEmail?: string;
contactPersonPhone?: string;
contactVerifiedPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;
generalManagerPhone?: string;
ownerName?: string;
ownerEmail?: string;
ownerPhone?: string;
poaName?: string;
poaPhone?: string;
poaEmail?: string;
poaLocation?: string;
poaAddress?: string;
/** The owner's passport number — the foreign-company identity credential. */
/**
* Passport numbers — the ALTERNATIVE to Fayda for a foreign company. Only the
* one belonging to the declared identity subject is ever collected.
*/
ownerPassportNumber?: string;
poaPassportNumber?: string;
}