From d2e682712dfbfab74148452a0063adddfeea0100 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 16 May 2026 18:03:34 +0200 Subject: [PATCH] feat(v2): lock deposit currency to machine.fiat_code (closes #26) Each machine handles exactly one currency today (operator-set on `dca_machines.fiat_code`). The deposit's currency is fully determined by the machine it's recorded against, so it shouldn't be operator- chooseable in the first place. Surfaced during 2026-05-16 E2E testing: Jordan had a "15 USD" deposit recorded against an EUR Sintra (operator typo in the freeform currency input). The balance summary is currency-blind (`SUM(amount)` over mixed currencies), so on the next cash-out the system distributed 15 EUR worth of sats on the strength of that 15 USD row. Worked out by chance; could have over-paid by ~10% if the actual EUR/USD rate had been further off. Fix: - `CreateDepositData` / `UpdateDepositData` no longer carry a `currency` field. Any client-submitted value is silently dropped at Pydantic validation, before reaching the handler. - `api_create_deposit` resolves the machine's `fiat_code` and passes it to `create_deposit(..., currency=...)` as a required keyword arg. The deposit row's `currency` column always matches the machine going forward. - UI: the freeform `` becomes a read-only `` slot on the amount field, sourced from the new `depositMachineFiatCode` computed (resolves via the selected client's machine). - `m005_lock_deposit_currency_to_machine_fiat_code` migration backfills existing rows: every `dca_deposits.currency` gets rewritten to match its joined `dca_machines.fiat_code`. Greg's stray `15 USD` row becomes `15 EUR` (the right answer at today's invariant). Multi-currency-per-machine support is explicitly out of scope here; when hardware ships that reads multiple denominations across currencies, the relevant changes are documented in issue #26's "Future" section (dca_machines.fiat_codes set, currency-aware balance summary, etc.). The current fix is "lock the input side"; that future work is "unlock it but constrained to the machine's declared set". 3 new unit tests (`tests/test_deposit_currency.py`) lock in the model-contract guarantees. Total suite 89 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- crud.py | 13 ++++++- migrations.py | 33 ++++++++++++++++ models.py | 16 +++++++- static/js/index.js | 18 +++++++-- templates/satmachineadmin/index.html | 21 ++++++++--- tests/test_deposit_currency.py | 56 ++++++++++++++++++++++++++++ views_api.py | 9 ++++- 7 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 tests/test_deposit_currency.py diff --git a/crud.py b/crud.py index 5a70fec..94c42e2 100644 --- a/crud.py +++ b/crud.py @@ -408,7 +408,16 @@ async def delete_dca_client(client_id: str) -> None: # ============================================================================= -async def create_deposit(creator_user_id: str, data: CreateDepositData) -> DcaDeposit: +async def create_deposit( + creator_user_id: str, data: CreateDepositData, *, currency: str +) -> DcaDeposit: + """Insert a deposit row. + + `currency` is passed explicitly by the caller (the API endpoint + resolves it from the target machine's `fiat_code`) rather than + coming off the request body — the operator doesn't get to choose + it (`aiolabs/satmachineadmin#26`). + """ deposit_id = urlsafe_short_hash() await db.execute( """ @@ -424,7 +433,7 @@ async def create_deposit(creator_user_id: str, data: CreateDepositData) -> DcaDe "machine_id": data.machine_id, "creator_user_id": creator_user_id, "amount": data.amount, - "currency": data.currency, + "currency": currency, "status": "pending", "notes": data.notes, "created_at": datetime.now(), diff --git a/migrations.py b/migrations.py index 0d9bc7d..508aa51 100644 --- a/migrations.py +++ b/migrations.py @@ -437,3 +437,36 @@ async def m004_introduce_dca_lp_table(db): "autoforward_enabled", ): await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}") + + +async def m005_lock_deposit_currency_to_machine_fiat_code(db): + """Rewrite every `dca_deposits.currency` row to match its joined + `dca_machines.fiat_code`. + + Today each machine handles exactly one currency (operator-set on + `dca_machines.fiat_code`); a deposit's currency is fully determined + by the machine it's recorded against. The deposit dialog was + historically a freeform text input, which let an operator typo a + currency code (e.g., a "15 USD" row landed against an EUR Sintra + during 2026-05-16 testing — that mismatch silently inflated the LP's + nominal balance because the balance summary is currency-blind). + + `aiolabs/satmachineadmin#26` locks the input side; this migration + fixes any rows already on disk. Idempotent: on a fresh install with + no mismatches it's a no-op UPDATE. + """ + await db.execute(""" + UPDATE satoshimachine.dca_deposits AS d + SET currency = ( + SELECT m.fiat_code + FROM satoshimachine.dca_machines m + WHERE m.id = d.machine_id + ) + WHERE EXISTS ( + SELECT 1 + FROM satoshimachine.dca_machines m + WHERE m.id = d.machine_id + AND m.fiat_code IS NOT NULL + AND m.fiat_code != d.currency + ) + """) diff --git a/models.py b/models.py index 431e70d..c61a515 100644 --- a/models.py +++ b/models.py @@ -165,10 +165,19 @@ class ClientBalanceSummary(BaseModel): class CreateDepositData(BaseModel): + """Operator records a fiat deposit against an LP enrolment. + + `currency` is server-set from the target machine's `fiat_code` at + write time — the API ignores any value the client submits. Each + machine currently handles exactly one currency (`dca_machines. + fiat_code`); allowing the operator to pick a different one at + deposit time would either be a typo or a future multi-currency + feature that doesn't exist yet (`aiolabs/satmachineadmin#26`). + """ + client_id: str machine_id: str amount: float - currency: str = "GTQ" notes: Optional[str] = None @validator("amount") @@ -192,8 +201,11 @@ class DcaDeposit(BaseModel): class UpdateDepositData(BaseModel): + """Operator edits on a pending deposit. `currency` removed — see + `CreateDepositData`; the currency is bound to the machine and not + editable after the row lands.""" + amount: Optional[float] = None - currency: Optional[str] = None notes: Optional[str] = None @validator("amount") diff --git a/static/js/index.js b/static/js/index.js index fb2a470..f67aa46 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -278,6 +278,15 @@ window.app = Vue.createApp({ const id = this.depositDialog.data.client_id return id ? this.clients.find(c => c.id === id) : null }, + depositMachineFiatCode() { + // Currency the deposit will land in — bound to the machine the + // selected LP is enrolled at. Resolved entirely client-side from + // already-loaded data, but the server has the final say (#26). + const c = this.selectedDepositClient + if (!c) return null + const m = this.machines.find(m => m.id === c.machine_id) + return m ? m.fiat_code : null + }, worklistBuckets() { return [ { @@ -966,7 +975,6 @@ window.app = Vue.createApp({ id: deposit.id, client_id: deposit.client_id, amount: deposit.amount, - currency: deposit.currency, notes: deposit.notes || '' } this.depositDialog.show = true @@ -978,13 +986,14 @@ window.app = Vue.createApp({ try { if (this.depositDialog.mode === 'add') { // machine_id is server-cross-checked but we send it explicitly. + // currency is server-resolved from the machine's fiat_code + // (#26); not in the request body. const client = this.clients.find(c => c.id === d.client_id) if (!client) throw new Error('client not found') const body = { client_id: d.client_id, machine_id: client.machine_id, amount: Number(d.amount), - currency: (d.currency || 'GTQ').trim(), notes: (d.notes || '').trim() || null } const {data} = await LNbits.api.request('POST', DEPOSITS_PATH, null, body) @@ -993,7 +1002,6 @@ window.app = Vue.createApp({ } else { const body = { amount: Number(d.amount), - currency: (d.currency || 'GTQ').trim(), notes: (d.notes || '').trim() || null } const {data} = await LNbits.api.request( @@ -1279,10 +1287,12 @@ window.app = Vue.createApp({ }, _emptyDepositForm() { + // currency is server-resolved from the selected client's machine + // fiat_code (see #26); not stored on the form, just displayed in + // the dialog via depositMachineFiatCode() computed. return { client_id: null, amount: null, - currency: 'GTQ', notes: '' } }, diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 5972481..c43450c 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -1136,14 +1136,23 @@ - - + :rules="[v => v > 0 || 'Must be > 0']"> + +