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 `<q-input label="Currency">` becomes a read-only
`<q-chip>` 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) <noreply@anthropic.com>
This commit is contained in:
parent
da25d2e1f8
commit
d2e682712d
7 changed files with 151 additions and 15 deletions
13
crud.py
13
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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
""")
|
||||
|
|
|
|||
16
models.py
16
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")
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1136,14 +1136,23 @@
|
|||
</q-banner>
|
||||
|
||||
<q-input v-model.number="depositDialog.data.amount"
|
||||
label="Amount (fiat)"
|
||||
:label="depositMachineFiatCode ? `Amount (${depositMachineFiatCode})` : 'Amount (fiat)'"
|
||||
type="number" step="0.01" min="0"
|
||||
class="q-mb-md" dense outlined
|
||||
:rules="[v => v > 0 || 'Must be > 0']"></q-input>
|
||||
|
||||
<q-input v-model="depositDialog.data.currency"
|
||||
label="Currency"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
:rules="[v => v > 0 || 'Must be > 0']">
|
||||
<template v-slot:append>
|
||||
<q-chip v-if="depositMachineFiatCode"
|
||||
dense color="grey-3" text-color="grey-9"
|
||||
:style="{fontWeight: 500}"
|
||||
v-text="depositMachineFiatCode">
|
||||
<q-tooltip>
|
||||
Currency is set by the selected LP's machine
|
||||
(<code v-text="depositMachineFiatCode"></code>) — not
|
||||
operator-editable. See aiolabs/satmachineadmin#26.
|
||||
</q-tooltip>
|
||||
</q-chip>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input v-model="depositDialog.data.notes"
|
||||
label="Notes (optional)"
|
||||
|
|
|
|||
56
tests/test_deposit_currency.py
Normal file
56
tests/test_deposit_currency.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""
|
||||
Locks in the contract from `aiolabs/satmachineadmin#26`: a deposit's
|
||||
currency is bound to its machine's `fiat_code`, never operator-chooseable.
|
||||
|
||||
The mechanism is two-layered:
|
||||
1. `CreateDepositData` / `UpdateDepositData` Pydantic models don't
|
||||
accept a `currency` field — any value a client submits is dropped
|
||||
at validation, before reaching the handler.
|
||||
2. The `api_create_deposit` endpoint resolves the machine's
|
||||
`fiat_code` server-side and passes it to `create_deposit(
|
||||
..., currency=...)`.
|
||||
|
||||
This test covers layer 1 (the model contract). Layer 2 is an
|
||||
endpoint-level behaviour better covered by an integration test against
|
||||
a running LNbits; tracked in #26 as a follow-up.
|
||||
"""
|
||||
|
||||
from ..models import CreateDepositData, UpdateDepositData
|
||||
|
||||
|
||||
def test_create_deposit_data_has_no_currency_field():
|
||||
"""A client posting `{currency: "USD"}` against an EUR machine must
|
||||
have that field silently dropped by validation — there's no public
|
||||
way to inject the wrong currency through this endpoint."""
|
||||
fields = CreateDepositData.__fields__
|
||||
assert "currency" not in fields, (
|
||||
f"CreateDepositData must not expose a `currency` field "
|
||||
f"(found {list(fields)})"
|
||||
)
|
||||
|
||||
|
||||
def test_update_deposit_data_has_no_currency_field():
|
||||
"""Same protection on the edit path: a pending deposit can have
|
||||
its amount / notes edited, but never its currency — that's bound
|
||||
to the machine."""
|
||||
fields = UpdateDepositData.__fields__
|
||||
assert "currency" not in fields, (
|
||||
f"UpdateDepositData must not expose a `currency` field "
|
||||
f"(found {list(fields)})"
|
||||
)
|
||||
|
||||
|
||||
def test_create_deposit_data_drops_unknown_currency_silently():
|
||||
"""Pydantic's default `Config` ignores unknown fields, so a stray
|
||||
`currency` on the request body parses cleanly without leaving
|
||||
a trace on the resulting model. Belt-and-braces — locks in the
|
||||
"input has no way to influence the currency" guarantee."""
|
||||
data = CreateDepositData(
|
||||
client_id="c1",
|
||||
machine_id="m1",
|
||||
amount=20.0,
|
||||
currency="USD", # ignored — field doesn't exist on the model
|
||||
)
|
||||
assert not hasattr(data, "currency")
|
||||
assert data.amount == 20.0
|
||||
assert data.machine_id == "m1"
|
||||
|
|
@ -324,7 +324,14 @@ async def api_create_deposit(
|
|||
"satmachineclient and select a DCA wallet before deposits "
|
||||
"can be recorded against them.",
|
||||
)
|
||||
return await create_deposit(user.id, data)
|
||||
# Currency is bound to the machine, not operator-choosable. Resolve
|
||||
# it server-side so an operator with a UI bug / curl mistake / older
|
||||
# client can't poison the LP balance with the wrong unit
|
||||
# (aiolabs/satmachineadmin#26).
|
||||
machine = await get_machine(data.machine_id)
|
||||
if machine is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
||||
return await create_deposit(user.id, data, currency=machine.fiat_code)
|
||||
|
||||
|
||||
@satmachineadmin_api_router.get("/api/v1/dca/deposits", response_model=list[DcaDeposit])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue