Commit graph

244 commits

Author SHA1 Message Date
9414a18f82 feat(v2): reject settlements that fail nostr attribution cross-check (S5 G5)
When LNbits' nostr-transport stamps `nostr_sender_pubkey` and
`nostr_event_id` onto Payment.extra (post aiolabs/lnbits PR #4), the
listener now cross-checks the signer against the resolved machine's
`machine_npub` before any distribution. Mismatch / absence / unparseable
pubkey → settlement is recorded with `status='rejected'` and the
reason in `error_message`, distribution is skipped.

Wire shape:

  bitspire.SettlementAttributionError + assert_nostr_attribution()
    Raises on absence, mismatch, or unparseable pubkey on either side.
    Normalises both `machine.machine_npub` (operator UI accepts hex
    or `npub1...`) and the stamped sender through
    `lnbits.utils.nostr.normalize_public_key` so the comparison is
    canonical-hex on both sides.

  tasks._handle_payment
    parse_settlement -> stamp nostr_event_id onto bitspire_event_id ->
    try assert_nostr_attribution: on failure, insert row with
    initial_status='rejected' + error_message, return without
    spawning process_settlement.

  crud.create_settlement_idempotent
    Now takes `initial_status` (required) and `error_message`.
    Normal path passes 'pending'; rejected path passes 'rejected'
    with the reason. Single-statement insert — no two-step pending->
    errored dance.

  crud.get_stuck_settlements_for_operator
    New `rejected` bucket alongside `errored` / `stuck_pending` /
    `stuck_processing`. Distinct because retry is wrong for these:
    the row was misrouted, not operationally failed.

  models.DcaSettlement.status enum extended with 'rejected'.
    Worklist response model carries the new bucket; API + UI plumbed
    end-to-end.

  static/js/index.js + templates/satmachineadmin/index.html
    New 'rejected' worklist bucket (deep-orange, gpp_bad icon).
    Force-reset button now scoped to stuck_pending / stuck_processing
    only — was 'not errored' which would have shown on rejected too.

10 unit tests in tests/test_nostr_attribution.py cover hex<->hex,
hex<->bech32, case-insensitivity, every absent variant, mismatch,
and unparseable on either side. All pass.

Closes the consumer-side of aiolabs/satmachineadmin#19 (G5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:39:30 +02:00
47916bdddd fix(v2): m002 — rename dca_commission_splits.wallet_id → target
The collapsed m001 introduced commit (2886dd7) renamed wallet_id → target
on dca_commission_splits, but a real-world install caught a subtle
LNbits-side wrinkle: the sqlite file persists across extension
uninstall+reinstall. LNbits' uninstall wipes the dbversions tracker
(so m001 re-runs), but NOT the satoshimachine.sqlite3 file. With
`CREATE TABLE IF NOT EXISTS` in m001, the pre-existing
dca_commission_splits table (created by an earlier partial m005 with
the old `wallet_id` column) survived unchanged. m001 marked itself
complete, then runtime queries blew up because the model expected
`target` but the DB still had `wallet_id`:

  ERROR | distribution.process_settlement:389
  unexpected: 1 validation error for CommissionSplit
  target
    field required (type=value_error.missing)

m002 fixes it idempotently:
  - Probes for the wallet_id column via SELECT
  - If it exists (stale install): ALTER TABLE … RENAME COLUMN
  - If the SELECT errors (fresh install or already renamed): no-op

ALTER TABLE … RENAME COLUMN is portable across SQLite 3.25+ and
PostgreSQL. Both backends preserve row data on rename.

Refs: aiolabs/satmachineadmin#9, found while validating cash-in flow
end-to-end (LNURL-withdraw redemption on the regtest stack).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:55:57 +02:00
a86f8dc25d fix(v2): refuse /retry when any leg already completed (double-pay guard)
Caught while answering the user's question about retry behaviour.

The /retry endpoint previously voided FAILED legs and flipped the
settlement back to 'pending', which then re-ran process_settlement.
But process_settlement re-creates every leg from scratch (super_fee +
operator_split + dca legs); it doesn't dedupe against already-completed
ones. So if a previous distribution attempt completed some legs and
failed others (status='errored' with mixed leg outcomes), hitting
/retry would re-pay every successful leg — actually double-paying real
sats.

Fix: refuse /retry with 400 when count_completed_legs_for_settlement > 0.
The error message tells the operator their options:
  - Edit the commission_splits ruleset to remove already-paid targets
    before retrying
  - Or pay the missing legs out-of-band

For the all-failed case (no completed legs), /retry continues to work
as before — all-or-nothing retry is safe.

This mirrors the existing partial-dispense guard
(distribution.apply_partial_dispense_and_redistribute) which refuses
when any leg has completed for the same reason (Lightning sats can't
be clawed back).

Splitpayments doesn't have this concern because each split is a
separate one-off payment with no retry semantics — they just log and
move on. Our model has an explicit retry but needs the symmetric
double-pay guard.

Future enhancement (post-v1): make process_settlement leg-aware so
it skips already-completed (settlement_id, leg_type, target) tuples
on re-run. Would let /retry handle partial-success cases too. Tracked
informally as an open thread; not on the omnibus issue yet.

76/76 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:38:32 +02:00
5de9cd5205 feat(v2): commission split target accepts wallet id, invoice key, LN address, or LNURL
Closes the operator-facing limitation that commission split legs could
only target the operator's own LNbits wallets. Adopting the splitpayments
pattern — a single `target` string accepts:
  - LNbits wallet id (UUID-shaped) — direct internal pay
  - LNbits wallet invoice key — resolved via get_wallet_for_key, then
    internal pay (lets the operator split to any LNbits user who shares
    their invoice key)
  - Lightning address (user@domain) — resolved via LNURL-pay
  - LNURL string (LNURL1...) — resolved via LNURL-pay

Schema (m001 update — fresh-install only; no operator data in production):
  dca_commission_splits.wallet_id → target

Backend (distribution.py):
  - New _pay_split_leg helper: routes the leg by target type. External
    targets (@ or LNURL prefix) go through get_pr_from_lnurl + pay_invoice;
    internal targets go through create_invoice + pay_invoice (the original
    path), with get_wallet_for_key as the first resolution step so
    invoice keys work as well as wallet ids.
  - _pay_operator_splits delegates per-leg payment to the new helper.
  - dca_payments rows still record the leg as leg_type='operator_split';
    external targets land destination_ln_address (the human-readable
    target), internal targets land destination_wallet_id.
  - Errors are caught and surfaced via the existing failed-leg path
    so /retry can re-run them.

Frontend (commission tab):
  - Each leg gets a per-row q-btn-toggle: "My wallet" vs "Lightning
    address / LNURL / invoice key". Wallet mode shows the q-select of
    the operator's own wallets (previous behaviour); external mode
    shows a free-text q-input.
  - On load, targetKind is inferred from whether the stored target
    matches one of the operator's wallet ids (renders as 'wallet')
    or not (renders as 'external'). The kind is UI-only, not persisted.
  - Leg row laid out in a bordered card so the toggle + 3-column layout
    don't crowd at narrow widths.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:37:33 +02:00
8968c0ae07 fix(v2)(ui): finish expanding self-closing q-* tags (rules-attribute corner case)
The earlier mass rewrite in cb19ba3 used a regex with `[^>]*?` to match
attribute spans, which stops at the first `>` it encounters. That broke
on tags with `:rules="[v => ...]"` where the JS arrow function's `>`
character truncated the match short of the actual `/>`. 8 tags survived
the rewrite (mostly form fields in dialog bodies). The Add-machine
dialog was the most visible victim — Vue's compiler tried to make
sense of the partially-malformed q-input and dumped the machine_npub
field plus the next two siblings side-by-side instead of stacked.

Fix: replaced the regex pass with a small stateful scanner that walks
the file tag-by-tag and is quote-aware (treats `>` inside paired
double or single quotes as literal characters, not tag terminators).
The scanner found and expanded the remaining 8 tags + verified zero
self-closing q-* tags remain anywhere in the template. 343 q-* opens
total in the file.

Caught while clicking through the Add-machine dialog in the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:25:03 +02:00
32484e3ce8 fix(v2): reorder /settlements/stuck before /settlements/{id} (route literal vs path-param collision)
FastAPI matches routes in declaration order. The literal /settlements/stuck
was being shadowed by /settlements/{settlement_id} declared earlier, so
GET /settlements/stuck was matching settlement_id="stuck" and 404'ing
with "Settlement not found". Caught while clicking through the v2 UI
post-reinstall: the Worklist tab couldn't load.

Fix: declare the literal sub-route first. Also added a NOTE comment
above the section so a future re-shuffle re-checks the order before
landing.

Verified routes register in correct order (line numbers in views_api.py):
  /settlements         (404)
  /settlements/stuck   (433)  ← literal
  /settlements/{id}    (463)  ← path-param
  /settlements/{id}/partial-dispense (478)
  /settlements/{id}/force-reset      (513)
  /settlements/{id}/retry            (565)
  /settlements/{id}/notes            (600)

76/76 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:22:10 +02:00
2886dd7394 chore(v2): collapse m001-m007 into single m001_satmachine_v2_initial
User confirmed no production servers are affected, so squashing the
staged migrations into a single source-of-truth migration is safe.

Reductions:
  m001-m004: legacy Lamassu schema (single-config + SSH-tunnel poller)
  m005:      v2 initial schema (had a SQLite CREATE-INDEX syntax bug)
  m006:      notes column
  m007:      processing_claim column + dca_machines.wallet_id UNIQUE
  ─────────  → m001_satmachine_v2_initial (single function)

What this commit changes:
  - Replaces seven migration functions with one. Diff -180 lines net
    (477 → 297). The collapsed migration carries the corrected SQLite
    syntax (no schema prefix on CREATE INDEX tables) and is idempotent
    end-to-end (CREATE TABLE/INDEX IF NOT EXISTS, seed check-then-insert).
  - All design choices the staged migrations earned are preserved in
    the inline comments: payment_hash idempotency key, absolute
    platform_fee_sats/operator_fee_sats, wallet_id UNIQUE defence-in-
    depth against IDOR, processing_claim optimistic-lock, notes
    append-only audit memo.
  - Pre-collapse history available in git on commits before this one.

What this commit does NOT change: schema. The final v2 tables / columns
/ indexes are identical to what m005+m006+m007 produced.

Upgrade path: anyone on the v2-bitspire branch with a partial-run
tracker (5/6/7) needs to uninstall + reinstall the extension to wipe
the dbversions tracker, then m001 runs fresh. Anyone on the legacy v1
main branch (tracker=4) does the same — uninstall + reinstall.

Refs: aiolabs/satmachineadmin#11 — closes the migration-collapse
follow-up that was deferred from fix bundle 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:15:28 +02:00
cb19ba3675 fix(v2): m005-m007 idempotency + SQLite CREATE INDEX syntax; template self-closing tags
Three live-test bugs caught while wiring the v2 frontend to a real
LNbits regtest instance.

1. **SQLite parse error on CREATE INDEX with schema-prefixed table.**
   `CREATE INDEX foo ON satoshimachine.bar (col)` errors with
   "near '.': syntax error" on SQLite. PG accepts the prefix on the
   table; SQLite expects the schema prefix on the INDEX NAME only,
   not on the table. Cleanest portable fix (libra extension pattern):
   drop `satoshimachine.` from the table reference inside CREATE INDEX.
   The index lands in the same schema as the table regardless.

2. **m005 non-idempotent after partial failure.** The previous bug
   above tripped m005 mid-flight (CREATE TABLE super_config + CREATE
   TABLE dca_machines succeeded, then the first CREATE INDEX errored
   and aborted). LNbits doesn't mark partial migrations done, so the
   next boot re-ran m005 — and CREATE TABLE super_config now errored
   with "table already exists". To make recovery clean:
   - CREATE TABLE IF NOT EXISTS on every table (13 tables)
   - CREATE INDEX IF NOT EXISTS on every index (10 indexes)
   - super_config seed INSERT wrapped in check-then-insert so the
     PK conflict on 'default' on re-run is avoided

3. **Vue compiler error code 30 — self-closing tags on non-void
   elements in templates/satmachineadmin/index.html.** The previous
   commit `98f82be` on satmachineclient called this out as a known
   LNbits UMD gotcha: Vue 3 UMD's compiler doesn't auto-expand `<q-icon />`
   the way SFCs do — the browser HTML parser sees the malformed self-
   closing tag and aborts compilation. 118 tags expanded from
   `<q-foo … />` to `<q-foo …></q-foo>` via mechanical rewrite.

Verified end-to-end against docker regtest-lnbits-1:
  - All three migrations (m005, m006, m007) ran cleanly
  - Schema has all 8 v2 tables + 10 indexes
  - "satmachineadmin v2 loaded" + invoice listener registered
  - /satmachineadmin/ returns 200; JS loads; super-config + machines
    endpoints respond

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:51 +02:00
b96837164e chore(v2): dead-code purge (fix bundle 3)
~1300 lines removed across four cleanups. Pure deletions; no behavioural
changes.

1. **transaction_processor.py — DELETED (1274 lines).** Orphaned v1 file
   that hasn't been imported anywhere since fix-bundle-1 wired the v2
   distribution chain. The historical Lamassu logic is preserved in git
   history at any commit on main.

2. **views_api.v2_in_progress_stub — DELETED.** The catch-all that
   returned 503 for any unmatched /api/v1/dca/* path. With P3a–P9g
   shipped, every documented endpoint is implemented; the catch-all was
   stale and (per issue #11 M7) unauthenticated, so it leaked the
   extension's existence to anonymous probes. Removed entirely.

3. **tasks.hourly_transaction_polling — DELETED.** v1 LegacyLamassu
   polling no-op. The associated `create_permanent_unique_task` spawn
   in __init__.py is also gone (was spawning a forever-sleeping task
   for no reason).

4. **__init__.py scaffolding artifacts.**
   - Replaced the placeholder "you can debug in your extension using
     'import logger from loguru'" template log with a meaningful
     "satmachineadmin v2 loaded" INFO line.
   - Dropped the now-stale `hourly_transaction_polling` import + spawn.
   - Sorted __all__ (RUF022).

Migration collapse (m001..m007 → single m001_v2_initial) was on the
fix-bundle-3 list but is deferred to a separate PR. The current
migrations are harmless on fresh installs (idempotent CREATE/DROP
chain) and collapsing them risks breaking the LNbits version tracker
on the off chance any operator has v1 data; better to do that as a
dedicated migration-discipline change once we're confident no v1
operator data exists in the wild.

Routes: 34 → 33 (catch-all gone). 76/76 tests pass.

Refs: aiolabs/satmachineadmin#11 — fix bundle 3  (modulo migration
collapse). Remaining in #11: M1-M12 + N1-N12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:00:43 +02:00
00b8253dd3 fix(v2): partial-dispense preserves original split ratio (H6)
Closes H6 from #11. The partial-dispense recompute path was reading the
CURRENT super_fee_pct via get_super_config() to re-derive the platform/
operator split. That breaks the "absolute fields are the source of
truth" invariant the v2 schema was built around: if super raises (or
lowers) the global rate between landing and partial-dispense, the
operator's share would retroactively shift — without any notice to the
operator and contrary to the original transaction's contract.

Fix: re-derive new_platform from the *original* platform_fee_sats /
commission_sats ratio stored on the settlement row, not from the
current super_config. The contract was locked at landing; rate changes
after the fact must not retroactively touch this transaction.

Before:
    super_config = await get_super_config()
    super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
    new_platform, new_operator = split_two_stage_commission(
        new_commission, super_fee_pct
    )

After:
    ratio = (settlement.platform_fee_sats / settlement.commission_sats
             if settlement.commission_sats > 0 else 0.0)
    new_platform = round(new_commission * ratio)
    new_platform = max(0, min(new_platform, new_commission))
    new_operator = new_commission - new_platform

Note: split_two_stage_commission at LANDING time (in bitspire.py) still
uses the current super_fee_pct — that's correct, the rate at landing is
the locked rate. Only the *recompute* path was wrong.

Tests:
  TestPartialDispenseSplitRatio.test_plan_scenario_30pct_lands_then_partial:
    100-sat commission @ 30% → partial to 50% → 15/35 (preserves ratio).
  test_super_changed_rate_doesnt_affect_existing_settlement:
    Super raises rate to 50% after a 30% landing; partial-dispense to
    50% must keep the ORIGINAL ~30% platform share, not the new 50%.
  test_zero_original_commission_yields_zero_platform: edge case.
  test_invariant_sum_equals_new_commission: parametrised sum invariant.

Also dropped the now-unused split_two_stage_commission import from
distribution.py (still used in bitspire.py at landing time and by the
test suite, just not in this file anymore).

54 / 54 tests pass.

Refs: aiolabs/satmachineadmin#11 — H6 
Remaining in #11: fix bundle 3 (dead-code purge), M and N items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:58:15 +02:00
ecef916dda fix(v2): decouple listener + skipped-leg audit (fix bundle 2)
Closes H4, H5, M8 from the v2-bitspire review (omnibus follow-up #11).

H4 — Decouple invoice listener from distribution.
  tasks._handle_payment now spawns process_settlement on a background
  task instead of awaiting it. The LNbits invoice queue is shared
  across every extension on the node; under load (a machine with 50
  LPs, a stalled internal payment, etc.) the previous synchronous path
  could freeze the queue for everyone. Concurrency is safe because
  fix bundle 1's claim_settlement_for_processing already prevents
  double-processing on listener re-fires.

  RUF006 fix: hold strong refs to in-flight tasks via a module-level
  set so the GC doesn't collect them mid-flight (asyncio.create_task
  only weakly references its task). Tasks self-clean via
  add_done_callback(set.discard).

H5 + M8 — Skipped-leg audit rows for stranded sats.
  Previously, four paths in distribution.py logged a warning and left
  sats in the machine wallet, marking the settlement 'processed' with
  no row-level visibility into where the un-paid sats sit:
    1. _pay_super_fee: super_fee_pct > 0 but super_fee_wallet_id unset
    2. _pay_operator_splits: no commission ruleset (default + override)
    3. _pay_dca_distributions: exchange_rate = 0 (fallback path)
    4. _pay_dca_distributions: no eligible LPs with positive balance
  Plus a fifth case the review didn't enumerate but is the same shape:
    5. _pay_dca_distributions: no flow-mode LPs at the machine at all

  Each now writes a dca_payments row with status='skipped', the
  intended leg_type (super_fee / operator_split / dca), the stranded
  amount in amount_sats, and a human-readable error_message explaining
  why. New _record_skipped_leg helper consolidates the pattern.

  This makes stranded sats visible in:
    - The machine detail dialog's settlements rows (the legs are
      filtered into the audit blob alongside completed/failed legs)
    - The payments CSV export
    - GET /api/v1/dca/payments?leg_type=...

  'skipped' is a documented leg-status value now (alongside pending /
  completed / failed / voided / refunded) — no schema change since
  status is TEXT.

Knock-on fix: void_open_legs_for_settlement (used by partial-dispense
recompute) now also includes status='skipped' in its WHERE clause so a
re-run doesn't double-count the audit rows from a prior attempt.

72/72 tests still pass. Lint clean.

Refs: aiolabs/satmachineadmin#11 — fix bundle 2 
Remaining in #11: H6 (partial-dispense split ratio) + fix bundle 3
(dead-code purge) + the M and N items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:49:16 +02:00
f4eb7ec928 feat(v2): super-fee edit + Worklist + Reports (P9f+g, completes P9)
Combines the final three P9 pieces into a single commit since each is
small and they share the JS state plumbing.

Super-fee edit (P9f — visible only to super_user):
  - "Edit" affordance on the platform-fee banner, gated on
    g.user.super_user (LNbits passes this through windowMixin)
  - Modal: super_fee_pct (decimal 0..1) + super_fee_wallet_id (text)
  - PUT /api/v1/dca/super-config (check_super_user on the backend)
  - Operators see the same banner read-only — no edit button rendered

Worklist tab (P9g part 1):
  - Reuses GET /api/v1/dca/settlements/stuck?threshold_minutes=N
  - Three labeled buckets: errored / stuck_pending / stuck_processing,
    each with row count chip
  - Per-row actions: open machine detail (reuses viewMachine), retry
    (for errored), force-reset (for stuck — confirmation dialog warns
    only-use-if-truly-stuck)
  - Threshold input (default 30 min) + manual refresh button
  - "All clear" green banner when worklist is empty
  - Auto-loads on `created()` so the badge count is accurate from boot

Reports tab (P9g part 2):
  - Four CSV download cards: machines / clients / deposits / payments
  - Clients CSV merges in the per-LP balance summary from clientBalances
    so the export captures total_deposits/payments/remaining + currency
  - Payments CSV lazy-loads from GET /api/v1/dca/payments since payments
    aren't cached in dashboard state (could be many rows)
  - _downloadCsv helper properly quotes/escapes values with embedded
    commas/quotes/newlines per RFC 4180
  - All exports are client-side; no new endpoint required

P9 is now complete (P9a–P9g). The v1 super-only Lamassu dashboard is
fully replaced. Operators can register machines, manage LPs + deposits,
configure commission splits, work through errored settlements, and
export their data — all against the v2 backend.

Total v2 frontend: ~1326 lines JS + ~1349 lines template, replacing
the v1's 773 + 851. Increase is from the much larger v2 surface
(machines, leg-typed payments, commission editor, worklist, settle-
balance, partial-dispense, notes, force-reset, retry).

Refs: aiolabs/satmachineadmin#9 — completes P9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:09:07 +02:00
5c8e629752 feat(v2): Commission splits editor (P9e)
Operator configures how the post-platform-fee commission remainder is
sliced across their wallets. Default ruleset applies fleet-wide; optional
per-machine overrides take precedence for that machine only.

Template (Commission tab content):
  - Scope selector: "Default ruleset" or one option per operator machine
    (override). Switching reloads the legs from the API.
  - Live sum indicator (green ✓ if 100%, red ✗ otherwise). Save button
    is disabled until the sum is valid.
  - Editable row per leg: wallet select + label input + pct input.
    Each row shows the % equivalent inline (e.g. 0.30 → 30.0%).
  - Add-leg button appends an empty row.
  - Preview banner: shows how an example 1000-sat operator commission
    would split across the current legs, mirroring the server-side
    last-leg-absorbs-rounding rule (calculations.allocate_operator_split_legs).
  - "Remove override" button on per-machine scopes: deletes the override
    so the default applies again (default legs untouched).
  - Empty-state banner explains the consequence of no rules: operator
    commission stays in the machine wallet.

JS:
  - commissionScope state: null = default, else machine_id
  - commissionScopeOptions computed: default + one per machine
  - commissionLegs[] mirror the server's CommissionSplitLeg shape
  - commissionSum / commissionSumValid: client-side invariant check
    matching the SetCommissionSplitsData validator (within 0.0001)
  - commissionPreview: pure JS port of allocate_operator_split_legs,
    so the visualization matches what the server actually does
  - saveCommissionSplits sends machine_id=null for default, else the
    machine id; legs sort_order set from array index
  - confirmDeleteCommissionOverride calls DELETE with ?machine_id=X to
    clear just the override (no body)
  - loadCommissionSplits called on created() so the tab is ready when
    the operator clicks it

Routes wired:
  GET    /api/v1/dca/commission-splits
  GET    /api/v1/dca/commission-splits?machine_id=X
  PUT    /api/v1/dca/commission-splits
  DELETE /api/v1/dca/commission-splits?machine_id=X

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:07:08 +02:00
ce4d7e4dd6 feat(v2): Deposits tab — record/confirm/reject workflow (P9d)
Operator records fiat handed in by LPs and drives the pending → confirmed
status transition that promotes deposits into LP balances.

Template (Deposits tab content + dialogs):
  - Filter strip: status dropdown (all/pending/confirmed/rejected) and
    LP dropdown (filtered by all the operator's LPs across machines)
  - Table columns: status badge, LP (+ machine subtitle), amount, created
    at, confirmed at, notes, action menu
  - Action menu (only enabled on pending status — confirmed/rejected are
    immutable for audit):
      • Confirm — flips to status='confirmed' + refreshes LP balance
      • Reject  — opens reject dialog for optional reason notes
      • Edit    — amount/currency/notes change
      • Delete
  - Empty-state banners: orange if no LPs (deposits are LP-scoped), blue
    if LPs exist but no deposits yet, grey if filters return nothing
  - Record-deposit dialog: LP select (auto-derives machine), amount,
    currency, notes
  - Edit-deposit dialog: amount/currency/notes; LP+machine immutable
  - Reject-deposit dialog: optional reason text persisted with the status

JS:
  - loadDeposits, depositStatusColor, clientUsernameById helpers
  - depositClientOptions computed: includes machine name in each option
    label so operators see exactly where the deposit will land
  - filteredDeposits computed: client-side filter on the loaded list
    (no server-side filter param — operator's deposit volume small enough)
  - submitDeposit handles both create and update paths; the create body
    explicitly includes machine_id (auto-derived from the selected LP)
    so the server can cross-check (client_id, machine_id) alignment
  - confirmDepositStatus refreshes the LP balance after confirming,
    since the confirmed deposit now affects remaining_balance display

Routes wired:
  GET    /api/v1/dca/deposits
  POST   /api/v1/dca/deposits
  PUT    /api/v1/dca/deposits/{id}
  PUT    /api/v1/dca/deposits/{id}/status
  DELETE /api/v1/dca/deposits/{id}

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:05:25 +02:00
0800a1acb0 feat(v2): Clients tab — LP management + settle balance modal (P9c)
Adds operator-scoped LP (liquidity provider) management. Operators
register LPs against specific machines, monitor remaining balances, and
settle small remainders via the P3e endpoint.

Template (Clients tab content + dialogs):
  - Table with columns: machine, LP (username/short user_id), wallet,
    DCA mode badge, remaining balance (color-coded: green if positive,
    grey if zero), autoforward icon (with tooltip showing LN address),
    status badge, action menu
  - Empty-state banners: orange if no machines yet (LPs are
    machine-scoped), blue if machines exist but no LPs registered
  - Register-LP dialog: machine select + user_id + wallet_id + display
    name + DCA mode (flow / fixed) + fixed-mode daily limit (conditional)
    + autoforward toggle + autoforward LN address (conditional)
  - Edit-LP dialog: same minus immutable user_id/wallet_id, plus status
    select (active/paused/closed)
  - Settle-balance dialog (closes #4): funding wallet select + exchange
    rate (operator-supplied) + optional amount_fiat (blank = full
    remaining) + notes textarea. Shows the LP's current remaining
    balance prominently before submission.

JS:
  - loadClients pulls all operator's LPs across their fleet
  - Per-LP balance summaries pre-loaded (one GET per LP — N+1, captured
    in review issue #11 M3 for follow-up with a single grouped JOIN)
  - openAddClientDialog / openEditClientDialog with separate cleaner
    helpers (_cleanClientCreate vs _cleanClientUpdate) since the v2
    API immutable-field rules differ between create and update
  - openSettleBalanceDialog refreshes balance immediately before
    showing the modal so the operator sees the up-to-date number
  - confirmDeleteClient + DELETE wired
  - machineNameById helper for displaying which machine an LP is at
  - machineOptions computed for the register-LP machine select
  - machinesById computed cache (avoids O(N*M) lookups in render loop)

Routes wired:
  GET    /api/v1/dca/clients
  GET    /api/v1/dca/clients/{id}/balance
  POST   /api/v1/dca/clients
  PUT    /api/v1/dca/clients/{id}
  DELETE /api/v1/dca/clients/{id}
  POST   /api/v1/dca/clients/{id}/settle

Refs: aiolabs/satmachineadmin#9 — closes the Clients-tab gap in #4 +
exposes the autoforward setting (#8) in operator UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:03:29 +02:00
13ac33047b feat(v2): machine detail dialog — settlements + per-row actions (P9b)
Adds the operator's primary workspace: a full-screen dialog opened from
any Fleet row that shows the machine's settlement history with action
menus for retry / partial-dispense / force-reset / note-add.

Template (templates/satmachineadmin/index.html):
  - Full-screen Quasar dialog with q-bar header (machine name + fiat
    chip + reload + close)
  - Machine metadata strip: npub (copyable), wallet_id, location,
    fallback_commission_pct
  - Settlements table: status badge, time, gross / net / commission
    (with super/op breakdown beneath), fiat amount, payment_hash short
  - Notes blob expansion under each settlement row (pre-formatted)
  - Per-row action menu (q-btn-dropdown):
      • Add note         — always available
      • Retry            — when status='errored'
      • Partial dispense — when status in {pending, errored}
      • Force-reset      — when status in {pending, processing}
  - Warning icon (⚠) on rows where used_fallback_split=true, namechecking
    aiolabs/lamassu-next#44 in the tooltip
  - Three sub-dialogs:
      • Partial-dispense with fraction/sats toggle + notes input
      • Add-note dialog (free-form, non-empty validation)
      • (Retry/force-reset use Quasar.Dialog inline)

JS (static/js/index.js):
  - viewMachine() opens detail and triggers reloadMachineDetail()
  - GET /api/v1/dca/machines/{id}/settlements feeds the table
  - confirmRetrySettlement → POST .../retry
  - openPartialDispense → POST .../partial-dispense
  - confirmForceReset    → POST .../force-reset
  - openSettlementNote   → POST .../notes
  - _replaceSettlement() updates the table row in-place from PUT/POST
    responses so the operator sees instant feedback without a reload
  - settlementStatusColor() maps statuses to Quasar badge colors
  - formatSats / formatFiat / formatTime helpers; respect locale

Also: added data/ + *.sqlite3 to .gitignore so the
2026-05-14 auth-key leak can't recur from this repo (the equivalent
fix already landed in satmachineclient on the matching branch).

Refs: aiolabs/satmachineadmin#9 — closes the operator-detail-view
gap for #3 (partial dispense) + #4 (settlement) UX

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:01:08 +02:00
21d159d709 feat(v2): tabbed dashboard skeleton + Fleet tab (P9a)
Replaces the v1 single-page super-only dashboard with the v2 operator-
scoped tabbed shell. This is the entry point for the v2 frontend — gets
the operator into a working state where they can register their first
machine and see the v2 endpoints behind a usable UI.

Template — templates/satmachineadmin/index.html (full rewrite):
  - Tab strip: Fleet | Clients | Deposits | Commission | Worklist | Reports
  - Header bar with operator-focused title + refresh button
  - Platform-fee banner reads super-config (visible to all operators)
  - Fleet tab: machines table + add/edit/delete row actions
  - Worklist tab gets a count badge (red) when stuck/errored settlements
    exist; populated by GET /settlements/stuck on load
  - Other tabs land placeholder banners pointing at their P9b–P9g task
  - Add-machine + edit-machine dialogs with full form, including
    fallback_commission_pct note that namechecks lamassu-next#44

JS — static/js/index.js (full rewrite):
  - Vue 3 + Quasar UMD app following the workspace CLAUDE.md conventions
  - ${ } delimiters (Jinja owns {{ }})
  - g.user guards (LNbits 1.4 timing — g.user can be null on initial mount)
  - For typography overrides, inline :style instead of utility classes
    (LNbits theme overrides Quasar's .text-* with !important)
  - Pale bg-*-1 backgrounds paired with explicit dark text class for
    dark-mode legibility
  - LNbits.api.request for all calls; Quasar.Notify for feedback;
    Quasar.copyToClipboard for npub copy
  - Routes wired:
      GET  /api/v1/dca/super-config        → banner readout
      GET  /api/v1/dca/machines            → fleet table
      POST /api/v1/dca/machines            → add modal
      PUT  /api/v1/dca/machines/{id}       → edit modal
      DELETE /api/v1/dca/machines/{id}     → confirm dialog
      GET  /api/v1/dca/settlements/stuck   → worklist tab badge

Deleted the entire v1 surface: lamassu_config form, SSH tunnel settings,
single-config polling controls, quick-deposit form (deposits get their
own tab in P9d), manual transaction dialog (the partial-dispense +
retry endpoints replace it in P9b), distribution drill-down dialog.

Next: P9b (machine detail drawer — settlements list, retry button,
partial-dispense modal, notes panel), P9c (clients tab), P9d (deposits
tab), P9e (commission splits editor), P9g (worklist + CSV reports).

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:58:43 +02:00
0bdee0f62b feat(v2): LP auto-forward to LN address (P6 — closes #8)
Closes satmachineadmin#8 — operator-configured LP autoforward to an
external Lightning address. The data path was already in place from P0d
(autoforward_enabled + autoforward_ln_address on dca_clients); this
commit wires the actual outbound LN-address payment.

Flow (in distribution._attempt_autoforward, called from the DCA leg path):
  1. DCA leg lands in LP's LNbits wallet (regular internal transfer)
  2. If client.autoforward_enabled AND autoforward_ln_address set:
       a. Wrap address in lnurl.LnAddress
       b. Resolve to bolt11 via lnbits.core.services.lnurl.get_pr_from_lnurl
       c. Pay bolt11 from LP's wallet via pay_invoice
       d. Record a leg_type='autoforward' dca_payments row with
          destination_ln_address set
  3. On ANY failure (malformed addr, LNURL resolution fail, payment
     timeout): log warning, mark the autoforward leg 'failed', and
     leave sats in the LP's LNbits wallet — the explicit safety
     constraint from the original issue.

Audit: every autoforward attempt records a row (success or fail) so
operators can see in payment history which forwards landed externally
vs which left sats in LNbits. The destination_ln_address column on
dca_payments was already nullable to support this use case.

Safety guards:
- Skip autoforward if the DCA leg itself failed (nothing to forward).
- _attempt_autoforward never re-raises — failed forwarding must not
  abort subsequent DCA legs for other LPs at this machine.
- Sats only move from the LP's wallet (which they own), never from
  the operator's or super's wallets.

Refactor: extracted _pay_one_dca_leg from _pay_dca_distributions
to keep the outer function under the C901 complexity limit.

72/72 tests pass.

Refs: aiolabs/satmachineadmin#9, closes #8 (autoforward feature
request) — marked once verified end-to-end with a real LN address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:46:02 +02:00
578f2c142d feat(v2): abandoned-tx queue + force-reset for stuck settlements (P3f)
Completes the P3 operator-UX cluster. Surfaces settlements that didn't
process cleanly as a queryable worklist so operators can investigate +
retry without scanning the full settlement history.

New endpoints:

  GET    /api/v1/dca/settlements/stuck?threshold_minutes=30
    Returns StuckSettlementsResponse with three buckets:
      - errored: distribution failed; existing /retry endpoint handles
      - stuck_pending: landed but never picked up (listener crashed
        before invoking process_settlement)
      - stuck_processing: claim taken but no completion in N minutes;
        processor crashed mid-flight, processing_claim is set but no
        terminal state landed

  POST   /api/v1/dca/settlements/{id}/force-reset
    Operator escape hatch for genuinely stuck settlements. Flips
    'pending'/'processing' → 'errored' so the /retry endpoint can take
    over. Refuses unless the settlement is older than threshold_minutes
    (default 30) so operators can't accidentally interrupt a
    slow-but-running settlement. Age check uses created_at as proxy.

CRUD:
- get_stuck_settlements_for_operator(uid, threshold_minutes) joins
  dca_settlements → dca_machines and returns the three lists
  scoped per operator. No age filter on 'errored' (operators always
  want to see those); age filter applies to 'pending'/'processing'.
- force_reset_stuck_settlement(id) UPDATEs 'pending'/'processing' to
  'errored', clears processing_claim, sets a marker error_message.

The retry endpoint shipped in fix bundle 1 (commit 3ede66f) is the
intended downstream — operator sees stuck-processing row, hits force-
reset (flips to errored), then hits retry (flips to pending, voids
failed legs, re-runs process_settlement via the claim path).

34 routes registered. 72/72 tests pass.

Refs: aiolabs/satmachineadmin#9 — completes P3 operator-UX cluster

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:43:20 +02:00
3ede66ff92 fix(v2)(security): wallet IDOR + settlement-processing concurrency
Closes the HIGH-severity security finding from the v2 branch review:
operator A could register a machine pointing at operator B's wallet_id
(or update their machine to do so), then drain B's wallet via the
settlement processor's pay_invoice call. LNbits' pay_invoice doesn't
enforce caller identity at the backend layer — wallet_id is trusted as
the source-of-truth for the source wallet.

Two-layer defence:

1. **API layer.** New _assert_wallet_owned_by helper in views_api.py
   refuses any wallet_id from the request body that doesn't resolve to a
   wallet owned by the authenticated operator. Applied on
   api_create_machine and api_update_machine. Pattern lifted from the
   existing api_settle_client_balance which already did this for
   funding_wallet_id (260-265 in the original file).

2. **DB layer.** m007 adds a UNIQUE index on dca_machines.wallet_id —
   even if a future endpoint forgets the API check, the DB rejects two
   rows claiming the same wallet. CREATE UNIQUE INDEX is portable across
   SQLite and PostgreSQL (ALTER TABLE ADD CONSTRAINT is not on SQLite).

Same commit also addresses concurrency findings H1+H2+H3 from the
architectural review (race conditions on process_settlement +
no retry path for errored settlements):

- m007 also adds processing_claim TEXT to dca_settlements.
- crud.claim_settlement_for_processing does optimistic-lock via
  UPDATE ... SET status='processing', processing_claim=:token
  WHERE id=:id AND status='pending'  (portable; no UPDATE...RETURNING).
  Read-back compares the token; only one concurrent caller wins.
- crud.reset_settlement_for_retry voids failed legs and flips
  'errored' → 'pending' so process_settlement re-runs them. Completed
  legs are LEFT IN PLACE — we never re-pay sats that already moved.
- crud.mark_settlement_status clears processing_claim on terminal
  states so a fresh claim attempt won't see a stale token.
- distribution.process_settlement now uses the claim instead of the
  status-read-and-check pattern. Concurrent listener re-fires +
  partial-dispense recomputes can't double-pay legs.
- New endpoint:
    POST /api/v1/dca/settlements/{id}/retry  (operator-scoped)
  Refuses if status != 'errored' (400). Resets, then re-runs
  process_settlement via the claim path.

DcaSettlement gains a processing_claim: Optional[str] field. Visible to
operators in settlement detail; stale claims (status='processing' for
many minutes) are a "processor crashed mid-flight" signal — operator
can manually mark errored + retry.

32 routes registered. 72/72 tests pass.

Refs: aiolabs/satmachineadmin#9 — closes the v2-branch security finding
and HIGH-priority concurrency findings from the internal review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:37:58 +02:00
d0a947b7e6 feat(v2): balance settlement at current rate (P3e)
Closes the v1 feature request satmachineadmin#4 (balance settlement for
small remaining LP balances). Operator hits 'Settle' on an LP, specifies
the exchange rate they're willing to honor, and the system pays out the
remaining fiat balance in sats from the operator's chosen funding wallet.

Avoids the Zeno's-paradox of vanishing tiny proportional shares — small
balances no longer drag on forever; they get cleanly zeroed.

New endpoint:
  POST /api/v1/dca/clients/{client_id}/settle
  body: SettleBalanceData {funding_wallet_id, exchange_rate,
                            amount_fiat?, notes?}

Flow (distribution.settle_lp_balance):
  1. Get LP's remaining balance summary
  2. amount_fiat capped at remaining (defaults to full remaining)
  3. amount_sats = round(amount_fiat * exchange_rate)
  4. Internal transfer funding_wallet → client.wallet via
     create_invoice(internal=True) + pay_invoice
  5. Records leg_type='settlement' in dca_payments

Two ownership checks at the API boundary: client (via machine→operator)
and funding_wallet_id (via lnbits.core.crud.get_wallet → wallet.user
== current operator). 400 (not 404) if funding wallet isn't owned —
operators can identify their own wallets so leaking existence is fine.

Updated get_client_balance_summary to count both leg_type='dca' AND
leg_type='settlement' completed legs against the LP's remaining
balance. Without this update, settled amounts would leave the LP's
balance unchanged in the summary and re-fire on the next bitSpire tx.

Exchange rate is operator-supplied and required — explicit so there's
no ambiguity about what rate was used. Operator can use exchange spot,
market midpoint, or a favorable rate as a gesture; the rate is recorded
on the dca_payments row alongside amount_fiat for audit.

72/72 tests still pass. 31 routes total.

Refs: aiolabs/satmachineadmin#9, closes #4 (in spirit, marked once
verified end-to-end)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:17:41 +02:00
2883eb7b79 feat(v2): partial-dispense + operator notes on settlements (P3d)
Closes the v1 feature request satmachineadmin#3 (partial transaction
processing) and adds operator-authored audit notes on settlements.

Schema (m006_add_settlement_notes):
  ALTER TABLE dca_settlements ADD COLUMN notes TEXT

The notes column is append-only (prepend with timestamp, never edit in
place). Stores both system-generated audit memos (partial-dispense
recompute provenance) and operator-authored free-form notes (cash-
drawer reconciliation context, off-LN refund records, etc.).

Partial-dispense endpoint:
  POST /api/v1/dca/settlements/{id}/partial-dispense
  body: PartialDispenseData {dispensed_fraction OR dispensed_sats, notes}

Recompute path (in distribution.apply_partial_dispense_and_redistribute):
  1. Refuse if any leg has status='completed' (Lightning can't claw back)
  2. Resolve new_gross from dispensed_fraction or dispensed_sats
  3. Linear-scale net/commission/fiat — preserves the original commission
     ratio exactly; only rounding may drift by 1 sat
  4. Re-stage-1 split using the CURRENT super_fee_pct (super may have
     changed the rate since the original landed)
  5. Build a memo capturing original values + reason + new values
  6. Void pending/failed legs (status → 'voided')
  7. Overwrite the settlement's monetary fields + prepend memo to notes
  8. Reset status to 'pending' → process_settlement re-runs distribution

Operator notes endpoint:
  POST /api/v1/dca/settlements/{id}/notes
  body: AppendSettlementNoteData {note}

Each operator note is timestamped (UTC) and tagged with the author's
user_id so the audit trail is accountable. Non-empty, max 2000 chars.

72/72 tests still pass. 30 routes total. The full-directory ruff number
ballooned to ~500 because it includes legacy transaction_processor.py
(orphaned, not imported anywhere) and other v1 cruft on the branch.
Files I actively maintain are clean.

Note: a richer queryable audit history (filter by author / time range /
action type / etc.) is being tracked as a separate future-work issue.
The notes-column approach here is the v1 audit story; the dedicated
history table will be additive.

Refs: aiolabs/satmachineadmin#9, closes #3 (in spirit, marked
once verified end-to-end)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:46:33 +02:00
e8dcbfe26e feat(v2): commission splits CRUD endpoints (P3c)
Adds 3 operator-scoped endpoints for managing the commission remainder
ruleset:

  GET    /api/v1/dca/commission-splits
                                       — operator's default ruleset
  GET    /api/v1/dca/commission-splits?machine_id=X
                                       — per-machine override (just the
                                          override, not the default)
  GET    /api/v1/dca/commission-splits?machine_id=X&effective=true
                                       — what the settlement processor
                                          actually applies (override if
                                          set, else operator default)
  PUT    /api/v1/dca/commission-splits — atomic replace; model validator
                                          enforces legs sum to 1.0
  DELETE /api/v1/dca/commission-splits — clear default (per-machine
                                          overrides still apply)
  DELETE /api/v1/dca/commission-splits?machine_id=X
                                       — clear per-machine override
                                          (falls back to default)

All routes verify operator owns the referenced machine (404 not 403 if
not). The DELETE path bypasses SetCommissionSplitsData's sum-to-1.0
validator by calling replace_commission_splits([]) directly, since an
empty ruleset is the correct "no rules" state — distribution.py logs a
warning and leaves operator_fee_sats in the machine wallet when this
happens.

28 routes registered total. 72/72 tests pass.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:16 +02:00
b7f6f0a696 feat(v2): deposit CRUD + confirmation endpoints (P3b)
Adds 6 operator-scoped deposit endpoints:

  POST   /api/v1/dca/deposits                  — record fiat from an LP
                                                  (creator_user_id = the
                                                  operator who recorded)
  GET    /api/v1/dca/deposits                  — operator's deposits (all)
  GET    /api/v1/dca/deposits?client_id=X      — scoped to one LP
  GET    /api/v1/dca/deposits/{id}             — single
  PUT    /api/v1/dca/deposits/{id}             — edit (pending only)
  PUT    /api/v1/dca/deposits/{id}/status      — confirm/reject
  DELETE /api/v1/dca/deposits/{id}             — delete (pending only)

Cross-checks (client_id, machine_id) at create to prevent operators
binding deposits across machines incorrectly. Edits + deletes are
restricted to pending status so confirmed deposits become immutable
audit records (consistent with v1's existing behaviour from commit
28241e7).

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:36:04 +02:00
7226b8289d feat(v2): client CRUD + balance summary endpoints (P3a)
Adds 6 operator-scoped LP management endpoints:

  POST   /api/v1/dca/clients                  — register LP at a machine
  GET    /api/v1/dca/clients                  — operator's LPs (all)
  GET    /api/v1/dca/clients?machine_id=X     — scoped to one machine
  GET    /api/v1/dca/clients/{id}             — single LP
  PUT    /api/v1/dca/clients/{id}             — update mode/autoforward/etc
  DELETE /api/v1/dca/clients/{id}             — delete
  GET    /api/v1/dca/clients/{id}/balance     — fiat balance summary

Ownership transitively checked via the LP's machine — operators can
only see/modify LPs at machines they own. New _machine_owned_by and
_client_owned_by helpers consolidate the 404-not-403 ownership pattern.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:35:15 +02:00
56be3e5c52 feat(v2): settlement distribution — three leg groups, super-fee write (P2)
After a settlement lands (P1a), this commit pays out the three leg
groups via LNbits internal transfers (create_invoice + pay_invoice with
internal=True). Wired synchronously from the invoice listener — latency
is one bitSpire-tx wide. process_settlement is idempotent (status guard)
so retries are safe.

distribution.py — three leg groups, in order:

  1. super_fee leg:
       platform_fee_sats → super_fee_wallet_id (if set)
       skip + warn if super fee % > 0 but wallet not configured
  2. operator_split legs:
       operator_fee_sats sliced per the operator's commission_splits
       ruleset (per-machine override or operator default)
       skip + warn if operator has no ruleset configured
  3. dca legs:
       net_sats distributed proportionally to active flow-mode LPs at
       this machine, each capped at the LP's remaining-fiat-balance-
       in-sats (preserves the v1 sync-mismatch fix from PR #2)
       skip if exchange_rate=0 (fallback path with missing rate)

Every leg lands a dca_payments row with the leg_type discriminator and
inherits Payment.tag "satmachine:{machine_npub}" so LNbits payment-
history filters work natively across machines + operators.

Atomicity model: LN payments cannot be rolled back. Each leg is
attempted independently; success/fail recorded on the dca_payments row.
The settlement is marked 'processed' only when every leg completed; any
failure marks 'errored' with a concatenated message but leaves successful
legs in place. Sats that don't pay out (failed legs, missing super
wallet, no commission ruleset, no LP coverage) remain in the machine's
wallet — visible to the operator on the dashboard.

calculations.py — extracted two pure helpers:

  split_two_stage_commission(commission_sats, super_fee_pct)
    Stage-1: super takes super_fee_pct (rounded); operator absorbs the
    rounding remainder so platform + operator == commission_sats exactly.

  allocate_operator_split_legs(operator_fee_sats, leg_pcts)
    Stage-2: distributes the remainder across N legs per pct rules. Last
    leg absorbs the rounding remainder so sum(legs) == operator_fee_sats.

50 new tests cover the plan's verification scenario:
  100 sats commission, super=30%, operator splits 50/30/20
  → super 30, operator 35/21/14. Sum 100 ✓
plus all the edge cases the plan called out (super=0, super=100,
single-leg, zero-fee, parametrised invariant on sums).

views_api.py adds the super-only platform-fee write endpoint:
  PUT /api/v1/dca/super-config  (check_super_user)

This is the only super-only endpoint in v2 — sets super_fee_pct and the
destination wallet for collecting the fee.

72/72 tests pass (22 calculation + 50 two-stage-split). 13 routes
registered against LNbits 1.4 (nostr-transport).

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:34:07 +02:00
10b79ae900 feat(v2): operator-scoped API surface — machines, settlements, payments (P1b)
Replaces the views_api.py stub with the v1 operator-scoped REST surface
needed for the P1 frontend tasks (machine onboarding by npub, settlement
review, payment-leg audit). All endpoints filter on the authenticated
user's id so two operators on the same LNbits instance can never see
each other's data.

Endpoints (12 routes):

Machines (CRUD):
  POST   /api/v1/dca/machines                  — add by npub + wallet_id
  GET    /api/v1/dca/machines                  — operator's fleet
  GET    /api/v1/dca/machines/{id}             — single (ownership check)
  PUT    /api/v1/dca/machines/{id}             — update (ownership check)
  DELETE /api/v1/dca/machines/{id}             — delete (ownership check)

Settlements (read-only at this phase):
  GET    /api/v1/dca/settlements               — operator-wide
  GET    /api/v1/dca/machines/{id}/settlements — per machine
  GET    /api/v1/dca/settlements/{id}          — single (ownership check)

Payments (leg-typed audit):
  GET    /api/v1/dca/payments?leg_type=…       — operator's payment legs

Super config (read-only here):
  GET    /api/v1/dca/super-config              — operators read the
                                                  platform fee they pay

Catch-all:
  /api/v1/dca/{...} → 503 with a precise message for not-yet-implemented
  endpoints (clients, deposits, commission splits, partial-tx,
  balance-settle, super-config write — all P2+).

All ownership checks live at the API boundary: if the route's resource
points to a machine the operator doesn't own, we 404 (not 403) so
operators can't probe for the existence of other operators' machines.

Verified routes register cleanly against LNbits 1.4 (nostr-transport).
22/22 calculation tests still green.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:50:07 +02:00
b91e49b642 feat(v2): wire bitSpire invoice listener + settlement landing (P1a)
Replaces the no-op tasks.py stub with a real invoice listener that lands
bitSpire settlements idempotently into dca_settlements.

Architecture: satmachineadmin runs *inside* the LNbits process, so it
plugs into LNbits' canonical extension hook (register_invoice_listener
from lnbits.tasks) instead of going through the Nostr transport layer.
External clients like bitSpire use Nostr; internal extensions consume
the resulting Payment objects directly. One invoice_listener queue per
extension, dispatched by invoice_callback_dispatcher.

Flow:
  bitSpire ATM (Nostr kind-21000)
    → LNbits nostr_transport handler
    → core Payment system (create_invoice + status=SUCCESS on settle)
    → invoice_callback_dispatcher
    → satmachineadmin's invoice_queue
    → _handle_payment filters by wallet_id → active machine
    → bitspire.parse_settlement reads Payment.extra (or back-derives)
    → create_settlement_idempotent (keyed on payment_hash UNIQUE)

The parser (new bitspire.py module) is bitSpire-specific:

- Happy path (post-aiolabs/lamassu-next#44): Payment.extra carries
  {source:"bitspire", net_sats, fee_sats, fee_pct, exchange_rate,
   currency, txid, machine_npub, bills, cassettes}. Read directly,
  zero back-derivation.
- Fallback path (pre-#44): extra is absent. Back-derive the split
  using machine.fallback_commission_pct with the Lamassu-style
  formula (calculations.calculate_commission), mark
  used_fallback_split=true, log a WARNING that namechecks the
  upstream issue so it's findable in logs.

Two-stage commission split (super first, operator remainder) is
computed at land time so the audit row is complete:
  platform_fee_sats = round(commission_sats * super_fee_pct)
  operator_fee_sats = commission_sats - platform_fee_sats

The actual payout (LP DCA legs + super-fee leg + operator-split legs)
happens in a separate settlement-processor task in P2. P1 only LANDS
the settlement with status='pending'.

Smoke-tested both paths against real LNbits 1.4 (nostr-transport venv):
  happy:    266800 gross → 258835 net + 7965 commission
            (2390 super @ 30%, 5575 operator)
  fallback: 266800 gross → 254095 net + 12705 commission @ 5% default

Also adds crud.get_active_machine_by_wallet_id, the lookup that gates
inbound payments to known machine wallets.

Refs: aiolabs/satmachineadmin#9, aiolabs/lamassu-next#44

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:48:44 +02:00
cba327d0f0 fix(v2): use payment_hash as settlement idempotency key
The initial m005 made bitspire_event_id the UNIQUE idempotency key on
dca_settlements, but settlements arriving through LNbits' invoice
listener (the canonical path per nostr-transport-branch architecture)
don't carry a Nostr event id at the Payment level — that's the
underlying transport's concern, not exposed to extensions.

The natural unique key is payment_hash:
- every LN invoice has a globally unique payment_hash
- subscription replays / dispatcher double-fires dedup via UNIQUE
- it's always present on the Payment object the invoice_listener delivers

Reshape the dca_settlements column constraints:
- payment_hash: TEXT NOT NULL UNIQUE (was: NOT NULL + separate index)
- bitspire_event_id: TEXT (was: NOT NULL UNIQUE) — kept nullable for
  a future path where we subscribe to raw kind-21000 Nostr events
  directly, bypassing the Payment system

Also rename the CRUD helper: get_settlement_by_event_id →
get_settlement_by_payment_hash, and update create_settlement_idempotent
to dedup on payment_hash. CreateDcaSettlementData / DcaSettlement
adjust accordingly.

The schema is unshipped (v2-bitspire branch is local only) — fixing
m005 in-place is appropriate. The separate dca_telemetry path for
kind-30078/30079 events already uses (machine_id, beacon_received_at)
semantics, so the UNIQUE-by-Nostr-event-id pattern isn't needed there
either.

Caught during P1a design before subscribing to register_invoice_listener.

Refs: aiolabs/satmachineadmin#9, aiolabs/lamassu-next#44

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:46:08 +02:00
937749f149 feat(v2): operator-scoped CRUD + stub legacy entry points
Replaces v1's super-only single-config CRUD with the v2 operator-scoped data
layer that matches the m005 schema:

- Machines: create/get/get_by_npub/list_for_operator/update/delete
- Clients: scoped per (machine, user). Adds list_for_operator (across an
  operator's fleet) and list_for_user (LP cross-operator view), plus
  get_flow_mode_clients_for_machine for the distribution algorithm.
- Deposits: now carry machine_id and creator_user_id; per-operator listing.
- Settlements: create_settlement_idempotent treats bitspire_event_id as the
  uniqueness key, returning the existing row on replay so subscription
  re-delivery is safe by construction. mark_settlement_status drives the
  pending → processed/partial/refunded/errored lifecycle.
- Commission splits: replace_commission_splits is an atomic per-scope
  replace; the SetCommissionSplitsData model already validates legs sum
  to 1.0 at the boundary. get_effective_commission_splits handles the
  per-machine-override-or-operator-default precedence.
- Payments: leg-typed (dca / super_fee / operator_split / settlement /
  autoforward / refund) with helpers for settlement/client/operator scopes.
- Balance summary: sums confirmed deposits minus completed dca legs.
- Telemetry: upsert_beacon_snapshot uses COALESCE so today's sparse
  kind-30078 payload doesn't clobber post-#43 fields when they start
  arriving. upsert_fleet_snapshot stores raw JSON until lamassu-next#42
  fixes the kind-30079 schema.
- Super config: singleton get/update.

Also stubs three legacy entry points so __init__.py imports cleanly while
the rest of P0/P1 is in flight:

- tasks.py: no-op stubs for wait_for_paid_invoices + hourly_transaction_polling.
  Real Nostr subscription manager lands in P1.
- views_api.py: a single /api/v1/dca/{...} catch-all returns 503 with a
  precise message. v2 endpoints land in P1+.
- views.py: drops the super-only check on the index page (v2 is
  operator-installable); platform-fee config moves to a super-only API in P1.

transaction_processor.py is left untouched but is now orphaned (no one
imports it) — gets a full rewrite in P1.

Refs: plan at ~/.claude/plans/snug-gliding-shamir.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:37:48 +02:00
013e3d5f6b feat(v2): rewrite models.py for v2 schema
Replaces the Lamassu-era data models (LamassuConfig, StoredLamassuTransaction,
single-config CRUD carriers) with the v2 Pydantic surface matching m005:

- Machine / CreateMachineData / UpdateMachineData: per-operator multi-machine
  registry keyed by Nostr npub. Replaces single-row LamassuConfig + SSH fields.
- DcaClient now scoped per (machine_id, user_id). Includes autoforward fields
  for satmachineadmin#8 (best-effort LN-address forwarding for LPs).
- DcaDeposit gains machine_id + creator_user_id (audit trail finding from v1).
- DcaSettlement: idempotency carrier for bitSpire kind-21000 events. Carries
  platform_fee_sats + operator_fee_sats as absolute ints (v1 hook for the v2
  customer-discount engine — see plan).
- CommissionSplitLeg / CommissionSplit / SetCommissionSplitsData: operator's
  remainder-distribution rules. SetCommissionSplitsData validates legs sum
  to 1.0 at the boundary so crud.py only sees valid sets.
- DcaPayment dropped transaction_type in favor of leg_type discriminator
  (dca | super_fee | operator_split | settlement | autoforward | refund).
  Gains settlement_id + machine_id + operator_user_id + destination_*.
- TelemetrySnapshot: sparse beacon + fleet snapshot fields, all nullable so
  we degrade gracefully against today's minimal kind-30078 payload.
- SuperConfig / UpdateSuperConfigData: super-only platform-fee carrier.
- PartialDispenseData (satmachineadmin#3), SettleBalanceData (satmachineadmin#4):
  operator UX action carriers.

Stays on pydantic v1 @validator pattern + Optional[X] hints to match the
rest of the codebase. The UP045 / N805 lint noise is pre-existing tech debt
across the repo, not introduced here.

Refs: plan at ~/.claude/plans/snug-gliding-shamir.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:33:16 +02:00
ae4e241d1c feat(v2): add m005 satmachine_v2 schema for bitSpire + multi-tenant
Breaking redesign. Drops the v1 Lamassu-era tables (lamassu_config,
lamassu_transactions, plus the singular-config dca_clients/deposits/payments)
and creates the v2 schema:

- dca_machines: per-operator multi-machine registry, keyed by Nostr npub.
  Replaces the single-row lamassu_config pattern.
- dca_settlements: bitSpire kind-21000 idempotency. platform_fee_sats and
  operator_fee_sats stored as absolute BIGINT — v1 hook so the v2 customer-
  discount engine can record who-forgave-what without a migration.
- dca_commission_splits: operator-defined remainder rules (per-machine or
  default; sum-to-1.0 invariant enforced at write).
- dca_payments: leg-typed (dca | super_fee | operator_split | settlement |
  autoforward | refund). Drops the old transaction_type field.
- dca_clients: now scoped per (machine_id, user_id) so an LP can hold
  positions across machines/operators on the same instance.
- dca_deposits: gains machine_id + creator_user_id for audit.
- dca_telemetry: sparse kind-30078 / kind-30079 snapshots; post-#43 fields
  nullable until lamassu-next enriches the beacon.
- super_config: singleton row for super_fee_pct + super_fee_wallet_id.

No backwards compatibility — operators on the previous schema must wipe and
re-onboard. Old migrations m001-m004 remain so fresh installs still walk the
versioned path; m005 drops their tables before creating the v2 schema.

Incidental: stripped trailing whitespace in m004 (W291/W293 hygiene).

Refs: plan at ~/.claude/plans/snug-gliding-shamir.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:30:45 +02:00
28241e70c3 feat: add deposit edit and delete for pending deposits
Some checks failed
ci.yml / feat: add deposit edit and delete for pending deposits (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.0.5
Add PUT /api/v1/dca/deposits/{id} endpoint to update amount, currency,
and notes on pending deposits. Add DELETE endpoint to remove deposits
not yet inserted into the machine. Both endpoints reject confirmed
deposits. Frontend now shows edit/delete buttons only for pending rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 16:00:04 +02:00
6eb076d5f6 chore: bump version to 0.0.4
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (3.10) (push) Has been cancelled
CI / tests (3.9) (push) Has been cancelled
2026-01-11 16:10:48 +01:00
545a0284a7 fix: cap DCA allocations when ATM cash exceeds tracked balances
Some checks failed
CI / lint (push) Waiting to run
CI / tests (3.10) (push) Blocked by required conditions
CI / tests (3.9) (push) Blocked by required conditions
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.0.4
When there's a sync mismatch (more cash in ATM than tracked client
balances), cap each client's allocation to their remaining fiat
balance equivalent in sats. Orphan sats stay in the source wallet.

This prevents over-allocation when deposits haven't been recorded
yet or when there's a timing mismatch between ATM transactions
and balance tracking.

- Detect sync mismatch: total_confirmed_deposits < fiat_amount
- In sync mismatch mode: allocate based on client balance, not tx amount
- Track orphan_sats that couldn't be distributed
- Normal mode unchanged: proportional distribution using calculate_distribution()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:54:48 +01:00
49f3670bac fix: cast amount to float for LNbits create_invoice API
Some checks are pending
CI / lint (push) Waiting to run
CI / tests (3.10) (push) Blocked by required conditions
CI / tests (3.9) (push) Blocked by required conditions
LNbits create_invoice expects amount as float, not int. Added
explicit float() cast to both DCA distribution and commission
payment invoice creation calls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:40:17 +01:00
397fd4b002 feat: add unit tests for DCA calculations with empirical Lamassu data
Some checks are pending
CI / lint (push) Waiting to run
CI / tests (3.10) (push) Blocked by required conditions
CI / tests (3.9) (push) Blocked by required conditions
- Extract pure calculation functions to calculations.py (no lnbits deps)
- transaction_processor.py now imports from calculations.py (DRY)
- Add 22 tests covering commission, distribution, and fiat round-trip
- Include real Lamassu transaction data (8.75%, 5.5% commission rates)
- Test edge cases: discounts (90%, 100%), zero commission, small amounts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:14:18 +01:00
8d94dcc2b7 fix: reset correct loading state after manual transaction processing
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (3.10) (push) Has been cancelled
CI / tests (3.9) (push) Has been cancelled
The finally block was resetting runningTestTransaction instead of
processingSpecificTransaction, causing the button to stay in loading
state after processing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:57:53 +01:00
ab4e3d6b12 fix: use check_user_exists for LNbits 1.4 compatibility
Some checks failed
CI / lint (push) Waiting to run
CI / tests (3.10) (push) Blocked by required conditions
CI / tests (3.9) (push) Blocked by required conditions
CI / lint (pull_request) Has been cancelled
/ release (push) Has been cancelled
CI / tests (3.10) (pull_request) Has been cancelled
CI / tests (3.9) (pull_request) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.0.3
LNbits 1.4 changed check_super_user to return Account (no wallets)
instead of User (with wallets). This broke the template rendering
because LNbits.map.user() requires the wallets property.

Switch to check_user_exists (returns User with wallets) and manually
check user.super_user for access control. This follows the same
pattern used by LNbits core admin pages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:14:48 +01:00
25be6cff87 Fix LNbits 1.4 compatibility: add null guards for g.user.wallets
Some checks failed
CI / lint (push) Waiting to run
CI / tests (3.10) (push) Blocked by required conditions
CI / tests (3.9) (push) Blocked by required conditions
CI / lint (pull_request) Has been cancelled
/ release (push) Has been cancelled
CI / tests (3.10) (pull_request) Has been cancelled
CI / tests (3.9) (pull_request) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.0.2
LNbits 1.4 changed g.user initialization (PR #3615), moving it from
windowMixin to base.html. This means g.user can be null during initial
Vue template evaluation.

- Use optional chaining g.user?.wallets || [] in template
- Add null guard before accessing this.g.user.wallets in JS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 11:11:19 +01:00
cd0d958c2c consolidate docs
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (3.10) (push) Has been cancelled
CI / tests (3.9) (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.0.1
2025-11-03 22:23:10 +01:00
1b7374fa70 Removes test transaction UI button
Removes the test transaction button from the admin UI.

The test transaction endpoint is still available in the API for development and debugging purposes.
2025-11-03 22:23:10 +01:00
fe38e08d4e Adds manual transaction processing feature
Implements functionality to manually process specific Lamassu transactions by ID, bypassing dispense checks.

This allows administrators to handle transactions that may have failed due to dispense issues or were settled manually outside of the automated process.

The feature includes a new UI dialog for entering the transaction ID and an API endpoint to fetch and process the transaction, crediting wallets and distributing funds according to the DCA configuration.
2025-11-03 22:23:08 +01:00
230beccc37 Improve balance verification in LamassuTransactionProcessor: Added rounding to two decimal places for balance and fiat amounts to ensure precision in comparisons. Enhanced logging for insufficient balance scenarios, improving clarity in transaction processing and error reporting. 2025-11-03 22:23:04 +01:00
077e097fc2 Refactor transaction data handling in LamassuTransactionProcessor: Improved consistency in processing None and empty values during data ingestion. Defaulted numeric fields to 0 and percentage fields to 0.0 for better error handling. Ensured clean extraction of transaction details, enhancing reliability in transaction processing. 2025-11-03 22:23:04 +01:00
4843b43147 FIX: exclude flow_clients remaining_balance values less than 0.01 2025-07-06 02:04:11 +02:00
5d41e0c50e Refine distribution logic in transaction processing: Updated the LamassuTransactionProcessor to only create distributions for clients with positive allocated sats. Enhanced logging to indicate when clients are skipped due to zero amounts, improving clarity and accuracy in transaction reporting. 2025-07-06 01:15:26 +02:00
bca39b91cd Enhance commission memo generation in transaction processing: Added discount handling to the commission memo in LamassuTransactionProcessor, allowing for the calculation of effective commission percentages when discounts are applied. This improves clarity in transaction details by providing a more accurate representation of commissions after discounts. 2025-07-06 00:54:19 +02:00
a864f285e4 Refactor m004_convert_to_gtq_storage migration: Streamlined the conversion of centavo amounts to GTQ by detecting the database type (PostgreSQL or SQLite) and applying appropriate data type changes and updates. This enhances clarity and ensures proper handling of data conversions across relevant tables. 2025-07-06 00:24:49 +02:00
c83ebf43ab 01 Refactor currency handling to store amounts in GTQ: Removed currency conversion utilities, updated models and API endpoints to directly handle GTQ amounts, and modified transaction processing logic for consistency. Enhanced frontend to reflect these changes, ensuring accurate display and submission of GTQ values across the application.
Refactor GTQ storage migration: Moved the conversion logic for centavo amounts to GTQ into a new migration function, m004_convert_to_gtq_storage, ensuring proper data type changes and updates across relevant tables. This enhances clarity and maintains the integrity of the migration process.
2025-07-06 00:13:03 +02:00