feat(companies): onboard co-operative unions and farms

They hold a TIN but no business licence, so there is no eTrade record to look
their registration up in. A checkbox on the first wizard step marks them, and
everything that assumed a trade licence bends around it:

- The company step replaces the eTrade lookup with typed registration details
  — name, region, zone, woreda, kebele, house number — required exactly because
  they are now on screen. applyEtradeSourcedFields skips the lookup rather than
  failing it, so what the customer sends is what is stored.
- No freight-forwarder role. Forwarding is licensed work, so the option is not
  offered, and the API refuses it at start-onboarding and at every later
  role-add rather than letting approval fail on a document they cannot produce.
- No per-role business-licence upload, client-side or in the completion gate.
- Their own document set (company_onboarding_documents_cooperative) merges on
  top of the nationality one, admin-managed like every other set. Nationality
  wins a fileKey collision so no slot renders twice, and the DARS paper is not
  injected into it — the set it merges onto already carries one.
- The owner is typed in full; with no eTrade manager on file the licence
  comparison reports "nothing to compare against", which backoffice now
  explains rather than leaving as a bare dash.

Stored as an attributes flag, not a column: everything it changes is
behavioural, and nothing queries or joins on it.
This commit is contained in:
Nathnael
2026-08-11 12:54:51 +00:00
parent 72164b0b8e
commit d6e349f329
40 changed files with 1188 additions and 118 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";
@@ -783,6 +785,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="audit-logs"
element={

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

@@ -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

@@ -315,6 +315,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

@@ -822,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} />
@@ -945,6 +955,11 @@ export default function CustomerDetailPage() {
>
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.

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

@@ -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

@@ -222,6 +222,12 @@ 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;

View File

@@ -1,6 +1,7 @@
import {
Box,
Button,
Checkbox,
Group,
Modal,
ScrollArea,
@@ -164,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>
>({});
@@ -214,6 +224,7 @@ 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
@@ -296,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");
@@ -310,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
@@ -463,6 +476,15 @@ export default function OnboardingWizardDialog({
// 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();
@@ -530,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>
@@ -537,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

@@ -36,7 +36,9 @@ import type {
import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep";
import OwnerStep from "./companyProfileForm/steps/OwnerStep";
import ContactStep from "./companyProfileForm/steps/ContactStep";
import RepresentationStep from "./companyProfileForm/steps/RepresentationStep";
import RepresentationStep, {
type IdentityMethod,
} from "./companyProfileForm/steps/RepresentationStep";
import DocumentsStep from "./companyProfileForm/steps/DocumentsStep";
export default function CompanyProfileForm({
@@ -60,6 +62,9 @@ export default function CompanyProfileForm({
onUploadDocuments,
identity: rawIdentity,
onIdentityChange,
cooperative = false,
declarationLocked = false,
extraDocumentSettingCode,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -105,6 +110,20 @@ export default function CompanyProfileForm({
* a freshly booted app, so it has nothing to notify.
*/
onIdentityChange?: () => void;
/**
* The company trades as a co-operative: a TIN but no business licence, so the
* eTrade lookup is replaced by typed registration details, the per-role
* licence upload is not owed, and its own document set applies on top of the
* nationality one.
*/
cooperative?: boolean;
/**
* The company operates as a freight forwarder, so the power-of-attorney
* answer is forced to "yes" and cannot be changed here.
*/
declarationLocked?: boolean;
/** Additional document set merged in (the co-operative one), if any. */
extraDocumentSettingCode?: string | null;
}) {
// A Fayda claim carries the phone as the national registry holds it, which is
// often a local number the form's E.164 validation (and the API's
@@ -168,12 +187,36 @@ export default function CompanyProfileForm({
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
// A co-operative's own documents come as a second, additive set — it uploads
// everything its nationality demands, plus the papers standing in for the
// business licence it does not hold. The API merges the same two sets when it
// decides what is outstanding.
const { data: extraSetting } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: extraDocumentSettingCode ?? "" },
enabled: Boolean(extraDocumentSettingCode),
refetchOnMount: false,
}),
);
const uploadSetting = useMemo(() => {
if (!nationalitySetting) return nationalitySetting;
if (!extraSetting?.fields?.length) return nationalitySetting;
// Nationality wins a fileKey collision, so a slot is never rendered twice.
const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey));
return {
...nationalitySetting,
fields: [
...nationalitySetting.fields,
...extraSetting.fields.filter((f) => !seen.has(f.fileKey)),
],
};
}, [nationalitySetting, extraSetting]);
// Which fields the current step renders an input for and therefore requires.
// Filled in further down (it depends on values this form owns), and read at
@@ -388,30 +431,58 @@ export default function CompanyProfileForm({
};
/**
* What no source supplied, per person.
* Which fields a verification owns, per person.
*
* A Fayda verification owns the fields its claims filled — the API refuses to
* let those be overwritten — but its email, phone and address claims are
* optional and routinely come back empty. eTrade fills the owner's name and
* phone, and nothing at all fills an email.
* optional and routinely come back empty. Everything it did NOT fill stays
* the customer's: an editable input, prefilled from eTrade or from what was
* saved earlier, and required precisely because there is an input for it.
*
* So "what still has to be asked" varies per company. Computed here, once,
* and handed to both the step (which renders an input per gap) and the schema
* (which requires exactly those): **a field is required if and only if there
* is an input on screen to fix it in.**
* Keyed off `verified`, deliberately, not off "does a value exist". A value
* exists the moment eTrade prefills the owner or 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. Only a verification locks.
*/
const ownerGaps = {
name: !identity?.owner.name?.trim(),
email: !identity?.owner.email?.trim(),
phone: !identity?.owner.phone?.trim(),
const ownerVerified = identity?.owner.verified ?? false;
const poaVerified = identity?.poa.verified ?? false;
const ownerLocked = {
name: ownerVerified && Boolean(identity?.owner.name?.trim()),
email: ownerVerified && Boolean(identity?.owner.email?.trim()),
phone: ownerVerified && Boolean(identity?.owner.phone?.trim()),
};
const poaGaps = {
name: !identity?.poa.name?.trim(),
email: !identity?.poa.email?.trim(),
phone: !identity?.poa.phone?.trim(),
address: !identity?.poa.address?.trim(),
const poaLocked = {
name: poaVerified && Boolean(identity?.poa.name?.trim()),
email: poaVerified && Boolean(identity?.poa.email?.trim()),
phone: poaVerified && Boolean(identity?.poa.phone?.trim()),
address: poaVerified && Boolean(identity?.poa.address?.trim()),
};
/**
* How a foreign company chose to prove its subject: Fayda, or a passport.
*
* An either/or rather than a fallback, so nothing is asked until one side is
* picked. Seeded from what already happened — a completed verification or a
* saved passport number is itself the answer — and only then held locally,
* because the choice is a UI fork with nothing to persist: what the API
* stores is the proof, not the route taken to it.
*/
const [identityMethod, setIdentityMethod] = useState<IdentityMethod | null>(
null,
);
const passportSaved = Boolean(
identity?.subject === "poa"
? identity?.poa.passportNumber?.trim()
: identity?.owner.passportNumber?.trim(),
);
const subjectVerified = identity?.subject === "poa" ? poaVerified : ownerVerified;
const effectiveMethod: IdentityMethod | null = !identity?.passportAccepted
? "fayda" // An Ethiopian company has no choice to make.
: subjectVerified
? "fayda"
: (identityMethod ?? (passportSaved ? "passport" : null));
// The owner's name from whichever source established them — powers the
// contact step's "same as owner" card.
const ownerName = firstPresent(identity?.owner.name, watch("ownerName"));
@@ -494,9 +565,12 @@ export default function CompanyProfileForm({
return errs;
};
// Every role needs at least one license file (existing or newly selected).
// Every role needs at least one license file (existing or newly selected)
// except a co-operative's, which holds no business licence at all. Its own
// document set is what stands in, and the API lifts the same requirement.
const validateLicenses = (): Record<string, string> => {
const errs: Record<string, string> = {};
if (cooperative) return errs;
for (const p of roleProfiles ?? []) {
const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0;
const hasExisting = p.existingFiles.length > 0;
@@ -549,7 +623,10 @@ export default function CompanyProfileForm({
const hasRegistrationDetails = registration.some((v) => v && v.trim());
// A previously-saved (rehydrated) TIN counts as verified without a refetch —
// the registration fields being populated at all is proof it passed before.
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
// A co-operative never runs the lookup, so there is nothing to be verified
// against; its TIN is validated by the schema like any other typed field.
const tinVerified =
cooperative || tinStatus === "verified" || hasRegistrationDetails;
// Single source of truth for step sequence — navigation, labels and the
// progress bar all derive from this so adding/removing a step is one edit.
@@ -589,18 +666,29 @@ export default function CompanyProfileForm({
Boolean(watch(passportField)?.trim()));
const requiredKeys: (keyof FormData)[] = [];
if (step === "owner") {
if (step === "company" && cooperative) {
// A co-operative has no eTrade record, so the fields every other company
// gets read-only from the licence are typed here — and are therefore
// required here. House number stays optional: plenty of addresses have none.
requiredKeys.push("companyName", "region", "zone", "woreda", "kebele");
} else if (step === "owner") {
// All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input
// is rendered for each one a Fayda claim did not already own.
if (ownerGaps.name) requiredKeys.push("ownerName");
if (ownerGaps.email) requiredKeys.push("ownerEmail");
if (ownerGaps.phone) requiredKeys.push("ownerPhone");
} else if (step === "representation" && identity?.poaDeclared === "yes") {
// Only once a representative is actually declared: a company that answered
// "no" has no representative to describe.
if (poaGaps.name) requiredKeys.push("poaName");
if (poaGaps.email) requiredKeys.push("poaEmail");
if (poaGaps.phone) requiredKeys.push("poaPhone");
// is rendered for each one a Fayda verification does not own.
if (!ownerLocked.name) requiredKeys.push("ownerName");
if (!ownerLocked.email) requiredKeys.push("ownerEmail");
if (!ownerLocked.phone) requiredKeys.push("ownerPhone");
} else if (
step === "representation" &&
identity?.poaDeclared === "yes" &&
// The details are only on screen once the person is established — before
// that the step is still asking how to prove them, and requiring a name
// with no input rendered is the dead Continue button this rule exists to
// prevent.
(poaVerified || effectiveMethod === "passport")
) {
if (!poaLocked.name) requiredKeys.push("poaName");
if (!poaLocked.email) requiredKeys.push("poaEmail");
if (!poaLocked.phone) requiredKeys.push("poaPhone");
}
requiredKeysRef.current = requiredKeys;
@@ -695,6 +783,8 @@ export default function CompanyProfileForm({
}
// The TIN must resolve to a real eTrade record before anything else on
// this step is even worth validating — gates here rather than through zod.
// A co-operative is exempt: it has no licence for eTrade to hold, so
// `tinVerified` is true for it and only the duplicate-TIN check applies.
if (step === "company" && tinStatus === "taken") {
setSaveError(
"This TIN is already registered to another company account.",
@@ -793,6 +883,7 @@ export default function CompanyProfileForm({
tinStatus={tinStatus}
tinVerified={tinVerified}
hasRegistrationDetails={hasRegistrationDetails}
cooperative={cooperative}
onETradeDataLoaded={handleETradeDataLoaded}
onETradeStatusChange={setTinStatus}
onETradeReset={handleETradeReset}
@@ -804,7 +895,8 @@ export default function CompanyProfileForm({
form={form}
identity={identity}
etradeOwner={etradeOwner}
gaps={ownerGaps}
locked={ownerLocked}
cooperative={cooperative}
/>
)}
@@ -814,7 +906,10 @@ export default function CompanyProfileForm({
identity={identity}
onDeclare={handleDeclare}
declarePending={declarePending}
gaps={poaGaps}
declarationLocked={declarationLocked}
locked={poaLocked}
method={effectiveMethod}
onMethodChange={setIdentityMethod}
poaDocumentSetting={poaDocumentSetting}
documentFiles={documentFiles}
uploadedDocumentKeys={uploadedDocumentKeys}
@@ -840,7 +935,10 @@ export default function CompanyProfileForm({
uploadedDocumentKeys={uploadedDocumentKeys}
documentFieldErrors={documentFieldErrors}
onDocumentFilesChange={handleDocumentFilesChange}
roleProfiles={roleProfiles}
// A co-operative union or farm holds no business licence, so the
// per-role upload cards are not shown at all — offering a slot
// nothing can fill reads as an unfinishable step.
roleProfiles={cooperative ? [] : roleProfiles}
licenseFiles={licenseFiles}
licenseFieldErrors={licenseFieldErrors}
onLicenseFilesChange={handleLicenseFilesChange}

View File

@@ -65,18 +65,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,24 +102,44 @@ 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",
]),
);
});
});

View File

@@ -22,11 +22,11 @@ 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"),
// 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
@@ -40,10 +40,15 @@ export const onboardingSchema = z.object({
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(),
@@ -107,6 +112,12 @@ 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",
@@ -184,8 +195,18 @@ export const ETRADE_BUNDLE_FIELDS = [
*/
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
// Only what this step actually renders an input for. The company name and the
// registered address are eTrade's, shown read-only.
company: ["tinNumber", "vatNumber"],
// 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"],

View File

@@ -1,7 +1,7 @@
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 { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
@@ -16,6 +16,12 @@ export interface CompanyInfoStepProps {
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;
@@ -26,6 +32,7 @@ export default function CompanyInfoStep({
tinStatus,
tinVerified,
hasRegistrationDetails,
cooperative = false,
onETradeDataLoaded,
onETradeStatusChange,
onETradeReset,
@@ -33,6 +40,7 @@ export default function CompanyInfoStep({
const {
register,
watch,
setValue,
formState: { errors },
} = form;
@@ -41,43 +49,112 @@ export default function CompanyInfoStep({
<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>
<StepSection
index={2}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={onETradeDataLoaded}
onStatusChange={onETradeStatusChange}
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/>
{tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
{cooperative ? (
<>
<StepSection
index={2}
title="Company TIN"
subtitle="A co-operative union or farm has no trade licence for us to look up, so we take the TIN as you give it."
status={watch("tinNumber")?.trim() && !errors.tinNumber ? "done" : "todo"}
>
<TextInput
aria-label="Company TIN"
placeholder="0012345678"
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
</StepSection>
<StepSection
index={3}
title="Registration details"
subtitle="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"
data={[...ETHIOPIAN_REGIONS]}
searchable
value={watch("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>
</>
) : (
<StepSection
index={2}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={onETradeDataLoaded}
onStatusChange={onETradeStatusChange}
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
/>
{tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
)}
</Stack>
);
}

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

@@ -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

@@ -184,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 }[];
@@ -324,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

@@ -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;