Amaury Aparicio - Cash Register solution — Rust WASM core + TanStack Start UI#105
Open
AmauryAparicio wants to merge 18 commits intoTrueFit:mainfrom
Open
Amaury Aparicio - Cash Register solution — Rust WASM core + TanStack Start UI#105AmauryAparicio wants to merge 18 commits intoTrueFit:mainfrom
AmauryAparicio wants to merge 18 commits intoTrueFit:mainfrom
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.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:
Money(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.CurrencyandChangeStrategyare traits. Adding EUR is one file. Adding a new strategy (e.g. "prefer quarters") is one file. The type system enforces the contract.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:
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:
Step-by-step
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.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.
Build the config JSON (
app/src/lib/build-config.ts)A pure function converts the UI state to the JSON shape the Rust
RulesEngineexpects.Server function call (
app/src/server/process-file.ts)TanStack Start's
createServerFnhandles the RPC. I extracted the actual work into a pureprocessWithWasm(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 (seesrc/server/__tests__/process-file.test.ts). The wrapper only exists to inject the real WASM at runtime: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:RulesEngineProcessResponseRules 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-timematcharm update — the compiler is the reviewer.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.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 theDenominationstruct itself — no runtime pluralization logic, because denomination names are a fixed closed set.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 theResultsTableor theErrorBanner.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:
Conditionvariant inrules/schema.rsand onematcharm inrules/mod.rs. The compiler forces you to handle it everywhere.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
Testing strategy
buildConfigJsonprocessWithWasmwith a mocked WASM module — tests success, throw, invalid JSON, schema validation failure, error responsesTwo 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. ThecreateServerFnwrapper is just glue.wasm-types.ts— a 6-line file exporting theWasmModuletype. 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
Then open
http://localhost:3456, uploadsample-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
i64cents. 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_filecaptures 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
Denominationstruct, 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
Thanks for reading this far. Happy to walk through any part of it in detail.