fix: update payment currency handling to require explicit selection by customer

This commit is contained in:
Marshal
2026-08-04 11:40:08 +00:00
parent 56697d8fc5
commit 45216c5624
3 changed files with 73 additions and 8 deletions

View File

@@ -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({
<Controller
name="paymentCurrency"
control={form.control}
render={({ field }) => (
render={({ field, fieldState }) => (
<Box mb="lg">
<StepLabel>Billing currency *</StepLabel>
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
Your contract is quoted in USD. Pick the currency this shipment is
invoiced in the total is converted for you.
</Text>
{/* Rendered unselected until the customer chooses: SegmentedControl
highlights whatever value it is given, so passing a fallback
here would look like a made choice. */}
<SegmentedControl
value={field.value ?? "USD"}
value={field.value || ""}
onChange={(v) => field.onChange(v)}
data={[
{ label: "Select…", value: "", disabled: true },
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
color="edr-green"
radius={10}
/>
{fieldState.error && (
<Text fz={12.5} c="red.6" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
)}
/>

View File

@@ -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<string, unknown> = {}) => ({
...initialShipmentFormValues,
cargoWeightTons: "10",
...over,
});
const currencyIssues = (input: Record<string, unknown>) => {
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);
});
});

View File

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