refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation

Cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md`
and at `~/dev/coordination/log.md` (2026-05-26). Canonical names with
explicit units across satmachineadmin, lamassu-next, atm-tui:

  - `wire_sats` — actual Lightning payment amount (direction-agnostic;
                  was `gross_sats`, only "gross" for cash-out)
  - `principal_sats` — market-rate sats before commission (unchanged)
  - `fee_sats` — commission (was `commission_sats` internally;
                 already the wire format)
  - `fee_fraction` — commission rate as unit fraction in [0, 1]
                     (was `*_pct` / `fee_percent`; eliminates the
                     latent 100x bug from `feePercent * 100` on the
                     lamassu-next side)

Invariants enforced in bitspire._assert_sat_invariants on every
parsed settlement — range (all sats >= 0, 0 <= fee_fraction <= 1) +
direction-specific sum:

  - cash-out: wire_sats == principal_sats + fee_sats
  - cash-in:  wire_sats == principal_sats - fee_sats
              AND fee_sats <= principal_sats

Breaches raise SettlementInvariantError; tasks._handle_payment
records the row as `status='rejected'` with the exception message
and skips distribution. Attribution failure path symmetric.

Schema changes (m001 + m006):

  - dca_settlements.gross_sats              -> wire_sats
  - dca_settlements.commission_sats         -> fee_sats
  - super_config.super_fee_pct              -> super_fee_fraction
  - dca_commission_splits.pct               -> fraction
  - dca_machines.fallback_commission_pct    DROPPED (obsolete)
  - dca_settlements.used_fallback_split     DROPPED (obsolete)

m006 idempotently renames + drops columns on existing installs;
m001 lays down the canonical schema for fresh installs.

Obsolete code removed (Lamassu-era reverse-derivation):

  - calculations.calculate_commission — back-derived principal+fee
    from gross-with-commission-baked-in. v2 stamps both directly.
  - calculations.calculate_exchange_rate — bitSpire stamps directly.
  - bitspire._parse_fallback — sole caller of calculate_commission.
  - Machine.fallback_commission_fraction — only read by _parse_fallback.
  - DcaSettlement.used_fallback_split — only written by _parse_fallback.

parse_settlement now raises SettlementMetadataError if Payment.extra
lacks the bitSpire stamp or required absolute sat fields. No silent
back-derivation; upstream-bug surfacing via dashboard rejection.

Frontend (JS + Quasar templates) updated for the column renames and
the removed fallback fields. Settlements table renders "Wire" + "Fee"
columns; the "(fallback split)" warning badge is gone.

Tests:
  - test_calculations.py: kept distribution tests; deleted
    calculate_commission + calculate_exchange_rate tests.
  - test_two_stage_split.py: renamed variables; rewrote docstring
    value literals (e.g. `super_fee_fraction=0.30` not `=30%`).
  - test_nostr_attribution.py: dropped fallback_commission_fraction
    from machine fixture.
  - 72/72 pass on regtest container.

Cross-codebase follow-ups tracked in coordination log:
  - lamassu-next: rename `fee_percent` -> `fee_fraction` on
    Payment.extra + state.db; drop the `* 100` at lightning.ts:780.
  - atm-tui: read `fee_fraction` column in db.zig.

Memory artefacts:
  - reference_sat_amount_vocabulary.md (canonical + invariants)
  - feedback_pct_to_fraction_renames_need_value_sweep.md (gotcha)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-26 20:08:30 +02:00
commit d717a6e214
12 changed files with 530 additions and 681 deletions

View file

@ -1,24 +1,22 @@
# Satoshi Machine v2 — bitSpire payment parser.
#
# Translates an inbound LNbits Payment (cash-out customer paid the ATM's
# invoice) into the principal/commission split needed by satmachineadmin.
# Translates an inbound LNbits Payment into a CreateDcaSettlementData by
# reading the canonical split fields bitSpire stamps on Payment.extra per
# aiolabs/lamassu-next#44 (`source: "bitspire"`, `principal_sats`,
# `fee_sats`, `exchange_rate`, etc.).
#
# Happy path: bitSpire populates Payment.extra with the canonical split
# fields per aiolabs/lamassu-next#44 — we read them directly.
#
# Fallback path: extra is missing (older bitSpire, edge case). We back-derive
# the split from the machine's fallback_commission_pct using the Lamassu-era
# formula (base = total / (1 + commission)) and mark used_fallback_split=true
# so the audit trail shows we estimated.
# No back-derivation. If Payment.extra is missing the bitSpire stamp or
# any required field, we raise SettlementMetadataError and the caller
# records the settlement as 'rejected' for upstream investigation — the
# Lamassu-era reverse-derivation from gross-with-commission-baked-in is
# obsolete now that the wire carries principal_sats and fee_sats
# directly.
from __future__ import annotations
import json
from typing import Any, Optional, Tuple
from typing import Any, Optional
from loguru import logger
from .calculations import calculate_commission
from .models import CreateDcaSettlementData, Machine
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound
@ -74,6 +72,34 @@ class SettlementAttributionError(ValueError):
"""
class SettlementInvariantError(ValueError):
"""A sat-amount or fee-fraction value violates the cross-codebase
canonical invariants (see
`~/.claude/projects/.../memory/reference_sat_amount_vocabulary.md`).
Raised by `_assert_sat_invariants`. Caller treats it like
SettlementAttributionError record as rejected, don't distribute.
A breach means something upstream (bitSpire, the relay, a buggy
consumer) is stamping garbage on Payment.extra; we don't want to
quietly silently distribute against corrupt numbers.
"""
class SettlementMetadataError(ValueError):
"""Payment.extra is missing the bitSpire stamp or required fields.
Raised by `parse_settlement`. Caller records the settlement as
'rejected' with the exception message in `error_message`. Operator
investigates the ATM that issued the invoice a bitSpire ATM that
landed on a satmachineadmin-managed wallet without stamping the
canonical fields is a real upstream bug (lamassu-next side), not a
graceful-degradation case. Pre-v2 reverse-derivation from the
wire amount + a machine-level fallback rate is no longer supported:
the wire-format contract (lamassu-next#44) is that the ATM always
stamps `principal_sats` and `fee_sats` explicitly.
"""
def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
"""Assert that the originating Nostr signer pubkey matches the machine.
@ -111,49 +137,124 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
)
def _assert_sat_invariants(
*,
tx_type: str,
wire_sats: int,
principal_sats: int,
fee_sats: int,
fee_fraction: Optional[float] = None,
) -> None:
"""Enforce the cross-codebase canonical sat-amount invariants on the
parsed settlement values BEFORE building the `CreateDcaSettlementData`.
Range invariants (all cases):
- wire_sats, principal_sats, fee_sats are all non-negative integers.
- fee_fraction (if provided) is in [0, 1].
Sum invariants (direction-specific):
- cash_out: wire_sats == principal_sats + fee_sats
- cash_in: wire_sats == principal_sats - fee_sats
AND fee_sats <= principal_sats
(commission cannot exceed the principal in a cash-in;
a customer can't owe negative sats)
The fee_fraction × principal_sats sanity check ( fee_sats ±1) is
intentionally NOT enforced here fee_fraction is informational on
Payment.extra; the absolute fee_sats stamp is the audit anchor and
the source of truth. The two can drift by a few sats due to upstream
rounding without indicating corruption. If we ever observe drift
>1% of fee_sats we'll add the check.
Raises SettlementInvariantError with a precise message on any breach.
Reference: `reference_sat_amount_vocabulary.md`.
"""
# Range checks
if wire_sats < 0:
raise SettlementInvariantError(f"wire_sats must be >= 0, got {wire_sats}")
if principal_sats < 0:
raise SettlementInvariantError(
f"principal_sats must be >= 0, got {principal_sats}"
)
if fee_sats < 0:
raise SettlementInvariantError(f"fee_sats must be >= 0, got {fee_sats}")
if fee_fraction is not None and not (0.0 <= fee_fraction <= 1.0):
raise SettlementInvariantError(
f"fee_fraction must be in [0, 1], got {fee_fraction} "
f"(if you see a value >1 the upstream may be stamping percentage "
f"instead of fraction — check lamassu-next#? rename status)"
)
# Sum invariants per direction
if tx_type == "cash_out":
expected_wire = principal_sats + fee_sats
if wire_sats != expected_wire:
raise SettlementInvariantError(
f"cash-out wire_sats invariant violated: "
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
f"+ fee_sats({fee_sats}) = {expected_wire}"
)
elif tx_type == "cash_in":
if fee_sats > principal_sats:
raise SettlementInvariantError(
f"cash-in fee_sats({fee_sats}) cannot exceed "
f"principal_sats({principal_sats}) — commission > principal "
f"would mean a customer owes negative sats"
)
expected_wire = principal_sats - fee_sats
if wire_sats != expected_wire:
raise SettlementInvariantError(
f"cash-in wire_sats invariant violated: "
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
f"- fee_sats({fee_sats}) = {expected_wire}"
)
else:
raise SettlementInvariantError(
f"unknown tx_type={tx_type!r}; expected 'cash_out' or 'cash_in'"
)
def parse_settlement(
machine: Machine,
payment_hash: str,
gross_sats: int,
wire_sats: int,
extra: dict,
super_fee_pct: float,
) -> Tuple[CreateDcaSettlementData, bool]:
super_fee_fraction: float,
) -> CreateDcaSettlementData:
"""Build a CreateDcaSettlementData for an inbound payment landing on
`machine`'s wallet.
Returns (data, used_fallback): when `used_fallback` is True, bitSpire
didn't populate Payment.extra so we back-derived the split. Caller
should log this for visibility once aiolabs/lamassu-next#44 ships,
fallback usage should drop to zero.
Requires bitSpire's canonical Payment.extra stamp (source="bitspire"
plus the absolute sat amounts) per aiolabs/lamassu-next#44. Raises
`SettlementMetadataError` on missing/partial stamp caller records
the settlement as 'rejected' for upstream investigation. Raises
`SettlementInvariantError` if the stamped values violate the
canonical sat-amount invariants (range + sum, see
`_assert_sat_invariants`).
"""
if is_bitspire_payment(extra):
data = _parse_extra(machine, payment_hash, gross_sats, extra, super_fee_pct)
return data, False
logger.warning(
f"satmachineadmin: settlement on machine {machine.machine_npub[:12]}... "
f"missing bitSpire extra metadata; back-deriving via "
f"fallback_commission_pct={machine.fallback_commission_pct}. "
f"See aiolabs/lamassu-next#44."
if not (0.0 <= super_fee_fraction <= 1.0):
raise SettlementInvariantError(
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
)
if not is_bitspire_payment(extra):
raise SettlementMetadataError(
f"Payment.extra missing `source: \"bitspire\"` marker on machine "
f"{machine.machine_npub[:12]}... — invoice did not come through "
f"a bitSpire ATM, or the ATM firmware is older than "
f"aiolabs/lamassu-next#44 and didn't stamp the canonical fields"
)
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct), True
def _parse_extra(
machine: Machine,
payment_hash: str,
gross_sats: int,
extra: dict,
super_fee_pct: float,
) -> CreateDcaSettlementData:
"""Happy path: bitSpire populated Payment.extra per lamassu-next#44."""
principal_sats = _coerce_int(extra.get("principal_sats"))
fee_sats = _coerce_int(extra.get("fee_sats"))
if principal_sats is None or fee_sats is None:
# Missing key fields — shouldn't happen post-#44 but defensive.
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct)
commission_sats = fee_sats
platform_fee_sats = round(commission_sats * super_fee_pct)
operator_fee_sats = commission_sats - platform_fee_sats
raise SettlementMetadataError(
f"Payment.extra has source=bitspire but is missing required "
f"fields principal_sats={extra.get('principal_sats')!r} or "
f"fee_sats={extra.get('fee_sats')!r}; the wire-format contract "
f"(lamassu-next#44) requires both. Investigate the ATM "
f"firmware on machine {machine.machine_npub[:12]}..."
)
platform_fee_sats = round(fee_sats * super_fee_fraction)
operator_fee_sats = fee_sats - platform_fee_sats
exchange_rate = _coerce_float(extra.get("exchange_rate"))
if exchange_rate is None or exchange_rate <= 0:
# Without exchange rate we can't compute fiat. Use 1.0 as a stand-in
@ -163,66 +264,39 @@ def _parse_extra(
# dispenser ledger (lamassu-next@8318489). It's the cash that
# physically entered (cash-in) or exited (cash-out) the machine —
# canonical, not derived. We never recompute it from sats × rate
# downstream: the relationship is't load-bearing (commission lives
# downstream: the relationship isn't load-bearing (commission lives
# in BTC today, but the cash side has its own ground truth).
fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0
fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code
return CreateDcaSettlementData(
tx_type = _coerce_str(extra.get("type")) or "cash_out"
data = CreateDcaSettlementData(
machine_id=machine.id,
payment_hash=payment_hash,
bitspire_event_id=None,
bitspire_txid=_coerce_str(extra.get("txid")),
gross_sats=gross_sats,
wire_sats=wire_sats,
fiat_amount=fiat_amount,
fiat_code=fiat_code,
exchange_rate=exchange_rate,
principal_sats=principal_sats,
commission_sats=commission_sats,
fee_sats=fee_sats,
platform_fee_sats=platform_fee_sats,
operator_fee_sats=operator_fee_sats,
used_fallback_split=False,
tx_type=_coerce_str(extra.get("type")) or "cash_out",
tx_type=tx_type,
bills_json=_json_dumps(extra.get("bills")),
cassettes_json=_json_dumps(extra.get("cassettes")),
)
def _parse_fallback(
machine: Machine,
payment_hash: str,
gross_sats: int,
super_fee_pct: float,
) -> CreateDcaSettlementData:
"""Back-derive the split using the machine's fallback_commission_pct.
Same formula as the Lamassu integration used:
base_amount = round(gross / (1 + commission_pct))
commission = gross - base_amount
"""
principal_sats, commission_sats, _effective = calculate_commission(
crypto_atoms=gross_sats,
commission_percentage=machine.fallback_commission_pct,
discount=0.0,
)
platform_fee_sats = round(commission_sats * super_fee_pct)
operator_fee_sats = commission_sats - platform_fee_sats
# No exchange rate from the wire; leave fiat_amount=0 so it's visibly
# incomplete on the operator's reconciliation screen.
return CreateDcaSettlementData(
machine_id=machine.id,
payment_hash=payment_hash,
bitspire_event_id=None,
bitspire_txid=None,
gross_sats=gross_sats,
fiat_amount=0.0,
fiat_code=machine.fiat_code,
exchange_rate=0.0,
principal_sats=principal_sats,
commission_sats=commission_sats,
platform_fee_sats=platform_fee_sats,
operator_fee_sats=operator_fee_sats,
used_fallback_split=True,
tx_type="cash_out",
bills_json=None,
cassettes_json=None,
# Enforce the cross-codebase canonical sat-amount invariants on the
# values bitSpire stamped (post-rename: `fee_fraction` is preferred;
# the old `fee_percent` field is deliberately NOT read here because
# of the 100× misinterpretation risk during the rename window — the
# absolute `fee_sats` stamp is the audit anchor and the sum
# invariants below catch any garbage at the wire).
_assert_sat_invariants(
tx_type=data.tx_type,
wire_sats=data.wire_sats,
principal_sats=data.principal_sats,
fee_sats=data.fee_sats,
fee_fraction=_coerce_float(extra.get("fee_fraction")),
)
return data

View file

@ -3,52 +3,19 @@ Pure calculation functions for DCA transaction processing.
These functions have no external dependencies (no lnbits, no database)
and can be easily tested in isolation.
What's intentionally NOT here (deleted 2026-05-26):
- `calculate_commission` (back-derive principal+fee from a gross-with-
commission-baked-in wire amount). Lamassu-era reverse-derivation;
obsolete since bitSpire stamps `principal_sats` AND `fee_sats`
directly on Payment.extra per aiolabs/lamassu-next#44.
- `calculate_exchange_rate` (principal / fiat_amount). bitSpire stamps
`exchange_rate` directly on Payment.extra too. Not used in production.
"""
from typing import Dict, Tuple
def calculate_commission(
crypto_atoms: int,
commission_percentage: float,
discount: float = 0.0
) -> Tuple[int, int, float]:
"""
Calculate commission split from a Lamassu transaction.
The crypto_atoms from Lamassu already includes the commission baked in.
This function extracts the base amount (for DCA distribution) and
commission amount (for commission wallet).
Formula:
effective_commission = commission_percentage * (100 - discount) / 100
base_amount = round(crypto_atoms / (1 + effective_commission))
commission_amount = crypto_atoms - base_amount
Args:
crypto_atoms: Total sats from Lamassu (includes commission)
commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%)
discount: Discount percentage on commission (e.g., 10.0 for 10% off)
Returns:
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
Example:
>>> calculate_commission(266800, 0.03, 0.0)
(259029, 7771, 0.03)
"""
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
return base_crypto_atoms, commission_amount_sats, effective_commission
def calculate_distribution(
base_amount_sats: int,
client_balances: Dict[str, float],
@ -132,14 +99,15 @@ def calculate_distribution(
def split_two_stage_commission(
commission_sats: int, super_fee_pct: float
fee_sats: int, super_fee_fraction: float
) -> Tuple[int, int]:
"""Stage-1 of the v2 commission split: super takes `super_fee_pct` of the
total commission; the remainder is what the operator's own ruleset acts on.
"""Stage-1 of the v2 commission split: super takes `super_fee_fraction`
of the total fee; the remainder is what the operator's own ruleset
acts on.
Returns (platform_fee_sats, operator_fee_sats). Platform is rounded;
operator absorbs the rounding remainder so platform_fee + operator_fee
== commission_sats exactly.
== fee_sats exactly.
Examples:
>>> split_two_stage_commission(100, 0.30)
@ -151,23 +119,28 @@ def split_two_stage_commission(
>>> split_two_stage_commission(100, 1.0)
(100, 0)
"""
if commission_sats <= 0:
if not (0.0 <= super_fee_fraction <= 1.0):
raise ValueError(
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
)
if fee_sats <= 0:
return 0, 0
platform = round(commission_sats * super_fee_pct)
platform = max(0, min(platform, commission_sats))
operator = commission_sats - platform
platform = round(fee_sats * super_fee_fraction)
platform = max(0, min(platform, fee_sats))
operator = fee_sats - platform
return platform, operator
def allocate_operator_split_legs(
operator_fee_sats: int, leg_pcts: list
operator_fee_sats: int, leg_fractions: list
) -> list:
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
across N leg wallets per `leg_pcts` (each in 0..1, sum should equal 1.0).
across N leg wallets per `leg_fractions` (each in [0, 1], sum should
equal 1.0).
The last leg absorbs the rounding remainder so the sum of allocations
exactly equals operator_fee_sats (assuming pcts sum to ~1.0). Returns
a list of integer sat amounts in the same order as leg_pcts.
exactly equals operator_fee_sats (assuming fractions sum to ~1.0).
Returns a list of integer sat amounts in the same order as leg_fractions.
Examples:
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
@ -179,33 +152,24 @@ def allocate_operator_split_legs(
>>> allocate_operator_split_legs(0, [0.5, 0.5])
[0, 0]
"""
if not leg_pcts:
if not leg_fractions:
return []
if operator_fee_sats <= 0:
return [0] * len(leg_pcts)
return [0] * len(leg_fractions)
for f in leg_fractions:
if not (0.0 <= float(f) <= 1.0):
raise ValueError(
f"every leg fraction must be in [0, 1], got {f}"
)
allocations: list = []
remaining = operator_fee_sats
for idx, pct in enumerate(leg_pcts):
if idx == len(leg_pcts) - 1:
for idx, fraction in enumerate(leg_fractions):
if idx == len(leg_fractions) - 1:
allocations.append(remaining)
else:
amount = round(operator_fee_sats * float(pct))
amount = round(operator_fee_sats * float(fraction))
allocations.append(amount)
remaining -= amount
return allocations
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
"""
Calculate exchange rate in sats per fiat unit.
Args:
base_crypto_atoms: Base amount in sats (after commission)
fiat_amount: Fiat amount dispensed
Returns:
Exchange rate as sats per fiat unit
"""
if fiat_amount <= 0:
return 0.0
return base_crypto_atoms / fiat_amount

41
crud.py
View file

@ -78,10 +78,9 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
"""
INSERT INTO satoshimachine.dca_machines
(id, operator_user_id, machine_npub, wallet_id, name, location,
fiat_code, is_active, fallback_commission_pct, created_at, updated_at)
fiat_code, is_active, created_at, updated_at)
VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name,
:location, :fiat_code, :is_active, :fallback_commission_pct,
:created_at, :updated_at)
:location, :fiat_code, :is_active, :created_at, :updated_at)
""",
{
"id": machine_id,
@ -92,7 +91,6 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
"location": data.location,
"fiat_code": data.fiat_code,
"is_active": True,
"fallback_commission_pct": data.fallback_commission_pct,
"created_at": now,
"updated_at": now,
},
@ -555,14 +553,14 @@ async def create_settlement_idempotent(
"""
INSERT INTO satoshimachine.dca_settlements
(id, machine_id, payment_hash, bitspire_event_id, bitspire_txid,
gross_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
commission_sats, platform_fee_sats, operator_fee_sats,
used_fallback_split, tx_type, bills_json, cassettes_json,
wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
fee_sats, platform_fee_sats, operator_fee_sats,
tx_type, bills_json, cassettes_json,
status, error_message, created_at)
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code,
:exchange_rate, :principal_sats, :commission_sats,
:platform_fee_sats, :operator_fee_sats, :used_fallback_split,
:bitspire_txid, :wire_sats, :fiat_amount, :fiat_code,
:exchange_rate, :principal_sats, :fee_sats,
:platform_fee_sats, :operator_fee_sats,
:tx_type, :bills_json, :cassettes_json, :status,
:error_message, :created_at)
""",
@ -572,15 +570,14 @@ async def create_settlement_idempotent(
"payment_hash": data.payment_hash,
"bitspire_event_id": data.bitspire_event_id,
"bitspire_txid": data.bitspire_txid,
"gross_sats": data.gross_sats,
"wire_sats": data.wire_sats,
"fiat_amount": data.fiat_amount,
"fiat_code": data.fiat_code,
"exchange_rate": data.exchange_rate,
"principal_sats": data.principal_sats,
"commission_sats": data.commission_sats,
"fee_sats": data.fee_sats,
"platform_fee_sats": data.platform_fee_sats,
"operator_fee_sats": data.operator_fee_sats,
"used_fallback_split": data.used_fallback_split,
"tx_type": data.tx_type,
"bills_json": data.bills_json,
"cassettes_json": data.cassettes_json,
@ -840,9 +837,9 @@ async def reset_settlement_for_retry(
async def apply_partial_dispense(
settlement_id: str,
*,
new_gross_sats: int,
new_wire_sats: int,
new_principal_sats: int,
new_commission_sats: int,
new_fee_sats: int,
new_platform_fee_sats: int,
new_operator_fee_sats: int,
new_fiat_amount: float,
@ -858,9 +855,9 @@ async def apply_partial_dispense(
await db.execute(
"""
UPDATE satoshimachine.dca_settlements
SET gross_sats = :gross,
SET wire_sats = :gross,
principal_sats = :principal,
commission_sats = :commission,
fee_sats = :commission,
platform_fee_sats = :platform,
operator_fee_sats = :operator,
fiat_amount = :fiat,
@ -875,9 +872,9 @@ async def apply_partial_dispense(
""",
{
"id": settlement_id,
"gross": new_gross_sats,
"gross": new_wire_sats,
"principal": new_principal_sats,
"commission": new_commission_sats,
"commission": new_fee_sats,
"platform": new_platform_fee_sats,
"operator": new_operator_fee_sats,
"fiat": new_fiat_amount,
@ -1013,9 +1010,9 @@ async def replace_commission_splits(
await db.execute(
"""
INSERT INTO satoshimachine.dca_commission_splits
(id, machine_id, operator_user_id, target, label, pct,
(id, machine_id, operator_user_id, target, label, fraction,
sort_order, created_at)
VALUES (:id, :machine_id, :uid, :target, :label, :pct,
VALUES (:id, :machine_id, :uid, :target, :label, :fraction,
:sort_order, :created_at)
""",
{
@ -1024,7 +1021,7 @@ async def replace_commission_splits(
"uid": operator_user_id,
"target": leg.target,
"label": leg.label,
"pct": leg.pct,
"fraction": leg.fraction,
"sort_order": leg.sort_order,
"created_at": now,
},

View file

@ -111,32 +111,32 @@ async def _record_skipped_leg(
)
def _resolve_partial_dispense_gross(
def _resolve_partial_dispense_wire(
settlement: DcaSettlement, data: PartialDispenseData
) -> int:
if data.dispensed_sats is not None:
new_gross = int(data.dispensed_sats)
new_wire = int(data.dispensed_sats)
elif data.dispensed_fraction is not None:
new_gross = round(settlement.gross_sats * float(data.dispensed_fraction))
new_wire = round(settlement.wire_sats * float(data.dispensed_fraction))
else:
raise ValueError("provide one of dispensed_sats or dispensed_fraction")
if new_gross < 0:
if new_wire < 0:
raise ValueError("partial dispense cannot be negative")
if new_gross > settlement.gross_sats:
if new_wire > settlement.wire_sats:
raise ValueError(
f"partial dispense ({new_gross} sats) cannot exceed the original "
f"gross ({settlement.gross_sats} sats)"
f"partial dispense ({new_wire} sats) cannot exceed the original "
f"wire amount ({settlement.wire_sats} sats)"
)
return new_gross
return new_wire
def _build_partial_dispense_memo(
settlement: DcaSettlement,
data: PartialDispenseData,
*,
new_gross: int,
new_wire: int,
new_principal: int,
new_commission: int,
new_fee: int,
new_platform: int,
new_operator: int,
) -> str:
@ -148,13 +148,13 @@ def _build_partial_dispense_memo(
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
return (
f"[{ts}] partial dispense applied — {adjust}. "
f"Original gross={settlement.gross_sats} "
f"Original wire={settlement.wire_sats} "
f"principal={settlement.principal_sats} "
f"commission={settlement.commission_sats} "
f"fee={settlement.fee_sats} "
f"(super_fee={settlement.platform_fee_sats} "
f"operator_fee={settlement.operator_fee_sats}). "
f"New gross={new_gross} principal={new_principal} "
f"commission={new_commission} "
f"New wire={new_wire} principal={new_principal} "
f"fee={new_fee} "
f"(super_fee={new_platform} operator_fee={new_operator}). "
f"Reason: {reason}"
)
@ -275,10 +275,10 @@ async def apply_partial_dispense_and_redistribute(
When a bitSpire dispense fails mid-transaction (e.g., dispenser jam after
6 of 10 bills), the operator confirms the actual amount dispensed and we
re-allocate the split against that partial gross. Sat amounts scale
re-allocate the split against that partial wire amount. Sat amounts scale
linearly, preserving the original commission ratio exactly. The two-stage
super/operator split also scales by the *original* platform_fee_sats /
commission_sats ratio rather than re-reading current super_fee_pct
fee_sats ratio rather than re-reading current super_fee_fraction
this honors the "absolute fields are the source of truth" invariant
even when super has changed the global rate since the settlement landed
(closes #11 H6).
@ -296,8 +296,8 @@ async def apply_partial_dispense_and_redistribute(
settlement = await get_settlement(settlement_id)
if settlement is None:
raise ValueError(f"settlement {settlement_id} not found")
if settlement.gross_sats <= 0:
raise ValueError("cannot partial-dispense a zero-gross settlement")
if settlement.wire_sats <= 0:
raise ValueError("cannot partial-dispense a zero-wire settlement")
completed = await count_completed_legs_for_settlement(settlement_id)
if completed > 0:
raise ValueError(
@ -305,33 +305,33 @@ async def apply_partial_dispense_and_redistribute(
"(Lightning payments can't be clawed back)"
)
new_gross = _resolve_partial_dispense_gross(settlement, data)
new_wire = _resolve_partial_dispense_wire(settlement, data)
# Linear scale preserves the original commission ratio exactly.
scale = new_gross / settlement.gross_sats
new_commission = round(settlement.commission_sats * scale)
new_principal = new_gross - new_commission
scale = new_wire / settlement.wire_sats
new_fee = round(settlement.fee_sats * scale)
new_principal = new_wire - new_fee
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
# settlement row — NOT the current super_fee_pct. The contract was
# settlement row — NOT the current super_fee_fraction. The contract was
# locked at landing; super raising or lowering the global rate after
# the fact must not retroactively change this transaction's share.
# Operator absorbs the rounding remainder so platform + operator
# == new_commission exactly.
if settlement.commission_sats > 0:
ratio = settlement.platform_fee_sats / settlement.commission_sats
# == new_fee exactly.
if settlement.fee_sats > 0:
ratio = settlement.platform_fee_sats / settlement.fee_sats
else:
ratio = 0.0
new_platform = round(new_commission * ratio)
new_platform = max(0, min(new_platform, new_commission))
new_operator = new_commission - new_platform
new_platform = round(new_fee * ratio)
new_platform = max(0, min(new_platform, new_fee))
new_operator = new_fee - new_platform
memo = _build_partial_dispense_memo(
settlement,
data,
new_gross=new_gross,
new_wire=new_wire,
new_principal=new_principal,
new_commission=new_commission,
new_fee=new_fee,
new_platform=new_platform,
new_operator=new_operator,
)
@ -339,9 +339,9 @@ async def apply_partial_dispense_and_redistribute(
await void_open_legs_for_settlement(settlement_id)
updated = await apply_partial_dispense(
settlement_id,
new_gross_sats=new_gross,
new_wire_sats=new_wire,
new_principal_sats=new_principal,
new_commission_sats=new_commission,
new_fee_sats=new_fee,
new_platform_fee_sats=new_platform,
new_operator_fee_sats=new_operator,
new_fiat_amount=new_fiat,
@ -467,7 +467,7 @@ async def _pay_operator_splits(
# Pure allocator handles the rounding rule (last leg absorbs remainder).
leg_amounts = allocate_operator_split_legs(
settlement.operator_fee_sats,
[float(leg.pct) for leg in splits],
[float(leg.fraction) for leg in splits],
)
for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)):
if amount <= 0:

View file

@ -62,7 +62,7 @@ async def m001_satmachine_v2_initial(db):
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
id TEXT PRIMARY KEY,
super_fee_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
super_fee_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
@ -72,7 +72,7 @@ async def m001_satmachine_v2_initial(db):
)
if not existing:
await db.execute(
"INSERT INTO satoshimachine.super_config (id, super_fee_pct) "
"INSERT INTO satoshimachine.super_config (id, super_fee_fraction) "
"VALUES ('default', 0.0000)"
)
@ -88,7 +88,6 @@ async def m001_satmachine_v2_initial(db):
location TEXT,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
is_active BOOLEAN NOT NULL DEFAULT true,
fallback_commission_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0500,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
@ -180,10 +179,10 @@ async def m001_satmachine_v2_initial(db):
# append-only audit memo for partial-dispense + operator notes.
#
# platform_fee_sats and operator_fee_sats are absolute BIGINT,
# NOT derived percentages — when the v2 customer-discount engine
# NOT derived fractions — when the v2 customer-discount engine
# ships, these two columns are the audit-grade record of who
# forgave what per transaction. Do not collapse them into a single
# commission_pct. See plan section "Customer discounts" and #10.
# fee_fraction. See plan section "Customer discounts" and #10.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
id TEXT PRIMARY KEY,
@ -191,15 +190,14 @@ async def m001_satmachine_v2_initial(db):
payment_hash TEXT NOT NULL UNIQUE,
bitspire_event_id TEXT,
bitspire_txid TEXT,
gross_sats BIGINT NOT NULL,
wire_sats BIGINT NOT NULL,
fiat_amount DECIMAL(10,2) NOT NULL,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
exchange_rate REAL NOT NULL,
principal_sats BIGINT NOT NULL,
commission_sats BIGINT NOT NULL,
fee_sats BIGINT NOT NULL,
platform_fee_sats BIGINT NOT NULL,
operator_fee_sats BIGINT NOT NULL,
used_fallback_split BOOLEAN NOT NULL DEFAULT false,
tx_type TEXT NOT NULL,
bills_json TEXT,
cassettes_json TEXT,
@ -217,9 +215,9 @@ async def m001_satmachine_v2_initial(db):
)
# 7. dca_commission_splits — operator's rules for distributing the
# *remainder* (commission_sats - platform_fee_sats). One row per
# *remainder* (fee_sats - platform_fee_sats). One row per
# leg. machine_id=NULL = operator default; non-null = per-machine
# override. Sum(pct) per (operator, machine) must equal 1.0 —
# override. Sum(fraction) per (operator, machine) must equal 1.0 —
# enforced at write-time in crud.py.
#
# `target` accepts any of (splitpayments-style):
@ -235,7 +233,7 @@ async def m001_satmachine_v2_initial(db):
operator_user_id TEXT NOT NULL,
target TEXT NOT NULL,
label TEXT,
pct DECIMAL(10,4) NOT NULL,
fraction DECIMAL(10,4) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
@ -439,6 +437,76 @@ async def m004_introduce_dca_lp_table(db):
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
async def m006_rename_to_canonical_sat_vocabulary(db):
"""Adopt the cross-codebase canonical sat-amount vocabulary AND drop
the now-obsolete Lamassu-era fallback columns, per the decision at
memory `reference_sat_amount_vocabulary.md` (2026-05-26):
Renames:
- dca_settlements.gross_sats wire_sats
- dca_settlements.commission_sats fee_sats
- super_config.super_fee_pct super_fee_fraction
- dca_commission_splits.pct fraction
Drops (Lamassu-era reverse-derivation is obsolete since bitSpire
stamps both `principal_sats` AND `fee_sats` directly on
Payment.extra per lamassu-next#44 — there's nothing to back-derive):
- dca_machines.fallback_commission_pct (was the rate used by the
deleted `_parse_fallback` path)
- dca_settlements.used_fallback_split (was the per-row marker for
that path)
Same canonical applies on the lamassu-next + atm-tui side; the
rename is coordinated via `~/dev/coordination/log.md` (2026-05-26).
Each step is idempotent probe for the OLD column; rename/drop only
if present; otherwise no-op (covers fresh installs where m001
already laid down the canonical schema).
Why a single migration: all driven by the same decision and any
external code wants to see the whole rename + cleanup land at once.
"""
renames = [
("dca_settlements", "gross_sats", "wire_sats"),
("dca_settlements", "commission_sats", "fee_sats"),
("super_config", "super_fee_pct", "super_fee_fraction"),
("dca_commission_splits", "pct", "fraction"),
]
for table, old_col, new_col in renames:
try:
await db.fetchone(
f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1"
)
except Exception:
# old column doesn't exist; either rename already landed or
# m001 produced the canonical schema directly on fresh install.
continue
await db.execute(
f"ALTER TABLE satoshimachine.{table} "
f"RENAME COLUMN {old_col} TO {new_col}"
)
# Drop the Lamassu-era fallback columns. Same idempotency pattern.
# Try both old (_pct) and new (_fraction) names for the dca_machines
# column since an install could be at either rename state.
drops = [
("dca_machines", "fallback_commission_pct"),
("dca_machines", "fallback_commission_fraction"),
("dca_settlements", "used_fallback_split"),
]
for table, col in drops:
try:
await db.fetchone(
f"SELECT {col} FROM satoshimachine.{table} LIMIT 1"
)
except Exception:
# column doesn't exist; either already dropped or never present.
continue
await db.execute(
f"ALTER TABLE satoshimachine.{table} 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`.

View file

@ -29,18 +29,6 @@ class CreateMachineData(BaseModel):
name: Optional[str] = None
location: Optional[str] = None
fiat_code: str = "GTQ"
# Used only when bitSpire's settlement event omits principal_sats/
# fee_sats in Payment.extra (older bitSpire or edge cases). See
# plan's lamassu-next informational issue #1.
fallback_commission_pct: float = 0.05
@validator("fallback_commission_pct")
def commission_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("fallback_commission_pct must be between 0 and 1")
return round(float(v), 4)
class Machine(BaseModel):
@ -52,7 +40,6 @@ class Machine(BaseModel):
location: Optional[str]
fiat_code: str
is_active: bool
fallback_commission_pct: float
created_at: datetime
updated_at: datetime
@ -63,15 +50,6 @@ class UpdateMachineData(BaseModel):
fiat_code: Optional[str] = None
is_active: Optional[bool] = None
wallet_id: Optional[str] = None
fallback_commission_pct: Optional[float] = None
@validator("fallback_commission_pct")
def commission_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("fallback_commission_pct must be between 0 and 1")
return round(float(v), 4)
# =============================================================================
@ -225,7 +203,7 @@ class UpdateDepositStatusData(BaseModel):
# =============================================================================
# platform_fee_sats and operator_fee_sats are absolute audit-grade values.
# Today they equal the contractual split; tomorrow (post-v1 promo engine)
# they record who-forgave-what. DO NOT collapse them into a single pct.
# they record who-forgave-what. DO NOT collapse them into a single fraction.
# See plan section "Customer discounts & promotions (post-v1)".
@ -234,15 +212,14 @@ class CreateDcaSettlementData(BaseModel):
payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion
bitspire_txid: Optional[str] = None
gross_sats: int
wire_sats: int
fiat_amount: float
fiat_code: str = "GTQ"
exchange_rate: float
principal_sats: int
commission_sats: int
fee_sats: int
platform_fee_sats: int
operator_fee_sats: int
used_fallback_split: bool = False
tx_type: str # 'cash_out' | 'cash_in'
bills_json: Optional[str] = None
cassettes_json: Optional[str] = None
@ -254,15 +231,14 @@ class DcaSettlement(BaseModel):
payment_hash: str
bitspire_event_id: Optional[str]
bitspire_txid: Optional[str]
gross_sats: int
wire_sats: int
fiat_amount: float
fiat_code: str
exchange_rate: float
principal_sats: int
commission_sats: int
fee_sats: int
platform_fee_sats: int
operator_fee_sats: int
used_fallback_split: bool
tx_type: str
bills_json: Optional[str]
cassettes_json: Optional[str]
@ -295,8 +271,8 @@ class DcaSettlement(BaseModel):
# Commission splits — operator-defined remainder allocation per machine.
# =============================================================================
# machine_id=NULL means operator's default; non-null means per-machine override.
# Sum of pct across rows for a (operator_user_id, machine_id) scope must be 1.0,
# enforced at write-time in crud.py.
# Sum of fraction across rows for a (operator_user_id, machine_id) scope must
# be 1.0, enforced at write-time in crud.py.
class CommissionSplitLeg(BaseModel):
@ -311,7 +287,7 @@ class CommissionSplitLeg(BaseModel):
target: str
label: Optional[str] = None
pct: float
fraction: float
sort_order: int = 0
@validator("target")
@ -321,10 +297,10 @@ class CommissionSplitLeg(BaseModel):
raise ValueError("target cannot be empty")
return v
@validator("pct")
def pct_in_unit_range(cls, v):
@validator("fraction")
def fraction_in_unit_range(cls, v):
if v < 0 or v > 1:
raise ValueError("pct must be between 0 and 1")
raise ValueError("fraction must be between 0 and 1")
return round(float(v), 4)
@ -334,7 +310,7 @@ class CommissionSplit(BaseModel):
operator_user_id: str
target: str
label: Optional[str]
pct: float
fraction: float
sort_order: int
created_at: datetime
@ -351,9 +327,9 @@ class SetCommissionSplitsData(BaseModel):
@validator("legs")
def legs_sum_to_one(cls, v):
total = round(sum(leg.pct for leg in v), 4)
total = round(sum(leg.fraction for leg in v), 4)
if abs(total - 1.0) > 0.0001:
raise ValueError(f"split percentages must sum to 1.0 (got {total})")
raise ValueError(f"split fractions must sum to 1.0 (got {total})")
return v
@ -440,21 +416,21 @@ class TelemetrySnapshot(BaseModel):
class SuperConfig(BaseModel):
id: str
super_fee_pct: float
super_fee_fraction: float
super_fee_wallet_id: Optional[str]
updated_at: datetime
class UpdateSuperConfigData(BaseModel):
super_fee_pct: Optional[float] = None
super_fee_fraction: Optional[float] = None
super_fee_wallet_id: Optional[str] = None
@validator("super_fee_pct")
@validator("super_fee_fraction")
def fee_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("super_fee_pct must be between 0 and 1")
raise ValueError("super_fee_fraction must be between 0 and 1")
return round(float(v), 4)

View file

@ -82,7 +82,7 @@ window.app = Vue.createApp({
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
{
name: 'error_message',
label: 'Error',
@ -100,7 +100,7 @@ window.app = Vue.createApp({
superFeeDialog: {
show: false,
saving: false,
data: {super_fee_pct: 0, super_fee_wallet_id: ''}
data: {super_fee_fraction: 0, super_fee_wallet_id: ''}
},
// UI configuration -----------------------------------------------
@ -111,12 +111,6 @@ window.app = Vue.createApp({
{name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'},
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
{name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'},
{
name: 'fallback_commission_pct',
label: 'Fallback %',
field: 'fallback_commission_pct',
align: 'right'
},
{name: 'actions', label: 'Actions', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 10, sortBy: 'name'}
@ -166,12 +160,12 @@ window.app = Vue.createApp({
columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
{name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'},
{
name: 'commission_sats',
label: 'Commission',
field: 'commission_sats',
name: 'fee_sats',
label: 'Fee',
field: 'fee_sats',
align: 'right'
},
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
@ -332,7 +326,7 @@ window.app = Vue.createApp({
},
commissionSum() {
return this.commissionLegs.reduce(
(acc, leg) => acc + (Number(leg.pct) || 0), 0
(acc, leg) => acc + (Number(leg.fraction) || 0), 0
)
},
commissionSumValid() {
@ -351,7 +345,7 @@ window.app = Vue.createApp({
if (idx === this.commissionLegs.length - 1) {
sats = remaining
} else {
sats = Math.round(total * (Number(leg.pct) || 0))
sats = Math.round(total * (Number(leg.fraction) || 0))
remaining -= sats
}
out.push({label: leg.label, sats})
@ -531,7 +525,7 @@ window.app = Vue.createApp({
// -----------------------------------------------------------------
openSuperFeeDialog() {
this.superFeeDialog.data = {
super_fee_pct: this.superConfig?.super_fee_pct ?? 0,
super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
}
this.superFeeDialog.show = true
@ -544,7 +538,7 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.request(
'PUT', SUPER_FEE_PATH, null,
{
super_fee_pct: Number(d.super_fee_pct),
super_fee_fraction: Number(d.super_fee_fraction),
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
}
)
@ -565,7 +559,7 @@ window.app = Vue.createApp({
this._downloadCsv(
'machines.csv',
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
'is_active', 'fallback_commission_pct', 'created_at'],
'is_active', 'created_at'],
this.machines
)
},
@ -687,7 +681,6 @@ window.app = Vue.createApp({
location: machine.location || '',
wallet_id: machine.wallet_id,
fiat_code: machine.fiat_code,
fallback_commission_pct: machine.fallback_commission_pct,
is_active: machine.is_active
}
this.editMachineDialog.show = true
@ -706,7 +699,6 @@ window.app = Vue.createApp({
location: d.location,
wallet_id: d.wallet_id,
fiat_code: d.fiat_code,
fallback_commission_pct: d.fallback_commission_pct,
is_active: d.is_active
}
)
@ -1118,7 +1110,7 @@ window.app = Vue.createApp({
target: leg.target || '',
targetKind: this._inferTargetKind(leg.target),
label: leg.label || '',
pct: Number(leg.pct) || 0
fraction: Number(leg.fraction) || 0
}))
} catch (e) {
this.commissionLegs = []
@ -1140,7 +1132,7 @@ window.app = Vue.createApp({
target: this.walletOptions[0]?.value || '',
targetKind: 'wallet',
label: '',
pct: 0
fraction: 0
})
},
@ -1157,7 +1149,7 @@ window.app = Vue.createApp({
legs: this.commissionLegs.map((leg, idx) => ({
target: (leg.target || '').toString().trim(),
label: leg.label || null,
pct: Number(leg.pct),
fraction: Number(leg.fraction),
sort_order: idx
}))
}
@ -1330,8 +1322,7 @@ window.app = Vue.createApp({
wallet_id: null,
name: '',
location: '',
fiat_code: 'GTQ',
fallback_commission_pct: 0.05
fiat_code: 'GTQ'
}
},
@ -1341,8 +1332,7 @@ window.app = Vue.createApp({
wallet_id: d.wallet_id,
name: (d.name || '').trim() || null,
location: (d.location || '').trim() || null,
fiat_code: (d.fiat_code || 'GTQ').trim(),
fallback_commission_pct: Number(d.fallback_commission_pct ?? 0.05)
fiat_code: (d.fiat_code || 'GTQ').trim()
}
},

154
tasks.py
View file

@ -3,14 +3,26 @@
# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then
# for each successful inbound payment:
# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip.
# 2. Parses Payment.extra for bitSpire split metadata (post-lamassu-next#44).
# Falls back to machine.fallback_commission_pct if extra is absent.
# 3. Computes the two-stage split (super_fee first, operator remainder).
# 4. Inserts a dca_settlements row idempotently (keyed by payment_hash).
# 5. Spawns the distribution processor on a background task so the
# 2. Verifies the originating Nostr signer matches the machine identity
# (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey
# stamped by lnbits nostr-transport dispatcher).
# 3. Parses Payment.extra for bitSpire's canonical split stamp per
# aiolabs/lamassu-next#44 (`source: "bitspire"`, principal_sats,
# fee_sats, exchange_rate). Raises if the stamp is missing or
# garbage (no more Lamassu-era reverse-derivation fallback).
# 4. Computes the two-stage split (super_fee first, operator remainder).
# 5. Inserts a dca_settlements row idempotently (keyed by payment_hash).
# 6. Spawns the distribution processor on a background task so the
# LNbits invoice queue (which serves ALL extensions on the node)
# keeps draining while we move sats. Concurrency is safe because
# process_settlement now uses an optimistic-lock claim (fix bundle 1).
#
# Rejection paths (settlement still recorded with status='rejected' for
# operator forensics, but distribution is skipped):
# - SettlementAttributionError: signer mismatch (G5).
# - SettlementMetadataError: Payment.extra missing bitSpire stamp.
# - SettlementInvariantError: stamped values violate the canonical
# sat-amount invariants (range/sum).
import asyncio
@ -20,6 +32,8 @@ from loguru import logger
from .bitspire import (
SettlementAttributionError,
SettlementInvariantError,
SettlementMetadataError,
assert_nostr_attribution,
parse_settlement,
)
@ -29,6 +43,7 @@ from .crud import (
get_super_config,
)
from .distribution import process_settlement
from .models import CreateDcaSettlementData, Machine
LISTENER_NAME = "ext_satmachineadmin"
@ -64,32 +79,102 @@ async def _handle_payment(payment: Payment) -> None:
if machine is None:
return
extra = payment.extra or {}
# 1) Attribution FIRST — uses only `extra.nostr_sender_pubkey` (no parse
# needed). If this fails, every subsequent field on `extra` is
# attacker-controlled and untrustworthy — record a minimal rejected
# row with placeholder zeros (don't display unverified split numbers
# in the operator dashboard).
try:
assert_nostr_attribution(machine, extra)
except SettlementAttributionError as exc:
await _record_rejected(payment, machine, exc)
return
# 2) Parse + invariants. parse_settlement enforces the canonical
# sat-amount invariants on the bitSpire-stamped numbers (range +
# direction-specific sum). Raises SettlementMetadataError if the
# stamp is missing, SettlementInvariantError on any range/sum
# breach.
super_config = await get_super_config()
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
data, used_fallback = parse_settlement(
super_fee_fraction = (
float(super_config.super_fee_fraction) if super_config else 0.0
)
try:
data = parse_settlement(
machine=machine,
payment_hash=payment.payment_hash,
gross_sats=payment.sat,
wire_sats=payment.sat,
extra=extra,
super_fee_pct=super_fee_pct,
super_fee_fraction=super_fee_fraction,
)
except (SettlementMetadataError, SettlementInvariantError) as exc:
await _record_rejected(payment, machine, exc)
return
# Stamp the originating Nostr event id (the kind-21000 create_invoice
# RPC) onto the row for post-hoc forensics — pairs with the
# assert_nostr_attribution check below so an auditor can trace
# settlement -> RPC event -> signing key without trusting our DB.
# RPC) onto the row for post-hoc forensics — an auditor can trace
# settlement → RPC event → signing key without trusting our DB.
nostr_event_id = extra.get("nostr_event_id")
if isinstance(nostr_event_id, str) and nostr_event_id:
data.bitspire_event_id = nostr_event_id
# Cross-check the signature-verified signer pubkey (stamped by
# LNbits' nostr-transport dispatcher onto Payment.extra) against
# the machine identity. Routing today is wallet_id-only with no
# cryptographic binding — this restores end-to-end attribution
# between "the npub that asked LNbits for the invoice" and "the
# machine we're crediting" (aiolabs/satmachineadmin#19, G5).
try:
assert_nostr_attribution(machine, extra)
except SettlementAttributionError as exc:
# 3) Insert + distribute.
settlement = await create_settlement_idempotent(data, initial_status="pending")
if settlement is None:
logger.error(
f"satmachineadmin: failed to insert settlement for "
f"payment_hash={payment.payment_hash[:12]}..."
)
return
logger.info(
f"satmachineadmin: landed settlement {settlement.id} for "
f"machine={machine.machine_npub[:12]}... "
f"wire={data.wire_sats}sats principal={data.principal_sats}sats "
f"fee={data.fee_sats}sats "
f"(super_fee={data.platform_fee_sats} "
f"operator_fee={data.operator_fee_sats})"
)
# Spawn distribution on a background task so the LNbits invoice queue
# (shared across all extensions) keeps draining while we move sats.
# Concurrency-safe: process_settlement uses claim_settlement_for_processing
# so a listener re-fire can't double-process. Listener latency is now
# bounded by the create_settlement_idempotent insert, not by the N+M
# internal pay_invoice round-trips of a full distribution.
task = asyncio.create_task(process_settlement(settlement.id))
_inflight_distributions.add(task)
task.add_done_callback(_inflight_distributions.discard)
async def _record_rejected(
payment: Payment, machine: Machine, exc: Exception
) -> None:
"""Insert a minimal `dca_settlements` row with `status='rejected'` and
the exception message for operator forensics.
Used for every rejection path (attribution / metadata / invariant).
The split fields are zero placeholders we deliberately do NOT
display attacker-supplied numbers in the operator dashboard. The
wire amount (`payment.sat`) is the only value LNbits authenticated;
everything else from Payment.extra is untrusted in this branch.
"""
data = CreateDcaSettlementData(
machine_id=machine.id,
payment_hash=payment.payment_hash,
wire_sats=payment.sat,
fiat_amount=0.0,
fiat_code=machine.fiat_code,
exchange_rate=0.0,
principal_sats=0,
fee_sats=0,
platform_fee_sats=0,
operator_fee_sats=0,
# tx_type is unknown for rejection paths; default to cash_out
# (the only direction currently wired). When S8 lands the
# listener will branch on tx_type from extra, and this default
# gets revisited.
tx_type="cash_out",
)
rejected = await create_settlement_idempotent(
data, initial_status="rejected", error_message=str(exc)
)
@ -104,30 +189,3 @@ async def _handle_payment(payment: Payment) -> None:
f"(machine={machine.machine_npub[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
)
return
settlement = await create_settlement_idempotent(data, initial_status="pending")
if settlement is None:
logger.error(
f"satmachineadmin: failed to insert settlement for "
f"payment_hash={payment.payment_hash[:12]}..."
)
return
fb = " (fallback split)" if used_fallback else ""
logger.info(
f"satmachineadmin: landed settlement {settlement.id} for "
f"machine={machine.machine_npub[:12]}... "
f"gross={data.gross_sats}sats principal={data.principal_sats}sats "
f"commission={data.commission_sats}sats "
f"(super_fee={data.platform_fee_sats} "
f"operator_fee={data.operator_fee_sats}){fb}"
)
# Spawn distribution on a background task so the LNbits invoice queue
# (shared across all extensions) keeps draining while we move sats.
# Concurrency-safe: process_settlement uses claim_settlement_for_processing
# so a listener re-fire can't double-process. Listener latency is now
# bounded by the create_settlement_idempotent insert, not by the N+M
# internal pay_invoice round-trips of a full distribution.
task = asyncio.create_task(process_settlement(settlement.id))
_inflight_distributions.add(task)
task.add_done_callback(_inflight_distributions.discard)

View file

@ -31,13 +31,13 @@
<q-banner
v-if="superConfig"
class="q-mb-md"
:class="superConfig.super_fee_pct > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
:class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
<template v-slot:avatar>
<q-icon name="account_balance" :color="superConfig.super_fee_pct > 0 ? 'blue' : 'grey'"></q-icon>
<q-icon name="account_balance" :color="superConfig.super_fee_fraction > 0 ? 'blue' : 'grey'"></q-icon>
</template>
<span :style="{fontWeight: 500}">
LNbits platform fee:
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_pct * 100).toFixed(2) }%</span>
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_fraction * 100).toFixed(2) }%</span>
of each transaction's commission.
</span>
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
@ -143,12 +143,6 @@
v-text="shortId(props.row.wallet_id)"></code>
</q-td>
<q-td key="fiat_code" v-text="props.row.fiat_code"></q-td>
<q-td key="fallback_commission_pct">
<span v-text="(props.row.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
<q-tooltip>
Used only when bitSpire doesn't supply a per-tx split.
</q-tooltip>
</q-td>
<q-td key="actions" auto-width>
<q-btn flat dense round size="sm" icon="visibility"
color="primary"
@ -529,13 +523,13 @@
dense outlined></q-input>
</div>
<div class="col-5 col-md-3">
<q-input v-model.number="leg.pct"
<q-input v-model.number="leg.fraction"
label="% (0..1)"
type="number" step="0.01" min="0" max="1"
dense outlined>
<template v-slot:append>
<span :style="{fontSize: '0.75em', opacity: 0.6}"
v-text="((leg.pct || 0) * 100).toFixed(1) + '%'"></span>
v-text="((leg.fraction || 0) * 100).toFixed(1) + '%'"></span>
</template>
</q-input>
</div>
@ -630,8 +624,8 @@
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="gross_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="error_message">
<span :style="{fontSize: '0.85em', opacity: 0.8}"
@ -792,14 +786,6 @@
dense outlined
:rules="[v => !!v || 'Pick a wallet']"></q-select>
<q-input
v-model.number="addMachineDialog.data.fallback_commission_pct"
label="Fallback commission % (decimal: 0.05 = 5%)"
hint="Only used if bitSpire doesn't supply a per-tx split (lamassu-next#44)."
type="number" step="0.0001" min="0" max="1"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model="addMachineDialog.data.fiat_code"
label="Fiat code"
@ -855,12 +841,6 @@
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
<span v-text="machineDetail.machine.location || '—'"></span>
</div>
<div class="col-6 col-md-3">
<div class="text-caption" :style="{opacity: 0.6}">
Fallback commission %
</div>
<span v-text="(machineDetail.machine.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
@ -893,27 +873,19 @@
<q-td key="status">
<q-badge :color="settlementStatusColor(props.row.status)"
:label="props.row.status"></q-badge>
<q-icon v-if="props.row.used_fallback_split"
name="warning_amber" color="orange" size="sm"
class="q-ml-xs">
<q-tooltip>
Fallback split — bitSpire didn't supply per-tx
net/fee. See lamassu-next#44.
</q-tooltip>
</q-icon>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="gross_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="principal_sats" class="text-right">
<span v-text="formatSats(props.row.principal_sats)"></span>
</q-td>
<q-td key="commission_sats" class="text-right">
<span v-text="formatSats(props.row.commission_sats)"></span>
<q-td key="fee_sats" class="text-right">
<span v-text="formatSats(props.row.fee_sats)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}">
super
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
@ -996,7 +968,7 @@
<q-icon name="info" color="blue"></q-icon>
</template>
Original gross:
<b v-text="formatSats(partialDispenseDialog.settlement.gross_sats)"></b>.
<b v-text="formatSats(partialDispenseDialog.settlement.wire_sats)"></b>.
Provide what was actually dispensed. Sat amounts will scale linearly,
the commission split will recompute, and distribution will re-run.
</q-banner>
@ -1019,7 +991,7 @@
label="Dispensed sats"
hint="Exact sat amount actually dispensed (≤ original gross)"
type="number" step="1" min="0"
:max="partialDispenseDialog.settlement.gross_sats"
:max="partialDispenseDialog.settlement.wire_sats"
dense outlined></q-input>
</q-tab-panel>
</q-tab-panels>
@ -1085,7 +1057,7 @@
Operators see this as a read-only banner. Wallet ID is where the
collected fee lands; typically a wallet you (the super) own.
</p>
<q-input v-model.number="superFeeDialog.data.super_fee_pct"
<q-input v-model.number="superFeeDialog.data.super_fee_fraction"
label="Fee % (decimal, 0..1)"
hint="0.30 = 30% of every operator's commission"
type="number" step="0.0001" min="0" max="1"
@ -1337,10 +1309,6 @@
emit-value map-options
class="q-mb-md"
dense outlined></q-select>
<q-input v-model.number="editMachineDialog.data.fallback_commission_pct"
label="Fallback commission %"
type="number" step="0.0001" min="0" max="1"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="editMachineDialog.data.fiat_code"
label="Fiat code" class="q-mb-md" dense outlined></q-input>
<q-toggle v-model="editMachineDialog.data.is_active"

View file

@ -1,114 +1,19 @@
"""
Tests for DCA transaction calculations using empirical data.
Tests for DCA transaction calculations.
These tests verify commission and distribution calculations against
real Lamassu transaction data to ensure the math is correct.
Covers the pure-function helpers that survive the 2026-05-26 cleanup:
- calculate_distribution (proportional split across LPs by balance)
The previous test surface for `calculate_commission` and
`calculate_exchange_rate` was deleted alongside those functions the
Lamassu-era reverse-derivation is obsolete now that bitSpire stamps
`principal_sats` and `fee_sats` directly on Payment.extra.
Two-stage commission split tests live in `test_two_stage_split.py`.
"""
import pytest
from decimal import Decimal
from typing import Dict, List, Tuple
# Import from the parent package (following lnurlp pattern)
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
# =============================================================================
# COMMISSION CALCULATION TESTS
# =============================================================================
class TestCommissionCalculation:
"""Tests for commission calculation logic."""
# Empirical test cases: (crypto_atoms, commission%, discount%, expected_base, expected_commission)
# Formula: base = round(crypto_atoms / (1 + effective_commission))
# Where: effective_commission = commission_percentage * (100 - discount) / 100
EMPIRICAL_COMMISSION_CASES = [
# =============================================================
# REAL LAMASSU TRANSACTIONS (extracted from production database)
# =============================================================
# 8.75% commission, no discount - small transaction
# 15600 / 1.0875 = 14344.827... → 14345
(15600, 0.0875, 0.0, 14345, 1255),
# 8.75% commission, no discount - large transaction
# 309200 / 1.0875 = 284322.298... → 284322
(309200, 0.0875, 0.0, 284322, 24878),
# 5.5% commission, no discount
# 309500 / 1.055 = 293364.928... → 293365
(309500, 0.055, 0.0, 293365, 16135),
# 5.5% commission with 100% discount (no commission charged)
# effective = 0.055 * (100-100)/100 = 0
(292400, 0.055, 100.0, 292400, 0),
# 5.5% commission with 90% discount
# effective = 0.055 * (100-90)/100 = 0.0055
# 115000 / 1.0055 = 114370.96... → 114371
(115000, 0.055, 90.0, 114371, 629),
# 5.5% commission, no discount - 1300 GTQ transaction
# 205600 / 1.055 = 194881.516... → 194882
# Note: This tx showed 0.01 GTQ rounding discrepancy in per-client fiat
(205600, 0.055, 0.0, 194882, 10718),
# =============================================================
# SYNTHETIC TEST CASES (edge cases)
# =============================================================
# Zero commission - all goes to base
(100000, 0.0, 0.0, 100000, 0),
# Small amount edge case (1 sat minimum)
(100, 0.03, 0.0, 97, 3),
]
@pytest.mark.parametrize(
"crypto_atoms,commission_pct,discount,expected_base,expected_commission",
EMPIRICAL_COMMISSION_CASES,
ids=[
"lamassu_8.75pct_small",
"lamassu_8.75pct_large",
"lamassu_5.5pct_no_discount",
"lamassu_5.5pct_100pct_discount",
"lamassu_5.5pct_90pct_discount",
"lamassu_5.5pct_1300gtq",
"zero_commission",
"small_amount_100sats",
]
)
def test_commission_calculation(
self,
crypto_atoms: int,
commission_pct: float,
discount: float,
expected_base: int,
expected_commission: int
):
"""Test commission calculation against empirical data."""
base, commission, _ = calculate_commission(crypto_atoms, commission_pct, discount)
assert base == expected_base, f"Base amount mismatch: got {base}, expected {expected_base}"
assert commission == expected_commission, f"Commission mismatch: got {commission}, expected {expected_commission}"
# Invariant: base + commission must equal total
assert base + commission == crypto_atoms, "Base + commission must equal total crypto_atoms"
def test_commission_invariant_always_sums_to_total(self):
"""Commission + base must always equal the original amount."""
test_values = [1, 100, 1000, 10000, 100000, 266800, 1000000]
commission_rates = [0.0, 0.01, 0.03, 0.05, 0.10]
discounts = [0.0, 10.0, 25.0, 50.0]
for crypto_atoms in test_values:
for comm_rate in commission_rates:
for discount in discounts:
base, commission, _ = calculate_commission(crypto_atoms, comm_rate, discount)
assert base + commission == crypto_atoms, \
f"Invariant failed: {base} + {commission} != {crypto_atoms} " \
f"(rate={comm_rate}, discount={discount})"
from ..calculations import calculate_distribution
# =============================================================================
@ -157,7 +62,6 @@ class TestDistributionCalculation:
def test_distribution_invariant_sums_to_total(self):
"""Total distributed sats must always equal base amount."""
# Test with various client configurations
test_cases = [
{"a": 100.0},
{"a": 100.0, "b": 100.0},
@ -215,156 +119,6 @@ class TestDistributionCalculation:
assert distributions == {}
def test_fiat_round_trip_invariant(self):
"""
Verify that distributed sats convert back to original fiat amount.
The sum of each client's fiat equivalent should equal the original
fiat amount (within rounding tolerance).
"""
# Use real Lamassu transaction data
test_cases = [
# (crypto_atoms, fiat_amount, commission_pct, discount, client_balances)
(309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}),
(309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}),
(292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}),
(115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}),
# Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients
(205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}),
]
for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases:
# Calculate commission and base amount
base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount)
# Calculate exchange rate
exchange_rate = calculate_exchange_rate(base_sats, fiat_amount)
# Distribute sats to clients
distributions = calculate_distribution(base_sats, client_balances)
# Convert each client's sats back to fiat
total_fiat_distributed = sum(
sats / exchange_rate for sats in distributions.values()
)
# Should equal original fiat amount (within small rounding tolerance)
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
# =============================================================================
# EMPIRICAL END-TO-END TESTS
# =============================================================================
class TestEmpiricalTransactions:
"""
End-to-end tests using real Lamassu transaction data.
Add your empirical test cases here! Each case should include:
- Transaction details (crypto_atoms, fiat, commission, discount)
- Client balances at time of transaction
- Expected distribution outcome
"""
# TODO: Add your empirical data here
# Example structure:
EMPIRICAL_SCENARIOS = [
{
"name": "real_tx_266800sats_two_equal_clients",
"transaction": {
"crypto_atoms": 266800,
"fiat_amount": 2000,
"commission_percentage": 0.03,
"discount": 0.0,
},
"client_balances": {
"client_a": 1000.00, # 50% of total
"client_b": 1000.00, # 50% of total
},
# 266800 / 1.03 = 259029
"expected_base_sats": 259029,
"expected_commission_sats": 7771,
"expected_distributions": {
# 259029 / 2 = 129514.5 → both get 129514 or 129515
# With banker's rounding: 129514.5 → 129514 (even)
# Remainder of 1 sat goes to first client by fractional sort
"client_a": 129515,
"client_b": 129514,
},
},
# Add more scenarios from your real data!
]
@pytest.mark.parametrize(
"scenario",
EMPIRICAL_SCENARIOS,
ids=[s["name"] for s in EMPIRICAL_SCENARIOS]
)
def test_empirical_scenario(self, scenario):
"""Test full transaction flow against empirical data."""
tx = scenario["transaction"]
# Calculate commission
base, commission, _ = calculate_commission(
tx["crypto_atoms"],
tx["commission_percentage"],
tx["discount"]
)
assert base == scenario["expected_base_sats"], \
f"Base amount mismatch in {scenario['name']}"
assert commission == scenario["expected_commission_sats"], \
f"Commission mismatch in {scenario['name']}"
# Calculate distribution
distributions = calculate_distribution(
base,
scenario["client_balances"]
)
# Verify each client's allocation
for client_id, expected_sats in scenario["expected_distributions"].items():
actual_sats = distributions.get(client_id, 0)
assert actual_sats == expected_sats, \
f"Distribution mismatch for {client_id} in {scenario['name']}: " \
f"got {actual_sats}, expected {expected_sats}"
# Verify total distribution equals base
assert sum(distributions.values()) == base, \
f"Total distribution doesn't match base in {scenario['name']}"
# =============================================================================
# EDGE CASE TESTS
# =============================================================================
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_minimum_amount_1_sat(self):
"""Test with minimum possible amount (1 sat)."""
base, commission, _ = calculate_commission(1, 0.03, 0.0)
# With 3% commission on 1 sat, base rounds to 1, commission to 0
assert base + commission == 1
def test_large_transaction(self):
"""Test with large transaction (100 BTC worth of sats)."""
crypto_atoms = 10_000_000_000 # 100 BTC in sats
base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0)
assert base + commission == crypto_atoms
assert commission > 0
def test_100_percent_discount(self):
"""100% discount should result in zero commission."""
base, commission, effective = calculate_commission(100000, 0.03, 100.0)
assert effective == 0.0
assert commission == 0
assert base == 100000
def test_many_clients_distribution(self):
"""Test distribution with many clients."""
# 10 clients with varying balances

View file

@ -37,7 +37,6 @@ def _machine(npub: str) -> Machine:
location=None,
fiat_code="EUR",
is_active=True,
fallback_commission_pct=0.05,
created_at=now,
updated_at=now,
)

View file

@ -2,11 +2,12 @@
Tests for the v2 two-stage commission split (super first, operator remainder).
The plan calls out a verification scenario explicitly:
super_fee_pct=30%, operator split 50/30/20 on a 100-sat commission
super_wallet gets 30, operator_self gets 35, employee 21, maint 14.
super_fee_fraction=0.30 (i.e. 30%), operator splits [0.5, 0.3, 0.2] on a
100-sat fee super_wallet gets 30, operator legs get 35 / 21 / 14.
Also covers the edge cases: super_fee_pct=0 (no super), super_fee_pct=1.0
(everything to super), single-leg operator ruleset, zero operator fee.
Also covers the edge cases: super_fee_fraction=0.0 (no super takes the
whole fee), super_fee_fraction=1.0 (super takes everything), single-leg
operator ruleset, zero operator fee.
"""
import pytest
@ -18,7 +19,7 @@ from ..calculations import (
class TestSplitTwoStageCommission:
"""Stage-1: super takes super_fee_pct of commission; operator gets rest."""
"""Stage-1: super takes super_fee_fraction of the fee; operator gets rest."""
def test_plan_example_100sats_30pct(self):
platform, operator = split_two_stage_commission(100, 0.30)
@ -33,12 +34,12 @@ class TestSplitTwoStageCommission:
assert operator == 5575 # 7965 - 2390
assert platform + operator == 7965
def test_super_pct_zero_leaves_all_to_operator(self):
def test_super_fraction_zero_leaves_all_to_operator(self):
platform, operator = split_two_stage_commission(7965, 0.0)
assert platform == 0
assert operator == 7965
def test_super_pct_one_takes_everything(self):
def test_super_fraction_one_takes_everything(self):
platform, operator = split_two_stage_commission(7965, 1.0)
assert platform == 7965
assert operator == 0
@ -54,13 +55,13 @@ class TestSplitTwoStageCommission:
assert platform == 0
assert operator == 0
@pytest.mark.parametrize("commission_sats", [1, 7, 100, 7965, 1_000_000])
@pytest.mark.parametrize("super_pct", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
def test_invariant_sum_equals_commission(self, commission_sats, super_pct):
platform, operator = split_two_stage_commission(commission_sats, super_pct)
assert platform + operator == commission_sats
assert 0 <= platform <= commission_sats
assert 0 <= operator <= commission_sats
@pytest.mark.parametrize("fee_sats", [1, 7, 100, 7965, 1_000_000])
@pytest.mark.parametrize("super_fraction", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
def test_invariant_sum_equals_commission(self, fee_sats, super_fraction):
platform, operator = split_two_stage_commission(fee_sats, super_fraction)
assert platform + operator == fee_sats
assert 0 <= platform <= fee_sats
assert 0 <= operator <= fee_sats
class TestAllocateOperatorSplitLegs:
@ -102,7 +103,7 @@ class TestAllocateOperatorSplitLegs:
assert amounts[2] == 100 - amounts[0] - amounts[1]
@pytest.mark.parametrize(
"operator_fee,pcts",
"operator_fee,fractions",
[
(1, [0.5, 0.5]),
(7, [0.5, 0.3, 0.2]),
@ -111,8 +112,8 @@ class TestAllocateOperatorSplitLegs:
(1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
],
)
def test_invariant_sum_equals_operator_fee(self, operator_fee, pcts):
amounts = allocate_operator_split_legs(operator_fee, pcts)
def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions):
amounts = allocate_operator_split_legs(operator_fee, fractions)
assert sum(amounts) == operator_fee
assert all(a >= 0 for a in amounts)
@ -121,21 +122,21 @@ class TestEndToEndScenarios:
"""The full two-stage split — super then operator legs — composed."""
def test_plan_example_full(self):
# 100 sats commission, super=30%, operator splits 50/30/20.
# 100 sats fee, super_fee_fraction=0.30, operator splits [0.5, 0.3, 0.2].
platform, operator = split_two_stage_commission(100, 0.30)
legs = allocate_operator_split_legs(operator, [0.5, 0.3, 0.2])
assert platform == 30
assert legs == [35, 21, 14]
assert platform + sum(legs) == 100
def test_super_pct_zero_full_pipeline(self):
def test_super_fraction_zero_full_pipeline(self):
platform, operator = split_two_stage_commission(7965, 0.0)
legs = allocate_operator_split_legs(operator, [1.0])
assert platform == 0
assert legs == [7965]
assert platform + sum(legs) == 7965
def test_super_pct_one_full_pipeline(self):
def test_super_fraction_one_full_pipeline(self):
platform, operator = split_two_stage_commission(7965, 1.0)
legs = allocate_operator_split_legs(operator, [0.5, 0.5])
assert platform == 7965
@ -147,27 +148,27 @@ class TestEndToEndScenarios:
class TestPartialDispenseSplitRatio:
"""The partial-dispense recompute (H6 fix) must preserve the ORIGINAL
platform/operator ratio from the landed settlement NOT re-derive
from the current super_fee_pct.
from the current super_fee_fraction.
These tests cover the math; the actual function lives in distribution.py
and is exercised end-to-end via integration testing. Here we verify the
invariant a future maintainer should never break.
"""
def _recompute(self, original_commission, original_platform_fee, new_commission):
def _recompute(self, original_fee, original_platform_fee, new_fee):
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
if original_commission > 0:
ratio = original_platform_fee / original_commission
if original_fee > 0:
ratio = original_platform_fee / original_fee
else:
ratio = 0.0
new_platform = round(new_commission * ratio)
new_platform = max(0, min(new_platform, new_commission))
new_operator = new_commission - new_platform
new_platform = round(new_fee * ratio)
new_platform = max(0, min(new_platform, new_fee))
new_operator = new_fee - new_platform
return new_platform, new_operator
def test_plan_scenario_30pct_lands_then_partial(self):
# Landed at super_fee_pct=30%: 100-sat commission → 30 / 70.
# Partial-dispense to 50% gross → new_commission = 50.
# Landed at super_fee_fraction=0.30: 100-sat fee → 30 / 70.
# Partial-dispense to 50% gross → new_fee = 50.
# Original ratio (30/100 = 0.30) preserved.
new_platform, new_operator = self._recompute(100, 30, 50)
assert new_platform == 15
@ -175,9 +176,9 @@ class TestPartialDispenseSplitRatio:
assert new_platform + new_operator == 50
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
# Landed at super_fee_pct=30% (commission 7965, platform 2390).
# Landed at super_fee_fraction=0.30 (fee 7965, platform 2390).
# Super then raises rate to 50% globally. Operator partial-dispenses
# to 50% gross → new_commission = 3982 (round(7965 * 0.5)).
# to 50% gross → new_fee = 3982 (round(7965 * 0.5)).
# Original ratio (2390/7965 ≈ 0.30) MUST still apply, not 50%.
new_platform, new_operator = self._recompute(7965, 2390, 3982)
# Expected with original ratio: round(3982 * 0.30006...) = 1195
@ -187,17 +188,17 @@ class TestPartialDispenseSplitRatio:
# Original platform share was ~30%; preserved within rounding.
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
def test_zero_original_commission_yields_zero_platform(self):
def test_zero_original_fee_yields_zero_platform(self):
new_platform, new_operator = self._recompute(0, 0, 0)
assert new_platform == 0
assert new_operator == 0
def test_invariant_sum_equals_new_commission(self):
def test_invariant_sum_equals_new_fee(self):
# Random-ish parameter sweep over realistic values.
cases = [
(100, 30, 50),
(100, 0, 50), # original platform_fee was 0 (super_pct=0)
(100, 100, 50), # original platform_fee was 100 (super_pct=100)
(100, 0, 50), # original platform_fee was 0 (super_fraction=0)
(100, 100, 50), # original platform_fee was 100 (super_fraction=100)
(7965, 2390, 3982),
(7965, 7965, 3982),
(1_000_000, 333_333, 250_000),