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:
parent
6348c55e37
commit
d717a6e214
12 changed files with 530 additions and 681 deletions
256
bitspire.py
256
bitspire.py
|
|
@ -1,24 +1,22 @@
|
|||
# Satoshi Machine v2 — bitSpire payment parser.
|
||||
#
|
||||
# Translates an inbound LNbits Payment (cash-out customer paid the ATM's
|
||||
# invoice) into the principal/commission split needed by satmachineadmin.
|
||||
# Translates an inbound LNbits Payment into a CreateDcaSettlementData by
|
||||
# reading the canonical split fields bitSpire stamps on Payment.extra per
|
||||
# aiolabs/lamassu-next#44 (`source: "bitspire"`, `principal_sats`,
|
||||
# `fee_sats`, `exchange_rate`, etc.).
|
||||
#
|
||||
# Happy path: bitSpire populates Payment.extra with the canonical split
|
||||
# fields per aiolabs/lamassu-next#44 — we read them directly.
|
||||
#
|
||||
# Fallback path: extra is missing (older bitSpire, edge case). We back-derive
|
||||
# the split from the machine's fallback_commission_pct using the Lamassu-era
|
||||
# formula (base = total / (1 + commission)) and mark used_fallback_split=true
|
||||
# so the audit trail shows we estimated.
|
||||
# No back-derivation. If Payment.extra is missing the bitSpire stamp or
|
||||
# any required field, we raise SettlementMetadataError and the caller
|
||||
# records the settlement as 'rejected' for upstream investigation — the
|
||||
# Lamassu-era reverse-derivation from gross-with-commission-baked-in is
|
||||
# obsolete now that the wire carries principal_sats and fee_sats
|
||||
# directly.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .calculations import calculate_commission
|
||||
from .models import CreateDcaSettlementData, Machine
|
||||
|
||||
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound
|
||||
|
|
@ -74,6 +72,34 @@ class SettlementAttributionError(ValueError):
|
|||
"""
|
||||
|
||||
|
||||
class SettlementInvariantError(ValueError):
|
||||
"""A sat-amount or fee-fraction value violates the cross-codebase
|
||||
canonical invariants (see
|
||||
`~/.claude/projects/.../memory/reference_sat_amount_vocabulary.md`).
|
||||
|
||||
Raised by `_assert_sat_invariants`. Caller treats it like
|
||||
SettlementAttributionError — record as rejected, don't distribute.
|
||||
A breach means something upstream (bitSpire, the relay, a buggy
|
||||
consumer) is stamping garbage on Payment.extra; we don't want to
|
||||
quietly silently distribute against corrupt numbers.
|
||||
"""
|
||||
|
||||
|
||||
class SettlementMetadataError(ValueError):
|
||||
"""Payment.extra is missing the bitSpire stamp or required fields.
|
||||
|
||||
Raised by `parse_settlement`. Caller records the settlement as
|
||||
'rejected' with the exception message in `error_message`. Operator
|
||||
investigates the ATM that issued the invoice — a bitSpire ATM that
|
||||
landed on a satmachineadmin-managed wallet without stamping the
|
||||
canonical fields is a real upstream bug (lamassu-next side), not a
|
||||
graceful-degradation case. Pre-v2 reverse-derivation from the
|
||||
wire amount + a machine-level fallback rate is no longer supported:
|
||||
the wire-format contract (lamassu-next#44) is that the ATM always
|
||||
stamps `principal_sats` and `fee_sats` explicitly.
|
||||
"""
|
||||
|
||||
|
||||
def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
||||
"""Assert that the originating Nostr signer pubkey matches the machine.
|
||||
|
||||
|
|
@ -111,49 +137,124 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _assert_sat_invariants(
|
||||
*,
|
||||
tx_type: str,
|
||||
wire_sats: int,
|
||||
principal_sats: int,
|
||||
fee_sats: int,
|
||||
fee_fraction: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Enforce the cross-codebase canonical sat-amount invariants on the
|
||||
parsed settlement values BEFORE building the `CreateDcaSettlementData`.
|
||||
|
||||
Range invariants (all cases):
|
||||
- wire_sats, principal_sats, fee_sats are all non-negative integers.
|
||||
- fee_fraction (if provided) is in [0, 1].
|
||||
|
||||
Sum invariants (direction-specific):
|
||||
- cash_out: wire_sats == principal_sats + fee_sats
|
||||
- cash_in: wire_sats == principal_sats - fee_sats
|
||||
AND fee_sats <= principal_sats
|
||||
(commission cannot exceed the principal in a cash-in;
|
||||
a customer can't owe negative sats)
|
||||
|
||||
The fee_fraction × principal_sats sanity check (≈ fee_sats ±1) is
|
||||
intentionally NOT enforced here — fee_fraction is informational on
|
||||
Payment.extra; the absolute fee_sats stamp is the audit anchor and
|
||||
the source of truth. The two can drift by a few sats due to upstream
|
||||
rounding without indicating corruption. If we ever observe drift
|
||||
>1% of fee_sats we'll add the check.
|
||||
|
||||
Raises SettlementInvariantError with a precise message on any breach.
|
||||
Reference: `reference_sat_amount_vocabulary.md`.
|
||||
"""
|
||||
# Range checks
|
||||
if wire_sats < 0:
|
||||
raise SettlementInvariantError(f"wire_sats must be >= 0, got {wire_sats}")
|
||||
if principal_sats < 0:
|
||||
raise SettlementInvariantError(
|
||||
f"principal_sats must be >= 0, got {principal_sats}"
|
||||
)
|
||||
if fee_sats < 0:
|
||||
raise SettlementInvariantError(f"fee_sats must be >= 0, got {fee_sats}")
|
||||
if fee_fraction is not None and not (0.0 <= fee_fraction <= 1.0):
|
||||
raise SettlementInvariantError(
|
||||
f"fee_fraction must be in [0, 1], got {fee_fraction} "
|
||||
f"(if you see a value >1 the upstream may be stamping percentage "
|
||||
f"instead of fraction — check lamassu-next#? rename status)"
|
||||
)
|
||||
|
||||
# Sum invariants per direction
|
||||
if tx_type == "cash_out":
|
||||
expected_wire = principal_sats + fee_sats
|
||||
if wire_sats != expected_wire:
|
||||
raise SettlementInvariantError(
|
||||
f"cash-out wire_sats invariant violated: "
|
||||
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
|
||||
f"+ fee_sats({fee_sats}) = {expected_wire}"
|
||||
)
|
||||
elif tx_type == "cash_in":
|
||||
if fee_sats > principal_sats:
|
||||
raise SettlementInvariantError(
|
||||
f"cash-in fee_sats({fee_sats}) cannot exceed "
|
||||
f"principal_sats({principal_sats}) — commission > principal "
|
||||
f"would mean a customer owes negative sats"
|
||||
)
|
||||
expected_wire = principal_sats - fee_sats
|
||||
if wire_sats != expected_wire:
|
||||
raise SettlementInvariantError(
|
||||
f"cash-in wire_sats invariant violated: "
|
||||
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
|
||||
f"- fee_sats({fee_sats}) = {expected_wire}"
|
||||
)
|
||||
else:
|
||||
raise SettlementInvariantError(
|
||||
f"unknown tx_type={tx_type!r}; expected 'cash_out' or 'cash_in'"
|
||||
)
|
||||
|
||||
|
||||
def parse_settlement(
|
||||
machine: Machine,
|
||||
payment_hash: str,
|
||||
gross_sats: int,
|
||||
wire_sats: int,
|
||||
extra: dict,
|
||||
super_fee_pct: float,
|
||||
) -> Tuple[CreateDcaSettlementData, bool]:
|
||||
super_fee_fraction: float,
|
||||
) -> CreateDcaSettlementData:
|
||||
"""Build a CreateDcaSettlementData for an inbound payment landing on
|
||||
`machine`'s wallet.
|
||||
|
||||
Returns (data, used_fallback): when `used_fallback` is True, bitSpire
|
||||
didn't populate Payment.extra so we back-derived the split. Caller
|
||||
should log this for visibility — once aiolabs/lamassu-next#44 ships,
|
||||
fallback usage should drop to zero.
|
||||
Requires bitSpire's canonical Payment.extra stamp (source="bitspire"
|
||||
plus the absolute sat amounts) per aiolabs/lamassu-next#44. Raises
|
||||
`SettlementMetadataError` on missing/partial stamp — caller records
|
||||
the settlement as 'rejected' for upstream investigation. Raises
|
||||
`SettlementInvariantError` if the stamped values violate the
|
||||
canonical sat-amount invariants (range + sum, see
|
||||
`_assert_sat_invariants`).
|
||||
"""
|
||||
if is_bitspire_payment(extra):
|
||||
data = _parse_extra(machine, payment_hash, gross_sats, extra, super_fee_pct)
|
||||
return data, False
|
||||
logger.warning(
|
||||
f"satmachineadmin: settlement on machine {machine.machine_npub[:12]}... "
|
||||
f"missing bitSpire extra metadata; back-deriving via "
|
||||
f"fallback_commission_pct={machine.fallback_commission_pct}. "
|
||||
f"See aiolabs/lamassu-next#44."
|
||||
)
|
||||
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."""
|
||||
if not (0.0 <= super_fee_fraction <= 1.0):
|
||||
raise SettlementInvariantError(
|
||||
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
|
||||
)
|
||||
if not is_bitspire_payment(extra):
|
||||
raise SettlementMetadataError(
|
||||
f"Payment.extra missing `source: \"bitspire\"` marker on machine "
|
||||
f"{machine.machine_npub[:12]}... — invoice did not come through "
|
||||
f"a bitSpire ATM, or the ATM firmware is older than "
|
||||
f"aiolabs/lamassu-next#44 and didn't stamp the canonical fields"
|
||||
)
|
||||
principal_sats = _coerce_int(extra.get("principal_sats"))
|
||||
fee_sats = _coerce_int(extra.get("fee_sats"))
|
||||
if principal_sats is None or fee_sats is None:
|
||||
# Missing key fields — shouldn't happen post-#44 but defensive.
|
||||
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct)
|
||||
commission_sats = fee_sats
|
||||
platform_fee_sats = round(commission_sats * super_fee_pct)
|
||||
operator_fee_sats = commission_sats - platform_fee_sats
|
||||
raise SettlementMetadataError(
|
||||
f"Payment.extra has source=bitspire but is missing required "
|
||||
f"fields principal_sats={extra.get('principal_sats')!r} or "
|
||||
f"fee_sats={extra.get('fee_sats')!r}; the wire-format contract "
|
||||
f"(lamassu-next#44) requires both. Investigate the ATM "
|
||||
f"firmware on machine {machine.machine_npub[:12]}..."
|
||||
)
|
||||
platform_fee_sats = round(fee_sats * super_fee_fraction)
|
||||
operator_fee_sats = fee_sats - platform_fee_sats
|
||||
exchange_rate = _coerce_float(extra.get("exchange_rate"))
|
||||
if exchange_rate is None or exchange_rate <= 0:
|
||||
# Without exchange rate we can't compute fiat. Use 1.0 as a stand-in
|
||||
|
|
@ -163,66 +264,39 @@ def _parse_extra(
|
|||
# dispenser ledger (lamassu-next@8318489). It's the cash that
|
||||
# physically entered (cash-in) or exited (cash-out) the machine —
|
||||
# canonical, not derived. We never recompute it from sats × rate
|
||||
# downstream: the relationship is't load-bearing (commission lives
|
||||
# downstream: the relationship isn't load-bearing (commission lives
|
||||
# in BTC today, but the cash side has its own ground truth).
|
||||
fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0
|
||||
fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code
|
||||
return CreateDcaSettlementData(
|
||||
tx_type = _coerce_str(extra.get("type")) or "cash_out"
|
||||
data = CreateDcaSettlementData(
|
||||
machine_id=machine.id,
|
||||
payment_hash=payment_hash,
|
||||
bitspire_event_id=None,
|
||||
bitspire_txid=_coerce_str(extra.get("txid")),
|
||||
gross_sats=gross_sats,
|
||||
wire_sats=wire_sats,
|
||||
fiat_amount=fiat_amount,
|
||||
fiat_code=fiat_code,
|
||||
exchange_rate=exchange_rate,
|
||||
principal_sats=principal_sats,
|
||||
commission_sats=commission_sats,
|
||||
fee_sats=fee_sats,
|
||||
platform_fee_sats=platform_fee_sats,
|
||||
operator_fee_sats=operator_fee_sats,
|
||||
used_fallback_split=False,
|
||||
tx_type=_coerce_str(extra.get("type")) or "cash_out",
|
||||
tx_type=tx_type,
|
||||
bills_json=_json_dumps(extra.get("bills")),
|
||||
cassettes_json=_json_dumps(extra.get("cassettes")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_fallback(
|
||||
machine: Machine,
|
||||
payment_hash: str,
|
||||
gross_sats: int,
|
||||
super_fee_pct: float,
|
||||
) -> CreateDcaSettlementData:
|
||||
"""Back-derive the split using the machine's fallback_commission_pct.
|
||||
|
||||
Same formula as the Lamassu integration used:
|
||||
base_amount = round(gross / (1 + commission_pct))
|
||||
commission = gross - base_amount
|
||||
"""
|
||||
principal_sats, commission_sats, _effective = calculate_commission(
|
||||
crypto_atoms=gross_sats,
|
||||
commission_percentage=machine.fallback_commission_pct,
|
||||
discount=0.0,
|
||||
)
|
||||
platform_fee_sats = round(commission_sats * super_fee_pct)
|
||||
operator_fee_sats = commission_sats - platform_fee_sats
|
||||
# No exchange rate from the wire; leave fiat_amount=0 so it's visibly
|
||||
# incomplete on the operator's reconciliation screen.
|
||||
return CreateDcaSettlementData(
|
||||
machine_id=machine.id,
|
||||
payment_hash=payment_hash,
|
||||
bitspire_event_id=None,
|
||||
bitspire_txid=None,
|
||||
gross_sats=gross_sats,
|
||||
fiat_amount=0.0,
|
||||
fiat_code=machine.fiat_code,
|
||||
exchange_rate=0.0,
|
||||
principal_sats=principal_sats,
|
||||
commission_sats=commission_sats,
|
||||
platform_fee_sats=platform_fee_sats,
|
||||
operator_fee_sats=operator_fee_sats,
|
||||
used_fallback_split=True,
|
||||
tx_type="cash_out",
|
||||
bills_json=None,
|
||||
cassettes_json=None,
|
||||
# Enforce the cross-codebase canonical sat-amount invariants on the
|
||||
# values bitSpire stamped (post-rename: `fee_fraction` is preferred;
|
||||
# the old `fee_percent` field is deliberately NOT read here because
|
||||
# of the 100× misinterpretation risk during the rename window — the
|
||||
# absolute `fee_sats` stamp is the audit anchor and the sum
|
||||
# invariants below catch any garbage at the wire).
|
||||
_assert_sat_invariants(
|
||||
tx_type=data.tx_type,
|
||||
wire_sats=data.wire_sats,
|
||||
principal_sats=data.principal_sats,
|
||||
fee_sats=data.fee_sats,
|
||||
fee_fraction=_coerce_float(extra.get("fee_fraction")),
|
||||
)
|
||||
return data
|
||||
|
|
|
|||
108
calculations.py
108
calculations.py
|
|
@ -3,52 +3,19 @@ Pure calculation functions for DCA transaction processing.
|
|||
|
||||
These functions have no external dependencies (no lnbits, no database)
|
||||
and can be easily tested in isolation.
|
||||
|
||||
What's intentionally NOT here (deleted 2026-05-26):
|
||||
- `calculate_commission` (back-derive principal+fee from a gross-with-
|
||||
commission-baked-in wire amount). Lamassu-era reverse-derivation;
|
||||
obsolete since bitSpire stamps `principal_sats` AND `fee_sats`
|
||||
directly on Payment.extra per aiolabs/lamassu-next#44.
|
||||
- `calculate_exchange_rate` (principal / fiat_amount). bitSpire stamps
|
||||
`exchange_rate` directly on Payment.extra too. Not used in production.
|
||||
"""
|
||||
|
||||
from typing import Dict, Tuple
|
||||
|
||||
|
||||
def calculate_commission(
|
||||
crypto_atoms: int,
|
||||
commission_percentage: float,
|
||||
discount: float = 0.0
|
||||
) -> Tuple[int, int, float]:
|
||||
"""
|
||||
Calculate commission split from a Lamassu transaction.
|
||||
|
||||
The crypto_atoms from Lamassu already includes the commission baked in.
|
||||
This function extracts the base amount (for DCA distribution) and
|
||||
commission amount (for commission wallet).
|
||||
|
||||
Formula:
|
||||
effective_commission = commission_percentage * (100 - discount) / 100
|
||||
base_amount = round(crypto_atoms / (1 + effective_commission))
|
||||
commission_amount = crypto_atoms - base_amount
|
||||
|
||||
Args:
|
||||
crypto_atoms: Total sats from Lamassu (includes commission)
|
||||
commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%)
|
||||
discount: Discount percentage on commission (e.g., 10.0 for 10% off)
|
||||
|
||||
Returns:
|
||||
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
|
||||
|
||||
Example:
|
||||
>>> calculate_commission(266800, 0.03, 0.0)
|
||||
(259029, 7771, 0.03)
|
||||
"""
|
||||
if commission_percentage > 0:
|
||||
effective_commission = commission_percentage * (100 - discount) / 100
|
||||
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
||||
commission_amount_sats = crypto_atoms - base_crypto_atoms
|
||||
else:
|
||||
effective_commission = 0.0
|
||||
base_crypto_atoms = crypto_atoms
|
||||
commission_amount_sats = 0
|
||||
|
||||
return base_crypto_atoms, commission_amount_sats, effective_commission
|
||||
|
||||
|
||||
def calculate_distribution(
|
||||
base_amount_sats: int,
|
||||
client_balances: Dict[str, float],
|
||||
|
|
@ -132,14 +99,15 @@ def calculate_distribution(
|
|||
|
||||
|
||||
def split_two_stage_commission(
|
||||
commission_sats: int, super_fee_pct: float
|
||||
fee_sats: int, super_fee_fraction: float
|
||||
) -> Tuple[int, int]:
|
||||
"""Stage-1 of the v2 commission split: super takes `super_fee_pct` of the
|
||||
total commission; the remainder is what the operator's own ruleset acts on.
|
||||
"""Stage-1 of the v2 commission split: super takes `super_fee_fraction`
|
||||
of the total fee; the remainder is what the operator's own ruleset
|
||||
acts on.
|
||||
|
||||
Returns (platform_fee_sats, operator_fee_sats). Platform is rounded;
|
||||
operator absorbs the rounding remainder so platform_fee + operator_fee
|
||||
== commission_sats exactly.
|
||||
== fee_sats exactly.
|
||||
|
||||
Examples:
|
||||
>>> split_two_stage_commission(100, 0.30)
|
||||
|
|
@ -151,23 +119,28 @@ def split_two_stage_commission(
|
|||
>>> split_two_stage_commission(100, 1.0)
|
||||
(100, 0)
|
||||
"""
|
||||
if commission_sats <= 0:
|
||||
if not (0.0 <= super_fee_fraction <= 1.0):
|
||||
raise ValueError(
|
||||
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
|
||||
)
|
||||
if fee_sats <= 0:
|
||||
return 0, 0
|
||||
platform = round(commission_sats * super_fee_pct)
|
||||
platform = max(0, min(platform, commission_sats))
|
||||
operator = commission_sats - platform
|
||||
platform = round(fee_sats * super_fee_fraction)
|
||||
platform = max(0, min(platform, fee_sats))
|
||||
operator = fee_sats - platform
|
||||
return platform, operator
|
||||
|
||||
|
||||
def allocate_operator_split_legs(
|
||||
operator_fee_sats: int, leg_pcts: list
|
||||
operator_fee_sats: int, leg_fractions: list
|
||||
) -> list:
|
||||
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
|
||||
across N leg wallets per `leg_pcts` (each in 0..1, sum should equal 1.0).
|
||||
across N leg wallets per `leg_fractions` (each in [0, 1], sum should
|
||||
equal 1.0).
|
||||
|
||||
The last leg absorbs the rounding remainder so the sum of allocations
|
||||
exactly equals operator_fee_sats (assuming pcts sum to ~1.0). Returns
|
||||
a list of integer sat amounts in the same order as leg_pcts.
|
||||
exactly equals operator_fee_sats (assuming fractions sum to ~1.0).
|
||||
Returns a list of integer sat amounts in the same order as leg_fractions.
|
||||
|
||||
Examples:
|
||||
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
|
||||
|
|
@ -179,33 +152,24 @@ def allocate_operator_split_legs(
|
|||
>>> allocate_operator_split_legs(0, [0.5, 0.5])
|
||||
[0, 0]
|
||||
"""
|
||||
if not leg_pcts:
|
||||
if not leg_fractions:
|
||||
return []
|
||||
if operator_fee_sats <= 0:
|
||||
return [0] * len(leg_pcts)
|
||||
return [0] * len(leg_fractions)
|
||||
for f in leg_fractions:
|
||||
if not (0.0 <= float(f) <= 1.0):
|
||||
raise ValueError(
|
||||
f"every leg fraction must be in [0, 1], got {f}"
|
||||
)
|
||||
allocations: list = []
|
||||
remaining = operator_fee_sats
|
||||
for idx, pct in enumerate(leg_pcts):
|
||||
if idx == len(leg_pcts) - 1:
|
||||
for idx, fraction in enumerate(leg_fractions):
|
||||
if idx == len(leg_fractions) - 1:
|
||||
allocations.append(remaining)
|
||||
else:
|
||||
amount = round(operator_fee_sats * float(pct))
|
||||
amount = round(operator_fee_sats * float(fraction))
|
||||
allocations.append(amount)
|
||||
remaining -= amount
|
||||
return allocations
|
||||
|
||||
|
||||
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
|
||||
"""
|
||||
Calculate exchange rate in sats per fiat unit.
|
||||
|
||||
Args:
|
||||
base_crypto_atoms: Base amount in sats (after commission)
|
||||
fiat_amount: Fiat amount dispensed
|
||||
|
||||
Returns:
|
||||
Exchange rate as sats per fiat unit
|
||||
"""
|
||||
if fiat_amount <= 0:
|
||||
return 0.0
|
||||
return base_crypto_atoms / fiat_amount
|
||||
|
|
|
|||
41
crud.py
41
crud.py
|
|
@ -78,10 +78,9 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
|
|||
"""
|
||||
INSERT INTO satoshimachine.dca_machines
|
||||
(id, operator_user_id, machine_npub, wallet_id, name, location,
|
||||
fiat_code, is_active, fallback_commission_pct, created_at, updated_at)
|
||||
fiat_code, is_active, created_at, updated_at)
|
||||
VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name,
|
||||
:location, :fiat_code, :is_active, :fallback_commission_pct,
|
||||
:created_at, :updated_at)
|
||||
:location, :fiat_code, :is_active, :created_at, :updated_at)
|
||||
""",
|
||||
{
|
||||
"id": machine_id,
|
||||
|
|
@ -92,7 +91,6 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
|
|||
"location": data.location,
|
||||
"fiat_code": data.fiat_code,
|
||||
"is_active": True,
|
||||
"fallback_commission_pct": data.fallback_commission_pct,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
},
|
||||
|
|
@ -555,14 +553,14 @@ async def create_settlement_idempotent(
|
|||
"""
|
||||
INSERT INTO satoshimachine.dca_settlements
|
||||
(id, machine_id, payment_hash, bitspire_event_id, bitspire_txid,
|
||||
gross_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
|
||||
commission_sats, platform_fee_sats, operator_fee_sats,
|
||||
used_fallback_split, tx_type, bills_json, cassettes_json,
|
||||
wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
|
||||
fee_sats, platform_fee_sats, operator_fee_sats,
|
||||
tx_type, bills_json, cassettes_json,
|
||||
status, error_message, created_at)
|
||||
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
|
||||
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code,
|
||||
:exchange_rate, :principal_sats, :commission_sats,
|
||||
:platform_fee_sats, :operator_fee_sats, :used_fallback_split,
|
||||
:bitspire_txid, :wire_sats, :fiat_amount, :fiat_code,
|
||||
:exchange_rate, :principal_sats, :fee_sats,
|
||||
:platform_fee_sats, :operator_fee_sats,
|
||||
:tx_type, :bills_json, :cassettes_json, :status,
|
||||
:error_message, :created_at)
|
||||
""",
|
||||
|
|
@ -572,15 +570,14 @@ async def create_settlement_idempotent(
|
|||
"payment_hash": data.payment_hash,
|
||||
"bitspire_event_id": data.bitspire_event_id,
|
||||
"bitspire_txid": data.bitspire_txid,
|
||||
"gross_sats": data.gross_sats,
|
||||
"wire_sats": data.wire_sats,
|
||||
"fiat_amount": data.fiat_amount,
|
||||
"fiat_code": data.fiat_code,
|
||||
"exchange_rate": data.exchange_rate,
|
||||
"principal_sats": data.principal_sats,
|
||||
"commission_sats": data.commission_sats,
|
||||
"fee_sats": data.fee_sats,
|
||||
"platform_fee_sats": data.platform_fee_sats,
|
||||
"operator_fee_sats": data.operator_fee_sats,
|
||||
"used_fallback_split": data.used_fallback_split,
|
||||
"tx_type": data.tx_type,
|
||||
"bills_json": data.bills_json,
|
||||
"cassettes_json": data.cassettes_json,
|
||||
|
|
@ -840,9 +837,9 @@ async def reset_settlement_for_retry(
|
|||
async def apply_partial_dispense(
|
||||
settlement_id: str,
|
||||
*,
|
||||
new_gross_sats: int,
|
||||
new_wire_sats: int,
|
||||
new_principal_sats: int,
|
||||
new_commission_sats: int,
|
||||
new_fee_sats: int,
|
||||
new_platform_fee_sats: int,
|
||||
new_operator_fee_sats: int,
|
||||
new_fiat_amount: float,
|
||||
|
|
@ -858,9 +855,9 @@ async def apply_partial_dispense(
|
|||
await db.execute(
|
||||
"""
|
||||
UPDATE satoshimachine.dca_settlements
|
||||
SET gross_sats = :gross,
|
||||
SET wire_sats = :gross,
|
||||
principal_sats = :principal,
|
||||
commission_sats = :commission,
|
||||
fee_sats = :commission,
|
||||
platform_fee_sats = :platform,
|
||||
operator_fee_sats = :operator,
|
||||
fiat_amount = :fiat,
|
||||
|
|
@ -875,9 +872,9 @@ async def apply_partial_dispense(
|
|||
""",
|
||||
{
|
||||
"id": settlement_id,
|
||||
"gross": new_gross_sats,
|
||||
"gross": new_wire_sats,
|
||||
"principal": new_principal_sats,
|
||||
"commission": new_commission_sats,
|
||||
"commission": new_fee_sats,
|
||||
"platform": new_platform_fee_sats,
|
||||
"operator": new_operator_fee_sats,
|
||||
"fiat": new_fiat_amount,
|
||||
|
|
@ -1013,9 +1010,9 @@ async def replace_commission_splits(
|
|||
await db.execute(
|
||||
"""
|
||||
INSERT INTO satoshimachine.dca_commission_splits
|
||||
(id, machine_id, operator_user_id, target, label, pct,
|
||||
(id, machine_id, operator_user_id, target, label, fraction,
|
||||
sort_order, created_at)
|
||||
VALUES (:id, :machine_id, :uid, :target, :label, :pct,
|
||||
VALUES (:id, :machine_id, :uid, :target, :label, :fraction,
|
||||
:sort_order, :created_at)
|
||||
""",
|
||||
{
|
||||
|
|
@ -1024,7 +1021,7 @@ async def replace_commission_splits(
|
|||
"uid": operator_user_id,
|
||||
"target": leg.target,
|
||||
"label": leg.label,
|
||||
"pct": leg.pct,
|
||||
"fraction": leg.fraction,
|
||||
"sort_order": leg.sort_order,
|
||||
"created_at": now,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -111,32 +111,32 @@ async def _record_skipped_leg(
|
|||
)
|
||||
|
||||
|
||||
def _resolve_partial_dispense_gross(
|
||||
def _resolve_partial_dispense_wire(
|
||||
settlement: DcaSettlement, data: PartialDispenseData
|
||||
) -> int:
|
||||
if data.dispensed_sats is not None:
|
||||
new_gross = int(data.dispensed_sats)
|
||||
new_wire = int(data.dispensed_sats)
|
||||
elif data.dispensed_fraction is not None:
|
||||
new_gross = round(settlement.gross_sats * float(data.dispensed_fraction))
|
||||
new_wire = round(settlement.wire_sats * float(data.dispensed_fraction))
|
||||
else:
|
||||
raise ValueError("provide one of dispensed_sats or dispensed_fraction")
|
||||
if new_gross < 0:
|
||||
if new_wire < 0:
|
||||
raise ValueError("partial dispense cannot be negative")
|
||||
if new_gross > settlement.gross_sats:
|
||||
if new_wire > settlement.wire_sats:
|
||||
raise ValueError(
|
||||
f"partial dispense ({new_gross} sats) cannot exceed the original "
|
||||
f"gross ({settlement.gross_sats} sats)"
|
||||
f"partial dispense ({new_wire} sats) cannot exceed the original "
|
||||
f"wire amount ({settlement.wire_sats} sats)"
|
||||
)
|
||||
return new_gross
|
||||
return new_wire
|
||||
|
||||
|
||||
def _build_partial_dispense_memo(
|
||||
settlement: DcaSettlement,
|
||||
data: PartialDispenseData,
|
||||
*,
|
||||
new_gross: int,
|
||||
new_wire: int,
|
||||
new_principal: int,
|
||||
new_commission: int,
|
||||
new_fee: int,
|
||||
new_platform: int,
|
||||
new_operator: int,
|
||||
) -> str:
|
||||
|
|
@ -148,13 +148,13 @@ def _build_partial_dispense_memo(
|
|||
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
return (
|
||||
f"[{ts}] partial dispense applied — {adjust}. "
|
||||
f"Original gross={settlement.gross_sats} "
|
||||
f"Original wire={settlement.wire_sats} "
|
||||
f"principal={settlement.principal_sats} "
|
||||
f"commission={settlement.commission_sats} "
|
||||
f"fee={settlement.fee_sats} "
|
||||
f"(super_fee={settlement.platform_fee_sats} "
|
||||
f"operator_fee={settlement.operator_fee_sats}). "
|
||||
f"New gross={new_gross} principal={new_principal} "
|
||||
f"commission={new_commission} "
|
||||
f"New wire={new_wire} principal={new_principal} "
|
||||
f"fee={new_fee} "
|
||||
f"(super_fee={new_platform} operator_fee={new_operator}). "
|
||||
f"Reason: {reason}"
|
||||
)
|
||||
|
|
@ -275,10 +275,10 @@ async def apply_partial_dispense_and_redistribute(
|
|||
|
||||
When a bitSpire dispense fails mid-transaction (e.g., dispenser jam after
|
||||
6 of 10 bills), the operator confirms the actual amount dispensed and we
|
||||
re-allocate the split against that partial gross. Sat amounts scale
|
||||
re-allocate the split against that partial wire amount. Sat amounts scale
|
||||
linearly, preserving the original commission ratio exactly. The two-stage
|
||||
super/operator split also scales by the *original* platform_fee_sats /
|
||||
commission_sats ratio rather than re-reading current super_fee_pct —
|
||||
fee_sats ratio rather than re-reading current super_fee_fraction —
|
||||
this honors the "absolute fields are the source of truth" invariant
|
||||
even when super has changed the global rate since the settlement landed
|
||||
(closes #11 H6).
|
||||
|
|
@ -296,8 +296,8 @@ async def apply_partial_dispense_and_redistribute(
|
|||
settlement = await get_settlement(settlement_id)
|
||||
if settlement is None:
|
||||
raise ValueError(f"settlement {settlement_id} not found")
|
||||
if settlement.gross_sats <= 0:
|
||||
raise ValueError("cannot partial-dispense a zero-gross settlement")
|
||||
if settlement.wire_sats <= 0:
|
||||
raise ValueError("cannot partial-dispense a zero-wire settlement")
|
||||
completed = await count_completed_legs_for_settlement(settlement_id)
|
||||
if completed > 0:
|
||||
raise ValueError(
|
||||
|
|
@ -305,33 +305,33 @@ async def apply_partial_dispense_and_redistribute(
|
|||
"(Lightning payments can't be clawed back)"
|
||||
)
|
||||
|
||||
new_gross = _resolve_partial_dispense_gross(settlement, data)
|
||||
new_wire = _resolve_partial_dispense_wire(settlement, data)
|
||||
# Linear scale preserves the original commission ratio exactly.
|
||||
scale = new_gross / settlement.gross_sats
|
||||
new_commission = round(settlement.commission_sats * scale)
|
||||
new_principal = new_gross - new_commission
|
||||
scale = new_wire / settlement.wire_sats
|
||||
new_fee = round(settlement.fee_sats * scale)
|
||||
new_principal = new_wire - new_fee
|
||||
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
|
||||
|
||||
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
|
||||
# settlement row — NOT the current super_fee_pct. The contract was
|
||||
# settlement row — NOT the current super_fee_fraction. The contract was
|
||||
# locked at landing; super raising or lowering the global rate after
|
||||
# the fact must not retroactively change this transaction's share.
|
||||
# Operator absorbs the rounding remainder so platform + operator
|
||||
# == new_commission exactly.
|
||||
if settlement.commission_sats > 0:
|
||||
ratio = settlement.platform_fee_sats / settlement.commission_sats
|
||||
# == new_fee exactly.
|
||||
if settlement.fee_sats > 0:
|
||||
ratio = settlement.platform_fee_sats / settlement.fee_sats
|
||||
else:
|
||||
ratio = 0.0
|
||||
new_platform = round(new_commission * ratio)
|
||||
new_platform = max(0, min(new_platform, new_commission))
|
||||
new_operator = new_commission - new_platform
|
||||
new_platform = round(new_fee * ratio)
|
||||
new_platform = max(0, min(new_platform, new_fee))
|
||||
new_operator = new_fee - new_platform
|
||||
|
||||
memo = _build_partial_dispense_memo(
|
||||
settlement,
|
||||
data,
|
||||
new_gross=new_gross,
|
||||
new_wire=new_wire,
|
||||
new_principal=new_principal,
|
||||
new_commission=new_commission,
|
||||
new_fee=new_fee,
|
||||
new_platform=new_platform,
|
||||
new_operator=new_operator,
|
||||
)
|
||||
|
|
@ -339,9 +339,9 @@ async def apply_partial_dispense_and_redistribute(
|
|||
await void_open_legs_for_settlement(settlement_id)
|
||||
updated = await apply_partial_dispense(
|
||||
settlement_id,
|
||||
new_gross_sats=new_gross,
|
||||
new_wire_sats=new_wire,
|
||||
new_principal_sats=new_principal,
|
||||
new_commission_sats=new_commission,
|
||||
new_fee_sats=new_fee,
|
||||
new_platform_fee_sats=new_platform,
|
||||
new_operator_fee_sats=new_operator,
|
||||
new_fiat_amount=new_fiat,
|
||||
|
|
@ -467,7 +467,7 @@ async def _pay_operator_splits(
|
|||
# Pure allocator handles the rounding rule (last leg absorbs remainder).
|
||||
leg_amounts = allocate_operator_split_legs(
|
||||
settlement.operator_fee_sats,
|
||||
[float(leg.pct) for leg in splits],
|
||||
[float(leg.fraction) for leg in splits],
|
||||
)
|
||||
for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)):
|
||||
if amount <= 0:
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ async def m001_satmachine_v2_initial(db):
|
|||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
|
||||
id TEXT PRIMARY KEY,
|
||||
super_fee_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
|
||||
super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
|
||||
super_fee_wallet_id TEXT,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
|
|
@ -72,7 +72,7 @@ async def m001_satmachine_v2_initial(db):
|
|||
)
|
||||
if not existing:
|
||||
await db.execute(
|
||||
"INSERT INTO satoshimachine.super_config (id, super_fee_pct) "
|
||||
"INSERT INTO satoshimachine.super_config (id, super_fee_fraction) "
|
||||
"VALUES ('default', 0.0000)"
|
||||
)
|
||||
|
||||
|
|
@ -88,7 +88,6 @@ async def m001_satmachine_v2_initial(db):
|
|||
location TEXT,
|
||||
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
fallback_commission_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0500,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
|
|
@ -180,10 +179,10 @@ async def m001_satmachine_v2_initial(db):
|
|||
# append-only audit memo for partial-dispense + operator notes.
|
||||
#
|
||||
# platform_fee_sats and operator_fee_sats are absolute BIGINT,
|
||||
# NOT derived percentages — when the v2 customer-discount engine
|
||||
# NOT derived fractions — when the v2 customer-discount engine
|
||||
# ships, these two columns are the audit-grade record of who
|
||||
# forgave what per transaction. Do not collapse them into a single
|
||||
# commission_pct. See plan section "Customer discounts" and #10.
|
||||
# fee_fraction. See plan section "Customer discounts" and #10.
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
|
@ -191,15 +190,14 @@ async def m001_satmachine_v2_initial(db):
|
|||
payment_hash TEXT NOT NULL UNIQUE,
|
||||
bitspire_event_id TEXT,
|
||||
bitspire_txid TEXT,
|
||||
gross_sats BIGINT NOT NULL,
|
||||
wire_sats BIGINT NOT NULL,
|
||||
fiat_amount DECIMAL(10,2) NOT NULL,
|
||||
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
||||
exchange_rate REAL NOT NULL,
|
||||
principal_sats BIGINT NOT NULL,
|
||||
commission_sats BIGINT NOT NULL,
|
||||
fee_sats BIGINT NOT NULL,
|
||||
platform_fee_sats BIGINT NOT NULL,
|
||||
operator_fee_sats BIGINT NOT NULL,
|
||||
used_fallback_split BOOLEAN NOT NULL DEFAULT false,
|
||||
tx_type TEXT NOT NULL,
|
||||
bills_json TEXT,
|
||||
cassettes_json TEXT,
|
||||
|
|
@ -217,9 +215,9 @@ async def m001_satmachine_v2_initial(db):
|
|||
)
|
||||
|
||||
# 7. dca_commission_splits — operator's rules for distributing the
|
||||
# *remainder* (commission_sats - platform_fee_sats). One row per
|
||||
# *remainder* (fee_sats - platform_fee_sats). One row per
|
||||
# leg. machine_id=NULL = operator default; non-null = per-machine
|
||||
# override. Sum(pct) per (operator, machine) must equal 1.0 —
|
||||
# override. Sum(fraction) per (operator, machine) must equal 1.0 —
|
||||
# enforced at write-time in crud.py.
|
||||
#
|
||||
# `target` accepts any of (splitpayments-style):
|
||||
|
|
@ -235,7 +233,7 @@ async def m001_satmachine_v2_initial(db):
|
|||
operator_user_id TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
label TEXT,
|
||||
pct DECIMAL(10,4) NOT NULL,
|
||||
fraction DECIMAL(10,4) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
|
|
@ -439,6 +437,76 @@ async def m004_introduce_dca_lp_table(db):
|
|||
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
|
||||
|
||||
|
||||
async def m006_rename_to_canonical_sat_vocabulary(db):
|
||||
"""Adopt the cross-codebase canonical sat-amount vocabulary AND drop
|
||||
the now-obsolete Lamassu-era fallback columns, per the decision at
|
||||
memory `reference_sat_amount_vocabulary.md` (2026-05-26):
|
||||
|
||||
Renames:
|
||||
- dca_settlements.gross_sats → wire_sats
|
||||
- dca_settlements.commission_sats → fee_sats
|
||||
- super_config.super_fee_pct → super_fee_fraction
|
||||
- dca_commission_splits.pct → fraction
|
||||
|
||||
Drops (Lamassu-era reverse-derivation is obsolete since bitSpire
|
||||
stamps both `principal_sats` AND `fee_sats` directly on
|
||||
Payment.extra per lamassu-next#44 — there's nothing to back-derive):
|
||||
- dca_machines.fallback_commission_pct (was the rate used by the
|
||||
deleted `_parse_fallback` path)
|
||||
- dca_settlements.used_fallback_split (was the per-row marker for
|
||||
that path)
|
||||
|
||||
Same canonical applies on the lamassu-next + atm-tui side; the
|
||||
rename is coordinated via `~/dev/coordination/log.md` (2026-05-26).
|
||||
|
||||
Each step is idempotent — probe for the OLD column; rename/drop only
|
||||
if present; otherwise no-op (covers fresh installs where m001
|
||||
already laid down the canonical schema).
|
||||
|
||||
Why a single migration: all driven by the same decision and any
|
||||
external code wants to see the whole rename + cleanup land at once.
|
||||
"""
|
||||
renames = [
|
||||
("dca_settlements", "gross_sats", "wire_sats"),
|
||||
("dca_settlements", "commission_sats", "fee_sats"),
|
||||
("super_config", "super_fee_pct", "super_fee_fraction"),
|
||||
("dca_commission_splits", "pct", "fraction"),
|
||||
]
|
||||
for table, old_col, new_col in renames:
|
||||
try:
|
||||
await db.fetchone(
|
||||
f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1"
|
||||
)
|
||||
except Exception:
|
||||
# old column doesn't exist; either rename already landed or
|
||||
# m001 produced the canonical schema directly on fresh install.
|
||||
continue
|
||||
await db.execute(
|
||||
f"ALTER TABLE satoshimachine.{table} "
|
||||
f"RENAME COLUMN {old_col} TO {new_col}"
|
||||
)
|
||||
|
||||
# Drop the Lamassu-era fallback columns. Same idempotency pattern.
|
||||
# Try both old (_pct) and new (_fraction) names for the dca_machines
|
||||
# column since an install could be at either rename state.
|
||||
drops = [
|
||||
("dca_machines", "fallback_commission_pct"),
|
||||
("dca_machines", "fallback_commission_fraction"),
|
||||
("dca_settlements", "used_fallback_split"),
|
||||
]
|
||||
for table, col in drops:
|
||||
try:
|
||||
await db.fetchone(
|
||||
f"SELECT {col} FROM satoshimachine.{table} LIMIT 1"
|
||||
)
|
||||
except Exception:
|
||||
# column doesn't exist; either already dropped or never present.
|
||||
continue
|
||||
await db.execute(
|
||||
f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}"
|
||||
)
|
||||
|
||||
|
||||
async def m005_lock_deposit_currency_to_machine_fiat_code(db):
|
||||
"""Rewrite every `dca_deposits.currency` row to match its joined
|
||||
`dca_machines.fiat_code`.
|
||||
|
|
|
|||
60
models.py
60
models.py
|
|
@ -29,18 +29,6 @@ class CreateMachineData(BaseModel):
|
|||
name: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
fiat_code: str = "GTQ"
|
||||
# Used only when bitSpire's settlement event omits principal_sats/
|
||||
# fee_sats in Payment.extra (older bitSpire or edge cases). See
|
||||
# plan's lamassu-next informational issue #1.
|
||||
fallback_commission_pct: float = 0.05
|
||||
|
||||
@validator("fallback_commission_pct")
|
||||
def commission_in_unit_range(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v < 0 or v > 1:
|
||||
raise ValueError("fallback_commission_pct must be between 0 and 1")
|
||||
return round(float(v), 4)
|
||||
|
||||
|
||||
class Machine(BaseModel):
|
||||
|
|
@ -52,7 +40,6 @@ class Machine(BaseModel):
|
|||
location: Optional[str]
|
||||
fiat_code: str
|
||||
is_active: bool
|
||||
fallback_commission_pct: float
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
|
@ -63,15 +50,6 @@ class UpdateMachineData(BaseModel):
|
|||
fiat_code: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
wallet_id: Optional[str] = None
|
||||
fallback_commission_pct: Optional[float] = None
|
||||
|
||||
@validator("fallback_commission_pct")
|
||||
def commission_in_unit_range(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v < 0 or v > 1:
|
||||
raise ValueError("fallback_commission_pct must be between 0 and 1")
|
||||
return round(float(v), 4)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -225,7 +203,7 @@ class UpdateDepositStatusData(BaseModel):
|
|||
# =============================================================================
|
||||
# platform_fee_sats and operator_fee_sats are absolute audit-grade values.
|
||||
# Today they equal the contractual split; tomorrow (post-v1 promo engine)
|
||||
# they record who-forgave-what. DO NOT collapse them into a single pct.
|
||||
# they record who-forgave-what. DO NOT collapse them into a single fraction.
|
||||
# See plan section "Customer discounts & promotions (post-v1)".
|
||||
|
||||
|
||||
|
|
@ -234,15 +212,14 @@ class CreateDcaSettlementData(BaseModel):
|
|||
payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
|
||||
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion
|
||||
bitspire_txid: Optional[str] = None
|
||||
gross_sats: int
|
||||
wire_sats: int
|
||||
fiat_amount: float
|
||||
fiat_code: str = "GTQ"
|
||||
exchange_rate: float
|
||||
principal_sats: int
|
||||
commission_sats: int
|
||||
fee_sats: int
|
||||
platform_fee_sats: int
|
||||
operator_fee_sats: int
|
||||
used_fallback_split: bool = False
|
||||
tx_type: str # 'cash_out' | 'cash_in'
|
||||
bills_json: Optional[str] = None
|
||||
cassettes_json: Optional[str] = None
|
||||
|
|
@ -254,15 +231,14 @@ class DcaSettlement(BaseModel):
|
|||
payment_hash: str
|
||||
bitspire_event_id: Optional[str]
|
||||
bitspire_txid: Optional[str]
|
||||
gross_sats: int
|
||||
wire_sats: int
|
||||
fiat_amount: float
|
||||
fiat_code: str
|
||||
exchange_rate: float
|
||||
principal_sats: int
|
||||
commission_sats: int
|
||||
fee_sats: int
|
||||
platform_fee_sats: int
|
||||
operator_fee_sats: int
|
||||
used_fallback_split: bool
|
||||
tx_type: str
|
||||
bills_json: Optional[str]
|
||||
cassettes_json: Optional[str]
|
||||
|
|
@ -295,8 +271,8 @@ class DcaSettlement(BaseModel):
|
|||
# Commission splits — operator-defined remainder allocation per machine.
|
||||
# =============================================================================
|
||||
# machine_id=NULL means operator's default; non-null means per-machine override.
|
||||
# Sum of pct across rows for a (operator_user_id, machine_id) scope must be 1.0,
|
||||
# enforced at write-time in crud.py.
|
||||
# Sum of fraction across rows for a (operator_user_id, machine_id) scope must
|
||||
# be 1.0, enforced at write-time in crud.py.
|
||||
|
||||
|
||||
class CommissionSplitLeg(BaseModel):
|
||||
|
|
@ -311,7 +287,7 @@ class CommissionSplitLeg(BaseModel):
|
|||
|
||||
target: str
|
||||
label: Optional[str] = None
|
||||
pct: float
|
||||
fraction: float
|
||||
sort_order: int = 0
|
||||
|
||||
@validator("target")
|
||||
|
|
@ -321,10 +297,10 @@ class CommissionSplitLeg(BaseModel):
|
|||
raise ValueError("target cannot be empty")
|
||||
return v
|
||||
|
||||
@validator("pct")
|
||||
def pct_in_unit_range(cls, v):
|
||||
@validator("fraction")
|
||||
def fraction_in_unit_range(cls, v):
|
||||
if v < 0 or v > 1:
|
||||
raise ValueError("pct must be between 0 and 1")
|
||||
raise ValueError("fraction must be between 0 and 1")
|
||||
return round(float(v), 4)
|
||||
|
||||
|
||||
|
|
@ -334,7 +310,7 @@ class CommissionSplit(BaseModel):
|
|||
operator_user_id: str
|
||||
target: str
|
||||
label: Optional[str]
|
||||
pct: float
|
||||
fraction: float
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
|
||||
|
|
@ -351,9 +327,9 @@ class SetCommissionSplitsData(BaseModel):
|
|||
|
||||
@validator("legs")
|
||||
def legs_sum_to_one(cls, v):
|
||||
total = round(sum(leg.pct for leg in v), 4)
|
||||
total = round(sum(leg.fraction for leg in v), 4)
|
||||
if abs(total - 1.0) > 0.0001:
|
||||
raise ValueError(f"split percentages must sum to 1.0 (got {total})")
|
||||
raise ValueError(f"split fractions must sum to 1.0 (got {total})")
|
||||
return v
|
||||
|
||||
|
||||
|
|
@ -440,21 +416,21 @@ class TelemetrySnapshot(BaseModel):
|
|||
|
||||
class SuperConfig(BaseModel):
|
||||
id: str
|
||||
super_fee_pct: float
|
||||
super_fee_fraction: float
|
||||
super_fee_wallet_id: Optional[str]
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UpdateSuperConfigData(BaseModel):
|
||||
super_fee_pct: Optional[float] = None
|
||||
super_fee_fraction: Optional[float] = None
|
||||
super_fee_wallet_id: Optional[str] = None
|
||||
|
||||
@validator("super_fee_pct")
|
||||
@validator("super_fee_fraction")
|
||||
def fee_in_unit_range(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v < 0 or v > 1:
|
||||
raise ValueError("super_fee_pct must be between 0 and 1")
|
||||
raise ValueError("super_fee_fraction must be between 0 and 1")
|
||||
return round(float(v), 4)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ window.app = Vue.createApp({
|
|||
columns: [
|
||||
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
||||
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
|
||||
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
|
||||
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
|
||||
{
|
||||
name: 'error_message',
|
||||
label: 'Error',
|
||||
|
|
@ -100,7 +100,7 @@ window.app = Vue.createApp({
|
|||
superFeeDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
data: {super_fee_pct: 0, super_fee_wallet_id: ''}
|
||||
data: {super_fee_fraction: 0, super_fee_wallet_id: ''}
|
||||
},
|
||||
|
||||
// UI configuration -----------------------------------------------
|
||||
|
|
@ -111,12 +111,6 @@ window.app = Vue.createApp({
|
|||
{name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'},
|
||||
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
|
||||
{name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'},
|
||||
{
|
||||
name: 'fallback_commission_pct',
|
||||
label: 'Fallback %',
|
||||
field: 'fallback_commission_pct',
|
||||
align: 'right'
|
||||
},
|
||||
{name: 'actions', label: 'Actions', field: 'id', align: 'right'}
|
||||
],
|
||||
pagination: {rowsPerPage: 10, sortBy: 'name'}
|
||||
|
|
@ -166,12 +160,12 @@ window.app = Vue.createApp({
|
|||
columns: [
|
||||
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
|
||||
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
|
||||
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
|
||||
{name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'},
|
||||
{
|
||||
name: 'commission_sats',
|
||||
label: 'Commission',
|
||||
field: 'commission_sats',
|
||||
name: 'fee_sats',
|
||||
label: 'Fee',
|
||||
field: 'fee_sats',
|
||||
align: 'right'
|
||||
},
|
||||
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
|
||||
|
|
@ -332,7 +326,7 @@ window.app = Vue.createApp({
|
|||
},
|
||||
commissionSum() {
|
||||
return this.commissionLegs.reduce(
|
||||
(acc, leg) => acc + (Number(leg.pct) || 0), 0
|
||||
(acc, leg) => acc + (Number(leg.fraction) || 0), 0
|
||||
)
|
||||
},
|
||||
commissionSumValid() {
|
||||
|
|
@ -351,7 +345,7 @@ window.app = Vue.createApp({
|
|||
if (idx === this.commissionLegs.length - 1) {
|
||||
sats = remaining
|
||||
} else {
|
||||
sats = Math.round(total * (Number(leg.pct) || 0))
|
||||
sats = Math.round(total * (Number(leg.fraction) || 0))
|
||||
remaining -= sats
|
||||
}
|
||||
out.push({label: leg.label, sats})
|
||||
|
|
@ -531,7 +525,7 @@ window.app = Vue.createApp({
|
|||
// -----------------------------------------------------------------
|
||||
openSuperFeeDialog() {
|
||||
this.superFeeDialog.data = {
|
||||
super_fee_pct: this.superConfig?.super_fee_pct ?? 0,
|
||||
super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0,
|
||||
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
|
||||
}
|
||||
this.superFeeDialog.show = true
|
||||
|
|
@ -544,7 +538,7 @@ window.app = Vue.createApp({
|
|||
const {data} = await LNbits.api.request(
|
||||
'PUT', SUPER_FEE_PATH, null,
|
||||
{
|
||||
super_fee_pct: Number(d.super_fee_pct),
|
||||
super_fee_fraction: Number(d.super_fee_fraction),
|
||||
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
|
||||
}
|
||||
)
|
||||
|
|
@ -565,7 +559,7 @@ window.app = Vue.createApp({
|
|||
this._downloadCsv(
|
||||
'machines.csv',
|
||||
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
|
||||
'is_active', 'fallback_commission_pct', 'created_at'],
|
||||
'is_active', 'created_at'],
|
||||
this.machines
|
||||
)
|
||||
},
|
||||
|
|
@ -687,7 +681,6 @@ window.app = Vue.createApp({
|
|||
location: machine.location || '',
|
||||
wallet_id: machine.wallet_id,
|
||||
fiat_code: machine.fiat_code,
|
||||
fallback_commission_pct: machine.fallback_commission_pct,
|
||||
is_active: machine.is_active
|
||||
}
|
||||
this.editMachineDialog.show = true
|
||||
|
|
@ -706,7 +699,6 @@ window.app = Vue.createApp({
|
|||
location: d.location,
|
||||
wallet_id: d.wallet_id,
|
||||
fiat_code: d.fiat_code,
|
||||
fallback_commission_pct: d.fallback_commission_pct,
|
||||
is_active: d.is_active
|
||||
}
|
||||
)
|
||||
|
|
@ -1118,7 +1110,7 @@ window.app = Vue.createApp({
|
|||
target: leg.target || '',
|
||||
targetKind: this._inferTargetKind(leg.target),
|
||||
label: leg.label || '',
|
||||
pct: Number(leg.pct) || 0
|
||||
fraction: Number(leg.fraction) || 0
|
||||
}))
|
||||
} catch (e) {
|
||||
this.commissionLegs = []
|
||||
|
|
@ -1140,7 +1132,7 @@ window.app = Vue.createApp({
|
|||
target: this.walletOptions[0]?.value || '',
|
||||
targetKind: 'wallet',
|
||||
label: '',
|
||||
pct: 0
|
||||
fraction: 0
|
||||
})
|
||||
},
|
||||
|
||||
|
|
@ -1157,7 +1149,7 @@ window.app = Vue.createApp({
|
|||
legs: this.commissionLegs.map((leg, idx) => ({
|
||||
target: (leg.target || '').toString().trim(),
|
||||
label: leg.label || null,
|
||||
pct: Number(leg.pct),
|
||||
fraction: Number(leg.fraction),
|
||||
sort_order: idx
|
||||
}))
|
||||
}
|
||||
|
|
@ -1330,8 +1322,7 @@ window.app = Vue.createApp({
|
|||
wallet_id: null,
|
||||
name: '',
|
||||
location: '',
|
||||
fiat_code: 'GTQ',
|
||||
fallback_commission_pct: 0.05
|
||||
fiat_code: 'GTQ'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1341,8 +1332,7 @@ window.app = Vue.createApp({
|
|||
wallet_id: d.wallet_id,
|
||||
name: (d.name || '').trim() || null,
|
||||
location: (d.location || '').trim() || null,
|
||||
fiat_code: (d.fiat_code || 'GTQ').trim(),
|
||||
fallback_commission_pct: Number(d.fallback_commission_pct ?? 0.05)
|
||||
fiat_code: (d.fiat_code || 'GTQ').trim()
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
146
tasks.py
146
tasks.py
|
|
@ -3,14 +3,26 @@
|
|||
# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then
|
||||
# for each successful inbound payment:
|
||||
# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip.
|
||||
# 2. Parses Payment.extra for bitSpire split metadata (post-lamassu-next#44).
|
||||
# Falls back to machine.fallback_commission_pct if extra is absent.
|
||||
# 3. Computes the two-stage split (super_fee first, operator remainder).
|
||||
# 4. Inserts a dca_settlements row idempotently (keyed by payment_hash).
|
||||
# 5. Spawns the distribution processor on a background task so the
|
||||
# 2. Verifies the originating Nostr signer matches the machine identity
|
||||
# (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey
|
||||
# stamped by lnbits nostr-transport dispatcher).
|
||||
# 3. Parses Payment.extra for bitSpire's canonical split stamp per
|
||||
# aiolabs/lamassu-next#44 (`source: "bitspire"`, principal_sats,
|
||||
# fee_sats, exchange_rate). Raises if the stamp is missing or
|
||||
# garbage (no more Lamassu-era reverse-derivation fallback).
|
||||
# 4. Computes the two-stage split (super_fee first, operator remainder).
|
||||
# 5. Inserts a dca_settlements row idempotently (keyed by payment_hash).
|
||||
# 6. Spawns the distribution processor on a background task so the
|
||||
# LNbits invoice queue (which serves ALL extensions on the node)
|
||||
# keeps draining while we move sats. Concurrency is safe because
|
||||
# process_settlement now uses an optimistic-lock claim (fix bundle 1).
|
||||
#
|
||||
# Rejection paths (settlement still recorded with status='rejected' for
|
||||
# operator forensics, but distribution is skipped):
|
||||
# - SettlementAttributionError: signer mismatch (G5).
|
||||
# - SettlementMetadataError: Payment.extra missing bitSpire stamp.
|
||||
# - SettlementInvariantError: stamped values violate the canonical
|
||||
# sat-amount invariants (range/sum).
|
||||
|
||||
import asyncio
|
||||
|
||||
|
|
@ -20,6 +32,8 @@ from loguru import logger
|
|||
|
||||
from .bitspire import (
|
||||
SettlementAttributionError,
|
||||
SettlementInvariantError,
|
||||
SettlementMetadataError,
|
||||
assert_nostr_attribution,
|
||||
parse_settlement,
|
||||
)
|
||||
|
|
@ -29,6 +43,7 @@ from .crud import (
|
|||
get_super_config,
|
||||
)
|
||||
from .distribution import process_settlement
|
||||
from .models import CreateDcaSettlementData, Machine
|
||||
|
||||
LISTENER_NAME = "ext_satmachineadmin"
|
||||
|
||||
|
|
@ -64,48 +79,47 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
if machine is None:
|
||||
return
|
||||
extra = payment.extra or {}
|
||||
|
||||
# 1) Attribution FIRST — uses only `extra.nostr_sender_pubkey` (no parse
|
||||
# needed). If this fails, every subsequent field on `extra` is
|
||||
# attacker-controlled and untrustworthy — record a minimal rejected
|
||||
# row with placeholder zeros (don't display unverified split numbers
|
||||
# in the operator dashboard).
|
||||
try:
|
||||
assert_nostr_attribution(machine, extra)
|
||||
except SettlementAttributionError as exc:
|
||||
await _record_rejected(payment, machine, exc)
|
||||
return
|
||||
|
||||
# 2) Parse + invariants. parse_settlement enforces the canonical
|
||||
# sat-amount invariants on the bitSpire-stamped numbers (range +
|
||||
# direction-specific sum). Raises SettlementMetadataError if the
|
||||
# stamp is missing, SettlementInvariantError on any range/sum
|
||||
# breach.
|
||||
super_config = await get_super_config()
|
||||
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
|
||||
data, used_fallback = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash=payment.payment_hash,
|
||||
gross_sats=payment.sat,
|
||||
extra=extra,
|
||||
super_fee_pct=super_fee_pct,
|
||||
super_fee_fraction = (
|
||||
float(super_config.super_fee_fraction) if super_config else 0.0
|
||||
)
|
||||
try:
|
||||
data = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash=payment.payment_hash,
|
||||
wire_sats=payment.sat,
|
||||
extra=extra,
|
||||
super_fee_fraction=super_fee_fraction,
|
||||
)
|
||||
except (SettlementMetadataError, SettlementInvariantError) as exc:
|
||||
await _record_rejected(payment, machine, exc)
|
||||
return
|
||||
|
||||
# Stamp the originating Nostr event id (the kind-21000 create_invoice
|
||||
# RPC) onto the row for post-hoc forensics — pairs with the
|
||||
# assert_nostr_attribution check below so an auditor can trace
|
||||
# settlement -> RPC event -> signing key without trusting our DB.
|
||||
# RPC) onto the row for post-hoc forensics — an auditor can trace
|
||||
# settlement → RPC event → signing key without trusting our DB.
|
||||
nostr_event_id = extra.get("nostr_event_id")
|
||||
if isinstance(nostr_event_id, str) and nostr_event_id:
|
||||
data.bitspire_event_id = nostr_event_id
|
||||
|
||||
# Cross-check the signature-verified signer pubkey (stamped by
|
||||
# LNbits' nostr-transport dispatcher onto Payment.extra) against
|
||||
# the machine identity. Routing today is wallet_id-only with no
|
||||
# cryptographic binding — this restores end-to-end attribution
|
||||
# between "the npub that asked LNbits for the invoice" and "the
|
||||
# machine we're crediting" (aiolabs/satmachineadmin#19, G5).
|
||||
try:
|
||||
assert_nostr_attribution(machine, extra)
|
||||
except SettlementAttributionError as exc:
|
||||
rejected = await create_settlement_idempotent(
|
||||
data, initial_status="rejected", error_message=str(exc)
|
||||
)
|
||||
if rejected is None:
|
||||
logger.error(
|
||||
f"satmachineadmin: failed to insert rejected settlement for "
|
||||
f"payment_hash={payment.payment_hash[:12]}..."
|
||||
)
|
||||
return
|
||||
logger.error(
|
||||
f"satmachineadmin: rejected settlement {rejected.id} "
|
||||
f"(machine={machine.machine_npub[:12]}..., "
|
||||
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
# 3) Insert + distribute.
|
||||
settlement = await create_settlement_idempotent(data, initial_status="pending")
|
||||
if settlement is None:
|
||||
logger.error(
|
||||
|
|
@ -113,14 +127,13 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
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"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}){fb}"
|
||||
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.
|
||||
|
|
@ -131,3 +144,48 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
task = asyncio.create_task(process_settlement(settlement.id))
|
||||
_inflight_distributions.add(task)
|
||||
task.add_done_callback(_inflight_distributions.discard)
|
||||
|
||||
|
||||
async def _record_rejected(
|
||||
payment: Payment, machine: Machine, exc: Exception
|
||||
) -> None:
|
||||
"""Insert a minimal `dca_settlements` row with `status='rejected'` and
|
||||
the exception message for operator forensics.
|
||||
|
||||
Used for every rejection path (attribution / metadata / invariant).
|
||||
The split fields are zero placeholders — we deliberately do NOT
|
||||
display attacker-supplied numbers in the operator dashboard. The
|
||||
wire amount (`payment.sat`) is the only value LNbits authenticated;
|
||||
everything else from Payment.extra is untrusted in this branch.
|
||||
"""
|
||||
data = CreateDcaSettlementData(
|
||||
machine_id=machine.id,
|
||||
payment_hash=payment.payment_hash,
|
||||
wire_sats=payment.sat,
|
||||
fiat_amount=0.0,
|
||||
fiat_code=machine.fiat_code,
|
||||
exchange_rate=0.0,
|
||||
principal_sats=0,
|
||||
fee_sats=0,
|
||||
platform_fee_sats=0,
|
||||
operator_fee_sats=0,
|
||||
# tx_type is unknown for rejection paths; default to cash_out
|
||||
# (the only direction currently wired). When S8 lands the
|
||||
# listener will branch on tx_type from extra, and this default
|
||||
# gets revisited.
|
||||
tx_type="cash_out",
|
||||
)
|
||||
rejected = await create_settlement_idempotent(
|
||||
data, initial_status="rejected", error_message=str(exc)
|
||||
)
|
||||
if rejected is None:
|
||||
logger.error(
|
||||
f"satmachineadmin: failed to insert rejected settlement for "
|
||||
f"payment_hash={payment.payment_hash[:12]}..."
|
||||
)
|
||||
return
|
||||
logger.error(
|
||||
f"satmachineadmin: rejected settlement {rejected.id} "
|
||||
f"(machine={machine.machine_npub[:12]}..., "
|
||||
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,13 +31,13 @@
|
|||
<q-banner
|
||||
v-if="superConfig"
|
||||
class="q-mb-md"
|
||||
:class="superConfig.super_fee_pct > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
|
||||
:class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="account_balance" :color="superConfig.super_fee_pct > 0 ? 'blue' : 'grey'"></q-icon>
|
||||
<q-icon name="account_balance" :color="superConfig.super_fee_fraction > 0 ? 'blue' : 'grey'"></q-icon>
|
||||
</template>
|
||||
<span :style="{fontWeight: 500}">
|
||||
LNbits platform fee:
|
||||
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_pct * 100).toFixed(2) }%</span>
|
||||
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_fraction * 100).toFixed(2) }%</span>
|
||||
of each transaction's commission.
|
||||
</span>
|
||||
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
|
||||
|
|
@ -143,12 +143,6 @@
|
|||
v-text="shortId(props.row.wallet_id)"></code>
|
||||
</q-td>
|
||||
<q-td key="fiat_code" v-text="props.row.fiat_code"></q-td>
|
||||
<q-td key="fallback_commission_pct">
|
||||
<span v-text="(props.row.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
|
||||
<q-tooltip>
|
||||
Used only when bitSpire doesn't supply a per-tx split.
|
||||
</q-tooltip>
|
||||
</q-td>
|
||||
<q-td key="actions" auto-width>
|
||||
<q-btn flat dense round size="sm" icon="visibility"
|
||||
color="primary"
|
||||
|
|
@ -529,13 +523,13 @@
|
|||
dense outlined></q-input>
|
||||
</div>
|
||||
<div class="col-5 col-md-3">
|
||||
<q-input v-model.number="leg.pct"
|
||||
<q-input v-model.number="leg.fraction"
|
||||
label="% (0..1)"
|
||||
type="number" step="0.01" min="0" max="1"
|
||||
dense outlined>
|
||||
<template v-slot:append>
|
||||
<span :style="{fontSize: '0.75em', opacity: 0.6}"
|
||||
v-text="((leg.pct || 0) * 100).toFixed(1) + '%'"></span>
|
||||
v-text="((leg.fraction || 0) * 100).toFixed(1) + '%'"></span>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
|
@ -630,8 +624,8 @@
|
|||
<span :style="{fontSize: '0.85em'}"
|
||||
v-text="formatTime(props.row.created_at)"></span>
|
||||
</q-td>
|
||||
<q-td key="gross_sats" class="text-right">
|
||||
<span v-text="formatSats(props.row.gross_sats)"></span>
|
||||
<q-td key="wire_sats" class="text-right">
|
||||
<span v-text="formatSats(props.row.wire_sats)"></span>
|
||||
</q-td>
|
||||
<q-td key="error_message">
|
||||
<span :style="{fontSize: '0.85em', opacity: 0.8}"
|
||||
|
|
@ -792,14 +786,6 @@
|
|||
dense outlined
|
||||
:rules="[v => !!v || 'Pick a wallet']"></q-select>
|
||||
|
||||
<q-input
|
||||
v-model.number="addMachineDialog.data.fallback_commission_pct"
|
||||
label="Fallback commission % (decimal: 0.05 = 5%)"
|
||||
hint="Only used if bitSpire doesn't supply a per-tx split (lamassu-next#44)."
|
||||
type="number" step="0.0001" min="0" max="1"
|
||||
class="q-mb-md"
|
||||
dense outlined></q-input>
|
||||
|
||||
<q-input
|
||||
v-model="addMachineDialog.data.fiat_code"
|
||||
label="Fiat code"
|
||||
|
|
@ -855,12 +841,6 @@
|
|||
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
|
||||
<span v-text="machineDetail.machine.location || '—'"></span>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="text-caption" :style="{opacity: 0.6}">
|
||||
Fallback commission %
|
||||
</div>
|
||||
<span v-text="(machineDetail.machine.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-mb-md"></q-separator>
|
||||
|
|
@ -893,27 +873,19 @@
|
|||
<q-td key="status">
|
||||
<q-badge :color="settlementStatusColor(props.row.status)"
|
||||
:label="props.row.status"></q-badge>
|
||||
<q-icon v-if="props.row.used_fallback_split"
|
||||
name="warning_amber" color="orange" size="sm"
|
||||
class="q-ml-xs">
|
||||
<q-tooltip>
|
||||
Fallback split — bitSpire didn't supply per-tx
|
||||
net/fee. See lamassu-next#44.
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td key="created_at">
|
||||
<span :style="{fontSize: '0.85em'}"
|
||||
v-text="formatTime(props.row.created_at)"></span>
|
||||
</q-td>
|
||||
<q-td key="gross_sats" class="text-right">
|
||||
<span v-text="formatSats(props.row.gross_sats)"></span>
|
||||
<q-td key="wire_sats" class="text-right">
|
||||
<span v-text="formatSats(props.row.wire_sats)"></span>
|
||||
</q-td>
|
||||
<q-td key="principal_sats" class="text-right">
|
||||
<span v-text="formatSats(props.row.principal_sats)"></span>
|
||||
</q-td>
|
||||
<q-td key="commission_sats" class="text-right">
|
||||
<span v-text="formatSats(props.row.commission_sats)"></span>
|
||||
<q-td key="fee_sats" class="text-right">
|
||||
<span v-text="formatSats(props.row.fee_sats)"></span>
|
||||
<div :style="{fontSize: '0.75em', opacity: 0.6}">
|
||||
super
|
||||
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
|
||||
|
|
@ -996,7 +968,7 @@
|
|||
<q-icon name="info" color="blue"></q-icon>
|
||||
</template>
|
||||
Original gross:
|
||||
<b v-text="formatSats(partialDispenseDialog.settlement.gross_sats)"></b>.
|
||||
<b v-text="formatSats(partialDispenseDialog.settlement.wire_sats)"></b>.
|
||||
Provide what was actually dispensed. Sat amounts will scale linearly,
|
||||
the commission split will recompute, and distribution will re-run.
|
||||
</q-banner>
|
||||
|
|
@ -1019,7 +991,7 @@
|
|||
label="Dispensed sats"
|
||||
hint="Exact sat amount actually dispensed (≤ original gross)"
|
||||
type="number" step="1" min="0"
|
||||
:max="partialDispenseDialog.settlement.gross_sats"
|
||||
:max="partialDispenseDialog.settlement.wire_sats"
|
||||
dense outlined></q-input>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
|
@ -1085,7 +1057,7 @@
|
|||
Operators see this as a read-only banner. Wallet ID is where the
|
||||
collected fee lands; typically a wallet you (the super) own.
|
||||
</p>
|
||||
<q-input v-model.number="superFeeDialog.data.super_fee_pct"
|
||||
<q-input v-model.number="superFeeDialog.data.super_fee_fraction"
|
||||
label="Fee % (decimal, 0..1)"
|
||||
hint="0.30 = 30% of every operator's commission"
|
||||
type="number" step="0.0001" min="0" max="1"
|
||||
|
|
@ -1337,10 +1309,6 @@
|
|||
emit-value map-options
|
||||
class="q-mb-md"
|
||||
dense outlined></q-select>
|
||||
<q-input v-model.number="editMachineDialog.data.fallback_commission_pct"
|
||||
label="Fallback commission %"
|
||||
type="number" step="0.0001" min="0" max="1"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model="editMachineDialog.data.fiat_code"
|
||||
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
||||
<q-toggle v-model="editMachineDialog.data.is_active"
|
||||
|
|
|
|||
|
|
@ -1,114 +1,19 @@
|
|||
"""
|
||||
Tests for DCA transaction calculations using empirical data.
|
||||
Tests for DCA transaction calculations.
|
||||
|
||||
These tests verify commission and distribution calculations against
|
||||
real Lamassu transaction data to ensure the math is correct.
|
||||
Covers the pure-function helpers that survive the 2026-05-26 cleanup:
|
||||
- calculate_distribution (proportional split across LPs by balance)
|
||||
|
||||
The previous test surface for `calculate_commission` and
|
||||
`calculate_exchange_rate` was deleted alongside those functions — the
|
||||
Lamassu-era reverse-derivation is obsolete now that bitSpire stamps
|
||||
`principal_sats` and `fee_sats` directly on Payment.extra.
|
||||
|
||||
Two-stage commission split tests live in `test_two_stage_split.py`.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# Import from the parent package (following lnurlp pattern)
|
||||
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMMISSION CALCULATION TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestCommissionCalculation:
|
||||
"""Tests for commission calculation logic."""
|
||||
|
||||
# Empirical test cases: (crypto_atoms, commission%, discount%, expected_base, expected_commission)
|
||||
# Formula: base = round(crypto_atoms / (1 + effective_commission))
|
||||
# Where: effective_commission = commission_percentage * (100 - discount) / 100
|
||||
EMPIRICAL_COMMISSION_CASES = [
|
||||
# =============================================================
|
||||
# REAL LAMASSU TRANSACTIONS (extracted from production database)
|
||||
# =============================================================
|
||||
|
||||
# 8.75% commission, no discount - small transaction
|
||||
# 15600 / 1.0875 = 14344.827... → 14345
|
||||
(15600, 0.0875, 0.0, 14345, 1255),
|
||||
|
||||
# 8.75% commission, no discount - large transaction
|
||||
# 309200 / 1.0875 = 284322.298... → 284322
|
||||
(309200, 0.0875, 0.0, 284322, 24878),
|
||||
|
||||
# 5.5% commission, no discount
|
||||
# 309500 / 1.055 = 293364.928... → 293365
|
||||
(309500, 0.055, 0.0, 293365, 16135),
|
||||
|
||||
# 5.5% commission with 100% discount (no commission charged)
|
||||
# effective = 0.055 * (100-100)/100 = 0
|
||||
(292400, 0.055, 100.0, 292400, 0),
|
||||
|
||||
# 5.5% commission with 90% discount
|
||||
# effective = 0.055 * (100-90)/100 = 0.0055
|
||||
# 115000 / 1.0055 = 114370.96... → 114371
|
||||
(115000, 0.055, 90.0, 114371, 629),
|
||||
|
||||
# 5.5% commission, no discount - 1300 GTQ transaction
|
||||
# 205600 / 1.055 = 194881.516... → 194882
|
||||
# Note: This tx showed 0.01 GTQ rounding discrepancy in per-client fiat
|
||||
(205600, 0.055, 0.0, 194882, 10718),
|
||||
|
||||
# =============================================================
|
||||
# SYNTHETIC TEST CASES (edge cases)
|
||||
# =============================================================
|
||||
|
||||
# Zero commission - all goes to base
|
||||
(100000, 0.0, 0.0, 100000, 0),
|
||||
|
||||
# Small amount edge case (1 sat minimum)
|
||||
(100, 0.03, 0.0, 97, 3),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"crypto_atoms,commission_pct,discount,expected_base,expected_commission",
|
||||
EMPIRICAL_COMMISSION_CASES,
|
||||
ids=[
|
||||
"lamassu_8.75pct_small",
|
||||
"lamassu_8.75pct_large",
|
||||
"lamassu_5.5pct_no_discount",
|
||||
"lamassu_5.5pct_100pct_discount",
|
||||
"lamassu_5.5pct_90pct_discount",
|
||||
"lamassu_5.5pct_1300gtq",
|
||||
"zero_commission",
|
||||
"small_amount_100sats",
|
||||
]
|
||||
)
|
||||
def test_commission_calculation(
|
||||
self,
|
||||
crypto_atoms: int,
|
||||
commission_pct: float,
|
||||
discount: float,
|
||||
expected_base: int,
|
||||
expected_commission: int
|
||||
):
|
||||
"""Test commission calculation against empirical data."""
|
||||
base, commission, _ = calculate_commission(crypto_atoms, commission_pct, discount)
|
||||
|
||||
assert base == expected_base, f"Base amount mismatch: got {base}, expected {expected_base}"
|
||||
assert commission == expected_commission, f"Commission mismatch: got {commission}, expected {expected_commission}"
|
||||
|
||||
# Invariant: base + commission must equal total
|
||||
assert base + commission == crypto_atoms, "Base + commission must equal total crypto_atoms"
|
||||
|
||||
def test_commission_invariant_always_sums_to_total(self):
|
||||
"""Commission + base must always equal the original amount."""
|
||||
test_values = [1, 100, 1000, 10000, 100000, 266800, 1000000]
|
||||
commission_rates = [0.0, 0.01, 0.03, 0.05, 0.10]
|
||||
discounts = [0.0, 10.0, 25.0, 50.0]
|
||||
|
||||
for crypto_atoms in test_values:
|
||||
for comm_rate in commission_rates:
|
||||
for discount in discounts:
|
||||
base, commission, _ = calculate_commission(crypto_atoms, comm_rate, discount)
|
||||
assert base + commission == crypto_atoms, \
|
||||
f"Invariant failed: {base} + {commission} != {crypto_atoms} " \
|
||||
f"(rate={comm_rate}, discount={discount})"
|
||||
from ..calculations import calculate_distribution
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -157,7 +62,6 @@ class TestDistributionCalculation:
|
|||
|
||||
def test_distribution_invariant_sums_to_total(self):
|
||||
"""Total distributed sats must always equal base amount."""
|
||||
# Test with various client configurations
|
||||
test_cases = [
|
||||
{"a": 100.0},
|
||||
{"a": 100.0, "b": 100.0},
|
||||
|
|
@ -215,156 +119,6 @@ class TestDistributionCalculation:
|
|||
|
||||
assert distributions == {}
|
||||
|
||||
def test_fiat_round_trip_invariant(self):
|
||||
"""
|
||||
Verify that distributed sats convert back to original fiat amount.
|
||||
|
||||
The sum of each client's fiat equivalent should equal the original
|
||||
fiat amount (within rounding tolerance).
|
||||
"""
|
||||
# Use real Lamassu transaction data
|
||||
test_cases = [
|
||||
# (crypto_atoms, fiat_amount, commission_pct, discount, client_balances)
|
||||
(309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}),
|
||||
(309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}),
|
||||
(292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}),
|
||||
(115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}),
|
||||
# Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients
|
||||
(205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}),
|
||||
]
|
||||
|
||||
for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases:
|
||||
# Calculate commission and base amount
|
||||
base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount)
|
||||
|
||||
# Calculate exchange rate
|
||||
exchange_rate = calculate_exchange_rate(base_sats, fiat_amount)
|
||||
|
||||
# Distribute sats to clients
|
||||
distributions = calculate_distribution(base_sats, client_balances)
|
||||
|
||||
# Convert each client's sats back to fiat
|
||||
total_fiat_distributed = sum(
|
||||
sats / exchange_rate for sats in distributions.values()
|
||||
)
|
||||
|
||||
# Should equal original fiat amount (within small rounding tolerance)
|
||||
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
|
||||
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
|
||||
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EMPIRICAL END-TO-END TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestEmpiricalTransactions:
|
||||
"""
|
||||
End-to-end tests using real Lamassu transaction data.
|
||||
|
||||
Add your empirical test cases here! Each case should include:
|
||||
- Transaction details (crypto_atoms, fiat, commission, discount)
|
||||
- Client balances at time of transaction
|
||||
- Expected distribution outcome
|
||||
"""
|
||||
|
||||
# TODO: Add your empirical data here
|
||||
# Example structure:
|
||||
EMPIRICAL_SCENARIOS = [
|
||||
{
|
||||
"name": "real_tx_266800sats_two_equal_clients",
|
||||
"transaction": {
|
||||
"crypto_atoms": 266800,
|
||||
"fiat_amount": 2000,
|
||||
"commission_percentage": 0.03,
|
||||
"discount": 0.0,
|
||||
},
|
||||
"client_balances": {
|
||||
"client_a": 1000.00, # 50% of total
|
||||
"client_b": 1000.00, # 50% of total
|
||||
},
|
||||
# 266800 / 1.03 = 259029
|
||||
"expected_base_sats": 259029,
|
||||
"expected_commission_sats": 7771,
|
||||
"expected_distributions": {
|
||||
# 259029 / 2 = 129514.5 → both get 129514 or 129515
|
||||
# With banker's rounding: 129514.5 → 129514 (even)
|
||||
# Remainder of 1 sat goes to first client by fractional sort
|
||||
"client_a": 129515,
|
||||
"client_b": 129514,
|
||||
},
|
||||
},
|
||||
# Add more scenarios from your real data!
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scenario",
|
||||
EMPIRICAL_SCENARIOS,
|
||||
ids=[s["name"] for s in EMPIRICAL_SCENARIOS]
|
||||
)
|
||||
def test_empirical_scenario(self, scenario):
|
||||
"""Test full transaction flow against empirical data."""
|
||||
tx = scenario["transaction"]
|
||||
|
||||
# Calculate commission
|
||||
base, commission, _ = calculate_commission(
|
||||
tx["crypto_atoms"],
|
||||
tx["commission_percentage"],
|
||||
tx["discount"]
|
||||
)
|
||||
|
||||
assert base == scenario["expected_base_sats"], \
|
||||
f"Base amount mismatch in {scenario['name']}"
|
||||
assert commission == scenario["expected_commission_sats"], \
|
||||
f"Commission mismatch in {scenario['name']}"
|
||||
|
||||
# Calculate distribution
|
||||
distributions = calculate_distribution(
|
||||
base,
|
||||
scenario["client_balances"]
|
||||
)
|
||||
|
||||
# Verify each client's allocation
|
||||
for client_id, expected_sats in scenario["expected_distributions"].items():
|
||||
actual_sats = distributions.get(client_id, 0)
|
||||
assert actual_sats == expected_sats, \
|
||||
f"Distribution mismatch for {client_id} in {scenario['name']}: " \
|
||||
f"got {actual_sats}, expected {expected_sats}"
|
||||
|
||||
# Verify total distribution equals base
|
||||
assert sum(distributions.values()) == base, \
|
||||
f"Total distribution doesn't match base in {scenario['name']}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EDGE CASE TESTS
|
||||
# =============================================================================
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and boundary conditions."""
|
||||
|
||||
def test_minimum_amount_1_sat(self):
|
||||
"""Test with minimum possible amount (1 sat)."""
|
||||
base, commission, _ = calculate_commission(1, 0.03, 0.0)
|
||||
# With 3% commission on 1 sat, base rounds to 1, commission to 0
|
||||
assert base + commission == 1
|
||||
|
||||
def test_large_transaction(self):
|
||||
"""Test with large transaction (100 BTC worth of sats)."""
|
||||
crypto_atoms = 10_000_000_000 # 100 BTC in sats
|
||||
base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0)
|
||||
|
||||
assert base + commission == crypto_atoms
|
||||
assert commission > 0
|
||||
|
||||
def test_100_percent_discount(self):
|
||||
"""100% discount should result in zero commission."""
|
||||
base, commission, effective = calculate_commission(100000, 0.03, 100.0)
|
||||
|
||||
assert effective == 0.0
|
||||
assert commission == 0
|
||||
assert base == 100000
|
||||
|
||||
def test_many_clients_distribution(self):
|
||||
"""Test distribution with many clients."""
|
||||
# 10 clients with varying balances
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ def _machine(npub: str) -> Machine:
|
|||
location=None,
|
||||
fiat_code="EUR",
|
||||
is_active=True,
|
||||
fallback_commission_pct=0.05,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
Tests for the v2 two-stage commission split (super first, operator remainder).
|
||||
|
||||
The plan calls out a verification scenario explicitly:
|
||||
super_fee_pct=30%, operator split 50/30/20 on a 100-sat commission
|
||||
→ super_wallet gets 30, operator_self gets 35, employee 21, maint 14.
|
||||
super_fee_fraction=0.30 (i.e. 30%), operator splits [0.5, 0.3, 0.2] on a
|
||||
100-sat fee → super_wallet gets 30, operator legs get 35 / 21 / 14.
|
||||
|
||||
Also covers the edge cases: super_fee_pct=0 (no super), super_fee_pct=1.0
|
||||
(everything to super), single-leg operator ruleset, zero operator fee.
|
||||
Also covers the edge cases: super_fee_fraction=0.0 (no super takes the
|
||||
whole fee), super_fee_fraction=1.0 (super takes everything), single-leg
|
||||
operator ruleset, zero operator fee.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
|
@ -18,7 +19,7 @@ from ..calculations import (
|
|||
|
||||
|
||||
class TestSplitTwoStageCommission:
|
||||
"""Stage-1: super takes super_fee_pct of commission; operator gets rest."""
|
||||
"""Stage-1: super takes super_fee_fraction of the fee; operator gets rest."""
|
||||
|
||||
def test_plan_example_100sats_30pct(self):
|
||||
platform, operator = split_two_stage_commission(100, 0.30)
|
||||
|
|
@ -33,12 +34,12 @@ class TestSplitTwoStageCommission:
|
|||
assert operator == 5575 # 7965 - 2390
|
||||
assert platform + operator == 7965
|
||||
|
||||
def test_super_pct_zero_leaves_all_to_operator(self):
|
||||
def test_super_fraction_zero_leaves_all_to_operator(self):
|
||||
platform, operator = split_two_stage_commission(7965, 0.0)
|
||||
assert platform == 0
|
||||
assert operator == 7965
|
||||
|
||||
def test_super_pct_one_takes_everything(self):
|
||||
def test_super_fraction_one_takes_everything(self):
|
||||
platform, operator = split_two_stage_commission(7965, 1.0)
|
||||
assert platform == 7965
|
||||
assert operator == 0
|
||||
|
|
@ -54,13 +55,13 @@ class TestSplitTwoStageCommission:
|
|||
assert platform == 0
|
||||
assert operator == 0
|
||||
|
||||
@pytest.mark.parametrize("commission_sats", [1, 7, 100, 7965, 1_000_000])
|
||||
@pytest.mark.parametrize("super_pct", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
|
||||
def test_invariant_sum_equals_commission(self, commission_sats, super_pct):
|
||||
platform, operator = split_two_stage_commission(commission_sats, super_pct)
|
||||
assert platform + operator == commission_sats
|
||||
assert 0 <= platform <= commission_sats
|
||||
assert 0 <= operator <= commission_sats
|
||||
@pytest.mark.parametrize("fee_sats", [1, 7, 100, 7965, 1_000_000])
|
||||
@pytest.mark.parametrize("super_fraction", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
|
||||
def test_invariant_sum_equals_commission(self, fee_sats, super_fraction):
|
||||
platform, operator = split_two_stage_commission(fee_sats, super_fraction)
|
||||
assert platform + operator == fee_sats
|
||||
assert 0 <= platform <= fee_sats
|
||||
assert 0 <= operator <= fee_sats
|
||||
|
||||
|
||||
class TestAllocateOperatorSplitLegs:
|
||||
|
|
@ -102,7 +103,7 @@ class TestAllocateOperatorSplitLegs:
|
|||
assert amounts[2] == 100 - amounts[0] - amounts[1]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operator_fee,pcts",
|
||||
"operator_fee,fractions",
|
||||
[
|
||||
(1, [0.5, 0.5]),
|
||||
(7, [0.5, 0.3, 0.2]),
|
||||
|
|
@ -111,8 +112,8 @@ class TestAllocateOperatorSplitLegs:
|
|||
(1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
|
||||
],
|
||||
)
|
||||
def test_invariant_sum_equals_operator_fee(self, operator_fee, pcts):
|
||||
amounts = allocate_operator_split_legs(operator_fee, pcts)
|
||||
def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions):
|
||||
amounts = allocate_operator_split_legs(operator_fee, fractions)
|
||||
assert sum(amounts) == operator_fee
|
||||
assert all(a >= 0 for a in amounts)
|
||||
|
||||
|
|
@ -121,21 +122,21 @@ class TestEndToEndScenarios:
|
|||
"""The full two-stage split — super then operator legs — composed."""
|
||||
|
||||
def test_plan_example_full(self):
|
||||
# 100 sats commission, super=30%, operator splits 50/30/20.
|
||||
# 100 sats fee, super_fee_fraction=0.30, operator splits [0.5, 0.3, 0.2].
|
||||
platform, operator = split_two_stage_commission(100, 0.30)
|
||||
legs = allocate_operator_split_legs(operator, [0.5, 0.3, 0.2])
|
||||
assert platform == 30
|
||||
assert legs == [35, 21, 14]
|
||||
assert platform + sum(legs) == 100
|
||||
|
||||
def test_super_pct_zero_full_pipeline(self):
|
||||
def test_super_fraction_zero_full_pipeline(self):
|
||||
platform, operator = split_two_stage_commission(7965, 0.0)
|
||||
legs = allocate_operator_split_legs(operator, [1.0])
|
||||
assert platform == 0
|
||||
assert legs == [7965]
|
||||
assert platform + sum(legs) == 7965
|
||||
|
||||
def test_super_pct_one_full_pipeline(self):
|
||||
def test_super_fraction_one_full_pipeline(self):
|
||||
platform, operator = split_two_stage_commission(7965, 1.0)
|
||||
legs = allocate_operator_split_legs(operator, [0.5, 0.5])
|
||||
assert platform == 7965
|
||||
|
|
@ -147,27 +148,27 @@ class TestEndToEndScenarios:
|
|||
class TestPartialDispenseSplitRatio:
|
||||
"""The partial-dispense recompute (H6 fix) must preserve the ORIGINAL
|
||||
platform/operator ratio from the landed settlement — NOT re-derive
|
||||
from the current super_fee_pct.
|
||||
from the current super_fee_fraction.
|
||||
|
||||
These tests cover the math; the actual function lives in distribution.py
|
||||
and is exercised end-to-end via integration testing. Here we verify the
|
||||
invariant a future maintainer should never break.
|
||||
"""
|
||||
|
||||
def _recompute(self, original_commission, original_platform_fee, new_commission):
|
||||
def _recompute(self, original_fee, original_platform_fee, new_fee):
|
||||
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
|
||||
if original_commission > 0:
|
||||
ratio = original_platform_fee / original_commission
|
||||
if original_fee > 0:
|
||||
ratio = original_platform_fee / original_fee
|
||||
else:
|
||||
ratio = 0.0
|
||||
new_platform = round(new_commission * ratio)
|
||||
new_platform = max(0, min(new_platform, new_commission))
|
||||
new_operator = new_commission - new_platform
|
||||
new_platform = round(new_fee * ratio)
|
||||
new_platform = max(0, min(new_platform, new_fee))
|
||||
new_operator = new_fee - new_platform
|
||||
return new_platform, new_operator
|
||||
|
||||
def test_plan_scenario_30pct_lands_then_partial(self):
|
||||
# Landed at super_fee_pct=30%: 100-sat commission → 30 / 70.
|
||||
# Partial-dispense to 50% gross → new_commission = 50.
|
||||
# Landed at super_fee_fraction=0.30: 100-sat fee → 30 / 70.
|
||||
# Partial-dispense to 50% gross → new_fee = 50.
|
||||
# Original ratio (30/100 = 0.30) preserved.
|
||||
new_platform, new_operator = self._recompute(100, 30, 50)
|
||||
assert new_platform == 15
|
||||
|
|
@ -175,9 +176,9 @@ class TestPartialDispenseSplitRatio:
|
|||
assert new_platform + new_operator == 50
|
||||
|
||||
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
|
||||
# Landed at super_fee_pct=30% (commission 7965, platform 2390).
|
||||
# Landed at super_fee_fraction=0.30 (fee 7965, platform 2390).
|
||||
# Super then raises rate to 50% globally. Operator partial-dispenses
|
||||
# to 50% gross → new_commission = 3982 (round(7965 * 0.5)).
|
||||
# to 50% gross → new_fee = 3982 (round(7965 * 0.5)).
|
||||
# Original ratio (2390/7965 ≈ 0.30) MUST still apply, not 50%.
|
||||
new_platform, new_operator = self._recompute(7965, 2390, 3982)
|
||||
# Expected with original ratio: round(3982 * 0.30006...) = 1195
|
||||
|
|
@ -187,17 +188,17 @@ class TestPartialDispenseSplitRatio:
|
|||
# Original platform share was ~30%; preserved within rounding.
|
||||
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
|
||||
|
||||
def test_zero_original_commission_yields_zero_platform(self):
|
||||
def test_zero_original_fee_yields_zero_platform(self):
|
||||
new_platform, new_operator = self._recompute(0, 0, 0)
|
||||
assert new_platform == 0
|
||||
assert new_operator == 0
|
||||
|
||||
def test_invariant_sum_equals_new_commission(self):
|
||||
def test_invariant_sum_equals_new_fee(self):
|
||||
# Random-ish parameter sweep over realistic values.
|
||||
cases = [
|
||||
(100, 30, 50),
|
||||
(100, 0, 50), # original platform_fee was 0 (super_pct=0)
|
||||
(100, 100, 50), # original platform_fee was 100 (super_pct=100)
|
||||
(100, 0, 50), # original platform_fee was 0 (super_fraction=0)
|
||||
(100, 100, 50), # original platform_fee was 100 (super_fraction=100)
|
||||
(7965, 2390, 3982),
|
||||
(7965, 7965, 3982),
|
||||
(1_000_000, 333_333, 250_000),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue