Lock deposit currency to machine.fiat_code (and prepare balance-summary for the multi-currency future) #26

Closed
opened 2026-05-16 14:58:26 +00:00 by padreug · 1 comment
Owner

Symptom

Surfaced during a 2026-05-16 end-to-end test against the local Sintra
(fiat_code=EUR). Jordan had been recorded with two deposits at the
same EUR machine:

deposit 1: 50.00 EUR    (correct)
deposit 2: 15.00 USD    (operator put the wrong currency in the form)

After 50 EUR of DCA payouts cleared his nominal balance, the next
40 EUR cash-out distributed 15 EUR worth of sats to him on the strength
of that 15 USD deposit — the balance summary is currency-blind:

# crud.get_client_balance_summary, approximately:
total_deposits = sum(d.amount for d in confirmed_deposits)   # ignores .currency
total_payments = sum(p.amount_fiat for p in completed_legs)  # ignores currency
remaining_balance = total_deposits - total_payments

SUM(amount) mixes 50 + 15 → 65 "units" regardless that one is EUR and
one is USD. With a real EUR/USD rate of ~0.92, Jordan's actual
USD-equivalent claim was ~€13.8; the system paid him €15 worth of sats.
Today's product reality (single currency per machine) shields us from
this in practice — but only if the operator entered the right currency
in the first place.

Root cause

dca_deposits.currency is a free-form TEXT column the operator
edits via the "Record deposit" dialog. Today's UI gives them an
unconstrained text input. The machine the deposit is recorded against
already has a fiat_code; the deposit's currency is fully
determined
by the machine and shouldn't be operator-choosable.

Fix (now): lock deposit currency to the machine's fiat_code

Current invariant: a dca_clients row is (machine, LP); an
LP-at-this-machine's enrolments are scoped to that machine's currency.
So:

  • Backend. In api_create_deposit (and api_update_deposit),
    override data.currency with the resolved machine's fiat_code
    rather than trusting the request body. The body field becomes
    optional / ignored.
  • UI. The deposit dialog's currency field becomes a read-only
    display of the machine's fiat_code for the selected LP/client.
    Operator can see it but can't change it. Saves a class of typo.
  • Data cleanup. Add a one-shot migration (m006 or similar) that
    rewrites every dca_deposits.currency row to match
    dca_machines.fiat_code joined on the deposit's machine_id. With
    the current dev data, that converts Jordan's 15 USD row to 15 EUR — which is the right answer at today's invariant.
  • Audit. Log a warning at upsert time if the operator submitted
    a non-matching currency (in case a programmatic client tries to
    bypass the UI lock).

Future (when machines handle more than one currency)

Some hardware reads multiple denominations across currencies (a USD
note next to a EUR note in different cassettes; multi-currency cash
recyclers). When that happens:

  • dca_machines gains either:
    • a fiat_codes: TEXT[] (set of supported currencies), or
    • a dca_machine_currencies join table.
  • dca_deposits.currency becomes operator-choosable again, but
    constrained to the machine's supported set (dropdown of the
    declared currencies, not free text).
  • dca_clients may need a currency dimension too — an LP enrolled at
    a multi-currency machine could have separate balances per currency,
    or a single combined balance with the operator selecting which
    pile to credit per deposit.
  • get_client_balance_summary becomes currency-aware: returns a
    per-currency breakdown, distribution math runs once per currency.
  • dca_lp.dca_wallet_id either stays single-wallet (LP accumulates
    in one wallet regardless of source currency, like today) or gains
    a per-currency mapping (LP wants USD deposits → USD-pegged LN
    service, EUR deposits → another).

Open product questions for that phase; not blocking the near-term fix.

Acceptance criteria (for the near-term fix only)

  • api_create_deposit ignores data.currency and writes the
    machine's fiat_code on the row.
  • api_update_deposit rejects attempts to change currency (or
    silently overwrites with the machine's fiat_code, equivalent
    effect).
  • Deposit dialog UI: currency field becomes a read-only label
    derived from the selected client's machine. No text input.
  • m006 migration backfills every dca_deposits.currency to match
    its machine's fiat_code.
  • Existing unit tests still pass; add one covering the override
    behaviour on api_create_deposit.

Out of scope

  • Multi-currency-per-machine machinery (deferred until product needs it).
  • Currency-aware get_client_balance_summary (only needed once a
    machine actually supports >1 currency).
  • Cross-currency forex/rate handling on LP balances.

References

  • Surfaced: 2026-05-16 E2E test with Jordan 15 USD deposit + Nancy 10
    EUR deposit at an EUR Sintra; settlement 7cyB2EmLdj4BUBLNkJQtBA.
  • Code:
    • views_api.api_create_deposit (~/api/v1/dca/deposits)
    • views_api.api_update_deposit
    • crud.create_deposit / update_deposit (SQL writes)
    • crud.get_client_balance_summary (currency-blind summation)
    • static/js/index.js deposit dialog data + _cleanDepositCreate
    • templates/satmachineadmin/index.html deposit dialog UI
## Symptom Surfaced during a 2026-05-16 end-to-end test against the local Sintra (`fiat_code=EUR`). Jordan had been recorded with two deposits at the same EUR machine: ``` deposit 1: 50.00 EUR (correct) deposit 2: 15.00 USD (operator put the wrong currency in the form) ``` After 50 EUR of DCA payouts cleared his nominal balance, the next 40 EUR cash-out distributed 15 EUR worth of sats to him on the strength of that 15 USD deposit — the balance summary is currency-blind: ```python # crud.get_client_balance_summary, approximately: total_deposits = sum(d.amount for d in confirmed_deposits) # ignores .currency total_payments = sum(p.amount_fiat for p in completed_legs) # ignores currency remaining_balance = total_deposits - total_payments ``` `SUM(amount)` mixes 50 + 15 → 65 "units" regardless that one is EUR and one is USD. With a real EUR/USD rate of ~0.92, Jordan's *actual* USD-equivalent claim was ~€13.8; the system paid him €15 worth of sats. Today's product reality (single currency per machine) shields us from this in practice — but only if the operator entered the right currency in the first place. ## Root cause `dca_deposits.currency` is a free-form `TEXT` column the operator edits via the "Record deposit" dialog. Today's UI gives them an unconstrained text input. The machine the deposit is recorded against already has a `fiat_code`; the deposit's currency is **fully determined** by the machine and shouldn't be operator-choosable. ## Fix (now): lock deposit currency to the machine's fiat_code Current invariant: a `dca_clients` row is `(machine, LP)`; an LP-at-this-machine's enrolments are scoped to that machine's currency. So: - **Backend.** In `api_create_deposit` (and `api_update_deposit`), override `data.currency` with the resolved machine's `fiat_code` rather than trusting the request body. The body field becomes optional / ignored. - **UI.** The deposit dialog's currency field becomes a read-only display of the machine's `fiat_code` for the selected LP/client. Operator can see it but can't change it. Saves a class of typo. - **Data cleanup.** Add a one-shot migration (m006 or similar) that rewrites every `dca_deposits.currency` row to match `dca_machines.fiat_code` joined on the deposit's `machine_id`. With the current dev data, that converts Jordan's `15 USD` row to `15 EUR` — which is the right answer at today's invariant. - **Audit.** Log a warning at upsert time if the operator submitted a non-matching currency (in case a programmatic client tries to bypass the UI lock). ## Future (when machines handle more than one currency) Some hardware reads multiple denominations across currencies (a USD note next to a EUR note in different cassettes; multi-currency cash recyclers). When that happens: - `dca_machines` gains either: - a `fiat_codes: TEXT[]` (set of supported currencies), or - a `dca_machine_currencies` join table. - `dca_deposits.currency` becomes operator-choosable again, but constrained to the machine's supported set (dropdown of the declared currencies, not free text). - `dca_clients` may need a currency dimension too — an LP enrolled at a multi-currency machine could have separate balances per currency, or a single combined balance with the operator selecting which pile to credit per deposit. - `get_client_balance_summary` becomes currency-aware: returns a per-currency breakdown, distribution math runs once per currency. - `dca_lp.dca_wallet_id` either stays single-wallet (LP accumulates in one wallet regardless of source currency, like today) or gains a per-currency mapping (LP wants USD deposits → USD-pegged LN service, EUR deposits → another). Open product questions for that phase; not blocking the near-term fix. ## Acceptance criteria (for the near-term fix only) - [ ] `api_create_deposit` ignores `data.currency` and writes the machine's `fiat_code` on the row. - [ ] `api_update_deposit` rejects attempts to change `currency` (or silently overwrites with the machine's `fiat_code`, equivalent effect). - [ ] Deposit dialog UI: currency field becomes a read-only label derived from the selected client's machine. No text input. - [ ] m006 migration backfills every `dca_deposits.currency` to match its machine's `fiat_code`. - [ ] Existing unit tests still pass; add one covering the override behaviour on `api_create_deposit`. ## Out of scope - Multi-currency-per-machine machinery (deferred until product needs it). - Currency-aware `get_client_balance_summary` (only needed once a machine actually supports >1 currency). - Cross-currency forex/rate handling on LP balances. ## References - Surfaced: 2026-05-16 E2E test with Jordan 15 USD deposit + Nancy 10 EUR deposit at an EUR Sintra; settlement `7cyB2EmLdj4BUBLNkJQtBA`. - Code: - `views_api.api_create_deposit` (~`/api/v1/dca/deposits`) - `views_api.api_update_deposit` - `crud.create_deposit` / `update_deposit` (SQL writes) - `crud.get_client_balance_summary` (currency-blind summation) - `static/js/index.js` deposit dialog data + `_cleanDepositCreate` - `templates/satmachineadmin/index.html` deposit dialog UI
Author
Owner

Closing — shipped at commit d2e6827 feat(v2): lock deposit currency to machine.fiat_code (closes #26). Forgejo didn't auto-close from the commit message; closing manually.

Backend override + UI lock + backfill migration all landed per the acceptance criteria. The multi-currency future scope stays open as a "when product needs it" item, not blocking.

Closing — shipped at commit `d2e6827 feat(v2): lock deposit currency to machine.fiat_code (closes #26)`. Forgejo didn't auto-close from the commit message; closing manually. Backend override + UI lock + backfill migration all landed per the acceptance criteria. The multi-currency future scope stays open as a "when product needs it" item, not blocking.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/satmachineadmin#26
No description provided.