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 6b2ce4470..33a1343a2 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -265,8 +265,10 @@ function NewShipmentBookingForm({ // still flip it per shipment. withReturn: contract.equipmentReturn === "WITH_RETURN", // The contract quotes USD; the customer bills this shipment in the - // currency they pick here. Intercity is always ETB. - paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD", + // currency they pick here. Intercity is always ETB, so it is preset; + // everything else starts empty so the customer picks deliberately + // instead of silently inheriting USD. + paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "", }, resolver: zodResolver( createShipmentFormSchema({ @@ -340,7 +342,11 @@ function NewShipmentBookingForm({ ...(values.contractRouteId ? { contractRouteId: values.contractRouteId } : {}), - paymentCurrency: values.paymentCurrency, + // Validation guarantees a currency by here; the guard keeps an empty + // value out of the payload rather than tripping the API's @IsIn check. + ...(values.paymentCurrency + ? { paymentCurrency: values.paymentCurrency } + : {}), // Intercity bookings carry no date — staff assign a passing train later. ...(values.scheduledDate ? { scheduledDate: new Date(values.scheduledDate).toISOString() } @@ -1131,23 +1137,32 @@ function ScheduleStep({ ( + render={({ field, fieldState }) => ( Billing currency * Your contract is quoted in USD. Pick the currency this shipment is invoiced in — the total is converted for you. + {/* Rendered unselected until the customer chooses: SegmentedControl + highlights whatever value it is given, so passing a fallback + here would look like a made choice. */} field.onChange(v)} data={[ + { label: "Select…", value: "", disabled: true }, { label: "USD", value: "USD" }, { label: "ETB", value: "ETB" }, ]} color="edr-green" radius={10} /> + {fieldState.error && ( + + {fieldState.error.message} + + )} )} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/currency.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/currency.test.ts new file mode 100644 index 000000000..ca818659c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/currency.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { createShipmentFormSchema, initialShipmentFormValues } from "./schema"; + +const schema = createShipmentFormSchema({ + isContainer: false, + isHazardous: false, + isReefer: false, + requiresDate: false, +}); + +const values = (over: Record = {}) => ({ + ...initialShipmentFormValues, + cargoWeightTons: "10", + ...over, +}); + +const currencyIssues = (input: Record) => { + const result = schema.safeParse(input); + return result.success + ? [] + : result.error.issues.filter((i) => i.path[0] === "paymentCurrency"); +}; + +describe("paymentCurrency validation", () => { + it("defaults to empty rather than silently picking USD", () => { + expect(initialShipmentFormValues.paymentCurrency ?? "").toBe(""); + }); + + it("rejects a submit with no currency chosen", () => { + const issues = currencyIssues(values({ paymentCurrency: "" })); + expect(issues).toHaveLength(1); + expect(issues[0].message).toBe("Select the billing currency for this shipment."); + }); + + it("accepts either currency once chosen", () => { + expect(currencyIssues(values({ paymentCurrency: "USD" }))).toHaveLength(0); + expect(currencyIssues(values({ paymentCurrency: "ETB" }))).toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 539296f5d..825112980 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -76,8 +76,9 @@ const shipmentFormBase = z.object({ // EXPORT rail: the specific train picked for the shipment day (schedule id). trainScheduleId: z.string().default(""), // The contract quotes in USD; the customer picks the billing currency for - // THIS shipment. Intercity is forced to ETB (server-enforced too). - paymentCurrency: z.enum(["USD", "ETB"]).default("USD"), + // THIS shipment. Starts empty so the choice is deliberate — validated as + // required below. Intercity is forced to ETB (server-enforced too). + paymentCurrency: z.enum(["USD", "ETB", ""]).default(""), // Container contracts only: return the empty container(s) to EDR after // unloading. Seeded from the contract's equipment return; bulk ignores it. withReturn: z.boolean().default(false), @@ -101,6 +102,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { }); } + // No default currency — the customer must pick one before submitting. + if (!data.paymentCurrency) { + refineCtx.addIssue({ + code: "custom", + path: ["paymentCurrency"], + message: "Select the billing currency for this shipment.", + }); + } + if (ctx.isContainer) { // Containerized cargo must say WHAT is inside — required per booking. if (!data.cargoDescription.trim()) { @@ -311,6 +321,6 @@ export const shipmentStepFields: Record< "bulkReeferQuantity", "withReturn", ], - 2: ["scheduledDate"], + 2: ["paymentCurrency", "scheduledDate"], 3: ["notes"], };