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 (