fix(v2): partial-dispense preserves original split ratio (H6)

Closes H6 from #11. The partial-dispense recompute path was reading the
CURRENT super_fee_pct via get_super_config() to re-derive the platform/
operator split. That breaks the "absolute fields are the source of
truth" invariant the v2 schema was built around: if super raises (or
lowers) the global rate between landing and partial-dispense, the
operator's share would retroactively shift — without any notice to the
operator and contrary to the original transaction's contract.

Fix: re-derive new_platform from the *original* platform_fee_sats /
commission_sats ratio stored on the settlement row, not from the
current super_config. The contract was locked at landing; rate changes
after the fact must not retroactively touch this transaction.

Before:
    super_config = await get_super_config()
    super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
    new_platform, new_operator = split_two_stage_commission(
        new_commission, super_fee_pct
    )

After:
    ratio = (settlement.platform_fee_sats / settlement.commission_sats
             if settlement.commission_sats > 0 else 0.0)
    new_platform = round(new_commission * ratio)
    new_platform = max(0, min(new_platform, new_commission))
    new_operator = new_commission - new_platform

Note: split_two_stage_commission at LANDING time (in bitspire.py) still
uses the current super_fee_pct — that's correct, the rate at landing is
the locked rate. Only the *recompute* path was wrong.

Tests:
  TestPartialDispenseSplitRatio.test_plan_scenario_30pct_lands_then_partial:
    100-sat commission @ 30% → partial to 50% → 15/35 (preserves ratio).
  test_super_changed_rate_doesnt_affect_existing_settlement:
    Super raises rate to 50% after a 30% landing; partial-dispense to
    50% must keep the ORIGINAL ~30% platform share, not the new 50%.
  test_zero_original_commission_yields_zero_platform: edge case.
  test_invariant_sum_equals_new_commission: parametrised sum invariant.

Also dropped the now-unused split_two_stage_commission import from
distribution.py (still used in bitspire.py at landing time and by the
test suite, just not in this file anymore).

54 / 54 tests pass.

Refs: aiolabs/satmachineadmin#11 — H6 
Remaining in #11: fix bundle 3 (dead-code purge), M and N items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:58:15 +02:00
commit 00b8253dd3
2 changed files with 88 additions and 10 deletions

View file

@ -34,7 +34,6 @@ from loguru import logger
from .calculations import (
allocate_operator_split_legs,
calculate_distribution,
split_two_stage_commission,
)
from .crud import (
apply_partial_dispense,
@ -268,9 +267,12 @@ 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
linearly, preserving the original commission ratio exactly; the two-stage
super/operator split is recomputed using the CURRENT super_fee_pct
(super may have changed the rate since the original landed).
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
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).
Hard guard: refuses if any dca_payments leg has already completed.
Lightning payments can't be clawed back, so we won't try.
@ -301,12 +303,19 @@ async def apply_partial_dispense_and_redistribute(
new_net = new_gross - new_commission
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
# Re-stage-1 split using the CURRENT super_fee_pct.
super_config = await get_super_config()
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
new_platform, new_operator = split_two_stage_commission(
new_commission, super_fee_pct
)
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
# settlement row — NOT the current super_fee_pct. 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
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
memo = _build_partial_dispense_memo(
settlement,