diff --git a/distribution.py b/distribution.py index 94e181d..7f47c7e 100644 --- a/distribution.py +++ b/distribution.py @@ -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, diff --git a/tests/test_two_stage_split.py b/tests/test_two_stage_split.py index 71490c6..beff376 100644 --- a/tests/test_two_stage_split.py +++ b/tests/test_two_stage_split.py @@ -142,3 +142,72 @@ class TestEndToEndScenarios: # Operator has zero to distribute; both legs get zero. assert legs == [0, 0] assert platform + sum(legs) == 7965 + + +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. + + 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): + """Mirror of the ratio math in apply_partial_dispense_and_redistribute.""" + if original_commission > 0: + ratio = original_platform_fee / original_commission + 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 + 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. + # Original ratio (30/100 = 0.30) preserved. + new_platform, new_operator = self._recompute(100, 30, 50) + assert new_platform == 15 + assert new_operator == 35 + 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). + # Super then raises rate to 50% globally. Operator partial-dispenses + # to 50% gross → new_commission = 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 + # With (broken) current rate of 50%: would be 1991 — much higher. + assert 1190 <= new_platform <= 1200 + assert new_platform + new_operator == 3982 + # 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): + 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): + # 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) + (7965, 2390, 3982), + (7965, 7965, 3982), + (1_000_000, 333_333, 250_000), + ] + for orig_comm, orig_plat, new_comm in cases: + new_platform, new_operator = self._recompute( + orig_comm, orig_plat, new_comm + ) + assert new_platform + new_operator == new_comm, ( + f"sum invariant violated: {orig_comm=} {orig_plat=} " + f"{new_comm=} → {new_platform=} {new_operator=}" + ) + assert 0 <= new_platform <= new_comm