refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation

Cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md`
and at `~/dev/coordination/log.md` (2026-05-26). Canonical names with
explicit units across satmachineadmin, lamassu-next, atm-tui:

  - `wire_sats` — actual Lightning payment amount (direction-agnostic;
                  was `gross_sats`, only "gross" for cash-out)
  - `principal_sats` — market-rate sats before commission (unchanged)
  - `fee_sats` — commission (was `commission_sats` internally;
                 already the wire format)
  - `fee_fraction` — commission rate as unit fraction in [0, 1]
                     (was `*_pct` / `fee_percent`; eliminates the
                     latent 100x bug from `feePercent * 100` on the
                     lamassu-next side)

Invariants enforced in bitspire._assert_sat_invariants on every
parsed settlement — range (all sats >= 0, 0 <= fee_fraction <= 1) +
direction-specific sum:

  - cash-out: wire_sats == principal_sats + fee_sats
  - cash-in:  wire_sats == principal_sats - fee_sats
              AND fee_sats <= principal_sats

Breaches raise SettlementInvariantError; tasks._handle_payment
records the row as `status='rejected'` with the exception message
and skips distribution. Attribution failure path symmetric.

Schema changes (m001 + m006):

  - dca_settlements.gross_sats              -> wire_sats
  - dca_settlements.commission_sats         -> fee_sats
  - super_config.super_fee_pct              -> super_fee_fraction
  - dca_commission_splits.pct               -> fraction
  - dca_machines.fallback_commission_pct    DROPPED (obsolete)
  - dca_settlements.used_fallback_split     DROPPED (obsolete)

m006 idempotently renames + drops columns on existing installs;
m001 lays down the canonical schema for fresh installs.

Obsolete code removed (Lamassu-era reverse-derivation):

  - calculations.calculate_commission — back-derived principal+fee
    from gross-with-commission-baked-in. v2 stamps both directly.
  - calculations.calculate_exchange_rate — bitSpire stamps directly.
  - bitspire._parse_fallback — sole caller of calculate_commission.
  - Machine.fallback_commission_fraction — only read by _parse_fallback.
  - DcaSettlement.used_fallback_split — only written by _parse_fallback.

parse_settlement now raises SettlementMetadataError if Payment.extra
lacks the bitSpire stamp or required absolute sat fields. No silent
back-derivation; upstream-bug surfacing via dashboard rejection.

Frontend (JS + Quasar templates) updated for the column renames and
the removed fallback fields. Settlements table renders "Wire" + "Fee"
columns; the "(fallback split)" warning badge is gone.

Tests:
  - test_calculations.py: kept distribution tests; deleted
    calculate_commission + calculate_exchange_rate tests.
  - test_two_stage_split.py: renamed variables; rewrote docstring
    value literals (e.g. `super_fee_fraction=0.30` not `=30%`).
  - test_nostr_attribution.py: dropped fallback_commission_fraction
    from machine fixture.
  - 72/72 pass on regtest container.

Cross-codebase follow-ups tracked in coordination log:
  - lamassu-next: rename `fee_percent` -> `fee_fraction` on
    Payment.extra + state.db; drop the `* 100` at lightning.ts:780.
  - atm-tui: read `fee_fraction` column in db.zig.

Memory artefacts:
  - reference_sat_amount_vocabulary.md (canonical + invariants)
  - feedback_pct_to_fraction_renames_need_value_sweep.md (gotcha)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-26 20:08:30 +02:00
commit d717a6e214
12 changed files with 530 additions and 681 deletions

View file

@ -29,18 +29,6 @@ class CreateMachineData(BaseModel):
name: Optional[str] = None
location: Optional[str] = None
fiat_code: str = "GTQ"
# Used only when bitSpire's settlement event omits principal_sats/
# fee_sats in Payment.extra (older bitSpire or edge cases). See
# plan's lamassu-next informational issue #1.
fallback_commission_pct: float = 0.05
@validator("fallback_commission_pct")
def commission_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("fallback_commission_pct must be between 0 and 1")
return round(float(v), 4)
class Machine(BaseModel):
@ -52,7 +40,6 @@ class Machine(BaseModel):
location: Optional[str]
fiat_code: str
is_active: bool
fallback_commission_pct: float
created_at: datetime
updated_at: datetime
@ -63,15 +50,6 @@ class UpdateMachineData(BaseModel):
fiat_code: Optional[str] = None
is_active: Optional[bool] = None
wallet_id: Optional[str] = None
fallback_commission_pct: Optional[float] = None
@validator("fallback_commission_pct")
def commission_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("fallback_commission_pct must be between 0 and 1")
return round(float(v), 4)
# =============================================================================
@ -225,7 +203,7 @@ class UpdateDepositStatusData(BaseModel):
# =============================================================================
# platform_fee_sats and operator_fee_sats are absolute audit-grade values.
# Today they equal the contractual split; tomorrow (post-v1 promo engine)
# they record who-forgave-what. DO NOT collapse them into a single pct.
# they record who-forgave-what. DO NOT collapse them into a single fraction.
# See plan section "Customer discounts & promotions (post-v1)".
@ -234,15 +212,14 @@ class CreateDcaSettlementData(BaseModel):
payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion
bitspire_txid: Optional[str] = None
gross_sats: int
wire_sats: int
fiat_amount: float
fiat_code: str = "GTQ"
exchange_rate: float
principal_sats: int
commission_sats: int
fee_sats: int
platform_fee_sats: int
operator_fee_sats: int
used_fallback_split: bool = False
tx_type: str # 'cash_out' | 'cash_in'
bills_json: Optional[str] = None
cassettes_json: Optional[str] = None
@ -254,15 +231,14 @@ class DcaSettlement(BaseModel):
payment_hash: str
bitspire_event_id: Optional[str]
bitspire_txid: Optional[str]
gross_sats: int
wire_sats: int
fiat_amount: float
fiat_code: str
exchange_rate: float
principal_sats: int
commission_sats: int
fee_sats: int
platform_fee_sats: int
operator_fee_sats: int
used_fallback_split: bool
tx_type: str
bills_json: Optional[str]
cassettes_json: Optional[str]
@ -295,8 +271,8 @@ class DcaSettlement(BaseModel):
# Commission splits — operator-defined remainder allocation per machine.
# =============================================================================
# machine_id=NULL means operator's default; non-null means per-machine override.
# Sum of pct across rows for a (operator_user_id, machine_id) scope must be 1.0,
# enforced at write-time in crud.py.
# Sum of fraction across rows for a (operator_user_id, machine_id) scope must
# be 1.0, enforced at write-time in crud.py.
class CommissionSplitLeg(BaseModel):
@ -311,7 +287,7 @@ class CommissionSplitLeg(BaseModel):
target: str
label: Optional[str] = None
pct: float
fraction: float
sort_order: int = 0
@validator("target")
@ -321,10 +297,10 @@ class CommissionSplitLeg(BaseModel):
raise ValueError("target cannot be empty")
return v
@validator("pct")
def pct_in_unit_range(cls, v):
@validator("fraction")
def fraction_in_unit_range(cls, v):
if v < 0 or v > 1:
raise ValueError("pct must be between 0 and 1")
raise ValueError("fraction must be between 0 and 1")
return round(float(v), 4)
@ -334,7 +310,7 @@ class CommissionSplit(BaseModel):
operator_user_id: str
target: str
label: Optional[str]
pct: float
fraction: float
sort_order: int
created_at: datetime
@ -351,9 +327,9 @@ class SetCommissionSplitsData(BaseModel):
@validator("legs")
def legs_sum_to_one(cls, v):
total = round(sum(leg.pct for leg in v), 4)
total = round(sum(leg.fraction for leg in v), 4)
if abs(total - 1.0) > 0.0001:
raise ValueError(f"split percentages must sum to 1.0 (got {total})")
raise ValueError(f"split fractions must sum to 1.0 (got {total})")
return v
@ -440,21 +416,21 @@ class TelemetrySnapshot(BaseModel):
class SuperConfig(BaseModel):
id: str
super_fee_pct: float
super_fee_fraction: float
super_fee_wallet_id: Optional[str]
updated_at: datetime
class UpdateSuperConfigData(BaseModel):
super_fee_pct: Optional[float] = None
super_fee_fraction: Optional[float] = None
super_fee_wallet_id: Optional[str] = None
@validator("super_fee_pct")
@validator("super_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_pct must be between 0 and 1")
raise ValueError("super_fee_fraction must be between 0 and 1")
return round(float(v), 4)