feat(v2): principal-based fee split — fixes super under-payment (#38 3/5)

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>
This commit is contained in:
Padreug 2026-06-01 11:24:09 +02:00
commit 1babdfbf06
8 changed files with 522 additions and 275 deletions

View file

@ -17,7 +17,8 @@ from __future__ import annotations
import json
from typing import Any, Optional
from .models import CreateDcaSettlementData, Machine
from .calculations import split_principal_based
from .models import CreateDcaSettlementData, Machine, SuperConfig
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound
# payment originated from an ATM cash-out and not some other extension or
@ -219,23 +220,30 @@ def parse_settlement(
payment_hash: str,
wire_sats: int,
extra: dict,
super_fee_fraction: float,
super_config: SuperConfig,
) -> CreateDcaSettlementData:
"""Build a CreateDcaSettlementData for an inbound payment landing on
`machine`'s wallet.
Splits the fee on a principal-based, direction-aware model
(aiolabs/satmachineadmin#37,#38):
platform_fee_sats = round(principal_sats * super_cash_{type}_fee_fraction)
operator_fee_sats = round(principal_sats * operator_cash_{type}_fee_fraction)
where the directional super fraction comes from `super_config` and
the operator fraction comes from `machine`. The bitspire-reported
`fee_sats` field is preserved on the settlement as the customer's
actual paid total, but is NOT used as input to the split.
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`).
`_assert_sat_invariants`) or `tx_type` is unknown.
"""
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 "
@ -253,8 +261,20 @@ def parse_settlement(
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
tx_type = _coerce_str(extra.get("type")) or "cash_out"
if tx_type == "cash_in":
super_frac = float(super_config.super_cash_in_fee_fraction)
operator_frac = float(machine.operator_cash_in_fee_fraction)
elif tx_type == "cash_out":
super_frac = float(super_config.super_cash_out_fee_fraction)
operator_frac = float(machine.operator_cash_out_fee_fraction)
else:
raise SettlementInvariantError(
f"unknown tx_type={tx_type!r}; expected 'cash_in' or 'cash_out'"
)
platform_fee_sats, operator_fee_sats = split_principal_based(
principal_sats, super_frac, operator_frac
)
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
@ -268,7 +288,6 @@ def parse_settlement(
# 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
tx_type = _coerce_str(extra.get("type")) or "cash_out"
data = CreateDcaSettlementData(
machine_id=machine.id,
payment_hash=payment_hash,