feat(v2): m009 + models — split fee fractions by direction (#38 1/5)

Adds the schema delta + Pydantic mirror for per-direction fee
configuration:

- super_config gains super_cash_in_fee_fraction / super_cash_out_fee_fraction
  (backfilled from the deprecated singleton on migrate-up so live config
  preserves intent).
- dca_machines gains operator_cash_in_fee_fraction / operator_cash_out_fee_fraction
  (default 0; operator-settable per machine via the upcoming UI).
- dca_settlements gains fee_mismatch_sats BIGINT NULL — Phase-1 observability
  column per coord-log §2026-06-01T07:00Z (lnbits) + option A locked.
- MAX_FEE_FRACTION_PER_DIRECTION = 0.15 lives in calculations.py as the
  single source of truth (defense-in-depth cap, mirrored on the consumer
  side per aiolabs/lamassu-next#57).

Pydantic validators on the new fields keep [0, 1] range checks; the
per-direction cap validation lives on the CRUD path in the next commit
(needs cross-row context: super-config change must validate against all
machines, machine change against current super-config).

Closes one step of #38 (Layer 1 of the operator-configurable fee
architecture, parent #37). Subsequent commits add CRUD, principal-based
split math (fixes the load-bearing super under-payment bug), and the
UI surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 10:18:37 +02:00
commit d87d0db324
3 changed files with 132 additions and 3 deletions

View file

@ -644,3 +644,77 @@ async def m008_flip_cassette_configs_pk_to_position(db):
await db.execute(
"ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs"
)
async def m009_split_fee_fractions_by_direction(db):
"""Split the singleton `super_fee_fraction` into per-direction fields
and add matching per-machine operator fee fractions. Adds the
`fee_mismatch_sats` audit column on settlements.
Architectural intent (per aiolabs/satmachineadmin#37):
- Super (lnbits administrator) sets X_in% and X_out% applies
across every machine on the lnbits instance, calculated against
principal.
- Operator (per-machine) sets Y_in% and Y_out% sits on top of
super, calculated against principal.
- Total fee charged customer = (X+Y)% of principal per direction.
- Distribution: super gets X% of principal; operator gets Y%
(distributed through commission legs as today).
Fixes the load-bearing bug where the old `super_fee_fraction` was
interpreted as fraction-of-fee, under-paying the super by ~13× per
cashout. The post-migration split math (bitspire.py:parse_settlement
+ calculations.py:split_principal_based) is principal-based.
Schema delta:
- super_config gains super_cash_in_fee_fraction +
super_cash_out_fee_fraction (both backfilled
from the existing super_fee_fraction so live
config preserves intent across migrate-up).
- dca_machines gains operator_cash_in_fee_fraction +
operator_cash_out_fee_fraction (default 0;
operators set via the new UI surface).
- dca_settlements gains fee_mismatch_sats BIGINT NULL records
bitspire-reported fee minus expected per
satmachineadmin's principal-based recompute.
Phase 1 observability: log + record, never
reject (per coord-log §2026-06-01T07:00Z
lnbits advisory; option A locked).
Idempotency via column-probe pattern (same shape as m006's rename
sweep). The existing `super_config.super_fee_fraction` column is
NOT dropped here deprecated, removed in a follow-up release after
callers migrate to the directional fields.
"""
additions = [
("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("super_config", "super_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("dca_machines", "operator_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("dca_machines", "operator_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("dca_settlements", "fee_mismatch_sats", "BIGINT"),
]
for table, col, coltype in additions:
try:
await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1")
# column already present — migration partially-ran previously, skip
continue
except Exception:
pass
await db.execute(
f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}"
)
# Backfill super-config directional fractions from the legacy singleton
# so the live deployment's super_fee_fraction setting carries forward.
# Guarded WHERE clause: only fire when both new fields are still at
# their DEFAULT 0 (i.e., this is a first migrate-up, not a repeat).
await db.execute(
"""
UPDATE satoshimachine.super_config
SET super_cash_in_fee_fraction = super_fee_fraction,
super_cash_out_fee_fraction = super_fee_fraction
WHERE super_cash_in_fee_fraction = 0
AND super_cash_out_fee_fraction = 0
AND super_fee_fraction > 0
"""
)