diff --git a/web/src/api/cluckwork.ts b/web/src/api/cluckwork.ts index 8dbaecaeb..4c156d47c 100644 --- a/web/src/api/cluckwork.ts +++ b/web/src/api/cluckwork.ts @@ -486,6 +486,15 @@ export function formatMoney(minorUnits: number, currencyCode: string, minorUnit: return `${value.toFixed(minorUnit)} ${currencyCode}`; } +// Inverse of formatMoney: parse a user-entered decimal amount into integer minor +// units at the currency's scale. The `Math.round` absorbs binary-float artifacts +// (e.g. 0.29 * 100 = 28.999… which would truncate to 28 without it). Returns NaN +// for non-numeric input and may return a negative — callers apply their own +// finite/non-negative guard and context-specific error message. +export function parseMoneyToMinorUnits(text: string, minorUnit: number): number { + return Math.round(parseFloat(text) * 10 ** minorUnit); +} + // --- Audit trail (#93, admin-only, read-only) --- export interface AuditEvent { diff --git a/web/src/api/formatMoney.test.ts b/web/src/api/formatMoney.test.ts index 4233f6dd2..de7533cc3 100644 --- a/web/src/api/formatMoney.test.ts +++ b/web/src/api/formatMoney.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { formatMoney } from "./cluckwork"; +import { formatMoney, parseMoneyToMinorUnits } from "./cluckwork"; // formatMoney renders minor units using the currency's own minor-unit count — // the value snapshotted on each row (F25) — never a hardcoded 2 decimals. @@ -29,3 +29,72 @@ describe("formatMoney", () => { expect(formatMoney(1000000, "EUR", 2)).toBe("10000.00 EUR"); }); }); + +// parseMoneyToMinorUnits is the inverse used when reading a user-typed amount +// back into the wire format. The Math.round is load-bearing: naive +// `parseFloat * 10**n` leaves binary-float dust that would truncate a cent. +describe("parseMoneyToMinorUnits", () => { + it("parses a 2-decimal amount", () => { + expect(parseMoneyToMinorUnits("10.50", 2)).toBe(1050); + }); + + // The load-bearing case: 0.29 * 100 = 28.999999999999996 in IEEE-754, which + // Math.round rescues to 29 (a truncating parse would ship 28). NB 10.10 * 100 + // is exactly 1010 in JS — not a float-dust case, so 0.29/1.10 are the real pins. + it("rounds away binary-float artifacts (0.29 → 29, not 28)", () => { + expect(parseMoneyToMinorUnits("0.29", 2)).toBe(29); + expect(parseMoneyToMinorUnits("1.10", 2)).toBe(110); // 110.00000000000001 → 110 + expect(parseMoneyToMinorUnits("10.10", 2)).toBe(1010); + }); + + it("scales to the currency's minor unit (0-dec JPY, 3-dec BHD)", () => { + expect(parseMoneyToMinorUnits("5", 0)).toBe(5); + expect(parseMoneyToMinorUnits("0.123", 3)).toBe(123); + }); + + it("accepts an integer string and a leading-dot decimal", () => { + expect(parseMoneyToMinorUnits("7", 2)).toBe(700); + expect(parseMoneyToMinorUnits(".5", 2)).toBe(50); + }); + + it("returns NaN for non-numeric, empty, or whitespace-only input", () => { + expect(parseMoneyToMinorUnits("abc", 2)).toBeNaN(); + expect(parseMoneyToMinorUnits("", 2)).toBeNaN(); + expect(parseMoneyToMinorUnits(" ", 2)).toBeNaN(); + }); + + // Inherited parseFloat behaviour, pinned so a future change is a conscious one: + it("takes the leading number of a partly-numeric string (parseFloat semantics)", () => { + expect(parseMoneyToMinorUnits("10abc", 2)).toBe(1000); + }); + + it("propagates Infinity (caught by the callers' Number.isFinite guard)", () => { + expect(parseMoneyToMinorUnits("Infinity", 2)).toBe(Infinity); + }); + + it("passes a negative through (caller rejects < 0)", () => { + expect(parseMoneyToMinorUnits("-2.50", 2)).toBe(-250); + }); + + // A sub-minor negative rounds to -0, which slips a `< 0` guard (Object.is(-0,0) + // is false but -0 < 0 is false too). Documented so callers know the boundary. + it("rounds a sub-minor negative to -0 (slips a naive < 0 check)", () => { + expect(Object.is(parseMoneyToMinorUnits("-0.001", 2), -0)).toBe(true); + expect(parseMoneyToMinorUnits("-0.001", 2) < 0).toBe(false); + }); + + // Negatives are excluded here on purpose: formatMoney renders them fine, but + // the round-trip's point is the non-negative wire values callers actually send. + it("round-trips with formatMoney for non-negative amounts", () => { + for (const [minor, unit] of [ + [1050, 2], + [5, 0], + [123, 3], + [1000000, 2], + [0, 2], + ] as const) { + const display = formatMoney(minor, "USD", unit).split(" ")[0]; + expect(parseMoneyToMinorUnits(display, unit)).toBe(minor); + } + }); +}); diff --git a/web/src/routes/InventoryPage.tsx b/web/src/routes/InventoryPage.tsx index 9bf8e8985..b1acfc30e 100644 --- a/web/src/routes/InventoryPage.tsx +++ b/web/src/routes/InventoryPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import { createInventoryItem, activateInventoryItem, deactivateInventoryItem, formatMoney, getAccount, - listFlocks, listInventoryItems, listInventoryLots, listInventoryMovements, + listFlocks, listInventoryItems, listInventoryLots, listInventoryMovements, parseMoneyToMinorUnits, recordFeedUsage, recordInventoryAdjustment, recordInventoryPurchase, updateInventoryItem, } from "../api/cluckwork"; import type { Account, Flock, InventoryItem, InventoryLot, InventoryMovement } from "../api/cluckwork"; @@ -157,7 +157,7 @@ export function InventoryPage() { function toMinorUnits(text: string): number | null { if (!text.trim()) return null; - const parsed = Math.round(parseFloat(text) * 10 ** minorUnit); + const parsed = parseMoneyToMinorUnits(text, minorUnit); if (!Number.isFinite(parsed) || parsed < 0) throw new Error("Invalid cost."); return parsed; } diff --git a/web/src/routes/SalesPage.tsx b/web/src/routes/SalesPage.tsx index da8be998c..b4aaa650a 100644 --- a/web/src/routes/SalesPage.tsx +++ b/web/src/routes/SalesPage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { addOrderItem, cancelOrder, confirmOrder, createOrder, formatMoney, getOrder, listCustomers, listEggGrades, listOrderPayments, listOrders, listProducts, - recordPayment, + parseMoneyToMinorUnits, recordPayment, removeOrderItem, updateOrderItem, voidOrder, voidPayment, } from "../api/cluckwork"; import type { Customer, OrderPayments, Product, SalesOrder } from "../api/cluckwork"; @@ -196,7 +196,7 @@ export function SalesPage() { // Empty price → omit it: the server falls back to the product's default. let minorUnits: number | undefined; if (price.trim() !== "") { - minorUnits = Math.round(parseFloat(price) * 10 ** active.currencyMinorUnit); + minorUnits = parseMoneyToMinorUnits(price, active.currencyMinorUnit); if (!Number.isFinite(minorUnits) || minorUnits < 0) throw new Error("Invalid unit price."); } const scope = `add-item:${active.id}`; @@ -209,7 +209,7 @@ export function SalesPage() { const onUpdateItem = (itemId: string) => run(async () => { if (!active) return; - const minorUnits = Math.round(parseFloat(editPrice) * 10 ** active.currencyMinorUnit); + const minorUnits = parseMoneyToMinorUnits(editPrice, active.currencyMinorUnit); if (!Number.isFinite(minorUnits) || minorUnits < 0) throw new Error("Invalid unit price."); const scope = `update-item:${itemId}`; await updateOrderItem(active.id, itemId,