Skip to content

Commit 1cdc27b

Browse files
committed
feat(web): return a mixed pile without a face — default "Mixed" denom on redeposits
A coin-roll return is a lump of face going back to the bank; the reconciliation math nets returns globally and never reads their denomination (only buys use denom, for box throughput). The engine's own invariant tests already feed it denomless returns — but the Return-to-bank workflow was forcing a single denom, over-constraining the UI relative to the domain model. So a mixed pile (dollars + halves + dimes + quarters) had no faithful entry, which is exactly what you can't always keep track of. - Return-to-bank defaults denom to "" ("Mixed / whole deposit"); the specific denoms stay selectable for when you do know. - The Edit grid's denom column gains a first-class "Mixed" option so a denomless return round-trips cleanly instead of binding to an out-of-range blank select. - Add a store+calc round-trip test proving a denomless return persists as "" and nets against the float (to_redeposit). - e2e: assert the return is denomless and that its grid select renders "Mixed". No schema, API, or calc change — all three already treat a return as denomless.
1 parent fa9e3bd commit 1cdc27b

4 files changed

Lines changed: 92 additions & 3 deletions

File tree

internal/store/data_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package store_test
22

33
import (
4+
"math"
45
"testing"
56

7+
"github.com/tompscanlan/coinrollhunter/internal/calc"
68
"github.com/tompscanlan/coinrollhunter/internal/model"
79
"github.com/tompscanlan/coinrollhunter/internal/store"
810
)
@@ -79,3 +81,63 @@ func TestResolveDatasetReadPathColumnAlignment(t *testing.T) {
7981
t.Errorf("Metal = %q, want gold", lot.Metal)
8082
}
8183
}
84+
85+
// TestDenomlessReturnRoundTrips pins the "return a sum without a face" path — a
86+
// mixed-pile redeposit. A 'return' roll-txn may carry an empty denom: it persists
87+
// and reads back as "", and the float reconciliation nets it globally, so
88+
// to_redeposit drops by the full face regardless of denom. This is the single-pool
89+
// model (ADR-001/005): a return is cash going back to the bank; denomination is a
90+
// buy-only attribute (box throughput). Guards the mixed-return feature end to end
91+
// through the store + calc, not just the UI.
92+
func TestDenomlessReturnRoundTrips(t *testing.T) {
93+
s, err := store.Open(":memory:")
94+
if err != nil {
95+
t.Fatal(err)
96+
}
97+
t.Cleanup(func() { s.Close() })
98+
99+
if _, err := s.InsertRollTxn(model.RollTxn{
100+
Date: "2026-02-01", Bank: "Stock Yards", Action: "buy",
101+
Denom: "halves", Unit: "box", Amount: 1, FaceUSD: 500,
102+
}); err != nil {
103+
t.Fatal(err)
104+
}
105+
// The mixed redeposit: a lump sum back to the bank, no denom tracked (Denom "").
106+
if _, err := s.InsertRollTxn(model.RollTxn{
107+
Date: "2026-02-03", Bank: "Stock Yards", Action: "return",
108+
Unit: "face", Amount: 480, FaceUSD: 480,
109+
}); err != nil {
110+
t.Fatal(err)
111+
}
112+
113+
d, err := s.ResolveDataset()
114+
if err != nil {
115+
t.Fatal(err)
116+
}
117+
118+
var ret *model.RollTxn
119+
for i := range d.RollTxns {
120+
if d.RollTxns[i].Action == "return" {
121+
ret = &d.RollTxns[i]
122+
}
123+
}
124+
if ret == nil {
125+
t.Fatal("return roll-txn was not read back from the store")
126+
}
127+
if ret.Denom != "" {
128+
t.Errorf("return denom = %q, want \"\" (a denomless mixed return must persist blank)", ret.Denom)
129+
}
130+
if ret.FaceUSD != 480 {
131+
t.Errorf("return face = %v, want 480", ret.FaceUSD)
132+
}
133+
134+
// Single-pool reconciliation: bought 500, returned 480, nothing kept ⇒ $20 float.
135+
// The denomless return must net against the float exactly like a denom'd one.
136+
r := calc.Compute(d)
137+
if got := r.ToRedeposit; math.Abs(got-20) > 1e-9 {
138+
t.Errorf("to_redeposit = %v, want 20 (denomless return must net against the float)", got)
139+
}
140+
if math.Abs(r.Returns-480) > 1e-9 {
141+
t.Errorf("returns = %v, want 480", r.Returns)
142+
}
143+
}

qa/do-tab.e2e.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ try {
110110
await page.getByText('Recorded', { exact: false }).first().waitFor({ timeout: 5000 })
111111
const returns4 = (await api('/roll-txns')).filter((r) => r.action === 'return')
112112
ok('return recorded', returns4.length === 1 && returns4[0].face_usd === 400, `@ ${returns4[0]?.face_usd}`)
113+
// A redeposit is a lump of face going back — the denom defaults to "Mixed" ("") so
114+
// a mixed pile records as one sum (single-pool float; the math never reads it).
115+
ok('return is denomless (mixed pile — single-pool float)', returns4[0]?.denom === '', `denom ${JSON.stringify(returns4[0]?.denom)}`)
113116
await page.getByRole('button', { name: 'Done' }).click()
114117

115118
// === 5. Reconcile / close out === (record a forgotten keeper, then book the rest)
@@ -236,6 +239,21 @@ try {
236239
ok('source-type inert on return rows (om-kn0f)',
237240
returnSourceCells.length >= 1 && returnSourceCells.every((t) => t === '—'),
238241
JSON.stringify(returnSourceCells))
242+
243+
// A denomless (mixed) return must bind to the "Mixed" option (value '') and
244+
// render it — not an out-of-range blank select. denom is the 2nd select in a
245+
// row (action, denom, unit); source(5) is an inert span on return rows.
246+
const returnDenomSelects = await page.$$eval('section table tbody tr', (rows) =>
247+
rows
248+
.filter((r) => r.querySelector('select')?.value === 'return')
249+
.map((r) => {
250+
const sel = r.querySelectorAll('select')[1]
251+
return { value: sel?.value, label: sel?.selectedOptions[0]?.textContent?.trim() }
252+
}),
253+
)
254+
ok('mixed return denom binds to "Mixed" (value "") in Edit grid',
255+
returnDenomSelects.length >= 1 && returnDenomSelects.every((c) => c.value === '' && c.label === 'Mixed'),
256+
JSON.stringify(returnDenomSelects))
239257
} catch (e) {
240258
ok('UNCAUGHT', false, e.message)
241259
await page.screenshot({ path: `${SHOT}/do-error.png`, fullPage: true }).catch(() => {})

web/app/src/lib/components/workflows/ReturnToBank.svelte

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@
2626
// form state
2727
let bank = $state('')
2828
let date = $state(today())
29-
let denom = $state<string>('halves')
29+
// A redeposit is a lump of face going back; the denom is optional. Default to
30+
// "" ("Mixed") so a mixed pile (dollars + halves + dimes + quarters) records as
31+
// one sum — the reconciliation math nets returns globally and never reads their
32+
// denom (ADR-001/005 single-pool float). Pick a specific denom only if you know it.
33+
let denom = $state<string>('')
3034
let amount = $state(0)
3135
let notes = $state('')
3236
let banks = $state<string[]>([])
@@ -193,11 +197,12 @@
193197
</label>
194198

195199
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
196-
Denomination
200+
Denomination (optional)
197201
<select
198202
bind:value={denom}
199203
class="rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:border-ring focus:outline-none"
200204
>
205+
<option value="">Mixed / whole deposit</option>
201206
{#each DENOMS as d (d)}<option value={d}>{d}</option>{/each}
202207
</select>
203208
</label>

web/app/src/lib/grids.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,11 @@ export const rollTxnsGrid: GridConfig<RollTxn> = {
300300
{ accessorKey: 'date', header: 'Date', meta: { editor: 'date', width: '150px' } },
301301
{ accessorKey: 'bank', header: 'Bank', meta: { editor: 'autocomplete', placeholder: 'Stock Yards', suggestions: () => banks } },
302302
{ accessorKey: 'action', header: 'Action', meta: { editor: 'select', options: ['buy', 'return'], width: '100px' } },
303-
{ accessorKey: 'denom', header: 'Denom', meta: { editor: 'select', options: DENOMS, width: '110px' } },
303+
// A 'return' is a lump of face going back to the bank; its denom is optional
304+
// (a mixed deposit — the float nets returns globally, never per-denom). '' binds
305+
// to the "Mixed" option and renders it, so a mixed return round-trips cleanly
306+
// instead of showing an out-of-range blank select.
307+
{ accessorKey: 'denom', header: 'Denom', meta: { editor: 'select', optionsFn: () => [{ value: '', label: 'Mixed' }, ...DENOMS], width: '110px' } },
304308
{ accessorKey: 'unit', header: 'Unit', meta: { editor: 'select', options: ROLL_UNITS, width: '90px' } },
305309
// Buy-only attribute: a 'return' is just face going back to the bank, so the
306310
// cell renders inert ("—") on return rows (om-kn0f).

0 commit comments

Comments
 (0)