Replaces the broken fraction-of-fee math with fraction-of-principal, direction-aware. Pre-#38: super_fee_fraction was interpreted as `round(fee_sats * super_fraction)`, paying super ~13× below intent on every cashout since the bitspire wire-shape landed. Post-#38: super and operator shares are computed independently against principal using the per-direction fractions from SuperConfig + Machine. Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch code" (v2-bitspire hasn't shipped to users), no compat shims: - calculations.py: delete `split_two_stage_commission` (legacy fraction-of-fee). Keep `split_principal_based` as the sole split fn. - migrations.py m009: extend to also DROP the deprecated `super_fee_fraction` column after backfilling its value into the new directional fields. - models.py: drop `super_fee_fraction` from SuperConfig + UpdateSuperConfigData entirely. - bitspire.py parse_settlement: new signature takes `super_config: SuperConfig` instead of `super_fee_fraction: float`. Resolves directional fractions from super_config + machine by tx_type, then computes via split_principal_based. Raises SettlementInvariantError on unknown tx_type. - tasks.py: pass `super_config` through to parse_settlement; assert non-None (m001 inserts the singleton at install time — None is an impossible state). - partial-dispense ratio path in distribution.py is unchanged — still uses `settlement.platform_fee_sats / settlement.fee_sats` from the landed row, which is the right invariant (lock at landing) and independent of the per-direction config. Tests: - Rename `test_two_stage_split.py` → `test_operator_split_legs.py`. Drop the legacy-function test classes. Keep TestAllocateOperatorSplitLegs (still-production fn) and TestPartialDispenseSplitRatio (inline ratio math in distribution.py). - New `test_principal_based_fees.py`: pure-math tests for `split_principal_based` (six cases including a direct regression test pinning the pre-#38 bug at 240→3000 sats per 100k principal at 3% super), plus parse_settlement directional dispatch tests (cash-in routes through cash-in fractions; cash-out through cash-out; unknown tx_type raises; zero-zero free-charge ATM; cross- direction guard). Migration verified end-to-end via container restart: super_config columns post-m009 = id/super_fee_wallet_id/updated_at/ super_cash_in_fee_fraction/super_cash_out_fee_fraction (no super_fee_fraction). dca_machines + dca_settlements gained the expected new columns. 156/156 tests green. Refs: aiolabs/satmachineadmin#37 (parent), #38 (this layer). Closes the load-bearing super under-payment bug standalone. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""
|
||
Tests for the post-#38 principal-based fee split:
|
||
|
||
- `calculations.split_principal_based(principal_sats, super_frac,
|
||
operator_frac)` — pure-function math
|
||
- `bitspire.parse_settlement` — directional dispatch by tx_type
|
||
("cash_in" → super_cash_in + operator_cash_in;
|
||
"cash_out" → super_cash_out + operator_cash_out)
|
||
|
||
The bug this layer closes: pre-#38 math interpreted super_fee_fraction
|
||
as fraction-of-fee instead of fraction-of-principal, under-paying the
|
||
super by ~13× per cashout. Tests below pin the new math to the
|
||
intended fraction-of-principal model and verify the per-direction
|
||
routing through parse_settlement.
|
||
|
||
Fee mismatch recording (`fee_mismatch_sats` column, Phase 1
|
||
observability per coord-log §2026-06-01T07:00Z) lands in the next
|
||
commit; those tests live in `test_fee_mismatch_recording.py`.
|
||
"""
|
||
|
||
from datetime import datetime
|
||
|
||
import pytest
|
||
|
||
from ..bitspire import SettlementInvariantError, parse_settlement
|
||
from ..calculations import split_principal_based
|
||
from ..models import Machine, SuperConfig
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# split_principal_based — pure-function math
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSplitPrincipalBased:
|
||
def test_super_fraction_only(self):
|
||
"""Operator at 0% — super takes exactly super_frac of principal,
|
||
operator gets 0."""
|
||
platform, operator = split_principal_based(100_000, 0.03, 0.0)
|
||
assert platform == 3_000
|
||
assert operator == 0
|
||
|
||
def test_operator_fraction_only(self):
|
||
"""Super at 0% — operator takes exactly operator_frac of
|
||
principal, platform gets 0."""
|
||
platform, operator = split_principal_based(100_000, 0.0, 0.05)
|
||
assert platform == 0
|
||
assert operator == 5_000
|
||
|
||
def test_both_fractions(self):
|
||
"""Both shares independently computed against principal — total
|
||
is super + operator, not anchored to any fee_sats input."""
|
||
platform, operator = split_principal_based(100_000, 0.03, 0.05)
|
||
assert platform == 3_000
|
||
assert operator == 5_000
|
||
|
||
def test_zero_principal_yields_zero_shares(self):
|
||
platform, operator = split_principal_based(0, 0.03, 0.05)
|
||
assert platform == 0
|
||
assert operator == 0
|
||
|
||
def test_negative_principal_yields_zero_shares(self):
|
||
"""Defensive: negative principal can't happen in production but
|
||
the function should not produce negative outputs if it ever does."""
|
||
platform, operator = split_principal_based(-100, 0.03, 0.05)
|
||
assert platform == 0
|
||
assert operator == 0
|
||
|
||
def test_rounding_does_not_compound(self):
|
||
"""The two shares round independently — there is no carryover.
|
||
On a 1_000_000-sat principal with super=0.0333, operator=0.0777,
|
||
each share rounds against principal individually."""
|
||
platform, operator = split_principal_based(1_000_000, 0.0333, 0.0777)
|
||
assert platform == round(1_000_000 * 0.0333) # 33_300
|
||
assert operator == round(1_000_000 * 0.0777) # 77_700
|
||
|
||
def test_super_frac_out_of_range_raises(self):
|
||
with pytest.raises(ValueError, match="super_frac"):
|
||
split_principal_based(100_000, 1.5, 0.0)
|
||
with pytest.raises(ValueError, match="super_frac"):
|
||
split_principal_based(100_000, -0.1, 0.0)
|
||
|
||
def test_operator_frac_out_of_range_raises(self):
|
||
with pytest.raises(ValueError, match="operator_frac"):
|
||
split_principal_based(100_000, 0.0, 1.5)
|
||
with pytest.raises(ValueError, match="operator_frac"):
|
||
split_principal_based(100_000, 0.0, -0.1)
|
||
|
||
def test_super_under_payment_bug_regression(self):
|
||
"""Direct regression test for the bug this layer closes.
|
||
|
||
Pre-#38 math (deleted): `round(fee_sats * super_fraction)` with
|
||
fee_sats=8_000 (= 8% of 100_000 principal) and super_fraction=0.03
|
||
produced platform_fee_sats=240 — ~13× below intent.
|
||
|
||
Post-#38 math: split_principal_based(100_000, 0.03, 0.05) gives
|
||
platform=3_000, which IS the intended 3% of principal."""
|
||
platform, operator = split_principal_based(100_000, 0.03, 0.05)
|
||
# Post-#38: super gets intended 3% of principal (3_000 sats)
|
||
# Pre-#38 would have produced ~240 sats from round(8000 * 0.03).
|
||
assert platform == 3_000
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# parse_settlement — directional dispatch via tx_type
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _bitspire_extra(
|
||
*,
|
||
tx_type: str = "cash_out",
|
||
principal_sats: int = 100_000,
|
||
fee_sats: int = 8_000,
|
||
exchange_rate: float = 0.00001,
|
||
fiat_amount: float = 100.0,
|
||
currency: str = "EUR",
|
||
nostr_sender_pubkey: str = "a" * 64,
|
||
extra_overrides: dict | None = None,
|
||
):
|
||
"""Canonical bitspire-stamped Payment.extra dict for tests. Mirrors
|
||
the shape required by `is_bitspire_payment` + the canonical sat-
|
||
amount invariants in `_assert_sat_invariants`."""
|
||
base = {
|
||
"source": "bitspire",
|
||
"type": tx_type,
|
||
"principal_sats": principal_sats,
|
||
"fee_sats": fee_sats,
|
||
"fee_fraction": fee_sats / principal_sats if principal_sats else 0.0,
|
||
"exchange_rate": exchange_rate,
|
||
"fiat_amount": fiat_amount,
|
||
"currency": currency,
|
||
"txid": "fake-txid",
|
||
"nostr_sender_pubkey": nostr_sender_pubkey,
|
||
}
|
||
if extra_overrides:
|
||
base.update(extra_overrides)
|
||
return base
|
||
|
||
|
||
_NOW = datetime(2026, 6, 1, 12, 0, 0)
|
||
|
||
|
||
def _machine(
|
||
machine_id: str = "m1",
|
||
machine_npub: str = "a" * 64,
|
||
op_in: float = 0.0,
|
||
op_out: float = 0.0,
|
||
fiat_code: str = "EUR",
|
||
) -> Machine:
|
||
return Machine(
|
||
id=machine_id,
|
||
operator_user_id="op1",
|
||
machine_npub=machine_npub,
|
||
wallet_id="w1",
|
||
name="Test",
|
||
location=None,
|
||
fiat_code=fiat_code,
|
||
is_active=True,
|
||
operator_cash_in_fee_fraction=op_in,
|
||
operator_cash_out_fee_fraction=op_out,
|
||
created_at=_NOW,
|
||
updated_at=_NOW,
|
||
)
|
||
|
||
|
||
def _super_config(in_frac: float = 0.0, out_frac: float = 0.0) -> SuperConfig:
|
||
return SuperConfig(
|
||
id="default",
|
||
super_cash_in_fee_fraction=in_frac,
|
||
super_cash_out_fee_fraction=out_frac,
|
||
super_fee_wallet_id="super-wallet",
|
||
updated_at=_NOW,
|
||
)
|
||
|
||
|
||
class TestParseSettlementDirectional:
|
||
def test_cash_out_uses_cash_out_fractions(self):
|
||
"""tx_type='cash_out' must route to super_cash_out +
|
||
operator_cash_out fractions."""
|
||
machine = _machine(op_in=0.10, op_out=0.05)
|
||
super_cfg = _super_config(in_frac=0.10, out_frac=0.03)
|
||
extra = _bitspire_extra(tx_type="cash_out", principal_sats=100_000)
|
||
|
||
data = parse_settlement(
|
||
machine=machine,
|
||
payment_hash="ph1",
|
||
wire_sats=108_000,
|
||
extra=extra,
|
||
super_config=super_cfg,
|
||
)
|
||
# super_cash_out=0.03, operator_cash_out=0.05 against 100_000 principal
|
||
assert data.platform_fee_sats == 3_000
|
||
assert data.operator_fee_sats == 5_000
|
||
assert data.tx_type == "cash_out"
|
||
|
||
def test_cash_in_uses_cash_in_fractions(self):
|
||
"""tx_type='cash_in' must route to super_cash_in +
|
||
operator_cash_in fractions (not cash_out)."""
|
||
machine = _machine(op_in=0.04, op_out=0.10)
|
||
super_cfg = _super_config(in_frac=0.02, out_frac=0.10)
|
||
extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000)
|
||
|
||
# cash-in wire invariant: wire = principal - fee
|
||
data = parse_settlement(
|
||
machine=machine,
|
||
payment_hash="ph2",
|
||
wire_sats=92_000,
|
||
extra=extra,
|
||
super_config=super_cfg,
|
||
)
|
||
# super_cash_in=0.02, operator_cash_in=0.04 against 100_000 principal
|
||
assert data.platform_fee_sats == 2_000
|
||
assert data.operator_fee_sats == 4_000
|
||
assert data.tx_type == "cash_in"
|
||
|
||
def test_unknown_tx_type_raises(self):
|
||
machine = _machine()
|
||
super_cfg = _super_config()
|
||
extra = _bitspire_extra(
|
||
tx_type="cash_out",
|
||
extra_overrides={"type": "withdrawal"}, # not a known direction
|
||
)
|
||
with pytest.raises(SettlementInvariantError, match="unknown tx_type"):
|
||
parse_settlement(
|
||
machine=machine,
|
||
payment_hash="ph3",
|
||
wire_sats=108_000,
|
||
extra=extra,
|
||
super_config=super_cfg,
|
||
)
|
||
|
||
def test_zero_fractions_zero_split(self):
|
||
"""Free-charge ATM: both super + operator at 0 → platform and
|
||
operator fees are both 0, principal is the full take."""
|
||
machine = _machine(op_in=0.0, op_out=0.0)
|
||
super_cfg = _super_config(in_frac=0.0, out_frac=0.0)
|
||
extra = _bitspire_extra(
|
||
tx_type="cash_out", principal_sats=100_000, fee_sats=0
|
||
)
|
||
|
||
data = parse_settlement(
|
||
machine=machine,
|
||
payment_hash="ph4",
|
||
wire_sats=100_000,
|
||
extra=extra,
|
||
super_config=super_cfg,
|
||
)
|
||
assert data.platform_fee_sats == 0
|
||
assert data.operator_fee_sats == 0
|
||
assert data.principal_sats == 100_000
|
||
|
||
def test_cash_in_does_not_use_cash_out_config(self):
|
||
"""Cross-direction guard: cash-in must NOT pick up cash-out's
|
||
super or operator fractions even when they're set differently.
|
||
Pin both directions concretely to prove the dispatch."""
|
||
machine = _machine(op_in=0.01, op_out=0.10)
|
||
super_cfg = _super_config(in_frac=0.01, out_frac=0.10)
|
||
extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000)
|
||
|
||
# cash-in wire invariant: wire = principal - fee
|
||
data = parse_settlement(
|
||
machine=machine,
|
||
payment_hash="ph5",
|
||
wire_sats=92_000,
|
||
extra=extra,
|
||
super_config=super_cfg,
|
||
)
|
||
# Cash-in totals = 0.01 + 0.01 = 0.02; not 0.10 + 0.10 = 0.20
|
||
assert data.platform_fee_sats == 1_000 # 100_000 * 0.01
|
||
assert data.operator_fee_sats == 1_000 # 100_000 * 0.01
|