Skip to content

Amaury Aparicio - Cash Register solution — Rust WASM core + TanStack Start UI#105

Open
AmauryAparicio wants to merge 18 commits intoTrueFit:mainfrom
AmauryAparicio:AmauryAparicio
Open

Amaury Aparicio - Cash Register solution — Rust WASM core + TanStack Start UI#105
AmauryAparicio wants to merge 18 commits intoTrueFit:mainfrom
AmauryAparicio:AmauryAparicio

Conversation

@AmauryAparicio
Copy link
Copy Markdown

@AmauryAparicio AmauryAparicio commented Apr 11, 2026

Overview

My solution treats the problem statement not as a single-purpose script but as the seed of a real product — a cashier-facing tool that will grow new currencies, new rules, and new deployment targets. That framing drove every architectural decision below.

The application is split into two cleanly separated pieces:

  1. cash-register-wasm — a Rust crate compiled to WebAssembly. Contains all business logic: currencies, change strategies, rules engine, file parsing, output formatting. Zero knowledge of the web.
  2. app — a TanStack Start (React + Vite) application. Owns the UI, file upload, and configuration controls. Calls the Rust core through a server-side WASM module.

The Rust core is ~1,000 lines and has 59 tests (52 unit + 7 integration). The TS app has 44 tests (unit + API + component). 103 tests total.

Why Rust + WASM?

The README's "Things to Consider" section essentially asks: what happens when the business rules change? My read was that correctness of the math is the non-negotiable part, so that belongs in the strictest, most testable layer I could build. Rust gives me:

  • Integer-only money arithmeticMoney(i64) stores cents. Parsed once at the boundary ("2.13"213). No floating-point rounding ever enters the change calculation. This eliminates an entire class of financial bugs by construction.
  • Traits for extensibilityCurrency and ChangeStrategy are traits. Adding EUR is one file. Adding a new strategy (e.g. "prefer quarters") is one file. The type system enforces the contract.
  • Exhaustive pattern matching — rule condition types are a Rust enum; the compiler won't let me forget a branch when adding a new condition.

Compiling that core to WebAssembly means the same logic could run server-side in Node, client-side in the browser, or in a mobile shell — without rewriting it. For this submission I run it server-side under TanStack Start (thin client principle), but the portability is a real benefit, not a hypothetical one.

Why TanStack Start?

The problem says "flat file in, formatted output out." A CLI would solve that in ~40 lines. But the real question the prompt hints at is: how do cashiers actually use this? They need a form, a file picker, and clear results. TanStack Start gives me:

  • Server functions for the WASM call, so the browser never ships a WASM binary
  • File-based routing and SSR out of the box
  • Type-safe loaders and RPC with full type inference from Zod schemas

How the processing flow works

Here's a complete trace of what happens when a cashier uploads `2.12,3.00\n1.97,2.00\n3.33,5.00` and hits Process File:

┌─────────────────┐         ┌──────────────────┐        ┌────────────────┐
│  React UI       │  RPC    │  Server Function │  FFI   │  Rust WASM     │
│  (browser)      │ ──────► │  (Node.js)       │ ─────► │  core          │
│                 │         │                  │        │                │
│  FileUpload     │         │  processTransactions     │  process_file  │
│  RulesBuilder   │         │  - Zod validate  │        │  - parse lines │
│  ResultsTable   │         │  - load WASM     │        │  - apply rules │
│                 │ ◄────── │  - call process  │ ◄───── │  - pick strat  │
└─────────────────┘  JSON   │  - Zod parse     │  JSON  │  - make change │
                            │    response      │        │  - format str  │
                            └──────────────────┘        └────────────────┘

Step-by-step

  1. File upload (app/src/components/FileUpload.tsx)
    Drag-and-drop + click-to-browse. Uses FileReader.readAsText() to pull the file content into React state. The file never leaves the user's machine as a raw file — only the string contents are sent to the server function.

  2. Rules config (app/src/components/RulesBuilder.tsx)
    Instead of a free-form JSON editor, the user configures rules through structured controls — dropdowns for currency and strategy, a number input for the divisor, and a rule-card list with add/remove. Invalid configurations are structurally impossible, so no validation layer is needed between the UI and the WASM. This was a conscious tradeoff: less flexibility for the user, zero chance of runtime validation errors.

  3. Build the config JSON (app/src/lib/build-config.ts)
    A pure function converts the UI state to the JSON shape the Rust RulesEngine expects.

  4. Server function call (app/src/server/process-file.ts)
    TanStack Start's createServerFn handles the RPC. I extracted the actual work into a pure processWithWasm(wasm, data) function that takes the WASM module as a parameter — this is what makes the server function unit-testable with a mocked WASM module (see src/server/__tests__/process-file.test.ts). The wrapper only exists to inject the real WASM at runtime:

    .handler(async ({ data }) => {
      const wasm = loadWasmModule()
      return processWithWasm(wasm, data)  // ← pure, testable
    })
  5. WASM entry point (cash-register-wasm/src/lib.rs)
    Four #[wasm_bindgen] functions are exposed. The main one, process_transactions(file_content, rules_config_json), is the only one the server function calls. It:

    • Parses the JSON config into a RulesEngine
    • Runs each line through the processor pipeline
    • Returns a JSON-serialized ProcessResponse
  6. Rules engine (cash-register-wasm/src/rules/mod.rs)
    For each transaction, evaluates rules in order. First match wins; otherwise falls back to the default_strategy. Condition types are a tagged Rust enum (`divisible_by`, `amount_range`, `always`), so adding a new one requires a compile-time match arm update — the compiler is the reviewer.

  7. Strategy picks denominations (cash-register-wasm/src/strategy/)

    • MinimalChange — greedy largest-first. Given sorted denominations, iterate and take `change / denom.value` of each. O(n) where n = number of denominations.
    • RandomChange — on each iteration, picks a random denomination ≤ remaining change, subtracts it, repeats until zero. Because 1¢ is always eligible when anything is left, termination and correctness are guaranteed. There's no "what if it gets stuck" edge case.
  8. Formatter (cash-register-wasm/src/processor/formatter.rs)
    Turns `Vec<(denomination_index, count)>` into `"3 quarters,1 dime,3 pennies"`. Singular/plural pairs (penny/pennies) are stored on the Denomination struct itself — no runtime pluralization logic, because denomination names are a fixed closed set.

  9. Response to the UI
    The server function parses the JSON through ProcessResponseSchema (Zod). Success branches to a discriminated union { ok: true, data }, errors to { ok: false, error }. The UI uses a guard clause to render either the ResultsTable or the ErrorBanner.

  10. ResultsTable renders per-line output with strategy badges (minimal / random), input, output, and per-row error states. A single malformed line in the input file does NOT abort the batch — it's captured individually and the other lines still process.

The extensibility story — tested explicitly

The README asks three questions; each one has a concrete answer in the code:

Question Answer
What if the client needs to change the random divisor? Change one number in the config JSON (or one dropdown in the UI). No recompile.
What if they add another special case? Either add a new rule with an existing condition type (config change only), or add a new Condition variant in rules/schema.rs and one match arm in rules/mod.rs. The compiler forces you to handle it everywhere.
What if a new client is in France? Switch currency to EUR in the dropdown. The EUR implementation (cash-register-wasm/src/currency/eur.rs) supports comma-decimal parsing (`"2,13"`) and uses Euro coin denominations. Adding GBP or JPY is ~40 lines.

All three cases are covered by integration tests in cash-register-wasm/tests/integration_tests.rs.

Repository structure

cash-register-wasm/              # Rust crate → WASM
  src/
    lib.rs                       # wasm-bindgen public API
    types.rs                     # Money(i64), Denomination
    error.rs                     # CashError (thiserror)
    currency/                    # Currency trait + USD + EUR
    strategy/                    # ChangeStrategy trait + minimal + random
    rules/                       # JSON config → strategy selection
    processor/                   # Pipeline: parse → rules → format
  tests/integration_tests.rs     # Full-pipeline tests

app/                             # TanStack Start
  src/
    routes/
      __root.tsx                 # Root layout
      index.tsx                  # Split-panel main page
    components/
      FileUpload.tsx
      RulesBuilder.tsx           # Structured config controls
      ResultsTable.tsx           # Per-line output + strategy badges
      ErrorBanner.tsx
      __tests__/                 # Component tests (vitest + RTL)
    server/
      wasm-loader.ts             # Singleton loader
      wasm-types.ts              # WasmModule type (no WASM import)
      process-file.ts            # Server function + pure processWithWasm()
      __tests__/                 # API tests with mocked WASM
    lib/
      schemas.ts                 # Zod schemas (req/resp/config)
      build-config.ts            # UI state → JSON
      default-config.ts
      __tests__/
    styles/app.css               # Dark theme, split-panel layout

sample-input.txt                 # README sample data

Testing strategy

Layer Tool Count What it covers
Rust unit cargo test 52 Money parsing, denominations, strategies, rules engine, parser, formatter
Rust integration cargo test 7 Full pipeline against README sample (USD) + EUR denominations + per-line errors
TS unit (schemas) vitest 13 Zod validation for every schema
TS unit (utils) vitest 3 buildConfigJson
TS API vitest + mocks 6 processWithWasm with a mocked WASM module — tests success, throw, invalid JSON, schema validation failure, error responses
TS component vitest + RTL + jsdom 22 FileUpload, ErrorBanner, ResultsTable, RulesBuilder interactions
Total 103

Two design choices that made testing clean:

  • processWithWasm(wasm, data) — extracted from the server function handler. Takes the WASM module as a parameter, so tests pass a mock. The createServerFn wrapper is just glue.
  • wasm-types.ts — a 6-line file exporting the WasmModule type. Tests import the type from here without pulling in the actual WASM module (which vitest can't load without the wasm plugin). This cleanly separates the type boundary from the runtime boundary.

How to run it

# Prerequisites: Rust toolchain, wasm-pack, bun

# 1. Install JS deps
cd app && bun install

# 2. Run all Rust tests
bun run wasm:test           # 59 tests

# 3. Run all TS tests
bunx vitest run             # 44 tests

# 4. Start the dev server
bun run dev                 # builds WASM + starts Vite on :3456

Then open http://localhost:3456, upload sample-input.txt (at the repo root), and hit Process File.

Design decisions worth calling out

Integer cents, not floats

Every monetary value in the system is i64 cents. Floats never touch the change calculation. This is non-negotiable in financial code — `0.1 + 0.2 !== 0.3` is the kind of bug you find in production three months after launch. Parsing happens exactly once, at the boundary where strings enter the system.

Per-line error capture vs fail-fast

A cashier feeding a 100-line batch file should not lose all 100 results because line 47 has a typo. The Rust process_file captures parse errors per line and continues processing. The response includes both the successful results and the errors, and the UI shows them inline.

Structured UI controls, not a JSON editor

Originally I had a JSON textarea for the rules config. I realized that required a validation round-trip every time the user typed. Switching to constrained controls (dropdowns, number inputs) means invalid states are impossible to construct — no validation step is needed between the UI and the WASM. Less flexible for power users, but simpler, faster, and eliminates an entire error path.

Hybrid Rust + JSON for rules

I debated pure Rust traits vs pure JSON config for the rules system. Pure Rust is maximally type-safe but requires a recompile to change the divisor. Pure JSON is fully dynamic but loses type guarantees. I went with a hybrid: currencies and strategies are traits (compile-time type safety), rule composition is JSON (runtime configurable). A non-developer admin could edit the rules in production without touching code; adding a new strategy is still a Rust PR with full review.

Thin client — WASM runs server-side

WebAssembly can run in the browser, but I deliberately keep it server-side (called from a TanStack Start server function). Two reasons: (1) the browser doesn't download a WASM bundle, keeping the initial page fast; (2) if we ever need to keep the change logic proprietary (e.g. ship it behind an API), nothing architectural needs to change.

One Denomination struct, explicit plurals

`penny` → `pennies`, not `penny` → `penny + s`. Denomination names are a closed, finite set. Hardcoding both forms is simpler, correct for every case, and makes adding a new currency a pure data change with no logic involved.

Commit history

The branch has ~20 small, focused commits (one per logical task), following conventional commits. I kept each commit reviewable in isolation so a reviewer can walk the branch in order and see the architecture come together layer by layer.

Known issue

The WASM server-side runtime loading has a lingering issue I discovered late: with `wasm-pack --target bundler` + `vite-plugin-wasm`, the module resolves correctly at build time but the server-side execution path throws when the server function is actually invoked from the UI. The core and all tests pass (since tests mock the WASM module), but the end-to-end click-through doesn't yet succeed. The fix is either switching to `--target nodejs` with a custom CommonJS-to-ESM shim in the loader, or using `wasm?init` + manual `WebAssembly.instantiate` with `fs.readFile`. I opted to ship the submission with the issue documented rather than rush a half-baked fix — I'd rather discuss the tradeoff in the interview than paper over it.

UI

image image

Thanks for reading this far. Happy to walk through any part of it in detail.

Adds 44 tests across 7 files:
- schemas: Zod validation (13 tests)
- build-config: JSON serialization (3 tests)
- process-file: server function with mocked WASM (6 tests)
- FileUpload, ErrorBanner, ResultsTable, RulesBuilder components (22 tests)

Also extracts WasmModule type to wasm-types.ts for clean mocking,
and adds htmlFor to RulesBuilder labels for accessibility.
Document the two-crate architecture: Rust/WASM core and React +
TanStack Start frontend. Covers module boundaries, the file upload
→ server fn → WASM pipeline, rules/strategy/currency extension
points, and key gotchas (WASM build ordering, server-only loader,
asymmetric rule subjects).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant