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. # Satoshi Machine v2 — bitSpire payment parser.
# #
# Translates an inbound LNbits Payment (cash-out customer paid the ATM's # Translates an inbound LNbits Payment into a CreateDcaSettlementData by
# invoice) into the principal/commission split needed by satmachineadmin. # 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 # No back-derivation. If Payment.extra is missing the bitSpire stamp or
# fields per aiolabs/lamassu-next#44 — we read them directly. # any required field, we raise SettlementMetadataError and the caller
# # records the settlement as 'rejected' for upstream investigation — the
# Fallback path: extra is missing (older bitSpire, edge case). We back-derive # Lamassu-era reverse-derivation from gross-with-commission-baked-in is
# the split from the machine's fallback_commission_pct using the Lamassu-era # obsolete now that the wire carries principal_sats and fee_sats
# formula (base = total / (1 + commission)) and mark used_fallback_split=true # directly.
# so the audit trail shows we estimated.
from __future__ import annotations from __future__ import annotations
import json 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 from .models import CreateDcaSettlementData, Machine
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound # 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: def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
"""Assert that the originating Nostr signer pubkey matches the machine. """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( def parse_settlement(
machine: Machine, machine: Machine,
payment_hash: str, payment_hash: str,
gross_sats: int, wire_sats: int,
extra: dict, extra: dict,
super_fee_pct: float, super_fee_fraction: float,
) -> Tuple[CreateDcaSettlementData, bool]: ) -> CreateDcaSettlementData:
"""Build a CreateDcaSettlementData for an inbound payment landing on """Build a CreateDcaSettlementData for an inbound payment landing on
`machine`'s wallet. `machine`'s wallet.
Returns (data, used_fallback): when `used_fallback` is True, bitSpire Requires bitSpire's canonical Payment.extra stamp (source="bitspire"
didn't populate Payment.extra so we back-derived the split. Caller plus the absolute sat amounts) per aiolabs/lamassu-next#44. Raises
should log this for visibility once aiolabs/lamassu-next#44 ships, `SettlementMetadataError` on missing/partial stamp caller records
fallback usage should drop to zero. 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): if not (0.0 <= super_fee_fraction <= 1.0):
data = _parse_extra(machine, payment_hash, gross_sats, extra, super_fee_pct) raise SettlementInvariantError(
return data, False f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
logger.warning( )
f"satmachineadmin: settlement on machine {machine.machine_npub[:12]}... " if not is_bitspire_payment(extra):
f"missing bitSpire extra metadata; back-deriving via " raise SettlementMetadataError(
f"fallback_commission_pct={machine.fallback_commission_pct}. " f"Payment.extra missing `source: \"bitspire\"` marker on machine "
f"See aiolabs/lamassu-next#44." 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")) principal_sats = _coerce_int(extra.get("principal_sats"))
fee_sats = _coerce_int(extra.get("fee_sats")) fee_sats = _coerce_int(extra.get("fee_sats"))
if principal_sats is None or fee_sats is None: if principal_sats is None or fee_sats is None:
# Missing key fields — shouldn't happen post-#44 but defensive. raise SettlementMetadataError(
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct) f"Payment.extra has source=bitspire but is missing required "
commission_sats = fee_sats f"fields principal_sats={extra.get('principal_sats')!r} or "
platform_fee_sats = round(commission_sats * super_fee_pct) f"fee_sats={extra.get('fee_sats')!r}; the wire-format contract "
operator_fee_sats = commission_sats - platform_fee_sats 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")) exchange_rate = _coerce_float(extra.get("exchange_rate"))
if exchange_rate is None or exchange_rate <= 0: if exchange_rate is None or exchange_rate <= 0:
# Without exchange rate we can't compute fiat. Use 1.0 as a stand-in # 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 # dispenser ledger (lamassu-next@8318489). It's the cash that
# physically entered (cash-in) or exited (cash-out) the machine — # physically entered (cash-in) or exited (cash-out) the machine —
# canonical, not derived. We never recompute it from sats × rate # 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). # in BTC today, but the cash side has its own ground truth).
fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0 fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0
fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code 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, machine_id=machine.id,
payment_hash=payment_hash, payment_hash=payment_hash,
bitspire_event_id=None, bitspire_event_id=None,
bitspire_txid=_coerce_str(extra.get("txid")), bitspire_txid=_coerce_str(extra.get("txid")),
gross_sats=gross_sats, wire_sats=wire_sats,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
fiat_code=fiat_code, fiat_code=fiat_code,
exchange_rate=exchange_rate, exchange_rate=exchange_rate,
principal_sats=principal_sats, principal_sats=principal_sats,
commission_sats=commission_sats, fee_sats=fee_sats,
platform_fee_sats=platform_fee_sats, platform_fee_sats=platform_fee_sats,
operator_fee_sats=operator_fee_sats, operator_fee_sats=operator_fee_sats,
used_fallback_split=False, tx_type=tx_type,
tx_type=_coerce_str(extra.get("type")) or "cash_out",
bills_json=_json_dumps(extra.get("bills")), bills_json=_json_dumps(extra.get("bills")),
cassettes_json=_json_dumps(extra.get("cassettes")), cassettes_json=_json_dumps(extra.get("cassettes")),
) )
# Enforce the cross-codebase canonical sat-amount invariants on the
# values bitSpire stamped (post-rename: `fee_fraction` is preferred;
def _parse_fallback( # the old `fee_percent` field is deliberately NOT read here because
machine: Machine, # of the 100× misinterpretation risk during the rename window — the
payment_hash: str, # absolute `fee_sats` stamp is the audit anchor and the sum
gross_sats: int, # invariants below catch any garbage at the wire).
super_fee_pct: float, _assert_sat_invariants(
) -> CreateDcaSettlementData: tx_type=data.tx_type,
"""Back-derive the split using the machine's fallback_commission_pct. wire_sats=data.wire_sats,
principal_sats=data.principal_sats,
Same formula as the Lamassu integration used: fee_sats=data.fee_sats,
base_amount = round(gross / (1 + commission_pct)) fee_fraction=_coerce_float(extra.get("fee_fraction")),
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,
) )
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) These functions have no external dependencies (no lnbits, no database)
and can be easily tested in isolation. 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 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( def calculate_distribution(
base_amount_sats: int, base_amount_sats: int,
client_balances: Dict[str, float], client_balances: Dict[str, float],
@ -132,14 +99,15 @@ def calculate_distribution(
def split_two_stage_commission( def split_two_stage_commission(
commission_sats: int, super_fee_pct: float fee_sats: int, super_fee_fraction: float
) -> Tuple[int, int]: ) -> Tuple[int, int]:
"""Stage-1 of the v2 commission split: super takes `super_fee_pct` of the """Stage-1 of the v2 commission split: super takes `super_fee_fraction`
total commission; the remainder is what the operator's own ruleset acts on. 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; Returns (platform_fee_sats, operator_fee_sats). Platform is rounded;
operator absorbs the rounding remainder so platform_fee + operator_fee operator absorbs the rounding remainder so platform_fee + operator_fee
== commission_sats exactly. == fee_sats exactly.
Examples: Examples:
>>> split_two_stage_commission(100, 0.30) >>> split_two_stage_commission(100, 0.30)
@ -151,23 +119,28 @@ def split_two_stage_commission(
>>> split_two_stage_commission(100, 1.0) >>> split_two_stage_commission(100, 1.0)
(100, 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 return 0, 0
platform = round(commission_sats * super_fee_pct) platform = round(fee_sats * super_fee_fraction)
platform = max(0, min(platform, commission_sats)) platform = max(0, min(platform, fee_sats))
operator = commission_sats - platform operator = fee_sats - platform
return platform, operator return platform, operator
def allocate_operator_split_legs( def allocate_operator_split_legs(
operator_fee_sats: int, leg_pcts: list operator_fee_sats: int, leg_fractions: list
) -> list: ) -> list:
"""Stage-2 of the v2 commission split: the operator's remainder is sliced """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 The last leg absorbs the rounding remainder so the sum of allocations
exactly equals operator_fee_sats (assuming pcts sum to ~1.0). Returns exactly equals operator_fee_sats (assuming fractions sum to ~1.0).
a list of integer sat amounts in the same order as leg_pcts. Returns a list of integer sat amounts in the same order as leg_fractions.
Examples: Examples:
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2]) >>> 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]) >>> allocate_operator_split_legs(0, [0.5, 0.5])
[0, 0] [0, 0]
""" """
if not leg_pcts: if not leg_fractions:
return [] return []
if operator_fee_sats <= 0: 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 = [] allocations: list = []
remaining = operator_fee_sats remaining = operator_fee_sats
for idx, pct in enumerate(leg_pcts): for idx, fraction in enumerate(leg_fractions):
if idx == len(leg_pcts) - 1: if idx == len(leg_fractions) - 1:
allocations.append(remaining) allocations.append(remaining)
else: else:
amount = round(operator_fee_sats * float(pct)) amount = round(operator_fee_sats * float(fraction))
allocations.append(amount) allocations.append(amount)
remaining -= amount remaining -= amount
return allocations 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 INSERT INTO satoshimachine.dca_machines
(id, operator_user_id, machine_npub, wallet_id, name, location, (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, VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name,
:location, :fiat_code, :is_active, :fallback_commission_pct, :location, :fiat_code, :is_active, :created_at, :updated_at)
:created_at, :updated_at)
""", """,
{ {
"id": machine_id, "id": machine_id,
@ -92,7 +91,6 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
"location": data.location, "location": data.location,
"fiat_code": data.fiat_code, "fiat_code": data.fiat_code,
"is_active": True, "is_active": True,
"fallback_commission_pct": data.fallback_commission_pct,
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
}, },
@ -555,14 +553,14 @@ async def create_settlement_idempotent(
""" """
INSERT INTO satoshimachine.dca_settlements INSERT INTO satoshimachine.dca_settlements
(id, machine_id, payment_hash, bitspire_event_id, bitspire_txid, (id, machine_id, payment_hash, bitspire_event_id, bitspire_txid,
gross_sats, fiat_amount, fiat_code, exchange_rate, principal_sats, wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
commission_sats, platform_fee_sats, operator_fee_sats, fee_sats, platform_fee_sats, operator_fee_sats,
used_fallback_split, tx_type, bills_json, cassettes_json, tx_type, bills_json, cassettes_json,
status, error_message, created_at) status, error_message, created_at)
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id, VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code, :bitspire_txid, :wire_sats, :fiat_amount, :fiat_code,
:exchange_rate, :principal_sats, :commission_sats, :exchange_rate, :principal_sats, :fee_sats,
:platform_fee_sats, :operator_fee_sats, :used_fallback_split, :platform_fee_sats, :operator_fee_sats,
:tx_type, :bills_json, :cassettes_json, :status, :tx_type, :bills_json, :cassettes_json, :status,
:error_message, :created_at) :error_message, :created_at)
""", """,
@ -572,15 +570,14 @@ async def create_settlement_idempotent(
"payment_hash": data.payment_hash, "payment_hash": data.payment_hash,
"bitspire_event_id": data.bitspire_event_id, "bitspire_event_id": data.bitspire_event_id,
"bitspire_txid": data.bitspire_txid, "bitspire_txid": data.bitspire_txid,
"gross_sats": data.gross_sats, "wire_sats": data.wire_sats,
"fiat_amount": data.fiat_amount, "fiat_amount": data.fiat_amount,
"fiat_code": data.fiat_code, "fiat_code": data.fiat_code,
"exchange_rate": data.exchange_rate, "exchange_rate": data.exchange_rate,
"principal_sats": data.principal_sats, "principal_sats": data.principal_sats,
"commission_sats": data.commission_sats, "fee_sats": data.fee_sats,
"platform_fee_sats": data.platform_fee_sats, "platform_fee_sats": data.platform_fee_sats,
"operator_fee_sats": data.operator_fee_sats, "operator_fee_sats": data.operator_fee_sats,
"used_fallback_split": data.used_fallback_split,
"tx_type": data.tx_type, "tx_type": data.tx_type,
"bills_json": data.bills_json, "bills_json": data.bills_json,
"cassettes_json": data.cassettes_json, "cassettes_json": data.cassettes_json,
@ -840,9 +837,9 @@ async def reset_settlement_for_retry(
async def apply_partial_dispense( async def apply_partial_dispense(
settlement_id: str, settlement_id: str,
*, *,
new_gross_sats: int, new_wire_sats: int,
new_principal_sats: int, new_principal_sats: int,
new_commission_sats: int, new_fee_sats: int,
new_platform_fee_sats: int, new_platform_fee_sats: int,
new_operator_fee_sats: int, new_operator_fee_sats: int,
new_fiat_amount: float, new_fiat_amount: float,
@ -858,9 +855,9 @@ async def apply_partial_dispense(
await db.execute( await db.execute(
""" """
UPDATE satoshimachine.dca_settlements UPDATE satoshimachine.dca_settlements
SET gross_sats = :gross, SET wire_sats = :gross,
principal_sats = :principal, principal_sats = :principal,
commission_sats = :commission, fee_sats = :commission,
platform_fee_sats = :platform, platform_fee_sats = :platform,
operator_fee_sats = :operator, operator_fee_sats = :operator,
fiat_amount = :fiat, fiat_amount = :fiat,
@ -875,9 +872,9 @@ async def apply_partial_dispense(
""", """,
{ {
"id": settlement_id, "id": settlement_id,
"gross": new_gross_sats, "gross": new_wire_sats,
"principal": new_principal_sats, "principal": new_principal_sats,
"commission": new_commission_sats, "commission": new_fee_sats,
"platform": new_platform_fee_sats, "platform": new_platform_fee_sats,
"operator": new_operator_fee_sats, "operator": new_operator_fee_sats,
"fiat": new_fiat_amount, "fiat": new_fiat_amount,
@ -1013,9 +1010,9 @@ async def replace_commission_splits(
await db.execute( await db.execute(
""" """
INSERT INTO satoshimachine.dca_commission_splits 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) sort_order, created_at)
VALUES (:id, :machine_id, :uid, :target, :label, :pct, VALUES (:id, :machine_id, :uid, :target, :label, :fraction,
:sort_order, :created_at) :sort_order, :created_at)
""", """,
{ {
@ -1024,7 +1021,7 @@ async def replace_commission_splits(
"uid": operator_user_id, "uid": operator_user_id,
"target": leg.target, "target": leg.target,
"label": leg.label, "label": leg.label,
"pct": leg.pct, "fraction": leg.fraction,
"sort_order": leg.sort_order, "sort_order": leg.sort_order,
"created_at": now, "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 settlement: DcaSettlement, data: PartialDispenseData
) -> int: ) -> int:
if data.dispensed_sats is not None: 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: 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: else:
raise ValueError("provide one of dispensed_sats or dispensed_fraction") 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") raise ValueError("partial dispense cannot be negative")
if new_gross > settlement.gross_sats: if new_wire > settlement.wire_sats:
raise ValueError( raise ValueError(
f"partial dispense ({new_gross} sats) cannot exceed the original " f"partial dispense ({new_wire} sats) cannot exceed the original "
f"gross ({settlement.gross_sats} sats)" f"wire amount ({settlement.wire_sats} sats)"
) )
return new_gross return new_wire
def _build_partial_dispense_memo( def _build_partial_dispense_memo(
settlement: DcaSettlement, settlement: DcaSettlement,
data: PartialDispenseData, data: PartialDispenseData,
*, *,
new_gross: int, new_wire: int,
new_principal: int, new_principal: int,
new_commission: int, new_fee: int,
new_platform: int, new_platform: int,
new_operator: int, new_operator: int,
) -> str: ) -> str:
@ -148,13 +148,13 @@ def _build_partial_dispense_memo(
ts = datetime.now(timezone.utc).isoformat(timespec="seconds") ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
return ( return (
f"[{ts}] partial dispense applied — {adjust}. " f"[{ts}] partial dispense applied — {adjust}. "
f"Original gross={settlement.gross_sats} " f"Original wire={settlement.wire_sats} "
f"principal={settlement.principal_sats} " f"principal={settlement.principal_sats} "
f"commission={settlement.commission_sats} " f"fee={settlement.fee_sats} "
f"(super_fee={settlement.platform_fee_sats} " f"(super_fee={settlement.platform_fee_sats} "
f"operator_fee={settlement.operator_fee_sats}). " f"operator_fee={settlement.operator_fee_sats}). "
f"New gross={new_gross} principal={new_principal} " f"New wire={new_wire} principal={new_principal} "
f"commission={new_commission} " f"fee={new_fee} "
f"(super_fee={new_platform} operator_fee={new_operator}). " f"(super_fee={new_platform} operator_fee={new_operator}). "
f"Reason: {reason}" 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 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 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 linearly, preserving the original commission ratio exactly. The two-stage
super/operator split also scales by the *original* platform_fee_sats / 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 this honors the "absolute fields are the source of truth" invariant
even when super has changed the global rate since the settlement landed even when super has changed the global rate since the settlement landed
(closes #11 H6). (closes #11 H6).
@ -296,8 +296,8 @@ async def apply_partial_dispense_and_redistribute(
settlement = await get_settlement(settlement_id) settlement = await get_settlement(settlement_id)
if settlement is None: if settlement is None:
raise ValueError(f"settlement {settlement_id} not found") raise ValueError(f"settlement {settlement_id} not found")
if settlement.gross_sats <= 0: if settlement.wire_sats <= 0:
raise ValueError("cannot partial-dispense a zero-gross settlement") raise ValueError("cannot partial-dispense a zero-wire settlement")
completed = await count_completed_legs_for_settlement(settlement_id) completed = await count_completed_legs_for_settlement(settlement_id)
if completed > 0: if completed > 0:
raise ValueError( raise ValueError(
@ -305,33 +305,33 @@ async def apply_partial_dispense_and_redistribute(
"(Lightning payments can't be clawed back)" "(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. # Linear scale preserves the original commission ratio exactly.
scale = new_gross / settlement.gross_sats scale = new_wire / settlement.wire_sats
new_commission = round(settlement.commission_sats * scale) new_fee = round(settlement.fee_sats * scale)
new_principal = new_gross - new_commission new_principal = new_wire - new_fee
new_fiat = round(float(settlement.fiat_amount) * scale, 2) new_fiat = round(float(settlement.fiat_amount) * scale, 2)
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this # 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 # locked at landing; super raising or lowering the global rate after
# the fact must not retroactively change this transaction's share. # the fact must not retroactively change this transaction's share.
# Operator absorbs the rounding remainder so platform + operator # Operator absorbs the rounding remainder so platform + operator
# == new_commission exactly. # == new_fee exactly.
if settlement.commission_sats > 0: if settlement.fee_sats > 0:
ratio = settlement.platform_fee_sats / settlement.commission_sats ratio = settlement.platform_fee_sats / settlement.fee_sats
else: else:
ratio = 0.0 ratio = 0.0
new_platform = round(new_commission * ratio) new_platform = round(new_fee * ratio)
new_platform = max(0, min(new_platform, new_commission)) new_platform = max(0, min(new_platform, new_fee))
new_operator = new_commission - new_platform new_operator = new_fee - new_platform
memo = _build_partial_dispense_memo( memo = _build_partial_dispense_memo(
settlement, settlement,
data, data,
new_gross=new_gross, new_wire=new_wire,
new_principal=new_principal, new_principal=new_principal,
new_commission=new_commission, new_fee=new_fee,
new_platform=new_platform, new_platform=new_platform,
new_operator=new_operator, new_operator=new_operator,
) )
@ -339,9 +339,9 @@ async def apply_partial_dispense_and_redistribute(
await void_open_legs_for_settlement(settlement_id) await void_open_legs_for_settlement(settlement_id)
updated = await apply_partial_dispense( updated = await apply_partial_dispense(
settlement_id, settlement_id,
new_gross_sats=new_gross, new_wire_sats=new_wire,
new_principal_sats=new_principal, new_principal_sats=new_principal,
new_commission_sats=new_commission, new_fee_sats=new_fee,
new_platform_fee_sats=new_platform, new_platform_fee_sats=new_platform,
new_operator_fee_sats=new_operator, new_operator_fee_sats=new_operator,
new_fiat_amount=new_fiat, new_fiat_amount=new_fiat,
@ -467,7 +467,7 @@ async def _pay_operator_splits(
# Pure allocator handles the rounding rule (last leg absorbs remainder). # Pure allocator handles the rounding rule (last leg absorbs remainder).
leg_amounts = allocate_operator_split_legs( leg_amounts = allocate_operator_split_legs(
settlement.operator_fee_sats, 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)): for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)):
if amount <= 0: if amount <= 0:

View file

@ -62,7 +62,7 @@ async def m001_satmachine_v2_initial(db):
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.super_config ( CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
id TEXT PRIMARY KEY, 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, super_fee_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
@ -72,7 +72,7 @@ async def m001_satmachine_v2_initial(db):
) )
if not existing: if not existing:
await db.execute( 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)" "VALUES ('default', 0.0000)"
) )
@ -88,7 +88,6 @@ async def m001_satmachine_v2_initial(db):
location TEXT, location TEXT,
fiat_code TEXT NOT NULL DEFAULT 'GTQ', fiat_code TEXT NOT NULL DEFAULT 'GTQ',
is_active BOOLEAN NOT NULL DEFAULT true, 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}, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_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. # append-only audit memo for partial-dispense + operator notes.
# #
# platform_fee_sats and operator_fee_sats are absolute BIGINT, # 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 # ships, these two columns are the audit-grade record of who
# forgave what per transaction. Do not collapse them into a single # 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""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -191,15 +190,14 @@ async def m001_satmachine_v2_initial(db):
payment_hash TEXT NOT NULL UNIQUE, payment_hash TEXT NOT NULL UNIQUE,
bitspire_event_id TEXT, bitspire_event_id TEXT,
bitspire_txid TEXT, bitspire_txid TEXT,
gross_sats BIGINT NOT NULL, wire_sats BIGINT NOT NULL,
fiat_amount DECIMAL(10,2) NOT NULL, fiat_amount DECIMAL(10,2) NOT NULL,
fiat_code TEXT NOT NULL DEFAULT 'GTQ', fiat_code TEXT NOT NULL DEFAULT 'GTQ',
exchange_rate REAL NOT NULL, exchange_rate REAL NOT NULL,
principal_sats BIGINT NOT NULL, principal_sats BIGINT NOT NULL,
commission_sats BIGINT NOT NULL, fee_sats BIGINT NOT NULL,
platform_fee_sats BIGINT NOT NULL, platform_fee_sats BIGINT NOT NULL,
operator_fee_sats BIGINT NOT NULL, operator_fee_sats BIGINT NOT NULL,
used_fallback_split BOOLEAN NOT NULL DEFAULT false,
tx_type TEXT NOT NULL, tx_type TEXT NOT NULL,
bills_json TEXT, bills_json TEXT,
cassettes_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 # 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 # 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. # enforced at write-time in crud.py.
# #
# `target` accepts any of (splitpayments-style): # `target` accepts any of (splitpayments-style):
@ -235,7 +233,7 @@ async def m001_satmachine_v2_initial(db):
operator_user_id TEXT NOT NULL, operator_user_id TEXT NOT NULL,
target TEXT NOT NULL, target TEXT NOT NULL,
label TEXT, label TEXT,
pct DECIMAL(10,4) NOT NULL, fraction DECIMAL(10,4) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} 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}") 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): async def m005_lock_deposit_currency_to_machine_fiat_code(db):
"""Rewrite every `dca_deposits.currency` row to match its joined """Rewrite every `dca_deposits.currency` row to match its joined
`dca_machines.fiat_code`. `dca_machines.fiat_code`.

View file

@ -29,18 +29,6 @@ class CreateMachineData(BaseModel):
name: Optional[str] = None name: Optional[str] = None
location: Optional[str] = None location: Optional[str] = None
fiat_code: str = "GTQ" 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): class Machine(BaseModel):
@ -52,7 +40,6 @@ class Machine(BaseModel):
location: Optional[str] location: Optional[str]
fiat_code: str fiat_code: str
is_active: bool is_active: bool
fallback_commission_pct: float
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -63,15 +50,6 @@ class UpdateMachineData(BaseModel):
fiat_code: Optional[str] = None fiat_code: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
wallet_id: Optional[str] = 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. # platform_fee_sats and operator_fee_sats are absolute audit-grade values.
# Today they equal the contractual split; tomorrow (post-v1 promo engine) # 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)". # 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) payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion
bitspire_txid: Optional[str] = None bitspire_txid: Optional[str] = None
gross_sats: int wire_sats: int
fiat_amount: float fiat_amount: float
fiat_code: str = "GTQ" fiat_code: str = "GTQ"
exchange_rate: float exchange_rate: float
principal_sats: int principal_sats: int
commission_sats: int fee_sats: int
platform_fee_sats: int platform_fee_sats: int
operator_fee_sats: int operator_fee_sats: int
used_fallback_split: bool = False
tx_type: str # 'cash_out' | 'cash_in' tx_type: str # 'cash_out' | 'cash_in'
bills_json: Optional[str] = None bills_json: Optional[str] = None
cassettes_json: Optional[str] = None cassettes_json: Optional[str] = None
@ -254,15 +231,14 @@ class DcaSettlement(BaseModel):
payment_hash: str payment_hash: str
bitspire_event_id: Optional[str] bitspire_event_id: Optional[str]
bitspire_txid: Optional[str] bitspire_txid: Optional[str]
gross_sats: int wire_sats: int
fiat_amount: float fiat_amount: float
fiat_code: str fiat_code: str
exchange_rate: float exchange_rate: float
principal_sats: int principal_sats: int
commission_sats: int fee_sats: int
platform_fee_sats: int platform_fee_sats: int
operator_fee_sats: int operator_fee_sats: int
used_fallback_split: bool
tx_type: str tx_type: str
bills_json: Optional[str] bills_json: Optional[str]
cassettes_json: Optional[str] cassettes_json: Optional[str]
@ -295,8 +271,8 @@ class DcaSettlement(BaseModel):
# Commission splits — operator-defined remainder allocation per machine. # Commission splits — operator-defined remainder allocation per machine.
# ============================================================================= # =============================================================================
# machine_id=NULL means operator's default; non-null means per-machine override. # 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, # Sum of fraction across rows for a (operator_user_id, machine_id) scope must
# enforced at write-time in crud.py. # be 1.0, enforced at write-time in crud.py.
class CommissionSplitLeg(BaseModel): class CommissionSplitLeg(BaseModel):
@ -311,7 +287,7 @@ class CommissionSplitLeg(BaseModel):
target: str target: str
label: Optional[str] = None label: Optional[str] = None
pct: float fraction: float
sort_order: int = 0 sort_order: int = 0
@validator("target") @validator("target")
@ -321,10 +297,10 @@ class CommissionSplitLeg(BaseModel):
raise ValueError("target cannot be empty") raise ValueError("target cannot be empty")
return v return v
@validator("pct") @validator("fraction")
def pct_in_unit_range(cls, v): def fraction_in_unit_range(cls, v):
if v < 0 or v > 1: 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) return round(float(v), 4)
@ -334,7 +310,7 @@ class CommissionSplit(BaseModel):
operator_user_id: str operator_user_id: str
target: str target: str
label: Optional[str] label: Optional[str]
pct: float fraction: float
sort_order: int sort_order: int
created_at: datetime created_at: datetime
@ -351,9 +327,9 @@ class SetCommissionSplitsData(BaseModel):
@validator("legs") @validator("legs")
def legs_sum_to_one(cls, v): 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: 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 return v
@ -440,21 +416,21 @@ class TelemetrySnapshot(BaseModel):
class SuperConfig(BaseModel): class SuperConfig(BaseModel):
id: str id: str
super_fee_pct: float super_fee_fraction: float
super_fee_wallet_id: Optional[str] super_fee_wallet_id: Optional[str]
updated_at: datetime updated_at: datetime
class UpdateSuperConfigData(BaseModel): class UpdateSuperConfigData(BaseModel):
super_fee_pct: Optional[float] = None super_fee_fraction: Optional[float] = None
super_fee_wallet_id: Optional[str] = None super_fee_wallet_id: Optional[str] = None
@validator("super_fee_pct") @validator("super_fee_fraction")
def fee_in_unit_range(cls, v): def fee_in_unit_range(cls, v):
if v is None: if v is None:
return v return v
if v < 0 or v > 1: 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) return round(float(v), 4)

View file

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

154
tasks.py
View file

@ -3,14 +3,26 @@
# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then # Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then
# for each successful inbound payment: # for each successful inbound payment:
# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip. # 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). # 2. Verifies the originating Nostr signer matches the machine identity
# Falls back to machine.fallback_commission_pct if extra is absent. # (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey
# 3. Computes the two-stage split (super_fee first, operator remainder). # stamped by lnbits nostr-transport dispatcher).
# 4. Inserts a dca_settlements row idempotently (keyed by payment_hash). # 3. Parses Payment.extra for bitSpire's canonical split stamp per
# 5. Spawns the distribution processor on a background task so the # 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) # LNbits invoice queue (which serves ALL extensions on the node)
# keeps draining while we move sats. Concurrency is safe because # keeps draining while we move sats. Concurrency is safe because
# process_settlement now uses an optimistic-lock claim (fix bundle 1). # 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 import asyncio
@ -20,6 +32,8 @@ from loguru import logger
from .bitspire import ( from .bitspire import (
SettlementAttributionError, SettlementAttributionError,
SettlementInvariantError,
SettlementMetadataError,
assert_nostr_attribution, assert_nostr_attribution,
parse_settlement, parse_settlement,
) )
@ -29,6 +43,7 @@ from .crud import (
get_super_config, get_super_config,
) )
from .distribution import process_settlement from .distribution import process_settlement
from .models import CreateDcaSettlementData, Machine
LISTENER_NAME = "ext_satmachineadmin" LISTENER_NAME = "ext_satmachineadmin"
@ -64,32 +79,102 @@ async def _handle_payment(payment: Payment) -> None:
if machine is None: if machine is None:
return return
extra = payment.extra or {} 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_config = await get_super_config()
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0 super_fee_fraction = (
data, used_fallback = parse_settlement( float(super_config.super_fee_fraction) if super_config else 0.0
)
try:
data = parse_settlement(
machine=machine, machine=machine,
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
gross_sats=payment.sat, wire_sats=payment.sat,
extra=extra, 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 # Stamp the originating Nostr event id (the kind-21000 create_invoice
# RPC) onto the row for post-hoc forensics — pairs with the # RPC) onto the row for post-hoc forensics — an auditor can trace
# assert_nostr_attribution check below so an auditor can trace # settlement → RPC event → signing key without trusting our DB.
# settlement -> RPC event -> signing key without trusting our DB.
nostr_event_id = extra.get("nostr_event_id") nostr_event_id = extra.get("nostr_event_id")
if isinstance(nostr_event_id, str) and nostr_event_id: if isinstance(nostr_event_id, str) and nostr_event_id:
data.bitspire_event_id = nostr_event_id data.bitspire_event_id = nostr_event_id
# Cross-check the signature-verified signer pubkey (stamped by # 3) Insert + distribute.
# LNbits' nostr-transport dispatcher onto Payment.extra) against settlement = await create_settlement_idempotent(data, initial_status="pending")
# the machine identity. Routing today is wallet_id-only with no if settlement is None:
# cryptographic binding — this restores end-to-end attribution logger.error(
# between "the npub that asked LNbits for the invoice" and "the f"satmachineadmin: failed to insert settlement for "
# machine we're crediting" (aiolabs/satmachineadmin#19, G5). f"payment_hash={payment.payment_hash[:12]}..."
try: )
assert_nostr_attribution(machine, extra) return
except SettlementAttributionError as exc: 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( rejected = await create_settlement_idempotent(
data, initial_status="rejected", error_message=str(exc) 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"(machine={machine.machine_npub[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}" 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 <q-banner
v-if="superConfig" v-if="superConfig"
class="q-mb-md" 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> <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> </template>
<span :style="{fontWeight: 500}"> <span :style="{fontWeight: 500}">
LNbits platform fee: 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. of each transaction's commission.
</span> </span>
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm"> <span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
@ -143,12 +143,6 @@
v-text="shortId(props.row.wallet_id)"></code> v-text="shortId(props.row.wallet_id)"></code>
</q-td> </q-td>
<q-td key="fiat_code" v-text="props.row.fiat_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-td key="actions" auto-width>
<q-btn flat dense round size="sm" icon="visibility" <q-btn flat dense round size="sm" icon="visibility"
color="primary" color="primary"
@ -529,13 +523,13 @@
dense outlined></q-input> dense outlined></q-input>
</div> </div>
<div class="col-5 col-md-3"> <div class="col-5 col-md-3">
<q-input v-model.number="leg.pct" <q-input v-model.number="leg.fraction"
label="% (0..1)" label="% (0..1)"
type="number" step="0.01" min="0" max="1" type="number" step="0.01" min="0" max="1"
dense outlined> dense outlined>
<template v-slot:append> <template v-slot:append>
<span :style="{fontSize: '0.75em', opacity: 0.6}" <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> </template>
</q-input> </q-input>
</div> </div>
@ -630,8 +624,8 @@
<span :style="{fontSize: '0.85em'}" <span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span> v-text="formatTime(props.row.created_at)"></span>
</q-td> </q-td>
<q-td key="gross_sats" class="text-right"> <q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span> <span v-text="formatSats(props.row.wire_sats)"></span>
</q-td> </q-td>
<q-td key="error_message"> <q-td key="error_message">
<span :style="{fontSize: '0.85em', opacity: 0.8}" <span :style="{fontSize: '0.85em', opacity: 0.8}"
@ -792,14 +786,6 @@
dense outlined dense outlined
:rules="[v => !!v || 'Pick a wallet']"></q-select> :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 <q-input
v-model="addMachineDialog.data.fiat_code" v-model="addMachineDialog.data.fiat_code"
label="Fiat code" label="Fiat code"
@ -855,12 +841,6 @@
<div class="text-caption" :style="{opacity: 0.6}">Location</div> <div class="text-caption" :style="{opacity: 0.6}">Location</div>
<span v-text="machineDetail.machine.location || '—'"></span> <span v-text="machineDetail.machine.location || '—'"></span>
</div> </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> </div>
<q-separator class="q-mb-md"></q-separator> <q-separator class="q-mb-md"></q-separator>
@ -893,27 +873,19 @@
<q-td key="status"> <q-td key="status">
<q-badge :color="settlementStatusColor(props.row.status)" <q-badge :color="settlementStatusColor(props.row.status)"
:label="props.row.status"></q-badge> :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>
<q-td key="created_at"> <q-td key="created_at">
<span :style="{fontSize: '0.85em'}" <span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span> v-text="formatTime(props.row.created_at)"></span>
</q-td> </q-td>
<q-td key="gross_sats" class="text-right"> <q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span> <span v-text="formatSats(props.row.wire_sats)"></span>
</q-td> </q-td>
<q-td key="principal_sats" class="text-right"> <q-td key="principal_sats" class="text-right">
<span v-text="formatSats(props.row.principal_sats)"></span> <span v-text="formatSats(props.row.principal_sats)"></span>
</q-td> </q-td>
<q-td key="commission_sats" class="text-right"> <q-td key="fee_sats" class="text-right">
<span v-text="formatSats(props.row.commission_sats)"></span> <span v-text="formatSats(props.row.fee_sats)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}"> <div :style="{fontSize: '0.75em', opacity: 0.6}">
super super
<span v-text="formatSats(props.row.platform_fee_sats)"></span> <span v-text="formatSats(props.row.platform_fee_sats)"></span>
@ -996,7 +968,7 @@
<q-icon name="info" color="blue"></q-icon> <q-icon name="info" color="blue"></q-icon>
</template> </template>
Original gross: 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, Provide what was actually dispensed. Sat amounts will scale linearly,
the commission split will recompute, and distribution will re-run. the commission split will recompute, and distribution will re-run.
</q-banner> </q-banner>
@ -1019,7 +991,7 @@
label="Dispensed sats" label="Dispensed sats"
hint="Exact sat amount actually dispensed (≤ original gross)" hint="Exact sat amount actually dispensed (≤ original gross)"
type="number" step="1" min="0" type="number" step="1" min="0"
:max="partialDispenseDialog.settlement.gross_sats" :max="partialDispenseDialog.settlement.wire_sats"
dense outlined></q-input> dense outlined></q-input>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
@ -1085,7 +1057,7 @@
Operators see this as a read-only banner. Wallet ID is where the Operators see this as a read-only banner. Wallet ID is where the
collected fee lands; typically a wallet you (the super) own. collected fee lands; typically a wallet you (the super) own.
</p> </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)" label="Fee % (decimal, 0..1)"
hint="0.30 = 30% of every operator's commission" hint="0.30 = 30% of every operator's commission"
type="number" step="0.0001" min="0" max="1" type="number" step="0.0001" min="0" max="1"
@ -1337,10 +1309,6 @@
emit-value map-options emit-value map-options
class="q-mb-md" class="q-mb-md"
dense outlined></q-select> 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" <q-input v-model="editMachineDialog.data.fiat_code"
label="Fiat code" class="q-mb-md" dense outlined></q-input> label="Fiat code" class="q-mb-md" dense outlined></q-input>
<q-toggle v-model="editMachineDialog.data.is_active" <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 Covers the pure-function helpers that survive the 2026-05-26 cleanup:
real Lamassu transaction data to ensure the math is correct. - 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) # Import from the parent package (following lnurlp pattern)
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate from ..calculations import calculate_distribution
# =============================================================================
# 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})"
# ============================================================================= # =============================================================================
@ -157,7 +62,6 @@ class TestDistributionCalculation:
def test_distribution_invariant_sums_to_total(self): def test_distribution_invariant_sums_to_total(self):
"""Total distributed sats must always equal base amount.""" """Total distributed sats must always equal base amount."""
# Test with various client configurations
test_cases = [ test_cases = [
{"a": 100.0}, {"a": 100.0},
{"a": 100.0, "b": 100.0}, {"a": 100.0, "b": 100.0},
@ -215,156 +119,6 @@ class TestDistributionCalculation:
assert distributions == {} 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): def test_many_clients_distribution(self):
"""Test distribution with many clients.""" """Test distribution with many clients."""
# 10 clients with varying balances # 10 clients with varying balances

View file

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

View file

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