From 16c059252d146a7e3ba66380825d8e8fe7793daf Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Sat, 29 Aug 2026 08:13:52 +0000
Subject: [PATCH] feat(web): offer the currencies the API actually accepts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every currency picker decided for itself which currencies existed, from a
hardcoded pair, so an administrator's setting and the form could disagree
and the customer would only find out on submit. They now read
GET /exchange-settings/currencies.
CurrencySelector's allowUsd boolean becomes an allowed list. The caller is
choosing on two independent axes — trade direction (export and intercity
invoice in ETB whatever is picked) and what is switched on — so a second
boolean would have needed a third one next time.
Adds the DJF card and option with its own hint, the DJF tab on the finance
hub and the DJF row on the manual-payment settings card, both driven by
the per-currency flag rather than an if/else on two currencies. The
exchange-rate settings card gets the toggle itself, with the wording that
turning it off stops new choices rather than changing bookings already
priced in DJF.
The inline "ETB" | "USD" unions on the invoice filter and the customer
shipment and payment types are widened, so a DJF invoice is not mistyped
on arrival.
formatMoney needs no change — it already defaults to 0 fraction digits,
which is correct for DJF.
---
.../contracts/GlCreateBookingForm.tsx | 20 ++++---
.../src/hooks/useExchangeSettings.ts | 35 ++++++++++++
.../src/hooks/useManualPaymentSettings.ts | 2 +-
.../src/pages/invoices/FinanceHubPage.tsx | 25 +++++++--
.../src/pages/invoices/UsdPaymentsPage.tsx | 14 +++--
.../settings/ExchangeRateSettingsCard.tsx | 25 +++++++++
.../settings/ManualPaymentSettingsCard.tsx | 17 +++++-
.../src/services/exchangeSettings.service.ts | 23 ++++++++
.../services/manualPaymentSettings.service.ts | 3 +-
.../backoffice/src/types/customer.ts | 6 +-
.../backoffice/src/types/invoice.ts | 4 +-
.../portal/src/hooks/useEnabledCurrencies.ts | 31 +++++++++++
.../src/pages/bookings/EditBookingPage.tsx | 6 +-
.../payment-currency-field.tsx | 23 ++++----
.../pages/bookings/new-booking-form/schema.ts | 20 +++++--
.../new-booking-form/step2-service-type.tsx | 6 +-
.../src/pages/contracts/NewShipmentPage.tsx | 8 ++-
.../contracts/NewShipmentRequestPage.tsx | 11 +++-
.../CurrencySelector/CurrencySelector.tsx | 55 +++++++++++--------
19 files changed, 265 insertions(+), 69 deletions(-)
create mode 100644 apps/edr-freight-web/portal/src/hooks/useEnabledCurrencies.ts
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index eaa399f03..99ab2ce66 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -92,6 +92,8 @@ import {
emptyPartnerUnit,
} from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
+import { isPaymentCurrency, type PaymentCurrency } from "@edr/types";
+import { useEnabledCurrenciesQuery } from "@/hooks/useExchangeSettings";
/**
* Container sizes offered on the parent-booking panel. Fixed rather than taken
@@ -290,6 +292,10 @@ export default function GlCreateBookingForm() {
const requestBulkLocked =
bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null;
const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency);
+ // What an administrator has switched on, not a hardcoded pair — a currency turned off in
+ // Exchange Settings disappears from the picker instead of 400ing on submit.
+ const { data: enabledCurrencies } = useEnabledCurrenciesQuery();
+ const importCurrencies = enabledCurrencies ?? ["ETB", "USD"];
// The expired booking a Rebook is copying from (its cargo seeds the form).
const { data: copyFromBooking } = useQuery({
@@ -344,7 +350,7 @@ export default function GlCreateBookingForm() {
const [notes, setNotes] = useState("");
// IMPORT bookings pick ETB or USD — starts empty so the choice is
// deliberate (required before pricing). Everything else is forced to ETB.
- const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
+ const [paymentCurrency, setPaymentCurrency] = useState("");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState([]);
@@ -570,8 +576,7 @@ export default function GlCreateBookingForm() {
// Currency is the customer's choice on the request — seed it here; the
// selector below is disabled while the request specifies one.
if (
- bookingRequest.paymentCurrency === "USD" ||
- bookingRequest.paymentCurrency === "ETB"
+ isPaymentCurrency(bookingRequest.paymentCurrency)
) {
setPaymentCurrency(bookingRequest.paymentCurrency);
}
@@ -1131,7 +1136,7 @@ export default function GlCreateBookingForm() {
]);
// Only IMPORT actually chooses — the rest bill ETB regardless of the state.
- const effectiveCurrency: "USD" | "ETB" =
+ const effectiveCurrency: PaymentCurrency =
isImport && paymentCurrency ? paymentCurrency : "ETB";
const currencyError =
isImport && !paymentCurrency
@@ -1227,8 +1232,7 @@ export default function GlCreateBookingForm() {
// The partner's customer chose its own currency on its shipment request;
// only a partner without a request falls back to this booking's currency.
paymentCurrency:
- partnerRequest?.paymentCurrency === "USD" ||
- partnerRequest?.paymentCurrency === "ETB"
+ isPaymentCurrency(partnerRequest?.paymentCurrency)
? partnerRequest.paymentCurrency
: effectiveCurrency,
...(scheduledDate
@@ -2325,14 +2329,14 @@ export default function GlCreateBookingForm() {
{requestCurrencyLocked
? "The customer chose the billing currency on the shipment request — it cannot be changed."
: isImport
- ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
+ ? `Import shipments may be invoiced in ${importCurrencies.join(", ")}. Only ETB is paid online; the rest settle by bank transfer.`
: "Shipments are invoiced in ETB."}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts
index d5fca3c6e..b254b1162 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts
@@ -32,3 +32,38 @@ export const useSetExchangeFallbackRate = () => {
onError: handleError,
});
};
+
+export const useSetDjfEnabled = () => {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+ const { handleError } = useErrorHandler(t);
+
+ return useMutation({
+ mutationFn: (enabled: boolean) => exchangeSettingsService.setDjfEnabled(enabled),
+ onSuccess: (settings) => {
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY });
+ queryClient.invalidateQueries({ queryKey: ENABLED_CURRENCIES_KEY });
+ toast.success(
+ settings.djfEnabled
+ ? t("exchangeSettings.djfEnabled", "Djiboutian Franc billing enabled")
+ : t("exchangeSettings.djfDisabled", "Djiboutian Franc billing disabled"),
+ );
+ },
+ onError: handleError,
+ });
+};
+
+const ENABLED_CURRENCIES_KEY = ["enabledCurrencies"];
+
+/**
+ * Currencies a booking may be billed in right now. Every currency picker reads this
+ * instead of hardcoding a list, so switching one off in Exchange Settings removes it from
+ * the forms rather than leaving a choice the API rejects.
+ */
+export const useEnabledCurrenciesQuery = () =>
+ useQuery({
+ queryKey: ENABLED_CURRENCIES_KEY,
+ queryFn: () => exchangeSettingsService.enabledCurrencies(),
+ // Changes only when an administrator flips a toggle.
+ staleTime: 5 * 60_000,
+ });
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts
index b49f33376..ff370a198 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts
@@ -24,7 +24,7 @@ export const useUpdateManualPaymentSettings = () => {
return useMutation({
mutationFn: (
- patch: Partial>,
+ patch: Partial>,
) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx
index 6465be167..a2ea9872a 100644
--- a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx
@@ -1,6 +1,7 @@
import { Tabs } from "@mantine/core";
-import { Banknote, DollarSign, Receipt } from "lucide-react";
+import { Banknote, DollarSign, Landmark, Receipt } from "lucide-react";
import { useSearchParams } from "react-router-dom";
+import type { PaymentCurrency } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
@@ -53,10 +54,27 @@ const TABS = [
"Import and export invoices in USD that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: () => ,
},
+ {
+ key: "manual-payments-djf",
+ label: "Manual Payments (DJF)",
+ icon: Landmark,
+ permission: FREIGHT_PERMS.invoices.view,
+ manualCurrency: "DJF",
+ subtitle:
+ "Import and export invoices in DJF that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
+ Panel: () => ,
+ },
] as const;
type TabKey = (typeof TABS)[number]["key"];
+/** Currency → the flag on the settings row. Mirrors the API's own TOGGLE_COLUMN map. */
+const MANUAL_TOGGLE = {
+ ETB: "etbEnabled",
+ USD: "usdEnabled",
+ DJF: "djfEnabled",
+} as const satisfies Record;
+
export default function FinanceHubPage() {
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
@@ -64,9 +82,8 @@ export default function FinanceHubPage() {
// A currency whose manual-payment channel is switched off has no tab at all
// — the list would be empty and every confirmation refused.
const { data: manualSettings } = useManualPaymentSettingsQuery();
- const manualEnabled = (currency: "ETB" | "USD") =>
- !manualSettings ||
- (currency === "ETB" ? manualSettings.etbEnabled : manualSettings.usdEnabled);
+ const manualEnabled = (currency: PaymentCurrency) =>
+ !manualSettings || Boolean(manualSettings[MANUAL_TOGGLE[currency]]);
const visibleTabs = TABS.filter(
(tab) =>
diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx
index 8b3e58a5f..8fecfad82 100644
--- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx
@@ -1,4 +1,4 @@
-import { Freight } from "@edr/types";
+import { Freight, type PaymentCurrency } from "@edr/types";
import {
ActionIcon,
Badge,
@@ -274,7 +274,7 @@ function ConfirmCell({
export default function UsdPaymentsPanel({
currency,
}: {
- currency: "USD" | "ETB";
+ currency: PaymentCurrency;
}) {
const navigate = useNavigate();
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
@@ -296,9 +296,13 @@ export default function UsdPaymentsPanel({
// the fallback for a direct `?tab=` link, and the API refuses regardless.
const { data: manualSettings } = useManualPaymentSettingsQuery();
const currencyEnabled = manualSettings
- ? currency === "ETB"
- ? manualSettings.etbEnabled
- : manualSettings.usdEnabled
+ ? Boolean(
+ manualSettings[
+ ({ ETB: "etbEnabled", USD: "usdEnabled", DJF: "djfEnabled" } as const)[
+ currency
+ ]
+ ],
+ )
: true;
const filter = useMemo(
diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx
index da01b51ce..304fd8c8b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx
@@ -12,6 +12,7 @@ import { AlertTriangle, CheckCircle2, RefreshCw, Save } from "lucide-react";
import {
useExchangeSettingsQuery,
+ useSetDjfEnabled,
useSetExchangeFallbackRate,
} from "@/hooks/useExchangeSettings";
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
@@ -47,6 +48,7 @@ const formatTime = (value: string | null) =>
export default function ExchangeRateSettingsCard() {
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery();
const setRate = useSetExchangeFallbackRate();
+ const setDjf = useSetDjfEnabled();
const [draft, setDraft] = useState("");
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
@@ -151,6 +153,29 @@ export default function ExchangeRateSettingsCard() {
)}).`}
+
+
+
+
);
diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx
index 3fe5c1e1e..b71b997af 100644
--- a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx
@@ -1,3 +1,4 @@
+import type { PaymentCurrency } from "@edr/types";
import {
Card,
CardContent,
@@ -17,11 +18,11 @@ import {
useUpdateManualPaymentSettings,
} from "@/hooks/useManualPaymentSettings";
-type Currency = "ETB" | "USD";
+type Currency = PaymentCurrency;
const CURRENCIES: {
code: Currency;
- field: "etbEnabled" | "usdEnabled";
+ field: "etbEnabled" | "usdEnabled" | "djfEnabled";
icon: typeof Banknote;
title: string;
description: string;
@@ -42,6 +43,14 @@ const CURRENCIES: {
description:
"USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.",
},
+ {
+ code: "DJF",
+ field: "djfEnabled",
+ icon: Landmark,
+ title: "Franc (DJF) invoices",
+ description:
+ "DJF invoices settle on the Djibouti side. Switching this off leaves DJF customers with no way to be marked as paid.",
+ },
];
/**
@@ -60,7 +69,9 @@ export default function ManualPaymentSettingsCard() {
const { data, isLoading } = useManualPaymentSettingsQuery();
const update = useUpdateManualPaymentSettings();
- const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
+ const noneEnabled = Boolean(
+ data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled,
+ );
return (
diff --git a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts
index 37b8dd254..d19ddc47d 100644
--- a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts
@@ -1,3 +1,4 @@
+import type { PaymentCurrency } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -25,6 +26,8 @@ export interface ExchangeSettings {
fallbackSource: "AUTO" | "MANUAL";
lastSyncedAt: string | null;
updatedById: string | null;
+ /** Whether Djiboutian Franc may be chosen as a billing currency. */
+ djfEnabled: boolean;
feed?: ExchangeFeedStatus;
}
@@ -40,4 +43,24 @@ export const exchangeSettingsService = {
});
return unwrap(response.data);
},
+
+ setDjfEnabled: async (djfEnabled: boolean): Promise => {
+ // Sent alone: the PATCH leaves an omitted field untouched, so this must not
+ // re-submit the fallback rate and re-stamp it MANUAL.
+ const response = await client.patch>(BASE, {
+ djfEnabled,
+ });
+ return unwrap(response.data);
+ },
+
+ /**
+ * Currencies a booking or contract may be billed in right now. Readable by customers
+ * too, so the portal's picker offers exactly what the API will accept.
+ */
+ enabledCurrencies: async (): Promise => {
+ const response = await client.get>(
+ `${BASE}/currencies`,
+ );
+ return unwrap(response.data).currencies;
+ },
};
diff --git a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts
index 6710730cb..5f5194619 100644
--- a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts
@@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
export interface ManualPaymentSettings {
etbEnabled: boolean;
usdEnabled: boolean;
+ djfEnabled: boolean;
updatedById: string | null;
updatedAt?: string;
}
@@ -25,7 +26,7 @@ export const manualPaymentSettingsService = {
/** Partial: an omitted currency keeps its current setting. */
update: async (
- patch: Partial>,
+ patch: Partial>,
): Promise => {
const response = await client.patch>(
BASE,
diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts
index 1d3eb7588..0fe874cf5 100644
--- a/apps/edr-freight-web/backoffice/src/types/customer.ts
+++ b/apps/edr-freight-web/backoffice/src/types/customer.ts
@@ -1,3 +1,5 @@
+import type { PaymentCurrency } from "@edr/types";
+
/**
* Customer-management types for the freight backoffice.
*
@@ -420,7 +422,7 @@ export interface CustomerBooking {
originLabel: string;
destinationLabel: string;
totalAmount: number;
- currency: "ETB" | "USD";
+ currency: PaymentCurrency;
scheduledDate?: string | null;
createdAt: string;
}
@@ -469,7 +471,7 @@ export interface CustomerPayment {
/** Booking reference the payment settles. */
bookingReference: string;
amount: number;
- currency: "ETB" | "USD";
+ currency: PaymentCurrency;
method: CustomerPaymentMethod;
status: CustomerPaymentStatus;
paidAt?: string | null;
diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts
index 218e478fa..8518d1c20 100644
--- a/apps/edr-freight-web/backoffice/src/types/invoice.ts
+++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts
@@ -1,4 +1,4 @@
-import type { Freight } from "@edr/types";
+import type { Freight, PaymentCurrency } from "@edr/types";
/**
* What an invoice's `sourceId` points at, resolved server-side for display.
@@ -77,7 +77,7 @@ export interface InvoiceListFilter {
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
paymentMethods?: string;
search?: string;
- currency?: "USD" | "ETB";
+ currency?: PaymentCurrency;
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
issuedFrom?: string;
issuedTo?: string;
diff --git a/apps/edr-freight-web/portal/src/hooks/useEnabledCurrencies.ts b/apps/edr-freight-web/portal/src/hooks/useEnabledCurrencies.ts
new file mode 100644
index 000000000..c27047cf5
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/hooks/useEnabledCurrencies.ts
@@ -0,0 +1,31 @@
+import { useQuery } from "@tanstack/react-query";
+import type { PaymentCurrency } from "@edr/types";
+
+import { client } from "@/utils/api";
+import { unwrap } from "@/utils/endpoint";
+import type { ApiResponse } from "@/types/apiResponse";
+
+const KEY = ["enabled-currencies"] as const;
+
+/**
+ * Currencies a booking may be billed in right now.
+ *
+ * The picker used to hardcode ETB/USD, which meant a currency an administrator had not
+ * enabled could still be chosen and only fail on submit. The endpoint is readable by
+ * customers precisely so the form and the API agree.
+ *
+ * Falls back to ETB alone while loading — never to a guess that might not be accepted.
+ */
+export const useEnabledCurrencies = () =>
+ useQuery({
+ queryKey: KEY,
+ queryFn: async (): Promise => {
+ const response =
+ await client.get>(
+ "/exchange-settings/currencies",
+ );
+ return unwrap(response.data).currencies;
+ },
+ // Changes only when an administrator flips a toggle.
+ staleTime: 5 * 60_000,
+ });
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
index 26a59b405..391453577 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
@@ -56,6 +56,7 @@ import { SelectField } from "./new-booking-form/shared";
import { LocationPicker } from "./new-booking-form/LocationPicker";
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
+import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
const EDIT_SECTIONS = [
"service",
@@ -291,6 +292,9 @@ const DIRECTION_LABEL: Record = {
};
export default function EditBookingPage() {
+ // Offered currencies come from the API, so a currency an administrator has not
+ // enabled is never presented as a choice that would 400 on submit.
+ const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -686,7 +690,7 @@ export default function EditBookingPage() {
{/* USD billing is import-only; export/intercity stay ETB. */}
{(selectedService?.includesFirstMile ||
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx
index fb91b515b..b7fe31b20 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/payment-currency-field.tsx
@@ -1,5 +1,5 @@
import { Box, Group, Text } from "@mantine/core";
-import { Banknote, Check, DollarSign } from "lucide-react";
+import { Banknote, Check, DollarSign, Landmark } from "lucide-react";
import { Controller, type Control } from "react-hook-form";
import {
PAYMENT_CURRENCY_OPTIONS,
@@ -15,28 +15,31 @@ const CURRENCY_ICONS: Record<
> = {
USD: { icon: DollarSign, color: "#4F46E5" },
ETB: { icon: Banknote, color: "#0A6F4D" },
+ DJF: { icon: Landmark, color: "#B45309" },
};
export function PaymentCurrencyField({
control,
- allowUsd = false,
+ allowed = ["ETB"],
}: {
control: Control;
/**
- * Offer USD alongside ETB. Import shipments only — export and domestic
- * traffic is always invoiced in ETB.
+ * Which currencies to offer. Export and domestic traffic is always invoiced in ETB, so
+ * the caller narrows this to ETB there; on import it passes whatever an administrator
+ * has enabled.
*/
- allowUsd?: boolean;
+ allowed?: readonly PaymentCurrency[];
}) {
- const options = allowUsd
- ? PAYMENT_CURRENCY_OPTIONS
- : PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD");
+ const options = PAYMENT_CURRENCY_OPTIONS.filter((o) =>
+ allowed.includes(o.value),
+ );
+ const foreign = options.filter((o) => o.value !== "ETB").map((o) => o.value);
return (
Payment currency
- {allowUsd
- ? "Choose the currency for your freight quote and invoices. USD is paid by bank transfer, not online."
+ {foreign.length > 0
+ ? `Choose the currency for your freight quote and invoices. ${foreign.join(" and ")} ${foreign.length > 1 ? "are" : "is"} paid by bank transfer, not online.`
: "Choose the currency for your freight quote and invoices."}
;
-export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
-export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
+// Re-exported from @edr/types: this list and the one the API validates against had already
+// drifted, and a form offering a currency the API rejects fails only on submit.
+export { PAYMENT_CURRENCIES } from "@edr/types";
+export type { PaymentCurrency } from "@edr/types";
export const PAYMENT_CURRENCY_OPTIONS: Array<{
- value: PaymentCurrency;
+ value: SharedPaymentCurrency;
label: string;
description: string;
}> = [
@@ -92,6 +98,12 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
label: "USD",
description: "US Dollar — paid by bank transfer, slip sent to Finance.",
},
+ {
+ value: "DJF",
+ label: "DJF",
+ description:
+ "Djiboutian Franc — settled on the Djibouti side, in whole francs (no centimes).",
+ },
];
export const BOOKING_TYPES = ["one_time", "general_contract"] as const;
@@ -108,7 +120,7 @@ export const bookingFormSchema = z
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
- paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
+ paymentCurrency: z.enum(SHARED_PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
.object({
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
index 2c51cbb62..e2f4c0fd1 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
@@ -15,6 +15,7 @@ import { PaymentCurrencyField } from "./payment-currency-field";
import { LocationPicker } from "./LocationPicker";
import type { Freight } from "@edr/types";
+import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -31,6 +32,9 @@ export function Step2ServiceType({
referenceData?: Freight.BookingReferenceData;
}) {
const serviceTypeId = form.watch("serviceTypeId");
+ // Offered currencies come from the API, so a currency an administrator has not
+ // enabled is never presented as a choice that would 400 on submit.
+ const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
const operationType = form.watch("operationType");
const serviceType = referenceData?.service.find(
(s) => s.id === serviceTypeId,
@@ -136,7 +140,7 @@ export function Step2ServiceType({
{/* USD billing is import-only; export/intercity stay ETB. */}
{showServiceSections && (
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
index 544cc9449..efd3353df 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
@@ -83,6 +83,7 @@ import {
} from "./new-shipment-form/container-excel";
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
+import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
type ShipmentForm = ReturnType<
typeof useForm
@@ -1301,6 +1302,9 @@ function ScheduleStep({
}) {
const contractRouteId = form.watch("contractRouteId");
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
+ // Offered currencies come from the API, so a currency an administrator has not
+ // enabled is never presented as a choice that would 400 on submit.
+ const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
// Read the cargo entered in the previous step so the day list reflects what
// can actually be shipped (matching wagons + open train capacity).
@@ -1449,14 +1453,14 @@ function ScheduleStep({
Billing currency *
{isImport
- ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
+ ? `Import shipments may be invoiced in ${enabledCurrencies.join(", ")}. Only ETB is paid online; the rest settle by bank transfer.`
: "Shipments are invoiced in ETB."}
field.onChange(v)}
error={fieldState.error?.message}
- allowUsd={isImport}
+ allowed={isImport ? enabledCurrencies : ["ETB"]}
/>
)}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
index ce19146d2..c97f3f482 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
@@ -21,10 +21,15 @@ import type { Freight } from "@edr/types";
import { DatePickerInput } from "@mantine/dates";
import { contractsService } from "@/services/contracts.service";
+import { useEnabledCurrencies } from "@/hooks/useEnabledCurrencies";
+import type { PaymentCurrency } from "@edr/types";
const BORDER = "#E6ECF2";
export default function NewShipmentRequestPage() {
+ // Offered currencies come from the API, so a currency an administrator has not
+ // enabled is never presented as a choice that would 400 on submit.
+ const { data: enabledCurrencies = ["ETB" as const] } = useEnabledCurrencies();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [scheduledDate, setScheduledDate] = useState("");
@@ -38,7 +43,7 @@ export default function NewShipmentRequestPage() {
// to be invoiced in has to be stated here — the contract itself quotes USD.
// Starts empty so the billing-currency choice is deliberate — required at
// submit. Intercity/export are forced to ETB (server-enforced too).
- const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
+ const [paymentCurrency, setPaymentCurrency] = useState("");
const [currencyError, setCurrencyError] = useState();
const [notes, setNotes] = useState("");
@@ -125,7 +130,7 @@ export default function NewShipmentRequestPage() {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
paymentCurrency:
- isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"),
+ isIntercity || isExport ? "ETB" : (paymentCurrency as PaymentCurrency),
notes: notes.trim() || undefined,
};
@@ -266,7 +271,7 @@ export default function NewShipmentRequestPage() {
setCurrencyError(undefined);
}}
disabled={isIntercity || isExport}
- allowUsd={!isIntercity && !isExport}
+ allowed={!isIntercity && !isExport ? enabledCurrencies : ["ETB"]}
error={currencyError}
/>
diff --git a/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx b/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx
index f25a7b015..ef0959207 100644
--- a/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx
+++ b/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx
@@ -1,47 +1,58 @@
+import type { PaymentCurrency } from "@edr/types";
import { Box, Text } from "@mantine/core";
import { Check } from "lucide-react";
export interface CurrencySelectorProps {
/** Selected currency code, or "" when none picked yet. */
value: string;
- onChange: (currency: "USD" | "ETB") => void;
+ onChange: (currency: PaymentCurrency) => void;
disabled?: boolean;
/** Validation error shown under the cards. */
error?: string;
/**
- * Offer USD alongside ETB. Import shipments only — export and domestic
- * traffic is invoiced in ETB, so the option stays hidden everywhere else.
- * USD is settled by bank transfer, never through the online gateway.
+ * Which currencies to offer, in display order. ETB alone by default.
+ *
+ * Was a single `allowUsd` boolean; a list instead, because the caller now decides from
+ * two independent things — the trade direction (export and domestic traffic is invoiced
+ * in ETB) and what an administrator has switched on. A second boolean would have needed
+ * a third the next time.
*/
- allowUsd?: boolean;
+ allowed?: readonly PaymentCurrency[];
}
-const ETB_OPTION = {
- code: "ETB",
- symbol: "Br",
- name: "Ethiopian Birr",
- hint: "Pay online through the payment gateway",
-} as const;
-
-const USD_OPTION = {
- code: "USD",
- symbol: "$",
- name: "US Dollar",
- hint: "Paid by bank transfer — send the slip to Finance",
-} as const;
+const OPTIONS: Record = {
+ ETB: {
+ code: "ETB",
+ symbol: "Br",
+ name: "Ethiopian Birr",
+ hint: "Pay online through the payment gateway",
+ },
+ USD: {
+ code: "USD",
+ symbol: "$",
+ name: "US Dollar",
+ hint: "Paid by bank transfer — send the slip to Finance",
+ },
+ DJF: {
+ code: "DJF",
+ symbol: "Fdj",
+ name: "Djiboutian Franc",
+ hint: "Djibouti-side settlement — no centimes, amounts are whole francs",
+ },
+};
/**
- * Card-style USD/ETB billing-currency picker. Renders unselected when `value`
- * is "" so a required choice never looks pre-made.
+ * Card-style billing-currency picker. Renders unselected when `value` is ""
+ * so a required choice never looks pre-made.
*/
export function CurrencySelector({
value,
onChange,
disabled = false,
error,
- allowUsd = false,
+ allowed = ["ETB"],
}: CurrencySelectorProps) {
- const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION];
+ const options = allowed.map((code) => OPTIONS[code]).filter(Boolean);
return (