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:
parent
6348c55e37
commit
d717a6e214
12 changed files with 530 additions and 681 deletions
60
models.py
60
models.py
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue