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

@ -21,6 +21,11 @@ class CreateMachineData(BaseModel):
for this machine. The same operator can own multiple machines; each
machine gets its own wallet so per-machine accounting via Payment.tag
(set to "satmachine:{machine_npub}") works natively.
`operator_cash_*_fee_fraction` is the per-machine operator fee charged on
top of the platform-wide super fee. Both fractions sit on top of the
super's per-direction fractions and are calculated against principal,
not against any fee total. See aiolabs/satmachineadmin#37 / #38.
"""
machine_npub: str
@ -28,6 +33,16 @@ class CreateMachineData(BaseModel):
name: str | None = None
location: str | None = None
fiat_code: str = "GTQ"
operator_cash_in_fee_fraction: float = 0.0
operator_cash_out_fee_fraction: float = 0.0
@validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction")
def _operator_fee_in_unit_range(cls, v):
if v is None:
return 0.0
if v < 0 or v > 1:
raise ValueError("operator fee fraction must be between 0 and 1")
return round(float(v), 4)
class Machine(BaseModel):
@ -39,6 +54,8 @@ class Machine(BaseModel):
location: str | None
fiat_code: str
is_active: bool
operator_cash_in_fee_fraction: float = 0.0
operator_cash_out_fee_fraction: float = 0.0
created_at: datetime
updated_at: datetime
@ -49,6 +66,16 @@ class UpdateMachineData(BaseModel):
fiat_code: str | None = None
is_active: bool | None = None
wallet_id: str | None = None
operator_cash_in_fee_fraction: float | None = None
operator_cash_out_fee_fraction: float | None = None
@validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction")
def _operator_fee_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("operator fee fraction must be between 0 and 1")
return round(float(v), 4)
# =============================================================================
@ -220,6 +247,12 @@ class CreateDcaSettlementData(BaseModel):
platform_fee_sats: int
operator_fee_sats: int
tx_type: str # 'cash_out' | 'cash_in'
# Phase-1 observability column (aiolabs/satmachineadmin#38).
# `bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)` —
# positive means bitspire over-reported, negative means under-reported.
# Recorded unconditionally; WARN-logged when |delta| > tolerance. NULL
# only on pre-#38 rows.
fee_mismatch_sats: int | None = None
bills_json: str | None = None
cassettes_json: str | None = None
@ -239,6 +272,7 @@ class DcaSettlement(BaseModel):
platform_fee_sats: int
operator_fee_sats: int
tx_type: str
fee_mismatch_sats: int | None = None
bills_json: str | None
cassettes_json: str | None
# 'pending' (default at insert)
@ -415,21 +449,34 @@ class TelemetrySnapshot(BaseModel):
class SuperConfig(BaseModel):
id: str
# Deprecated singleton fee fraction — retained for one release while
# callers migrate to the per-direction fields below. The new math
# (bitspire.py:parse_settlement) only reads the directional fields.
super_fee_fraction: float
super_cash_in_fee_fraction: float = 0.0
super_cash_out_fee_fraction: float = 0.0
super_fee_wallet_id: str | None
updated_at: datetime
class UpdateSuperConfigData(BaseModel):
# Deprecated; setting either directional field is the supported path.
# Writes here continue to apply for one release for migration safety.
super_fee_fraction: float | None = None
super_cash_in_fee_fraction: float | None = None
super_cash_out_fee_fraction: float | None = None
super_fee_wallet_id: str | None = None
@validator("super_fee_fraction")
def fee_in_unit_range(cls, v):
@validator(
"super_fee_fraction",
"super_cash_in_fee_fraction",
"super_cash_out_fee_fraction",
)
def _fee_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("super_fee_fraction must be between 0 and 1")
raise ValueError("super fee fraction must be between 0 and 1")
return round(float(v), 4)