feat(web): offer the currencies the API actually accepts

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.
This commit is contained in:
Nathnael
2026-08-29 08:13:52 +00:00
parent 7e877bd6a0
commit 16c059252d
19 changed files with 265 additions and 69 deletions

View File

@@ -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<PaymentCurrency | "">("");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -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."}
</Text>
<CurrencySelector
value={isImport ? paymentCurrency : "ETB"}
onChange={setPaymentCurrency}
disabled={!isImport || requestCurrencyLocked}
allowUsd={isImport}
allowed={isImport ? importCurrencies : ["ETB"]}
error={currencyError}
/>
</Box>

View File

@@ -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,
});

View File

@@ -24,7 +24,7 @@ export const useUpdateManualPaymentSettings = () => {
return useMutation({
mutationFn: (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">>,
) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);

View File

@@ -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: () => <UsdPaymentsPanel currency="USD" />,
},
{
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: () => <UsdPaymentsPanel currency="DJF" />,
},
] 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<PaymentCurrency, string>;
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) =>

View File

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

View File

@@ -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<string>("");
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
@@ -151,6 +153,29 @@ export default function ExchangeRateSettingsCard() {
)}).`}
</p>
</div>
<div className="space-y-2 border-t pt-4">
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
className="mt-0.5 h-4 w-4"
disabled={isLoading || setDjf.isPending}
checked={data?.djfEnabled ?? false}
onChange={(e) => setDjf.mutate(e.target.checked)}
/>
<span>
<span className="font-medium">
Allow billing in Djiboutian Franc (DJF)
</span>
<span className="block text-muted-foreground">
Adds DJF to the currency choice on new bookings and shipment
requests. CBE quotes DJF in the same feed as USD, so no separate
rate is needed. Turning it off stops new choices; bookings
already priced in DJF keep their currency.
</span>
</span>
</label>
</div>
</CardContent>
</Card>
);

View File

@@ -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 (
<Card className="shadow-lg border-gray-200 dark:border-gray-700">

View File

@@ -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<ExchangeSettings> => {
// 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<ApiResponse<ExchangeSettings>>(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<PaymentCurrency[]> => {
const response = await client.get<ApiResponse<{ currencies: PaymentCurrency[] }>>(
`${BASE}/currencies`,
);
return unwrap(response.data).currencies;
},
};

View File

@@ -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<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">>,
): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
BASE,

View File

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

View File

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

View File

@@ -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<PaymentCurrency[]> => {
const response =
await client.get<ApiResponse<{ currencies: PaymentCurrency[] }>>(
"/exchange-settings/currencies",
);
return unwrap(response.data).currencies;
},
// Changes only when an administrator flips a toggle.
staleTime: 5 * 60_000,
});

View File

@@ -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<string, string> = {
};
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. */}
<PaymentCurrencyField
control={form.control}
allowUsd={operationType === "import"}
allowed={operationType === "import" ? enabledCurrencies : ["ETB"]}
/>
{(selectedService?.includesFirstMile ||

View File

@@ -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<BookingFormInputValues, any, BookingFormValues>;
/**
* 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 (
<Box mt={24}>
<StepLabel>Payment currency</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
{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."}
</Text>
<Controller

View File

@@ -1,3 +1,7 @@
import {
PAYMENT_CURRENCIES as SHARED_PAYMENT_CURRENCIES,
type PaymentCurrency as SharedPaymentCurrency,
} from "@edr/types";
import type { Freight } from "@edr/types";
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
@@ -73,11 +77,13 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
export type BookingDocuments = Record<string, File | File[] | null>;
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({

View File

@@ -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. */}
<PaymentCurrencyField
control={form.control}
allowUsd={operationType === "import"}
allowed={operationType === "import" ? enabledCurrencies : ["ETB"]}
/>
{showServiceSections && (

View File

@@ -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<ShipmentFormInputValues, any, ShipmentFormValues>
@@ -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({
<StepLabel>Billing currency *</StepLabel>
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
{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."}
</Text>
<CurrencySelector
value={field.value || ""}
onChange={(v) => field.onChange(v)}
error={fieldState.error?.message}
allowUsd={isImport}
allowed={isImport ? enabledCurrencies : ["ETB"]}
/>
</Box>
)}

View File

@@ -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<PaymentCurrency | "">("");
const [currencyError, setCurrencyError] = useState<string | undefined>();
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}
/>
</Box>

View File

@@ -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<PaymentCurrency, { code: PaymentCurrency; symbol: string; name: string; hint: string }> = {
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 (
<Box>
<Box