Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions web/src/api/cluckwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
71 changes: 70 additions & 1 deletion web/src/api/formatMoney.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
}
});
});
4 changes: 2 additions & 2 deletions web/src/routes/InventoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions web/src/routes/SalesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`;
Expand All @@ -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,
Expand Down
Loading