diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..c6de29ad
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+.vinxi/
+.output/
+src/wasm-pkg/
+src/routeTree.gen.ts
+bun.lock
+*.timestamp_*.js
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 00000000..ce3b2432
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "cash-register-app",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "wasm:build": "cd ../cash-register-wasm && wasm-pack build --target bundler --out-dir ../app/src/wasm-pkg",
+ "wasm:test": "cd ../cash-register-wasm && cargo test",
+ "dev": "bun run wasm:build && vite dev --port 3456",
+ "build": "bun run wasm:build && vite build",
+ "start": "vite preview",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "latest",
+ "@tanstack/react-start": "latest",
+ "@tanstack/router-plugin": "^1.132.0",
+ "react": "^19",
+ "react-dom": "^19",
+ "vite-plugin-top-level-await": "^1.6.0",
+ "vite-plugin-wasm": "^3.6.0",
+ "zod": "^3"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@types/node": "^25.6.0",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^5",
+ "jsdom": "^29.0.2",
+ "typescript": "^5",
+ "vite": "^7",
+ "vite-tsconfig-paths": "^5",
+ "vitest": "^3"
+ }
+}
diff --git a/app/src/components/ErrorBanner.tsx b/app/src/components/ErrorBanner.tsx
new file mode 100644
index 00000000..09cee5e4
--- /dev/null
+++ b/app/src/components/ErrorBanner.tsx
@@ -0,0 +1,17 @@
+type ErrorBannerProps = {
+ message: string
+ onDismiss?: () => void
+}
+
+export function ErrorBanner({ message, onDismiss }: ErrorBannerProps) {
+ return (
+
+ {message}
+ {onDismiss ? (
+
+ ) : null}
+
+ )
+}
diff --git a/app/src/components/FileUpload.tsx b/app/src/components/FileUpload.tsx
new file mode 100644
index 00000000..088abf2c
--- /dev/null
+++ b/app/src/components/FileUpload.tsx
@@ -0,0 +1,79 @@
+import { useCallback, useRef, useState } from "react"
+
+type FileUploadProps = {
+ onFileLoaded: (content: string, fileName: string) => void
+}
+
+export function FileUpload({ onFileLoaded }: FileUploadProps) {
+ const [fileName, setFileName] = useState(null)
+ const [isDragging, setIsDragging] = useState(false)
+ const inputRef = useRef(null)
+
+ const handleFile = useCallback(
+ (file: File) => {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const content = e.target?.result as string
+ setFileName(file.name)
+ onFileLoaded(content, file.name)
+ }
+ reader.readAsText(file)
+ },
+ [onFileLoaded],
+ )
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault()
+ setIsDragging(false)
+ const file = e.dataTransfer.files[0]
+ if (file) {
+ handleFile(file)
+ }
+ },
+ [handleFile],
+ )
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (file) {
+ handleFile(file)
+ }
+ },
+ [handleFile],
+ )
+
+ const isLoaded = fileName !== null
+
+ return (
+ {
+ e.preventDefault()
+ setIsDragging(true)
+ }}
+ onDragLeave={() => setIsDragging(false)}
+ onDrop={handleDrop}
+ onClick={() => inputRef.current?.click()}
+ >
+
{isLoaded ? "\u{1F4C4}" : "\u{1F4C1}"}
+
+ {isLoaded ? (
+ {fileName}
+ ) : (
+ <>
+ Drop file here or click to browse
+ >
+ )}
+
+
+
+ )
+}
diff --git a/app/src/components/ResultsTable.skeleton.tsx b/app/src/components/ResultsTable.skeleton.tsx
new file mode 100644
index 00000000..c8b63cba
--- /dev/null
+++ b/app/src/components/ResultsTable.skeleton.tsx
@@ -0,0 +1,36 @@
+export function ResultsTableSkeleton() {
+ return (
+
+
+
+
+
+ | # |
+ Input |
+ Output |
+ Strategy |
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/app/src/components/ResultsTable.tsx b/app/src/components/ResultsTable.tsx
new file mode 100644
index 00000000..3a155ee0
--- /dev/null
+++ b/app/src/components/ResultsTable.tsx
@@ -0,0 +1,58 @@
+import type { ProcessResponse } from "../lib/schemas"
+
+type ResultsTableProps = {
+ response: ProcessResponse
+}
+
+export function ResultsTable({ response }: ResultsTableProps) {
+ return (
+
+
+
+
+
+ | # |
+ Input |
+ Output |
+ Strategy |
+
+
+
+ {response.results.map((result) => (
+
+ | {result.line} |
+ {result.input} |
+
+ {result.error ? (
+ {result.error}
+ ) : (
+ result.output
+ )}
+ |
+
+ {result.strategy ? (
+
+ {result.strategy}
+
+ ) : null}
+ |
+
+ ))}
+
+
+
+
+
+
+ {response.total_lines} processed
+
+
+
+ {response.error_count} errors
+
+
+
+ )
+}
diff --git a/app/src/components/RulesBuilder.skeleton.tsx b/app/src/components/RulesBuilder.skeleton.tsx
new file mode 100644
index 00000000..9145ff40
--- /dev/null
+++ b/app/src/components/RulesBuilder.skeleton.tsx
@@ -0,0 +1,18 @@
+export function RulesBuilderSkeleton() {
+ return (
+
+ )
+}
diff --git a/app/src/components/RulesBuilder.tsx b/app/src/components/RulesBuilder.tsx
new file mode 100644
index 00000000..2074dc41
--- /dev/null
+++ b/app/src/components/RulesBuilder.tsx
@@ -0,0 +1,175 @@
+import type { Condition, Rule, RulesConfig } from "../lib/schemas"
+
+type RulesBuilderProps = {
+ config: RulesConfig
+ supportedCurrencies: string[]
+ supportedStrategies: string[]
+ onChange: (config: RulesConfig) => void
+}
+
+const CONDITION_TYPES = [
+ { value: "divisible_by", label: "divisible by" },
+ { value: "amount_range", label: "in range" },
+ { value: "always", label: "always" },
+] as const
+
+const STRATEGY_LABELS: Record = {
+ minimal: "Minimal change",
+ random: "Random denominations",
+}
+
+function strategyLabel(name: string): string {
+ return STRATEGY_LABELS[name] ?? name
+}
+
+export function RulesBuilder({
+ config,
+ supportedCurrencies,
+ supportedStrategies,
+ onChange,
+}: RulesBuilderProps) {
+ const updateCurrency = (currency: string) => {
+ onChange({ ...config, currency })
+ }
+
+ const updateDefaultStrategy = (default_strategy: string) => {
+ onChange({ ...config, default_strategy })
+ }
+
+ const updateRule = (index: number, rule: Rule) => {
+ const rules = config.rules.map((r, i) => (i === index ? rule : r))
+ onChange({ ...config, rules })
+ }
+
+ const removeRule = (index: number) => {
+ const rules = config.rules.filter((_, i) => i !== index)
+ onChange({ ...config, rules })
+ }
+
+ const addRule = () => {
+ const newRule: Rule = {
+ condition: { type: "divisible_by", divisor: 3 },
+ strategy: "random",
+ }
+ onChange({ ...config, rules: [...config.rules, newRule] })
+ }
+
+ const updateConditionType = (index: number, conditionType: Condition["type"]) => {
+ const rule = config.rules[index]
+ const condition: Condition =
+ conditionType === "divisible_by"
+ ? { type: "divisible_by", divisor: 3 }
+ : conditionType === "amount_range"
+ ? { type: "amount_range" }
+ : { type: "always" }
+ updateRule(index, { ...rule, condition })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rules
+
+
+ {config.rules.map((rule, i) => (
+
+
+ Rule {i + 1}
+
+
+
+ When owed is
+
+ {rule.condition.type === "divisible_by" ? (
+ {
+ const divisor = parseInt(e.target.value, 10)
+ if (!isNaN(divisor) && divisor > 0) {
+ updateRule(i, {
+ ...rule,
+ condition: { type: "divisible_by", divisor },
+ })
+ }
+ }}
+ />
+ ) : null}
+
+
+ then use
+
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/app/src/components/__tests__/ErrorBanner.test.tsx b/app/src/components/__tests__/ErrorBanner.test.tsx
new file mode 100644
index 00000000..4fa95965
--- /dev/null
+++ b/app/src/components/__tests__/ErrorBanner.test.tsx
@@ -0,0 +1,27 @@
+import { describe, it, expect, vi } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { ErrorBanner } from "../ErrorBanner"
+
+describe("ErrorBanner", () => {
+ it("renders message text", () => {
+ render()
+ expect(screen.getByText("Something broke")).toBeInTheDocument()
+ })
+
+ it("does not render dismiss button when onDismiss is not provided", () => {
+ render()
+ expect(screen.queryByRole("button")).not.toBeInTheDocument()
+ })
+
+ it("renders dismiss button when onDismiss is provided", () => {
+ render( {}} />)
+ expect(screen.getByRole("button")).toBeInTheDocument()
+ })
+
+ it("calls onDismiss when dismiss button is clicked", () => {
+ const onDismiss = vi.fn()
+ render()
+ screen.getByRole("button").click()
+ expect(onDismiss).toHaveBeenCalledOnce()
+ })
+})
diff --git a/app/src/components/__tests__/FileUpload.test.tsx b/app/src/components/__tests__/FileUpload.test.tsx
new file mode 100644
index 00000000..55c6b55f
--- /dev/null
+++ b/app/src/components/__tests__/FileUpload.test.tsx
@@ -0,0 +1,42 @@
+import { describe, it, expect, vi } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { FileUpload } from "../FileUpload"
+
+describe("FileUpload", () => {
+ it("renders drop zone with default text", () => {
+ render( {}} />)
+ expect(screen.getByText(/Drop file here/i)).toBeInTheDocument()
+ expect(screen.getByText(/click to browse/i)).toBeInTheDocument()
+ })
+
+ it("calls onFileLoaded with file content when file is selected", async () => {
+ const onFileLoaded = vi.fn()
+ const { container } = render()
+
+ const fileContent = "2.12,3.00\n1.97,2.00"
+ const file = new File([fileContent], "transactions.csv", { type: "text/csv" })
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
+
+ // Manually dispatch a change event with the file
+ Object.defineProperty(input, "files", { value: [file], configurable: true })
+ input.dispatchEvent(new Event("change", { bubbles: true }))
+
+ // FileReader is async, wait for it
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(onFileLoaded).toHaveBeenCalledWith(fileContent, "transactions.csv")
+ })
+
+ it("shows the file name after a file is loaded", async () => {
+ const { container } = render( {}} />)
+
+ const file = new File(["content"], "my-file.txt", { type: "text/plain" })
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
+ Object.defineProperty(input, "files", { value: [file], configurable: true })
+ input.dispatchEvent(new Event("change", { bubbles: true }))
+
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(screen.getByText("my-file.txt")).toBeInTheDocument()
+ })
+})
diff --git a/app/src/components/__tests__/ResultsTable.test.tsx b/app/src/components/__tests__/ResultsTable.test.tsx
new file mode 100644
index 00000000..a9e59356
--- /dev/null
+++ b/app/src/components/__tests__/ResultsTable.test.tsx
@@ -0,0 +1,100 @@
+import { describe, it, expect } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { ResultsTable } from "../ResultsTable"
+import type { ProcessResponse } from "../../lib/schemas"
+
+function makeResponse(overrides: Partial = {}): ProcessResponse {
+ return {
+ success: true,
+ results: [
+ {
+ line: 1,
+ input: "2.12,3.00",
+ output: "3 quarters,1 dime,3 pennies",
+ strategy: "minimal",
+ error: null,
+ },
+ {
+ line: 2,
+ input: "3.33,5.00",
+ output: "1 dollar,1 quarter,6 nickels,12 pennies",
+ strategy: "random",
+ error: null,
+ },
+ ],
+ total_lines: 2,
+ error_count: 0,
+ ...overrides,
+ }
+}
+
+describe("ResultsTable", () => {
+ it("renders each result row with line number, input, and output", () => {
+ render()
+
+ expect(screen.getByText("1")).toBeInTheDocument()
+ expect(screen.getByText("2")).toBeInTheDocument()
+ expect(screen.getByText("2.12,3.00")).toBeInTheDocument()
+ expect(screen.getByText("3.33,5.00")).toBeInTheDocument()
+ expect(screen.getByText("3 quarters,1 dime,3 pennies")).toBeInTheDocument()
+ expect(
+ screen.getByText("1 dollar,1 quarter,6 nickels,12 pennies"),
+ ).toBeInTheDocument()
+ })
+
+ it("renders strategy badges", () => {
+ render()
+
+ expect(screen.getByText("minimal")).toBeInTheDocument()
+ expect(screen.getByText("random")).toBeInTheDocument()
+ })
+
+ it("renders error text for error rows", () => {
+ const response = makeResponse({
+ success: false,
+ results: [
+ {
+ line: 1,
+ input: "bad line",
+ output: null,
+ strategy: null,
+ error: "Parse error on line 1",
+ },
+ ],
+ total_lines: 1,
+ error_count: 1,
+ })
+ render()
+
+ expect(screen.getByText("Parse error on line 1")).toBeInTheDocument()
+ })
+
+ it("renders summary stats", () => {
+ render()
+
+ expect(screen.getByText(/2 processed/)).toBeInTheDocument()
+ expect(screen.getByText(/0 errors/)).toBeInTheDocument()
+ })
+
+ it("shows error count correctly", () => {
+ const response = makeResponse({
+ total_lines: 5,
+ error_count: 2,
+ })
+ render()
+
+ expect(screen.getByText(/5 processed/)).toBeInTheDocument()
+ expect(screen.getByText(/2 errors/)).toBeInTheDocument()
+ })
+
+ it("renders empty state when no results", () => {
+ const response = makeResponse({
+ results: [],
+ total_lines: 0,
+ error_count: 0,
+ })
+ const { container } = render()
+
+ expect(container.querySelectorAll("tbody tr")).toHaveLength(0)
+ })
+})
diff --git a/app/src/components/__tests__/RulesBuilder.test.tsx b/app/src/components/__tests__/RulesBuilder.test.tsx
new file mode 100644
index 00000000..1a0139cf
--- /dev/null
+++ b/app/src/components/__tests__/RulesBuilder.test.tsx
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi } from "vitest"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { RulesBuilder } from "../RulesBuilder"
+import type { RulesConfig } from "../../lib/schemas"
+
+const defaultConfig: RulesConfig = {
+ currency: "USD",
+ default_strategy: "minimal",
+ rules: [
+ { condition: { type: "divisible_by", divisor: 3 }, strategy: "random" },
+ ],
+}
+
+const CURRENCIES = ["USD", "EUR"]
+const STRATEGIES = ["minimal", "random"]
+
+describe("RulesBuilder", () => {
+ it("renders currency dropdown with all supported currencies", () => {
+ render(
+ {}}
+ />,
+ )
+
+ const currencySelect = screen.getByLabelText(/Currency/i) as HTMLSelectElement
+ expect(currencySelect.value).toBe("USD")
+ expect(screen.getByRole("option", { name: "USD" })).toBeInTheDocument()
+ expect(screen.getByRole("option", { name: "EUR" })).toBeInTheDocument()
+ })
+
+ it("renders default strategy dropdown with label options", () => {
+ render(
+ {}}
+ />,
+ )
+
+ expect(screen.getByLabelText(/Default Strategy/i)).toBeInTheDocument()
+ expect(
+ screen.getAllByRole("option", { name: /Minimal change/i }).length,
+ ).toBeGreaterThan(0)
+ expect(
+ screen.getAllByRole("option", { name: /Random denominations/i }).length,
+ ).toBeGreaterThan(0)
+ })
+
+ it("renders existing rules", () => {
+ render(
+ {}}
+ />,
+ )
+
+ expect(screen.getByText(/Rule 1/)).toBeInTheDocument()
+ expect(screen.getByText(/When owed is/)).toBeInTheDocument()
+ expect(screen.getByDisplayValue("3")).toBeInTheDocument()
+ })
+
+ it("calls onChange when currency changes", () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ const currencySelect = screen.getByLabelText(/Currency/i)
+ fireEvent.change(currencySelect, { target: { value: "EUR" } })
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ currency: "EUR" }),
+ )
+ })
+
+ it("adds a new rule when Add Rule is clicked", () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole("button", { name: /Add Rule/i }))
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rules: expect.arrayContaining([
+ expect.anything(),
+ expect.objectContaining({
+ condition: expect.objectContaining({ type: "divisible_by" }),
+ }),
+ ]),
+ }),
+ )
+ const lastCall = onChange.mock.calls[0][0]
+ expect(lastCall.rules).toHaveLength(2)
+ })
+
+ it("removes a rule when remove button is clicked", () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ const removeButton = screen.getByRole("button", { name: "×" })
+ fireEvent.click(removeButton)
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ rules: [] }),
+ )
+ })
+
+ it("updates divisor when number input changes", () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ const divisorInput = screen.getByDisplayValue("3")
+ fireEvent.change(divisorInput, { target: { value: "5" } })
+
+ const lastCall = onChange.mock.calls[0][0]
+ expect(lastCall.rules[0].condition).toEqual({
+ type: "divisible_by",
+ divisor: 5,
+ })
+ })
+
+ it("ignores invalid divisor values", () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ const divisorInput = screen.getByDisplayValue("3")
+ fireEvent.change(divisorInput, { target: { value: "0" } })
+ fireEvent.change(divisorInput, { target: { value: "abc" } })
+
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it("changes condition type when dropdown changes", () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+
+ const conditionSelect = screen.getByDisplayValue("divisible by")
+ fireEvent.change(conditionSelect, { target: { value: "always" } })
+
+ const lastCall = onChange.mock.calls[0][0]
+ expect(lastCall.rules[0].condition).toEqual({ type: "always" })
+ })
+})
diff --git a/app/src/lib/__tests__/build-config.test.ts b/app/src/lib/__tests__/build-config.test.ts
new file mode 100644
index 00000000..bf50a4e6
--- /dev/null
+++ b/app/src/lib/__tests__/build-config.test.ts
@@ -0,0 +1,39 @@
+import { describe, it, expect } from "vitest"
+import { buildConfigJson } from "../build-config"
+import type { RulesConfig } from "../schemas"
+
+describe("buildConfigJson", () => {
+ it("serializes a simple config", () => {
+ const config: RulesConfig = {
+ currency: "USD",
+ default_strategy: "minimal",
+ rules: [],
+ }
+ const json = buildConfigJson(config)
+ expect(JSON.parse(json)).toEqual(config)
+ })
+
+ it("serializes config with divisible_by rule", () => {
+ const config: RulesConfig = {
+ currency: "USD",
+ default_strategy: "minimal",
+ rules: [
+ { condition: { type: "divisible_by", divisor: 3 }, strategy: "random" },
+ ],
+ }
+ const json = buildConfigJson(config)
+ const parsed = JSON.parse(json)
+ expect(parsed.rules[0].condition.type).toBe("divisible_by")
+ expect(parsed.rules[0].condition.divisor).toBe(3)
+ })
+
+ it("serializes EUR config", () => {
+ const config: RulesConfig = {
+ currency: "EUR",
+ default_strategy: "minimal",
+ rules: [],
+ }
+ const json = buildConfigJson(config)
+ expect(JSON.parse(json).currency).toBe("EUR")
+ })
+})
diff --git a/app/src/lib/__tests__/schemas.test.ts b/app/src/lib/__tests__/schemas.test.ts
new file mode 100644
index 00000000..352dc94b
--- /dev/null
+++ b/app/src/lib/__tests__/schemas.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect } from "vitest"
+import {
+ ProcessRequestSchema,
+ ProcessResponseSchema,
+ RulesConfigSchema,
+ ConditionSchema,
+} from "../schemas"
+
+describe("ProcessRequestSchema", () => {
+ it("accepts valid request", () => {
+ const result = ProcessRequestSchema.parse({
+ fileContent: "2.12,3.00",
+ rulesConfigJson: "{}",
+ })
+ expect(result.fileContent).toBe("2.12,3.00")
+ expect(result.rulesConfigJson).toBe("{}")
+ })
+
+ it("rejects empty fileContent", () => {
+ expect(() =>
+ ProcessRequestSchema.parse({ fileContent: "", rulesConfigJson: "{}" }),
+ ).toThrow()
+ })
+
+ it("rejects empty rulesConfigJson", () => {
+ expect(() =>
+ ProcessRequestSchema.parse({ fileContent: "x", rulesConfigJson: "" }),
+ ).toThrow()
+ })
+
+ it("rejects missing fields", () => {
+ expect(() => ProcessRequestSchema.parse({ fileContent: "x" })).toThrow()
+ })
+})
+
+describe("ProcessResponseSchema", () => {
+ it("parses successful response", () => {
+ const raw = {
+ success: true,
+ results: [
+ {
+ line: 1,
+ input: "2.12,3.00",
+ output: "3 quarters,1 dime,3 pennies",
+ strategy: "minimal",
+ error: null,
+ },
+ ],
+ total_lines: 1,
+ error_count: 0,
+ }
+ const result = ProcessResponseSchema.parse(raw)
+ expect(result.results).toHaveLength(1)
+ expect(result.results[0].strategy).toBe("minimal")
+ expect(result.results[0].error).toBeNull()
+ })
+
+ it("parses response with errors", () => {
+ const raw = {
+ success: false,
+ results: [
+ {
+ line: 1,
+ input: "bad line",
+ output: null,
+ strategy: null,
+ error: "Parse error",
+ },
+ ],
+ total_lines: 1,
+ error_count: 1,
+ }
+ const result = ProcessResponseSchema.parse(raw)
+ expect(result.results[0].error).toBe("Parse error")
+ expect(result.results[0].output).toBeNull()
+ })
+})
+
+describe("RulesConfigSchema", () => {
+ it("parses full config", () => {
+ const result = RulesConfigSchema.parse({
+ currency: "USD",
+ default_strategy: "minimal",
+ rules: [
+ { condition: { type: "divisible_by", divisor: 3 }, strategy: "random" },
+ ],
+ })
+ expect(result.currency).toBe("USD")
+ expect(result.rules).toHaveLength(1)
+ })
+
+ it("accepts empty rules array", () => {
+ const result = RulesConfigSchema.parse({
+ currency: "EUR",
+ default_strategy: "minimal",
+ rules: [],
+ })
+ expect(result.rules).toHaveLength(0)
+ })
+})
+
+describe("ConditionSchema", () => {
+ it("parses divisible_by condition", () => {
+ const result = ConditionSchema.parse({ type: "divisible_by", divisor: 3 })
+ expect(result).toEqual({ type: "divisible_by", divisor: 3 })
+ })
+
+ it("parses amount_range condition", () => {
+ const result = ConditionSchema.parse({
+ type: "amount_range",
+ min_cents: 100,
+ max_cents: 500,
+ })
+ expect(result.type).toBe("amount_range")
+ })
+
+ it("parses always condition", () => {
+ const result = ConditionSchema.parse({ type: "always" })
+ expect(result.type).toBe("always")
+ })
+
+ it("rejects unknown type", () => {
+ expect(() => ConditionSchema.parse({ type: "bogus" })).toThrow()
+ })
+
+ it("rejects divisible_by with divisor < 1", () => {
+ expect(() =>
+ ConditionSchema.parse({ type: "divisible_by", divisor: 0 }),
+ ).toThrow()
+ })
+})
diff --git a/app/src/lib/build-config.ts b/app/src/lib/build-config.ts
new file mode 100644
index 00000000..ab8ce8fb
--- /dev/null
+++ b/app/src/lib/build-config.ts
@@ -0,0 +1,5 @@
+import type { RulesConfig } from "./schemas"
+
+export function buildConfigJson(config: RulesConfig): string {
+ return JSON.stringify(config)
+}
diff --git a/app/src/lib/default-config.ts b/app/src/lib/default-config.ts
new file mode 100644
index 00000000..e2b5ab1e
--- /dev/null
+++ b/app/src/lib/default-config.ts
@@ -0,0 +1,12 @@
+import type { RulesConfig } from "./schemas"
+
+export const DEFAULT_RULES_CONFIG: RulesConfig = {
+ currency: "USD",
+ default_strategy: "minimal",
+ rules: [
+ {
+ condition: { type: "divisible_by", divisor: 3 },
+ strategy: "random",
+ },
+ ],
+}
diff --git a/app/src/lib/schemas.ts b/app/src/lib/schemas.ts
new file mode 100644
index 00000000..15264dce
--- /dev/null
+++ b/app/src/lib/schemas.ts
@@ -0,0 +1,53 @@
+import { z } from "zod"
+
+export const ProcessRequestSchema = z.object({
+ fileContent: z.string().min(1),
+ rulesConfigJson: z.string().min(1),
+})
+
+export type ProcessRequest = z.infer
+
+const LineResultSchema = z.object({
+ line: z.number(),
+ input: z.string(),
+ output: z.string().nullable(),
+ strategy: z.string().nullable(),
+ error: z.string().nullable(),
+})
+
+export const ProcessResponseSchema = z.object({
+ success: z.boolean(),
+ results: z.array(LineResultSchema),
+ total_lines: z.number(),
+ error_count: z.number(),
+})
+
+export type ProcessResponse = z.infer
+export type LineResult = z.infer
+
+export const ConditionSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("divisible_by"), divisor: z.number().int().min(1) }),
+ z.object({
+ type: z.literal("amount_range"),
+ min_cents: z.number().int().optional(),
+ max_cents: z.number().int().optional(),
+ }),
+ z.object({ type: z.literal("always") }),
+])
+
+export type Condition = z.infer
+
+export const RuleSchema = z.object({
+ condition: ConditionSchema,
+ strategy: z.string(),
+})
+
+export type Rule = z.infer
+
+export const RulesConfigSchema = z.object({
+ currency: z.string(),
+ default_strategy: z.string(),
+ rules: z.array(RuleSchema),
+})
+
+export type RulesConfig = z.infer
diff --git a/app/src/router.tsx b/app/src/router.tsx
new file mode 100644
index 00000000..a62aff81
--- /dev/null
+++ b/app/src/router.tsx
@@ -0,0 +1,19 @@
+import { createRouter as createTanStackRouter } from "@tanstack/react-router"
+import { routeTree } from "./routeTree.gen"
+
+export function getRouter() {
+ const router = createTanStackRouter({
+ routeTree,
+ scrollRestoration: true,
+ defaultPreload: "intent",
+ defaultPreloadStaleTime: 0,
+ })
+
+ return router
+}
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: ReturnType
+ }
+}
diff --git a/app/src/routes/__root.tsx b/app/src/routes/__root.tsx
new file mode 100644
index 00000000..7d83cf3d
--- /dev/null
+++ b/app/src/routes/__root.tsx
@@ -0,0 +1,31 @@
+import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: "utf-8" },
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
+ { title: "Cash Register" },
+ ],
+ }),
+ component: RootLayout,
+ shellComponent: RootDocument,
+})
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function RootLayout() {
+ return
+}
diff --git a/app/src/routes/index.tsx b/app/src/routes/index.tsx
new file mode 100644
index 00000000..e89e3c83
--- /dev/null
+++ b/app/src/routes/index.tsx
@@ -0,0 +1,102 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { useState } from "react"
+import { FileUpload } from "../components/FileUpload"
+import { RulesBuilder } from "../components/RulesBuilder"
+import { ResultsTable } from "../components/ResultsTable"
+import { ResultsTableSkeleton } from "../components/ResultsTable.skeleton"
+import { ErrorBanner } from "../components/ErrorBanner"
+import { DEFAULT_RULES_CONFIG } from "../lib/default-config"
+import { buildConfigJson } from "../lib/build-config"
+import { processTransactions } from "../server/process-file"
+import type { ProcessResponse, RulesConfig } from "../lib/schemas"
+import "../styles/app.css"
+
+export const Route = createFileRoute("/")({
+ component: IndexPage,
+})
+
+const SUPPORTED_CURRENCIES = ["USD", "EUR"]
+const SUPPORTED_STRATEGIES = ["minimal", "random"]
+
+function IndexPage() {
+ const [fileContent, setFileContent] = useState(null)
+ const [config, setConfig] = useState(DEFAULT_RULES_CONFIG)
+ const [result, setResult] = useState(null)
+ const [error, setError] = useState(null)
+ const [isProcessing, setIsProcessing] = useState(false)
+
+ const handleProcess = async () => {
+ if (!fileContent) {
+ return
+ }
+
+ setIsProcessing(true)
+ setError(null)
+ setResult(null)
+
+ const response = await processTransactions({
+ data: {
+ fileContent,
+ rulesConfigJson: buildConfigJson(config),
+ },
+ })
+
+ setIsProcessing(false)
+
+ if (!response.ok) {
+ setError(response.error)
+ return
+ }
+
+ setResult(response.data)
+ }
+
+ return (
+
+
+
+
+
+
+ Results
+
+ {error ? setError(null)} /> : null}
+
+ {isProcessing ? : null}
+
+ {result && !isProcessing ? : null}
+
+ {!result && !isProcessing && !error ? (
+
+ Upload a file and click Process to see results
+
+ ) : null}
+
+
+ )
+}
diff --git a/app/src/server/__tests__/process-file.test.ts b/app/src/server/__tests__/process-file.test.ts
new file mode 100644
index 00000000..6cc465ac
--- /dev/null
+++ b/app/src/server/__tests__/process-file.test.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect, vi } from "vitest"
+
+vi.mock("@tanstack/react-start", () => ({
+ createServerFn: () => ({
+ inputValidator: () => ({ handler: () => () => null }),
+ }),
+}))
+
+vi.mock("../wasm-loader", () => ({
+ loadWasmModule: vi.fn(),
+}))
+
+import { processWithWasm } from "../process-file"
+import type { WasmModule } from "../wasm-types"
+
+function makeWasmMock(overrides: Partial = {}): WasmModule {
+ return {
+ process_transactions: vi.fn(() =>
+ JSON.stringify({
+ success: true,
+ results: [
+ {
+ line: 1,
+ input: "2.12,3.00",
+ output: "3 quarters,1 dime,3 pennies",
+ strategy: "minimal",
+ error: null,
+ },
+ ],
+ total_lines: 1,
+ error_count: 0,
+ }),
+ ),
+ validate_config: vi.fn(),
+ list_supported_currencies: vi.fn(() => JSON.stringify(["USD", "EUR"])),
+ list_supported_strategies: vi.fn(() => JSON.stringify(["minimal", "random"])),
+ ...overrides,
+ }
+}
+
+describe("processWithWasm", () => {
+ it("returns ok with parsed response on success", async () => {
+ const wasm = makeWasmMock()
+ const result = await processWithWasm(wasm, {
+ fileContent: "2.12,3.00",
+ rulesConfigJson: '{"currency":"USD","default_strategy":"minimal","rules":[]}',
+ })
+
+ expect(result.ok).toBe(true)
+ if (result.ok) {
+ expect(result.data.results).toHaveLength(1)
+ expect(result.data.results[0].output).toBe("3 quarters,1 dime,3 pennies")
+ expect(result.data.total_lines).toBe(1)
+ }
+ })
+
+ it("passes fileContent and rulesConfigJson to wasm.process_transactions", async () => {
+ const processSpy = vi.fn(() =>
+ JSON.stringify({
+ success: true,
+ results: [],
+ total_lines: 0,
+ error_count: 0,
+ }),
+ )
+ const wasm = makeWasmMock({ process_transactions: processSpy })
+
+ await processWithWasm(wasm, {
+ fileContent: "file content here",
+ rulesConfigJson: '{"currency":"EUR"}',
+ })
+
+ expect(processSpy).toHaveBeenCalledWith(
+ "file content here",
+ '{"currency":"EUR"}',
+ )
+ })
+
+ it("returns ok:false when wasm throws", async () => {
+ const wasm = makeWasmMock({
+ process_transactions: vi.fn(() => {
+ throw new Error("WASM crashed")
+ }),
+ })
+
+ const result = await processWithWasm(wasm, {
+ fileContent: "x",
+ rulesConfigJson: "{}",
+ })
+
+ expect(result.ok).toBe(false)
+ if (!result.ok) {
+ expect(result.error).toBe("WASM crashed")
+ }
+ })
+
+ it("returns ok:false when wasm returns invalid JSON", async () => {
+ const wasm = makeWasmMock({
+ process_transactions: vi.fn(() => "not json"),
+ })
+
+ const result = await processWithWasm(wasm, {
+ fileContent: "x",
+ rulesConfigJson: "{}",
+ })
+
+ expect(result.ok).toBe(false)
+ })
+
+ it("returns ok:false when wasm returns response missing required fields", async () => {
+ const wasm = makeWasmMock({
+ process_transactions: vi.fn(() => JSON.stringify({ success: true })),
+ })
+
+ const result = await processWithWasm(wasm, {
+ fileContent: "x",
+ rulesConfigJson: "{}",
+ })
+
+ expect(result.ok).toBe(false)
+ })
+
+ it("handles response with errors", async () => {
+ const wasm = makeWasmMock({
+ process_transactions: vi.fn(() =>
+ JSON.stringify({
+ success: false,
+ results: [
+ {
+ line: 1,
+ input: "bad",
+ output: null,
+ strategy: null,
+ error: "Parse error",
+ },
+ ],
+ total_lines: 1,
+ error_count: 1,
+ }),
+ ),
+ })
+
+ const result = await processWithWasm(wasm, {
+ fileContent: "bad",
+ rulesConfigJson: "{}",
+ })
+
+ expect(result.ok).toBe(true)
+ if (result.ok) {
+ expect(result.data.error_count).toBe(1)
+ expect(result.data.results[0].error).toBe("Parse error")
+ }
+ })
+})
diff --git a/app/src/server/process-file.ts b/app/src/server/process-file.ts
new file mode 100644
index 00000000..e9a2e0da
--- /dev/null
+++ b/app/src/server/process-file.ts
@@ -0,0 +1,30 @@
+import { createServerFn } from "@tanstack/react-start"
+import { ProcessRequestSchema, ProcessResponseSchema } from "../lib/schemas"
+import type { ProcessRequest, ProcessResponse } from "../lib/schemas"
+import { loadWasmModule } from "./wasm-loader"
+import type { WasmModule } from "./wasm-types"
+
+export type ProcessResult =
+ | { ok: true; data: ProcessResponse }
+ | { ok: false; error: string }
+
+export async function processWithWasm(
+ wasm: WasmModule,
+ data: ProcessRequest,
+): Promise {
+ try {
+ const raw = wasm.process_transactions(data.fileContent, data.rulesConfigJson)
+ const parsed = ProcessResponseSchema.parse(JSON.parse(raw))
+ return { ok: true, data: parsed }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ return { ok: false, error: message }
+ }
+}
+
+export const processTransactions = createServerFn({ method: "POST" })
+ .inputValidator((data: unknown) => ProcessRequestSchema.parse(data))
+ .handler(async ({ data }): Promise => {
+ const wasm = loadWasmModule()
+ return processWithWasm(wasm, data)
+ })
diff --git a/app/src/server/wasm-loader.ts b/app/src/server/wasm-loader.ts
new file mode 100644
index 00000000..ca1b3042
--- /dev/null
+++ b/app/src/server/wasm-loader.ts
@@ -0,0 +1,18 @@
+import {
+ process_transactions,
+ validate_config,
+ list_supported_currencies,
+ list_supported_strategies,
+} from "../wasm-pkg/cash_register_wasm"
+import type { WasmModule } from "./wasm-types"
+
+export type { WasmModule }
+
+export function loadWasmModule(): WasmModule {
+ return {
+ process_transactions,
+ validate_config,
+ list_supported_currencies,
+ list_supported_strategies,
+ }
+}
diff --git a/app/src/server/wasm-types.ts b/app/src/server/wasm-types.ts
new file mode 100644
index 00000000..bdc44c93
--- /dev/null
+++ b/app/src/server/wasm-types.ts
@@ -0,0 +1,6 @@
+export type WasmModule = {
+ process_transactions: (fileContent: string, rulesConfigJson: string) => string
+ validate_config: (rulesConfigJson: string) => void
+ list_supported_currencies: () => string
+ list_supported_strategies: () => string
+}
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
new file mode 100644
index 00000000..9aae8fe0
--- /dev/null
+++ b/app/src/styles/app.css
@@ -0,0 +1,472 @@
+:root {
+ --bg: #0a0a0a;
+ --surface: #141414;
+ --surface-2: #1c1c1c;
+ --surface-3: #242424;
+ --border: #2a2a2a;
+ --border-hover: #3a3a3a;
+ --text: #e5e5e5;
+ --text-muted: #888;
+ --accent: #3b82f6;
+ --accent-hover: #2563eb;
+ --green: #22c55e;
+ --red: #ef4444;
+ --purple: #a855f7;
+ --radius: 12px;
+ --radius-sm: 8px;
+}
+
+*,
+*::before,
+*::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
+}
+
+/* Split Panel Layout */
+.split-container {
+ height: 100vh;
+ display: grid;
+ grid-template-columns: 380px 1fr;
+ grid-template-rows: auto 1fr;
+}
+
+.split-header {
+ grid-column: 1 / -1;
+ padding: 20px 28px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.split-header h2 {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.split-left {
+ padding: 24px;
+ border-right: 1px solid var(--border);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.split-right {
+ padding: 24px;
+ overflow-y: auto;
+ background: var(--surface);
+}
+
+/* Dropzone */
+.dropzone {
+ border: 2px dashed var(--border);
+ border-radius: var(--radius);
+ padding: 40px 24px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.dropzone:hover,
+.dropzone-dragging {
+ border-color: var(--accent);
+ background: rgba(59, 130, 246, 0.04);
+}
+
+.dropzone-loaded {
+ border-color: var(--green);
+ border-style: solid;
+ background: rgba(34, 197, 94, 0.04);
+}
+
+.dropzone-icon {
+ font-size: 32px;
+ margin-bottom: 12px;
+ opacity: 0.6;
+}
+
+.dropzone-loaded .dropzone-icon {
+ opacity: 1;
+}
+
+.dropzone-text {
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+.dropzone-text strong {
+ color: var(--accent);
+}
+
+.dropzone-loaded .dropzone-text strong {
+ color: var(--green);
+}
+
+/* Config controls */
+.config-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.config-field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.config-field-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-muted);
+}
+
+.section-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+}
+
+.select-control,
+.select-control-full {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 8px 32px 8px 14px;
+ color: var(--text);
+ font-size: 14px;
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%23888' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+}
+
+.select-control-full {
+ width: 100%;
+}
+
+.select-sm {
+ padding: 6px 28px 6px 10px;
+ font-size: 13px;
+}
+
+.number-input {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 6px 8px;
+ color: var(--text);
+ font-size: 13px;
+ width: 60px;
+ text-align: center;
+}
+
+.rules-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.rule-card {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.rule-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.rule-card-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.rule-card-remove {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ transition: all 0.2s;
+}
+
+.rule-card-remove:hover {
+ color: var(--red);
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.rule-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.rule-text {
+ font-size: 13px;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
+.btn-add-rule {
+ background: transparent;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-sm);
+ padding: 8px 16px;
+ color: var(--text-muted);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+ width: 100%;
+ text-align: center;
+}
+
+.btn-add-rule:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* Results */
+.results-container {
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.results-table-wrapper {
+ overflow-x: auto;
+}
+
+.results-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.results-table th {
+ text-align: left;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.results-table td {
+ padding: 12px 14px;
+ font-size: 14px;
+ border-bottom: 1px solid var(--border);
+ vertical-align: top;
+}
+
+.results-table tr:last-child td {
+ border-bottom: none;
+}
+
+.results-table .line-num {
+ color: var(--text-muted);
+ font-weight: 500;
+ width: 40px;
+}
+
+.results-table .input-col {
+ color: var(--text-muted);
+ font-family: monospace;
+ width: 120px;
+}
+
+.results-table .output-col {
+ font-weight: 500;
+}
+
+.results-table .strategy-col {
+ width: 80px;
+ text-align: center;
+}
+
+.row-error .output-col {
+ color: var(--red);
+}
+
+.error-text {
+ font-size: 13px;
+ font-style: italic;
+}
+
+.badge {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 99px;
+ font-weight: 600;
+}
+
+.badge-ok {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--green);
+}
+
+.badge-random {
+ background: rgba(168, 85, 247, 0.15);
+ color: var(--purple);
+}
+
+.results-summary {
+ padding: 14px;
+ font-size: 13px;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ border-top: 1px solid var(--border);
+}
+
+.summary-stat {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.summary-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.dot-green {
+ background: var(--green);
+}
+
+.dot-red {
+ background: var(--red);
+}
+
+/* Buttons */
+.btn {
+ padding: 10px 24px;
+ border-radius: var(--radius-sm);
+ border: none;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-full {
+ width: 100%;
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+}
+
+.btn-ghost:hover {
+ border-color: var(--border-hover);
+ color: var(--text);
+}
+
+/* Error Banner */
+.error-banner {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: var(--radius-sm);
+ padding: 12px 16px;
+ color: var(--red);
+ font-size: 14px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.error-banner-dismiss {
+ background: none;
+ border: none;
+ color: var(--red);
+ font-size: 18px;
+ cursor: pointer;
+ padding: 0 4px;
+}
+
+/* Empty state */
+.results-empty {
+ color: var(--text-muted);
+ font-size: 14px;
+ text-align: center;
+ padding: 60px 24px;
+}
+
+/* Skeletons */
+.skeleton {
+ background: var(--surface-2);
+ border-radius: var(--radius-sm);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+.skeleton-label {
+ width: 80px;
+ height: 14px;
+}
+
+.skeleton-select {
+ width: 100%;
+ height: 38px;
+}
+
+.skeleton-rule-card {
+ width: 100%;
+ height: 120px;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 0.4;
+ }
+ 50% {
+ opacity: 0.8;
+ }
+}
diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts
new file mode 100644
index 00000000..b9e76229
--- /dev/null
+++ b/app/src/test/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest"
diff --git a/app/tsconfig.json b/app/tsconfig.json
new file mode 100644
index 00000000..70860c5e
--- /dev/null
+++ b/app/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "types": ["node", "vitest/globals", "@testing-library/jest-dom"],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src", "vitest.config.ts"]
+}
diff --git a/app/vite.config.ts b/app/vite.config.ts
new file mode 100644
index 00000000..a3a56020
--- /dev/null
+++ b/app/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vite"
+import tsconfigPaths from "vite-tsconfig-paths"
+import { tanstackStart } from "@tanstack/react-start/plugin/vite"
+import viteReact from "@vitejs/plugin-react"
+import wasm from "vite-plugin-wasm"
+import topLevelAwait from "vite-plugin-top-level-await"
+
+const config = defineConfig({
+ plugins: [
+ wasm(),
+ topLevelAwait(),
+ tsconfigPaths({ projects: ["./tsconfig.json"] }),
+ tanstackStart(),
+ viteReact(),
+ ],
+})
+
+export default config
diff --git a/app/vitest.config.ts b/app/vitest.config.ts
new file mode 100644
index 00000000..5da5bfae
--- /dev/null
+++ b/app/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from "vitest/config"
+import tsconfigPaths from "vite-tsconfig-paths"
+import viteReact from "@vitejs/plugin-react"
+
+export default defineConfig({
+ plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] }), viteReact()],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ exclude: ["**/node_modules/**", "**/dist/**", "**/wasm-pkg/**"],
+ },
+})
diff --git a/cash-register-wasm/.gitignore b/cash-register-wasm/.gitignore
new file mode 100644
index 00000000..34aa146b
--- /dev/null
+++ b/cash-register-wasm/.gitignore
@@ -0,0 +1,2 @@
+target/
+pkg/
diff --git a/cash-register-wasm/Cargo.lock b/cash-register-wasm/Cargo.lock
new file mode 100644
index 00000000..3bc38b54
--- /dev/null
+++ b/cash-register-wasm/Cargo.lock
@@ -0,0 +1,512 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "cash-register-wasm"
+version = "0.1.0"
+dependencies = [
+ "getrandom",
+ "js-sys",
+ "rand",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "wasm-bindgen",
+ "wasm-bindgen-test",
+]
+
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
+[[package]]
+name = "cc"
+version = "1.2.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.184"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "minicov"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
+dependencies = [
+ "cc",
+ "walkdir",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-bindgen-test"
+version = "0.3.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea"
+dependencies = [
+ "async-trait",
+ "cast",
+ "js-sys",
+ "libm",
+ "minicov",
+ "nu-ansi-term",
+ "num-traits",
+ "oorandom",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test-macro",
+ "wasm-bindgen-test-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-test-macro"
+version = "0.3.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "wasm-bindgen-test-shared"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/cash-register-wasm/Cargo.toml b/cash-register-wasm/Cargo.toml
new file mode 100644
index 00000000..a1f573a4
--- /dev/null
+++ b/cash-register-wasm/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "cash-register-wasm"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+wasm-bindgen = "0.2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+rand = { version = "0.8", features = ["small_rng"] }
+js-sys = "0.3"
+getrandom = { version = "0.2", features = ["js"] }
+thiserror = "1"
+
+[dev-dependencies]
+wasm-bindgen-test = "0.3"
+
+[profile.release]
+opt-level = "s"
+lto = true
diff --git a/cash-register-wasm/src/currency/eur.rs b/cash-register-wasm/src/currency/eur.rs
new file mode 100644
index 00000000..81252646
--- /dev/null
+++ b/cash-register-wasm/src/currency/eur.rs
@@ -0,0 +1,68 @@
+use crate::error::CashError;
+use crate::types::{Denomination, Money};
+
+use super::Currency;
+
+pub struct EurCurrency;
+
+const EUR_DENOMINATIONS: &[Denomination] = &[
+ Denomination { value: 200, singular: "2-euro coin", plural: "2-euro coins" },
+ Denomination { value: 100, singular: "euro", plural: "euros" },
+ Denomination { value: 50, singular: "50-cent coin", plural: "50-cent coins" },
+ Denomination { value: 20, singular: "20-cent coin", plural: "20-cent coins" },
+ Denomination { value: 10, singular: "10-cent coin", plural: "10-cent coins" },
+ Denomination { value: 5, singular: "5-cent coin", plural: "5-cent coins" },
+ Denomination { value: 2, singular: "2-cent coin", plural: "2-cent coins" },
+ Denomination { value: 1, singular: "1-cent coin", plural: "1-cent coins" },
+];
+
+impl Currency for EurCurrency {
+ fn code(&self) -> &'static str {
+ "EUR"
+ }
+
+ fn denominations(&self) -> &[Denomination] {
+ EUR_DENOMINATIONS
+ }
+
+ fn parse_amount(&self, s: &str) -> Result {
+ Money::from_str_comma_decimal(s)
+ .or_else(|| Money::from_str_decimal(s))
+ .ok_or_else(|| CashError::ConfigParseError(
+ format!("Invalid EUR amount: '{}'", s),
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn denominations_ordered_largest_first() {
+ let currency = EurCurrency;
+ let denoms = currency.denominations();
+ for window in denoms.windows(2) {
+ assert!(window[0].value > window[1].value);
+ }
+ }
+
+ #[test]
+ fn smallest_denomination_is_one() {
+ let currency = EurCurrency;
+ let denoms = currency.denominations();
+ assert_eq!(denoms.last().unwrap().value, 1);
+ }
+
+ #[test]
+ fn parse_comma_decimal() {
+ let currency = EurCurrency;
+ assert_eq!(currency.parse_amount("2,13").unwrap(), Money(213));
+ }
+
+ #[test]
+ fn parse_dot_decimal_also_works() {
+ let currency = EurCurrency;
+ assert_eq!(currency.parse_amount("2.13").unwrap(), Money(213));
+ }
+}
diff --git a/cash-register-wasm/src/currency/mod.rs b/cash-register-wasm/src/currency/mod.rs
new file mode 100644
index 00000000..bf14254b
--- /dev/null
+++ b/cash-register-wasm/src/currency/mod.rs
@@ -0,0 +1,45 @@
+pub mod eur;
+pub mod usd;
+
+use crate::error::CashError;
+use crate::types::{Denomination, Money};
+
+pub trait Currency: Send + Sync {
+ fn code(&self) -> &'static str;
+ fn denominations(&self) -> &[Denomination];
+ fn parse_amount(&self, s: &str) -> Result;
+}
+
+pub fn currency_for_code(code: &str) -> Result, CashError> {
+ match code {
+ "USD" => Ok(Box::new(usd::UsdCurrency)),
+ "EUR" => Ok(Box::new(eur::EurCurrency)),
+ other => Err(CashError::UnknownCurrency(other.to_string())),
+ }
+}
+
+pub fn supported_currencies() -> &'static [&'static str] {
+ &["USD", "EUR"]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn registry_usd() {
+ let currency = currency_for_code("USD").unwrap();
+ assert_eq!(currency.code(), "USD");
+ }
+
+ #[test]
+ fn registry_eur() {
+ let currency = currency_for_code("EUR").unwrap();
+ assert_eq!(currency.code(), "EUR");
+ }
+
+ #[test]
+ fn registry_unknown() {
+ assert!(currency_for_code("GBP").is_err());
+ }
+}
diff --git a/cash-register-wasm/src/currency/usd.rs b/cash-register-wasm/src/currency/usd.rs
new file mode 100644
index 00000000..de694c35
--- /dev/null
+++ b/cash-register-wasm/src/currency/usd.rs
@@ -0,0 +1,64 @@
+use crate::error::CashError;
+use crate::types::{Denomination, Money};
+
+use super::Currency;
+
+pub struct UsdCurrency;
+
+const USD_DENOMINATIONS: &[Denomination] = &[
+ Denomination { value: 100, singular: "dollar", plural: "dollars" },
+ Denomination { value: 25, singular: "quarter", plural: "quarters" },
+ Denomination { value: 10, singular: "dime", plural: "dimes" },
+ Denomination { value: 5, singular: "nickel", plural: "nickels" },
+ Denomination { value: 1, singular: "penny", plural: "pennies" },
+];
+
+impl Currency for UsdCurrency {
+ fn code(&self) -> &'static str {
+ "USD"
+ }
+
+ fn denominations(&self) -> &[Denomination] {
+ USD_DENOMINATIONS
+ }
+
+ fn parse_amount(&self, s: &str) -> Result {
+ Money::from_str_decimal(s).ok_or_else(|| CashError::ConfigParseError(
+ format!("Invalid USD amount: '{}'", s),
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn denominations_ordered_largest_first() {
+ let currency = UsdCurrency;
+ let denoms = currency.denominations();
+ for window in denoms.windows(2) {
+ assert!(window[0].value > window[1].value);
+ }
+ }
+
+ #[test]
+ fn smallest_denomination_is_one() {
+ let currency = UsdCurrency;
+ let denoms = currency.denominations();
+ assert_eq!(denoms.last().unwrap().value, 1);
+ }
+
+ #[test]
+ fn parse_amount_valid() {
+ let currency = UsdCurrency;
+ assert_eq!(currency.parse_amount("2.13").unwrap(), Money(213));
+ assert_eq!(currency.parse_amount("0.01").unwrap(), Money(1));
+ }
+
+ #[test]
+ fn parse_amount_invalid() {
+ let currency = UsdCurrency;
+ assert!(currency.parse_amount("abc").is_err());
+ }
+}
diff --git a/cash-register-wasm/src/error.rs b/cash-register-wasm/src/error.rs
new file mode 100644
index 00000000..e25c8360
--- /dev/null
+++ b/cash-register-wasm/src/error.rs
@@ -0,0 +1,20 @@
+use serde::Serialize;
+use thiserror::Error;
+
+#[derive(Debug, Error, Serialize)]
+pub enum CashError {
+ #[error("Unknown currency: {0}")]
+ UnknownCurrency(String),
+
+ #[error("Unknown strategy: {0}")]
+ UnknownStrategy(String),
+
+ #[error("Invalid config: {0}")]
+ ConfigParseError(String),
+
+ #[error("Line {line}: {message}")]
+ ParseError { line: usize, message: String },
+
+ #[error("Line {line}: paid amount less than owed")]
+ InsufficientPayment { line: usize },
+}
diff --git a/cash-register-wasm/src/lib.rs b/cash-register-wasm/src/lib.rs
new file mode 100644
index 00000000..13089dbd
--- /dev/null
+++ b/cash-register-wasm/src/lib.rs
@@ -0,0 +1,40 @@
+pub mod currency;
+pub mod error;
+pub mod processor;
+pub mod rules;
+pub mod strategy;
+pub mod types;
+
+use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn process_transactions(file_content: &str, rules_config_json: &str) -> Result {
+ let engine = rules::RulesEngine::from_json(rules_config_json)
+ .map_err(|e| JsValue::from_str(&e.to_string()))?;
+
+ let response = processor::process_file(file_content, &engine)
+ .map_err(|e| JsValue::from_str(&e.to_string()))?;
+
+ serde_json::to_string(&response)
+ .map(|s| JsValue::from_str(&s))
+ .map_err(|e| JsValue::from_str(&e.to_string()))
+}
+
+#[wasm_bindgen]
+pub fn validate_config(rules_config_json: &str) -> Result<(), JsValue> {
+ rules::RulesEngine::from_json(rules_config_json)
+ .map(|_| ())
+ .map_err(|e| JsValue::from_str(&e.to_string()))
+}
+
+#[wasm_bindgen]
+pub fn list_supported_currencies() -> JsValue {
+ let currencies = currency::supported_currencies();
+ JsValue::from_str(&serde_json::to_string(currencies).unwrap())
+}
+
+#[wasm_bindgen]
+pub fn list_supported_strategies() -> JsValue {
+ let strategies = strategy::supported_strategies();
+ JsValue::from_str(&serde_json::to_string(strategies).unwrap())
+}
diff --git a/cash-register-wasm/src/processor/formatter.rs b/cash-register-wasm/src/processor/formatter.rs
new file mode 100644
index 00000000..7039a160
--- /dev/null
+++ b/cash-register-wasm/src/processor/formatter.rs
@@ -0,0 +1,62 @@
+use crate::types::Denomination;
+
+pub fn format_denominations(result: &[(usize, u32)], denominations: &[Denomination]) -> String {
+ result
+ .iter()
+ .filter(|(_, count)| *count > 0)
+ .map(|(i, count)| {
+ let denom = &denominations[*i];
+ let name = if *count == 1 {
+ denom.singular
+ } else {
+ denom.plural
+ };
+ format!("{} {}", count, name)
+ })
+ .collect::>()
+ .join(",")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::Denomination;
+
+ const TEST_DENOMS: &[Denomination] = &[
+ Denomination { value: 100, singular: "dollar", plural: "dollars" },
+ Denomination { value: 25, singular: "quarter", plural: "quarters" },
+ Denomination { value: 10, singular: "dime", plural: "dimes" },
+ Denomination { value: 5, singular: "nickel", plural: "nickels" },
+ Denomination { value: 1, singular: "penny", plural: "pennies" },
+ ];
+
+ #[test]
+ fn format_multiple_denominations() {
+ let result = vec![(1, 3), (2, 1), (4, 3)];
+ assert_eq!(format_denominations(&result, TEST_DENOMS), "3 quarters,1 dime,3 pennies");
+ }
+
+ #[test]
+ fn format_single_denomination() {
+ let result = vec![(0, 1)];
+ assert_eq!(format_denominations(&result, TEST_DENOMS), "1 dollar");
+ }
+
+ #[test]
+ fn format_plural_vs_singular() {
+ let result = vec![(0, 2)];
+ assert_eq!(format_denominations(&result, TEST_DENOMS), "2 dollars");
+ }
+
+ #[test]
+ fn format_empty() {
+ let result: Vec<(usize, u32)> = vec![];
+ assert_eq!(format_denominations(&result, TEST_DENOMS), "");
+ }
+
+ #[test]
+ fn format_skips_zero_count() {
+ let result = vec![(0, 0), (1, 3)];
+ assert_eq!(format_denominations(&result, TEST_DENOMS), "3 quarters");
+ }
+}
diff --git a/cash-register-wasm/src/processor/mod.rs b/cash-register-wasm/src/processor/mod.rs
new file mode 100644
index 00000000..e10113a0
--- /dev/null
+++ b/cash-register-wasm/src/processor/mod.rs
@@ -0,0 +1,177 @@
+pub mod formatter;
+pub mod parser;
+
+use serde::Serialize;
+
+use crate::currency;
+use crate::error::CashError;
+use crate::rules::RulesEngine;
+
+#[derive(Debug, Serialize)]
+pub struct LineResult {
+ pub line: usize,
+ pub input: String,
+ pub output: Option,
+ pub strategy: Option,
+ pub error: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ProcessResponse {
+ pub success: bool,
+ pub results: Vec,
+ pub total_lines: usize,
+ pub error_count: usize,
+}
+
+pub fn process_file(file_content: &str, rules_engine: &RulesEngine) -> Result {
+ let currency = currency::currency_for_code(rules_engine.currency_code())?;
+ let denominations = currency.denominations();
+
+ let lines: Vec<&str> = file_content
+ .lines()
+ .map(|l| l.trim())
+ .filter(|l| !l.is_empty())
+ .collect();
+
+ let total_lines = lines.len();
+ let mut error_count = 0;
+
+ let results: Vec = lines
+ .iter()
+ .enumerate()
+ .map(|(i, line)| {
+ let line_number = i + 1;
+
+ match parser::parse_line(line, line_number, currency.as_ref()) {
+ Err(e) => {
+ error_count += 1;
+ LineResult {
+ line: line_number,
+ input: line.to_string(),
+ output: None,
+ strategy: None,
+ error: Some(e.to_string()),
+ }
+ }
+ Ok(transaction) => {
+ if transaction.change.0 == 0 {
+ return LineResult {
+ line: line_number,
+ input: line.to_string(),
+ output: Some(String::new()),
+ strategy: Some("minimal".to_string()),
+ error: None,
+ };
+ }
+
+ match rules_engine.select_strategy(&transaction) {
+ Err(e) => {
+ error_count += 1;
+ LineResult {
+ line: line_number,
+ input: line.to_string(),
+ output: None,
+ strategy: None,
+ error: Some(e.to_string()),
+ }
+ }
+ Ok(strategy) => {
+ let change_result = strategy.make_change(transaction.change.0, denominations);
+ let formatted = formatter::format_denominations(&change_result, denominations);
+ LineResult {
+ line: line_number,
+ input: line.to_string(),
+ output: Some(formatted),
+ strategy: Some(strategy.name().to_string()),
+ error: None,
+ }
+ }
+ }
+ }
+ }
+ })
+ .collect();
+
+ Ok(ProcessResponse {
+ success: error_count == 0,
+ results,
+ total_lines,
+ error_count,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const DEFAULT_CONFIG: &str = r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" }
+ ]
+ }"#;
+
+ #[test]
+ fn process_single_line_minimal() {
+ let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap();
+ let response = process_file("2.12,3.00", &engine).unwrap();
+
+ assert_eq!(response.total_lines, 1);
+ assert_eq!(response.error_count, 0);
+ assert_eq!(response.results[0].output.as_deref(), Some("3 quarters,1 dime,3 pennies"));
+ assert_eq!(response.results[0].strategy.as_deref(), Some("minimal"));
+ }
+
+ #[test]
+ fn process_single_line_random_sums_correctly() {
+ let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap();
+ let response = process_file("3.33,5.00", &engine).unwrap();
+
+ assert_eq!(response.results[0].strategy.as_deref(), Some("random"));
+ let output = response.results[0].output.as_ref().unwrap();
+ assert!(!output.is_empty());
+ }
+
+ #[test]
+ fn process_multiple_lines() {
+ let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap();
+ let input = "2.12,3.00\n1.97,2.00\n3.33,5.00";
+ let response = process_file(input, &engine).unwrap();
+
+ assert_eq!(response.total_lines, 3);
+ assert_eq!(response.results[0].output.as_deref(), Some("3 quarters,1 dime,3 pennies"));
+ assert_eq!(response.results[1].output.as_deref(), Some("3 pennies"));
+ assert_eq!(response.results[2].strategy.as_deref(), Some("random"));
+ }
+
+ #[test]
+ fn process_with_bad_line_continues() {
+ let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap();
+ let input = "2.12,3.00\nbadline\n1.97,2.00";
+ let response = process_file(input, &engine).unwrap();
+
+ assert_eq!(response.total_lines, 3);
+ assert_eq!(response.error_count, 1);
+ assert!(response.results[1].error.is_some());
+ assert_eq!(response.results[2].output.as_deref(), Some("3 pennies"));
+ }
+
+ #[test]
+ fn process_skips_empty_lines() {
+ let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap();
+ let input = "2.12,3.00\n\n\n1.97,2.00";
+ let response = process_file(input, &engine).unwrap();
+
+ assert_eq!(response.total_lines, 2);
+ }
+
+ #[test]
+ fn process_exact_change() {
+ let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap();
+ let response = process_file("3.00,3.00", &engine).unwrap();
+
+ assert_eq!(response.results[0].output.as_deref(), Some(""));
+ }
+}
diff --git a/cash-register-wasm/src/processor/parser.rs b/cash-register-wasm/src/processor/parser.rs
new file mode 100644
index 00000000..406cfabb
--- /dev/null
+++ b/cash-register-wasm/src/processor/parser.rs
@@ -0,0 +1,84 @@
+use crate::currency::Currency;
+use crate::error::CashError;
+use crate::types::{Money, ParsedTransaction};
+
+pub fn parse_line(
+ line: &str,
+ line_number: usize,
+ currency: &dyn Currency,
+) -> Result {
+ let parts: Vec<&str> = line.split(',').collect();
+ if parts.len() != 2 {
+ return Err(CashError::ParseError {
+ line: line_number,
+ message: format!("Expected 'owed,paid' but got '{}'", line),
+ });
+ }
+
+ let owed = currency.parse_amount(parts[0]).map_err(|_| CashError::ParseError {
+ line: line_number,
+ message: format!("Invalid owed amount: '{}'", parts[0].trim()),
+ })?;
+
+ let paid = currency.parse_amount(parts[1]).map_err(|_| CashError::ParseError {
+ line: line_number,
+ message: format!("Invalid paid amount: '{}'", parts[1].trim()),
+ })?;
+
+ if paid.0 < owed.0 {
+ return Err(CashError::InsufficientPayment { line: line_number });
+ }
+
+ Ok(ParsedTransaction {
+ owed,
+ paid,
+ change: Money(paid.0 - owed.0),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::currency::usd::UsdCurrency;
+
+ #[test]
+ fn parse_valid_line() {
+ let currency = UsdCurrency;
+ let tx = parse_line("2.12,3.00", 1, ¤cy).unwrap();
+ assert_eq!(tx.owed, Money(212));
+ assert_eq!(tx.paid, Money(300));
+ assert_eq!(tx.change, Money(88));
+ }
+
+ #[test]
+ fn parse_line_with_whitespace() {
+ let currency = UsdCurrency;
+ let tx = parse_line(" 2.12 , 3.00 ", 1, ¤cy).unwrap();
+ assert_eq!(tx.change, Money(88));
+ }
+
+ #[test]
+ fn parse_line_missing_comma() {
+ let currency = UsdCurrency;
+ assert!(parse_line("2.12 3.00", 1, ¤cy).is_err());
+ }
+
+ #[test]
+ fn parse_line_insufficient_payment() {
+ let currency = UsdCurrency;
+ assert!(parse_line("5.00,3.00", 1, ¤cy).is_err());
+ }
+
+ #[test]
+ fn parse_line_exact_payment() {
+ let currency = UsdCurrency;
+ let tx = parse_line("3.00,3.00", 1, ¤cy).unwrap();
+ assert_eq!(tx.change, Money(0));
+ }
+
+ #[test]
+ fn parse_line_invalid_amount() {
+ let currency = UsdCurrency;
+ assert!(parse_line("abc,3.00", 1, ¤cy).is_err());
+ }
+}
diff --git a/cash-register-wasm/src/rules/mod.rs b/cash-register-wasm/src/rules/mod.rs
new file mode 100644
index 00000000..cec8d6e1
--- /dev/null
+++ b/cash-register-wasm/src/rules/mod.rs
@@ -0,0 +1,190 @@
+pub mod schema;
+
+use crate::error::CashError;
+use crate::strategy::{self, ChangeStrategy};
+use crate::types::ParsedTransaction;
+
+use schema::{Condition, RulesConfig};
+
+pub struct RulesEngine {
+ config: RulesConfig,
+}
+
+impl RulesEngine {
+ pub fn from_json(json: &str) -> Result {
+ let config: RulesConfig = serde_json::from_str(json)
+ .map_err(|e| CashError::ConfigParseError(e.to_string()))?;
+
+ strategy::strategy_for_name(&config.default_strategy)?;
+ for rule in &config.rules {
+ strategy::strategy_for_name(&rule.strategy)?;
+ }
+
+ Ok(RulesEngine { config })
+ }
+
+ pub fn currency_code(&self) -> &str {
+ &self.config.currency
+ }
+
+ pub fn select_strategy(
+ &self,
+ transaction: &ParsedTransaction,
+ ) -> Result, CashError> {
+ for rule in &self.config.rules {
+ if Self::matches_condition(&rule.condition, transaction) {
+ return strategy::strategy_for_name(&rule.strategy);
+ }
+ }
+ strategy::strategy_for_name(&self.config.default_strategy)
+ }
+
+ fn matches_condition(condition: &Condition, transaction: &ParsedTransaction) -> bool {
+ match condition {
+ Condition::DivisibleBy { divisor } => {
+ *divisor != 0 && transaction.owed.0 % divisor == 0
+ }
+ Condition::AmountRange { min_cents, max_cents } => {
+ let change = transaction.change.0;
+ let above_min = min_cents.map_or(true, |min| change >= min);
+ let below_max = max_cents.map_or(true, |max| change <= max);
+ above_min && below_max
+ }
+ Condition::Always => true,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::Money;
+
+ fn make_transaction(owed_cents: i64, paid_cents: i64) -> ParsedTransaction {
+ ParsedTransaction {
+ owed: Money(owed_cents),
+ paid: Money(paid_cents),
+ change: Money(paid_cents - owed_cents),
+ }
+ }
+
+ #[test]
+ fn default_strategy_when_no_rules_match() {
+ let engine = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": []
+ }"#).unwrap();
+
+ let tx = make_transaction(197, 200);
+ let strategy = engine.select_strategy(&tx).unwrap();
+ assert_eq!(strategy.name(), "minimal");
+ }
+
+ #[test]
+ fn divisible_by_3_selects_random() {
+ let engine = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" }
+ ]
+ }"#).unwrap();
+
+ let tx = make_transaction(333, 500);
+ let strategy = engine.select_strategy(&tx).unwrap();
+ assert_eq!(strategy.name(), "random");
+ }
+
+ #[test]
+ fn not_divisible_by_3_selects_minimal() {
+ let engine = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" }
+ ]
+ }"#).unwrap();
+
+ let tx = make_transaction(212, 300);
+ let strategy = engine.select_strategy(&tx).unwrap();
+ assert_eq!(strategy.name(), "minimal");
+ }
+
+ #[test]
+ fn custom_divisor() {
+ let engine = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "divisible_by", "divisor": 5 }, "strategy": "random" }
+ ]
+ }"#).unwrap();
+
+ let tx = make_transaction(500, 1000);
+ let strategy = engine.select_strategy(&tx).unwrap();
+ assert_eq!(strategy.name(), "random");
+ }
+
+ #[test]
+ fn amount_range_condition() {
+ let engine = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "amount_range", "min_cents": 100, "max_cents": 500 }, "strategy": "random" }
+ ]
+ }"#).unwrap();
+
+ let tx_in_range = make_transaction(100, 400);
+ assert_eq!(engine.select_strategy(&tx_in_range).unwrap().name(), "random");
+
+ let tx_out_of_range = make_transaction(100, 110);
+ assert_eq!(engine.select_strategy(&tx_out_of_range).unwrap().name(), "minimal");
+ }
+
+ #[test]
+ fn first_matching_rule_wins() {
+ let engine = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" },
+ { "condition": { "type": "always" }, "strategy": "minimal" }
+ ]
+ }"#).unwrap();
+
+ let tx = make_transaction(333, 500);
+ assert_eq!(engine.select_strategy(&tx).unwrap().name(), "random");
+ }
+
+ #[test]
+ fn invalid_strategy_in_config_fails() {
+ let result = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "bogus",
+ "rules": []
+ }"#);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn invalid_json_fails() {
+ let result = RulesEngine::from_json("not json");
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn divisor_zero_does_not_match() {
+ let engine = RulesEngine::from_json(r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "divisible_by", "divisor": 0 }, "strategy": "random" }
+ ]
+ }"#).unwrap();
+
+ let tx = make_transaction(333, 500);
+ assert_eq!(engine.select_strategy(&tx).unwrap().name(), "minimal");
+ }
+}
diff --git a/cash-register-wasm/src/rules/schema.rs b/cash-register-wasm/src/rules/schema.rs
new file mode 100644
index 00000000..34204c29
--- /dev/null
+++ b/cash-register-wasm/src/rules/schema.rs
@@ -0,0 +1,28 @@
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize)]
+pub struct RulesConfig {
+ pub currency: String,
+ pub default_strategy: String,
+ #[serde(default)]
+ pub rules: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Rule {
+ pub condition: Condition,
+ pub strategy: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum Condition {
+ DivisibleBy { divisor: i64 },
+ AmountRange {
+ #[serde(default)]
+ min_cents: Option,
+ #[serde(default)]
+ max_cents: Option,
+ },
+ Always,
+}
diff --git a/cash-register-wasm/src/strategy/minimal.rs b/cash-register-wasm/src/strategy/minimal.rs
new file mode 100644
index 00000000..566839f2
--- /dev/null
+++ b/cash-register-wasm/src/strategy/minimal.rs
@@ -0,0 +1,78 @@
+use crate::types::Denomination;
+
+use super::ChangeStrategy;
+
+pub struct MinimalChange;
+
+impl ChangeStrategy for MinimalChange {
+ fn name(&self) -> &'static str {
+ "minimal"
+ }
+
+ fn make_change(&self, change_cents: i64, denominations: &[Denomination]) -> Vec<(usize, u32)> {
+ let mut remaining = change_cents;
+ let mut result = Vec::new();
+
+ for (i, denom) in denominations.iter().enumerate() {
+ if remaining <= 0 {
+ break;
+ }
+ let count = remaining / denom.value;
+ if count > 0 {
+ result.push((i, count as u32));
+ remaining -= count * denom.value;
+ }
+ }
+
+ result
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::Denomination;
+
+ const TEST_DENOMS: &[Denomination] = &[
+ Denomination { value: 100, singular: "dollar", plural: "dollars" },
+ Denomination { value: 25, singular: "quarter", plural: "quarters" },
+ Denomination { value: 10, singular: "dime", plural: "dimes" },
+ Denomination { value: 5, singular: "nickel", plural: "nickels" },
+ Denomination { value: 1, singular: "penny", plural: "pennies" },
+ ];
+
+ #[test]
+ fn change_88_cents() {
+ let strategy = MinimalChange;
+ let result = strategy.make_change(88, TEST_DENOMS);
+ assert_eq!(result, vec![(1, 3), (2, 1), (4, 3)]);
+ }
+
+ #[test]
+ fn change_exact_dollar() {
+ let strategy = MinimalChange;
+ let result = strategy.make_change(100, TEST_DENOMS);
+ assert_eq!(result, vec![(0, 1)]);
+ }
+
+ #[test]
+ fn change_zero() {
+ let strategy = MinimalChange;
+ let result = strategy.make_change(0, TEST_DENOMS);
+ assert!(result.is_empty());
+ }
+
+ #[test]
+ fn change_one_cent() {
+ let strategy = MinimalChange;
+ let result = strategy.make_change(1, TEST_DENOMS);
+ assert_eq!(result, vec![(4, 1)]);
+ }
+
+ #[test]
+ fn change_167_cents() {
+ let strategy = MinimalChange;
+ let result = strategy.make_change(167, TEST_DENOMS);
+ assert_eq!(result, vec![(0, 1), (1, 2), (2, 1), (3, 1), (4, 2)]);
+ }
+}
diff --git a/cash-register-wasm/src/strategy/mod.rs b/cash-register-wasm/src/strategy/mod.rs
new file mode 100644
index 00000000..7de8547f
--- /dev/null
+++ b/cash-register-wasm/src/strategy/mod.rs
@@ -0,0 +1,44 @@
+pub mod minimal;
+pub mod random;
+
+use crate::error::CashError;
+use crate::types::Denomination;
+
+pub trait ChangeStrategy: Send + Sync {
+ fn name(&self) -> &'static str;
+ fn make_change(&self, change_cents: i64, denominations: &[Denomination]) -> Vec<(usize, u32)>;
+}
+
+pub fn strategy_for_name(name: &str) -> Result, CashError> {
+ match name {
+ "minimal" => Ok(Box::new(minimal::MinimalChange)),
+ "random" => Ok(Box::new(random::RandomChange)),
+ other => Err(CashError::UnknownStrategy(other.to_string())),
+ }
+}
+
+pub fn supported_strategies() -> &'static [&'static str] {
+ &["minimal", "random"]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn registry_minimal() {
+ let strategy = strategy_for_name("minimal").unwrap();
+ assert_eq!(strategy.name(), "minimal");
+ }
+
+ #[test]
+ fn registry_random() {
+ let strategy = strategy_for_name("random").unwrap();
+ assert_eq!(strategy.name(), "random");
+ }
+
+ #[test]
+ fn registry_unknown() {
+ assert!(strategy_for_name("greedy").is_err());
+ }
+}
diff --git a/cash-register-wasm/src/strategy/random.rs b/cash-register-wasm/src/strategy/random.rs
new file mode 100644
index 00000000..43894a7e
--- /dev/null
+++ b/cash-register-wasm/src/strategy/random.rs
@@ -0,0 +1,81 @@
+use rand::Rng;
+
+use crate::types::Denomination;
+
+use super::ChangeStrategy;
+
+pub struct RandomChange;
+
+impl ChangeStrategy for RandomChange {
+ fn name(&self) -> &'static str {
+ "random"
+ }
+
+ fn make_change(&self, change_cents: i64, denominations: &[Denomination]) -> Vec<(usize, u32)> {
+ let mut remaining = change_cents;
+ let mut counts = vec![0u32; denominations.len()];
+ let mut rng = rand::thread_rng();
+
+ while remaining > 0 {
+ let eligible: Vec = denominations
+ .iter()
+ .enumerate()
+ .filter(|(_, d)| d.value <= remaining)
+ .map(|(i, _)| i)
+ .collect();
+
+ if eligible.is_empty() {
+ break;
+ }
+
+ let pick = eligible[rng.gen_range(0..eligible.len())];
+ counts[pick] += 1;
+ remaining -= denominations[pick].value;
+ }
+
+ counts
+ .into_iter()
+ .enumerate()
+ .filter(|(_, count)| *count > 0)
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::types::Denomination;
+
+ const TEST_DENOMS: &[Denomination] = &[
+ Denomination { value: 100, singular: "dollar", plural: "dollars" },
+ Denomination { value: 25, singular: "quarter", plural: "quarters" },
+ Denomination { value: 10, singular: "dime", plural: "dimes" },
+ Denomination { value: 5, singular: "nickel", plural: "nickels" },
+ Denomination { value: 1, singular: "penny", plural: "pennies" },
+ ];
+
+ fn total_from_result(result: &[(usize, u32)], denoms: &[Denomination]) -> i64 {
+ result.iter().map(|(i, count)| denoms[*i].value * (*count as i64)).sum()
+ }
+
+ #[test]
+ fn random_change_sums_correctly() {
+ let strategy = RandomChange;
+ for amount in [1, 5, 10, 25, 33, 88, 100, 167, 250, 999] {
+ let result = strategy.make_change(amount, TEST_DENOMS);
+ assert_eq!(
+ total_from_result(&result, TEST_DENOMS),
+ amount,
+ "Failed for amount {}",
+ amount
+ );
+ }
+ }
+
+ #[test]
+ fn random_change_zero() {
+ let strategy = RandomChange;
+ let result = strategy.make_change(0, TEST_DENOMS);
+ assert!(result.is_empty());
+ }
+}
diff --git a/cash-register-wasm/src/types.rs b/cash-register-wasm/src/types.rs
new file mode 100644
index 00000000..75280be7
--- /dev/null
+++ b/cash-register-wasm/src/types.rs
@@ -0,0 +1,93 @@
+use serde::Serialize;
+use std::fmt;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
+pub struct Money(pub i64);
+
+impl Money {
+ pub fn from_str_decimal(s: &str) -> Option {
+ let trimmed = s.trim();
+ let parts: Vec<&str> = trimmed.split('.').collect();
+ match parts.len() {
+ 1 => {
+ let dollars = parts[0].parse::().ok()?;
+ Some(Money(dollars * 100))
+ }
+ 2 => {
+ let dollars = parts[0].parse::().ok()?;
+ let cent_str = parts[1];
+ if cent_str.len() > 2 {
+ return None;
+ }
+ let cents = if cent_str.len() == 1 {
+ cent_str.parse::().ok()? * 10
+ } else {
+ cent_str.parse::().ok()?
+ };
+ Some(Money(dollars * 100 + cents))
+ }
+ _ => None,
+ }
+ }
+
+ pub fn from_str_comma_decimal(s: &str) -> Option {
+ let replaced = s.trim().replace(',', ".");
+ Money::from_str_decimal(&replaced)
+ }
+}
+
+impl fmt::Display for Money {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}.{:02}", self.0 / 100, self.0 % 100)
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct Denomination {
+ pub value: i64,
+ pub singular: &'static str,
+ pub plural: &'static str,
+}
+
+#[derive(Debug, Clone)]
+pub struct ParsedTransaction {
+ pub owed: Money,
+ pub paid: Money,
+ pub change: Money,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_decimal_standard() {
+ assert_eq!(Money::from_str_decimal("2.13"), Some(Money(213)));
+ assert_eq!(Money::from_str_decimal("3.00"), Some(Money(300)));
+ assert_eq!(Money::from_str_decimal("0.01"), Some(Money(1)));
+ assert_eq!(Money::from_str_decimal("100"), Some(Money(10000)));
+ }
+
+ #[test]
+ fn parse_decimal_single_digit_cents() {
+ assert_eq!(Money::from_str_decimal("1.5"), Some(Money(150)));
+ }
+
+ #[test]
+ fn parse_decimal_invalid() {
+ assert_eq!(Money::from_str_decimal("abc"), None);
+ assert_eq!(Money::from_str_decimal("1.234"), None);
+ assert_eq!(Money::from_str_decimal(""), None);
+ }
+
+ #[test]
+ fn parse_decimal_whitespace() {
+ assert_eq!(Money::from_str_decimal(" 2.13 "), Some(Money(213)));
+ }
+
+ #[test]
+ fn parse_comma_decimal() {
+ assert_eq!(Money::from_str_comma_decimal("2,13"), Some(Money(213)));
+ assert_eq!(Money::from_str_comma_decimal("3,00"), Some(Money(300)));
+ }
+}
diff --git a/cash-register-wasm/tests/integration_tests.rs b/cash-register-wasm/tests/integration_tests.rs
new file mode 100644
index 00000000..0e946851
--- /dev/null
+++ b/cash-register-wasm/tests/integration_tests.rs
@@ -0,0 +1,86 @@
+use cash_register_wasm::processor;
+use cash_register_wasm::rules::RulesEngine;
+
+const USD_CONFIG: &str = r#"{
+ "currency": "USD",
+ "default_strategy": "minimal",
+ "rules": [
+ { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" }
+ ]
+}"#;
+
+const EUR_CONFIG: &str = r#"{
+ "currency": "EUR",
+ "default_strategy": "minimal",
+ "rules": []
+}"#;
+
+#[test]
+fn readme_sample_line_1() {
+ let engine = RulesEngine::from_json(USD_CONFIG).unwrap();
+ let response = processor::process_file("2.12,3.00", &engine).unwrap();
+
+ assert_eq!(response.results[0].output.as_deref(), Some("3 quarters,1 dime,3 pennies"));
+ assert_eq!(response.results[0].strategy.as_deref(), Some("minimal"));
+}
+
+#[test]
+fn readme_sample_line_2() {
+ let engine = RulesEngine::from_json(USD_CONFIG).unwrap();
+ let response = processor::process_file("1.97,2.00", &engine).unwrap();
+
+ assert_eq!(response.results[0].output.as_deref(), Some("3 pennies"));
+ assert_eq!(response.results[0].strategy.as_deref(), Some("minimal"));
+}
+
+#[test]
+fn readme_sample_line_3_random_strategy_used() {
+ let engine = RulesEngine::from_json(USD_CONFIG).unwrap();
+ let response = processor::process_file("3.33,5.00", &engine).unwrap();
+
+ assert_eq!(response.results[0].strategy.as_deref(), Some("random"));
+ assert!(!response.results[0].output.as_ref().unwrap().is_empty());
+}
+
+#[test]
+fn readme_full_sample() {
+ let engine = RulesEngine::from_json(USD_CONFIG).unwrap();
+ let input = "2.12,3.00\n1.97,2.00\n3.33,5.00";
+ let response = processor::process_file(input, &engine).unwrap();
+
+ assert_eq!(response.total_lines, 3);
+ assert!(response.results[0].error.is_none());
+ assert!(response.results[1].error.is_none());
+ assert!(response.results[2].error.is_none());
+}
+
+#[test]
+fn eur_currency_denominations() {
+ let engine = RulesEngine::from_json(EUR_CONFIG).unwrap();
+ let response = processor::process_file("1.50,2.00", &engine).unwrap();
+
+ let output = response.results[0].output.as_ref().unwrap();
+ assert_eq!(output, "1 50-cent coin");
+}
+
+#[test]
+fn eur_multiple_denominations() {
+ let engine = RulesEngine::from_json(EUR_CONFIG).unwrap();
+ let response = processor::process_file("0.27,1.00", &engine).unwrap();
+
+ let output = response.results[0].output.as_ref().unwrap();
+ assert_eq!(output, "1 50-cent coin,1 20-cent coin,1 2-cent coin,1 1-cent coin");
+}
+
+#[test]
+fn malformed_input_per_line_error() {
+ let engine = RulesEngine::from_json(USD_CONFIG).unwrap();
+ let input = "2.12,3.00\nnot_valid\n1.97,2.00";
+ let response = processor::process_file(input, &engine).unwrap();
+
+ assert_eq!(response.total_lines, 3);
+ assert_eq!(response.error_count, 1);
+ assert!(response.results[0].error.is_none());
+ assert!(response.results[1].error.is_some());
+ assert!(response.results[2].error.is_none());
+}
diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md
new file mode 100644
index 00000000..a38f2eaf
--- /dev/null
+++ b/docs/CODEBASE_MAP.md
@@ -0,0 +1,328 @@
+# System Overview
+
+CashRegister is a two-crate project: a **Rust/WebAssembly core** that computes change denominations for transaction files, and a **React + TanStack Start frontend** that wraps the WASM module behind a file-upload UI and a rules-builder form. The WASM core is built with `wasm-pack --target bundler` and imported directly into the Vite bundle via server-only code, so all processing runs inside a TanStack Start server function.
+
+```mermaid
+graph TB
+ subgraph Client["Browser (React 19 + TanStack Router)"]
+ FileUpload[FileUpload]
+ RulesBuilder[RulesBuilder]
+ ResultsTable[ResultsTable]
+ IndexPage[routes/index.tsx
state container]
+ end
+ subgraph Server["TanStack Start Server Function"]
+ ProcessFn[processTransactions
createServerFn POST]
+ WasmLoader[wasm-loader.ts]
+ Zod[Zod: ProcessRequest / ProcessResponse]
+ end
+ subgraph WASM["cash-register-wasm (Rust → WASM)"]
+ Lib[lib.rs
#wasm_bindgen API]
+ Rules[rules::RulesEngine]
+ Processor[processor::process_file]
+ Currency[Currency trait
USD · EUR]
+ Strategy[ChangeStrategy trait
minimal · random]
+ end
+
+ FileUpload --> IndexPage
+ RulesBuilder --> IndexPage
+ IndexPage -->|buildConfigJson| ProcessFn
+ ProcessFn --> Zod
+ Zod --> WasmLoader
+ WasmLoader --> Lib
+ Lib --> Rules
+ Lib --> Processor
+ Processor --> Currency
+ Processor --> Strategy
+ Processor -->|ProcessResponse JSON| WasmLoader
+ WasmLoader --> ProcessFn
+ ProcessFn -->|ProcessResult| IndexPage
+ IndexPage --> ResultsTable
+```
+
+## Directory Structure
+
+```
+CashRegister/
+├── app/ # React + TanStack Start frontend
+│ ├── src/
+│ │ ├── routes/ # File-based routing (__root, index)
+│ │ ├── components/ # UI components + __tests__
+│ │ │ ├── FileUpload.tsx # Drag-drop file reader
+│ │ │ ├── RulesBuilder.tsx # Config form (currency, strategies, rules)
+│ │ │ ├── ResultsTable.tsx # Per-line output table + summary
+│ │ │ └── ErrorBanner.tsx # Dismissible error display
+│ │ ├── lib/ # Shared Zod schemas, defaults, serializer
+│ │ ├── server/ # Server function + WASM loader (server-only)
+│ │ ├── wasm-pkg/ # wasm-pack output (consumed by server/)
+│ │ ├── styles/app.css # Hand-written dark theme (no Tailwind)
+│ │ ├── test/setup.ts # jest-dom matcher extension
+│ │ ├── router.tsx # getRouter() factory
+│ │ └── routeTree.gen.ts # Auto-generated route tree
+│ ├── vite.config.ts # wasm + topLevelAwait + tanstackStart + react
+│ ├── vitest.config.ts # jsdom, excludes wasm-pkg
+│ └── package.json # bun scripts: wasm:build, dev, build, test
+├── cash-register-wasm/ # Rust WASM crate
+│ ├── src/
+│ │ ├── lib.rs # #[wasm_bindgen] API surface
+│ │ ├── types.rs # Money (i64 cents), Denomination, ParsedTransaction
+│ │ ├── error.rs # CashError (thiserror)
+│ │ ├── currency/ # Currency trait + USD + EUR
+│ │ ├── processor/ # process_file pipeline + parser + formatter
+│ │ ├── rules/ # RulesEngine + RulesConfig schema (serde)
+│ │ └── strategy/ # ChangeStrategy trait + minimal + random
+│ ├── tests/integration_tests.rs # Black-box processor tests
+│ ├── pkg/ # wasm-pack build output
+│ └── Cargo.toml # cdylib + rlib, wasm-bindgen, rand/js, serde
+├── README.md # Product problem statement
+└── sample-input.txt # 3-line USD example
+```
+
+## Module Guide
+
+### app/routes — Routing
+
+**Purpose**: Single-route SPA shell.
+**Entry point**: [app/src/routes/__root.tsx](app/src/routes/__root.tsx)
+**Key files**:
+| File | Purpose |
+|------|---------|
+| [__root.tsx](app/src/routes/__root.tsx) | RootDocument shell (HTML + HeadContent + Scripts) and RootLayout outlet |
+| [index.tsx](app/src/routes/index.tsx) | The only page — owns all app state and orchestrates file → WASM → results flow |
+| [router.tsx](app/src/router.tsx) | `getRouter()` factory: `defaultPreload: "intent"`, scroll restoration |
+| [routeTree.gen.ts](app/src/routeTree.gen.ts) | Auto-generated by TanStack Router Vite plugin — do not edit |
+
+**Patterns**: TanStack Start with SSR enabled. Root route declares `` metadata via `head()` loader.
+**Gotchas**: `IndexPage` holds all state in local `useState` (no TanStack Query, no route loader) — data flow is explicit user-click → server fn call.
+
+---
+
+### app/components — UI Components
+
+**Purpose**: Stateless, fully-controlled presentation layer.
+**Key files**:
+| File | Purpose |
+|------|---------|
+| [FileUpload.tsx](app/src/components/FileUpload.tsx) | Drag-drop / click file input; `FileReader.readAsText()`; `onFileLoaded(content, fileName)` |
+| [RulesBuilder.tsx](app/src/components/RulesBuilder.tsx) | Controlled form for `RulesConfig` (currency select, default strategy, rules list) |
+| [ResultsTable.tsx](app/src/components/ResultsTable.tsx) | Renders `ProcessResponse.results` + summary footer; strategy badges (`badge-ok`/`badge-random`) |
+| [ErrorBanner.tsx](app/src/components/ErrorBanner.tsx) | Dismissible error message (dismiss button conditional on `onDismiss` prop) |
+| [ResultsTable.skeleton.tsx](app/src/components/ResultsTable.skeleton.tsx) | Hardcoded 3-row loading skeleton |
+| [RulesBuilder.skeleton.tsx](app/src/components/RulesBuilder.skeleton.tsx) | Skeleton (not currently rendered — RulesBuilder mounts synchronously) |
+
+**Patterns**: Fully controlled components — every edit fires `onChange` with a new config. No internal state in RulesBuilder. Skeletons mirror layout for `isProcessing` states.
+**Gotchas**:
+- `RulesBuilder` only exposes the `divisible_by` divisor input; `amount_range` has `min_cents`/`max_cents` in the schema but no UI fields yet.
+- Divisor input silently ignores `NaN` / `<= 0` (no validation error shown).
+- Condition-type switches reset condition fields to defaults.
+
+---
+
+### app/lib — Shared Schemas & Helpers
+
+**Purpose**: Zod schemas, defaults, serialization — single source of truth for client/server data contract.
+**Key files**:
+| File | Purpose |
+|------|---------|
+| [schemas.ts](app/src/lib/schemas.ts) | All Zod schemas: `ProcessRequest`, `ProcessResponse`, `Condition` (discriminated union), `Rule`, `RulesConfig` |
+| [default-config.ts](app/src/lib/default-config.ts) | `DEFAULT_RULES_CONFIG`: USD / minimal / one `divisible_by: 3 → random` rule |
+| [build-config.ts](app/src/lib/build-config.ts) | `buildConfigJson(config)` — isolated `JSON.stringify` for testability |
+
+**Patterns**: `ConditionSchema = z.discriminatedUnion("type", [...])`. Schemas are imported from both client (form state typing) and server (input validation) — single source of truth for the data contract.
+
+---
+
+### app/server — Server Functions & WASM Loader
+
+**Purpose**: Server-only code bridging TanStack Start RPC to the WASM module.
+**Key files**:
+| File | Purpose |
+|------|---------|
+| [process-file.ts](app/src/server/process-file.ts) | `processTransactions` server fn (`createServerFn({ method: "POST" })`) + `processWithWasm` pure helper |
+| [wasm-loader.ts](app/src/server/wasm-loader.ts) | `loadWasmModule()` — imports named exports from `wasm-pkg/` into a typed `WasmModule` shape |
+| [wasm-types.ts](app/src/server/wasm-types.ts) | `WasmModule` interface — tightens `any` returns from wasm-pack `.d.ts` to `string` |
+
+**Pipeline**: `ProcessRequestSchema.parse(input)` → `loadWasmModule()` → `wasm.process_transactions(...)` → `JSON.parse` → `ProcessResponseSchema.parse` → `ProcessResult = { ok: true, data } | { ok: false, error }`.
+**Patterns**: The server fn returns a discriminated envelope, not thrown errors — the client branches on `response.ok`. `processWithWasm` is extracted as a pure function so tests can drive it with a mocked `WasmModule` (no TanStack Start runtime needed).
+**Gotchas**: `wasm-loader.ts` must never run client-side. Tests mock it via `vi.mock("../wasm-loader", ...)` at the module level, and also mock `@tanstack/react-start`'s `createServerFn` chain.
+
+---
+
+### app/wasm-pkg — WASM Package (in-tree build output)
+
+**Purpose**: Output of `wasm-pack build --target bundler` run against `../cash-register-wasm`, consumed by `wasm-loader.ts`.
+**Notable**: `.d.ts` types all as `any` — the `WasmModule` interface in `wasm-types.ts` is the real typed contract. `package.json` declares `sideEffects: ["./cash_register_wasm.js", "./snippets/*"]` to preserve WASM init code under tree-shaking.
+**Build**: `bun run wasm:build` rebuilds the package. `dev` and `build` scripts chain it automatically. `vitest` excludes `**/wasm-pkg/**` from test runs.
+
+---
+
+### cash-register-wasm — Rust Core
+
+**Purpose**: Pure Rust implementation of the parsing, rules, and change-making logic, compiled to WASM and consumed by the frontend.
+
+#### lib.rs — Public API
+**Entry point**: [cash-register-wasm/src/lib.rs](cash-register-wasm/src/lib.rs)
+Four `#[wasm_bindgen]` exports:
+| Function | Returns |
+|---|---|
+| `process_transactions(file_content, rules_config_json)` | JSON string of `ProcessResponse` |
+| `validate_config(rules_config_json)` | void (throws on invalid config) |
+| `list_supported_currencies()` | JSON string array |
+| `list_supported_strategies()` | JSON string array |
+
+Errors are stringified into `JsValue` — JS receives plain strings, not structured errors. `process_transactions` returns a JSON **string** inside a `JsValue`, not a JS object; callers must `JSON.parse()`.
+
+#### types.rs — Value Types
+| Type | Purpose |
+|---|---|
+| `Money(i64)` | Newtype — stores cents as `i64`. `Display` divides by 100. Parsers: `from_str_decimal`, `from_str_comma_decimal` |
+| `Denomination { value, singular, plural }` | Coin/bill tier; value is in cents |
+| `ParsedTransaction { owed, paid, change }` | Result of parsing one `"owed,paid"` line |
+
+All arithmetic is integer. `"1.5"` normalizes to 150 cents (tenths).
+
+#### error.rs — `CashError`
+`thiserror`-derived unified error with variants: `UnknownCurrency`, `UnknownStrategy`, `ConfigParseError`, `ParseError { line, message }`, `InsufficientPayment { line }`. `Serialize` is derived but unused — errors bubble to `lib.rs` and are stringified into `JsValue`.
+
+#### currency/ — Currency Trait & Implementations
+- `trait Currency: Send + Sync` — `code()`, `denominations()`, `parse_amount()`
+- `currency_for_code(code) -> Result, CashError>` — factory
+- `supported_currencies() -> &'static [&'static str]` → `["USD", "EUR"]`
+- **USD** ([usd.rs](cash-register-wasm/src/currency/usd.rs)): 5 denominations: 100, 25, 10, 5, 1 cents; dot-decimal only. "Dollar" is the 100-cent tier — no $5/$10/$20.
+- **EUR** ([eur.rs](cash-register-wasm/src/currency/eur.rs)): 8 denominations: 200, 100, 50, 20, 10, 5, 2, 1 cents; tries comma-decimal first, falls back to dot.
+
+#### processor/ — Pipeline Orchestrator
+[processor/mod.rs](cash-register-wasm/src/processor/mod.rs) drives the full per-file pipeline. `process_file`:
+1. Resolves currency from `engine.currency_code()`.
+2. Splits input, trims, filters blanks.
+3. Per line: `parse_line` → if `change == 0` short-circuits to `""` + minimal → `engine.select_strategy(&tx)` → `strategy.make_change(change_cents, denominations)` → `format_denominations`.
+4. Line-level errors are caught per-line and recorded in `LineResult.error` (processing continues). Only currency resolution failure aborts the whole file.
+
+- [parser.rs](cash-register-wasm/src/processor/parser.rs): Splits on `,` (exactly two fields), parses each via `currency.parse_amount()`, enforces `paid >= owed`.
+- [formatter.rs](cash-register-wasm/src/processor/formatter.rs): `Vec<(index, count)>` → `"3 quarters,1 dime,3 pennies"` (comma-joined, singular/plural from `Denomination`, zero-count omitted).
+
+#### rules/ — Rules Engine
+- [schema.rs](cash-register-wasm/src/rules/schema.rs): Serde types. `Condition` uses `serde(tag = "type", rename_all = "snake_case")` → variants `divisible_by { divisor }`, `amount_range { min_cents?, max_cents? }`, `always`.
+- [mod.rs](cash-register-wasm/src/rules/mod.rs): `RulesEngine::from_json` deserializes and **eagerly validates all strategy names**. `select_strategy(&tx)` walks rules in order (first match wins), falls back to `default_strategy`.
+- **Asymmetric subjects**: `divisible_by` tests `owed` cents; `amount_range` tests `change` cents.
+
+#### strategy/ — Change Strategies
+- `trait ChangeStrategy: Send + Sync` — `name()`, `make_change(change_cents, denominations) -> Vec<(usize, u32)>`. The `usize` is the index into the currency's `denominations` slice (the coupling point with the formatter).
+- **minimal** ([minimal.rs](cash-register-wasm/src/strategy/minimal.rs)): Greedy largest-first. Deterministic.
+- **random** ([random.rs](cash-register-wasm/src/strategy/random.rs)): Random walk — each step picks uniformly from denominations that fit in the remaining amount. Uses `rand::thread_rng()`; non-deterministic.
+
+#### tests/integration_tests.rs
+Black-box tests covering the README sample lines (both minimal and random outcomes), EUR denominations, and malformed-input per-line error handling.
+
+## Data Flow
+
+### File Upload → WASM → Results
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant FileUpload
+ participant IndexPage
+ participant ProcessFn as processTransactions
(server fn)
+ participant WasmLoader
+ participant Wasm as cash_register_wasm
+
+ User->>FileUpload: Drop/select .csv or .txt
+ FileUpload->>FileUpload: FileReader.readAsText
+ FileUpload->>IndexPage: onFileLoaded(content, fileName)
+ User->>IndexPage: Click "Process File"
+ IndexPage->>IndexPage: buildConfigJson(config)
+ IndexPage->>ProcessFn: { fileContent, rulesConfigJson }
+ ProcessFn->>ProcessFn: ProcessRequestSchema.parse (Zod)
+ ProcessFn->>WasmLoader: loadWasmModule()
+ WasmLoader-->>ProcessFn: WasmModule
+ ProcessFn->>Wasm: process_transactions(fileContent, rulesConfigJson)
+ Wasm->>Wasm: RulesEngine::from_json (eager strategy validation)
+ Wasm->>Wasm: processor::process_file (per-line pipeline)
+ Wasm-->>ProcessFn: JSON string
+ ProcessFn->>ProcessFn: JSON.parse + ProcessResponseSchema.parse
+ ProcessFn-->>IndexPage: ProcessResult { ok, data | error }
+ alt ok
+ IndexPage->>User:
+ else error
+ IndexPage->>User:
+ end
+```
+
+### Rules Config Flow
+
+```
+DEFAULT_RULES_CONFIG (lib/default-config.ts)
+ → IndexPage useState
+ → (controlled, fires onChange on every edit)
+ → buildConfigJson(config) [JSON.stringify]
+ → passed as rulesConfigJson to processTransactions
+ → Zod validates ProcessRequest
+ → wasm.process_transactions(fileContent, rulesConfigJson)
+ → Rust: RulesEngine::from_json → validates strategy names eagerly
+```
+
+The frontend only validates the **envelope** (non-empty strings); the WASM module owns all semantic validation of the rules config.
+
+## Conventions
+
+- **Frontend naming**: React components use **PascalCase** filenames (`FileUpload.tsx`, `RulesBuilder.tsx`). Skeletons use `.skeleton.tsx` suffix. General helpers are kebab-case (`build-config.ts`, `default-config.ts`).
+- **Path alias**: `@/*` → `./src/*` (declared in [app/tsconfig.json](app/tsconfig.json), resolved by `vite-tsconfig-paths`). Use it instead of relative parents.
+- **Zod as the contract**: All client/server data types are inferred from Zod schemas in [app/src/lib/schemas.ts](app/src/lib/schemas.ts). Server function input is validated via `.inputValidator(data => ProcessRequestSchema.parse(data))`.
+- **Controlled forms**: `RulesBuilder` never holds its own state — it always calls `onChange` with a new config object.
+- **Error envelopes, not throws (over RPC)**: `processTransactions` returns a `ProcessResult` discriminated union; the client checks `response.ok` rather than catching.
+- **Trait + factory in Rust**: Both `Currency` and `ChangeStrategy` follow the same pattern — trait, string-keyed factory (`currency_for_code` / `strategy_for_name`), and a `supported_*()` accessor.
+
+## Gotchas
+
+1. **WASM must be built before Vite runs** — `wasm:build` is chained into `dev` and `build` in [app/package.json](app/package.json). A fresh clone with stale `wasm-pkg/` will fail.
+2. **`vite-plugin-wasm` + `vite-plugin-top-level-await` must precede `tanstackStart()`** in [app/vite.config.ts](app/vite.config.ts) or WASM imports won't be processed.
+3. **Vitest mocks WASM entirely** — [app/vitest.config.ts](app/vitest.config.ts) excludes `**/wasm-pkg/**`, and `process-file.test.ts` mocks both `@tanstack/react-start` and `../wasm-loader` at the module level.
+4. **`process_transactions` returns a JSON string, not a JS object** — the typed `WasmModule` interface in [wasm-types.ts](app/src/server/wasm-types.ts) reflects this (`string` return), then `processWithWasm` parses and Zod-validates it.
+5. **`wasm-loader.ts` is server-only** — importing it on the client would try to load the WASM binary in a browser context that's not wired for it. Keep it behind the server fn.
+6. **`getrandom` needs the `js` feature** (set in [Cargo.toml](cash-register-wasm/Cargo.toml)) or `rand::thread_rng()` panics at runtime inside WASM.
+7. **EUR line format is ambiguous** — the line parser splits on `,`, but EUR's `parse_amount` also accepts `,` as a decimal separator. A line like `"2,13,3,00"` would split into four fields and fail. EUR inputs must currently use dot-decimal at the line level.
+8. **Rule condition subjects are asymmetric** — `divisible_by` checks `owed`, `amount_range` checks `change`. Not documented in the UI.
+9. **`RulesBuilder` only exposes the `divisible_by` divisor input** — `amount_range`'s `min_cents`/`max_cents` and `always` have no UI fields yet, though the schema supports them.
+10. **TypeScript `.d.ts` from wasm-pack uses `any`** — the `WasmModule` interface in `wasm-types.ts` is the real type contract; never import wasm-pkg functions directly.
+11. **Strategy names in rules are validated eagerly** at `RulesEngine::from_json` time, even for rules that would never be reached.
+12. **`IndexPage` discards the `fileName`** from `FileUpload.onFileLoaded(content, fileName)` — currently unused.
+13. **No `else` blocks / no `let`** are repo conventions. Frontend code uses guard clauses; Rust code is unaffected.
+
+## Navigation Guide
+
+**To add a new frontend component**:
+1. Create `app/src/components/NewThing.tsx` (PascalCase).
+2. If loading state applies, create `NewThing.skeleton.tsx` next to it.
+3. Add `app/src/components/__tests__/NewThing.test.tsx`.
+
+**To add a new route**:
+1. Drop a file in [app/src/routes/](app/src/routes/). The TanStack Start plugin regenerates [routeTree.gen.ts](app/src/routeTree.gen.ts) automatically.
+2. Use route loaders for data (not `useEffect`).
+
+**To add a new server function**:
+1. Create `app/src/server/.ts` (or better: `.query.ts` / `.mutation.ts`).
+2. Use `createServerFn({ method: "POST" }).inputValidator(zod).handler(async ({ data }) => ...)`.
+3. Extract the pure logic into a helper (like `processWithWasm`) so tests don't need the TanStack Start runtime.
+
+**To add a new Zod schema**:
+1. Add it to [app/src/lib/schemas.ts](app/src/lib/schemas.ts) with an inferred TS type.
+2. Import the schema server-side for validation, the type client-side for form state.
+
+**To add a new currency**:
+1. Create `cash-register-wasm/src/currency/.rs` implementing the `Currency` trait.
+2. Wire it into [currency/mod.rs](cash-register-wasm/src/currency/mod.rs): add to `currency_for_code` match and `supported_currencies()`.
+3. Add integration tests in [tests/integration_tests.rs](cash-register-wasm/tests/integration_tests.rs).
+4. Rebuild WASM: `bun run wasm:build` in `app/`.
+
+**To add a new change strategy**:
+1. Create `cash-register-wasm/src/strategy/.rs` implementing `ChangeStrategy`.
+2. Wire into [strategy/mod.rs](cash-register-wasm/src/strategy/mod.rs): `strategy_for_name` match + `supported_strategies()`.
+3. Update the frontend's `SUPPORTED_STRATEGIES` in [routes/index.tsx](app/src/routes/index.tsx) (or fetch dynamically via `list_supported_strategies`, currently unused).
+4. Rebuild WASM.
+
+**To add a new rule condition type**:
+1. Add the variant to `Condition` in [rules/schema.rs](cash-register-wasm/src/rules/schema.rs) with the correct `serde` tag.
+2. Implement its match logic in `matches_condition` in [rules/mod.rs](cash-register-wasm/src/rules/mod.rs).
+3. Mirror it in the `ConditionSchema` discriminated union in [app/src/lib/schemas.ts](app/src/lib/schemas.ts).
+4. Add the UI in [RulesBuilder.tsx](app/src/components/RulesBuilder.tsx).
+5. Rebuild WASM.
diff --git a/sample-input.txt b/sample-input.txt
new file mode 100644
index 00000000..63a50779
--- /dev/null
+++ b/sample-input.txt
@@ -0,0 +1,3 @@
+2.12,3.00
+1.97,2.00
+3.33,5.00