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
256
bitspire.py
256
bitspire.py
|
|
@ -1,24 +1,22 @@
|
||||||
# Satoshi Machine v2 — bitSpire payment parser.
|
# Satoshi Machine v2 — bitSpire payment parser.
|
||||||
#
|
#
|
||||||
# Translates an inbound LNbits Payment (cash-out customer paid the ATM's
|
# Translates an inbound LNbits Payment into a CreateDcaSettlementData by
|
||||||
# invoice) into the principal/commission split needed by satmachineadmin.
|
# reading the canonical split fields bitSpire stamps on Payment.extra per
|
||||||
|
# aiolabs/lamassu-next#44 (`source: "bitspire"`, `principal_sats`,
|
||||||
|
# `fee_sats`, `exchange_rate`, etc.).
|
||||||
#
|
#
|
||||||
# Happy path: bitSpire populates Payment.extra with the canonical split
|
# No back-derivation. If Payment.extra is missing the bitSpire stamp or
|
||||||
# fields per aiolabs/lamassu-next#44 — we read them directly.
|
# any required field, we raise SettlementMetadataError and the caller
|
||||||
#
|
# records the settlement as 'rejected' for upstream investigation — the
|
||||||
# Fallback path: extra is missing (older bitSpire, edge case). We back-derive
|
# Lamassu-era reverse-derivation from gross-with-commission-baked-in is
|
||||||
# the split from the machine's fallback_commission_pct using the Lamassu-era
|
# obsolete now that the wire carries principal_sats and fee_sats
|
||||||
# formula (base = total / (1 + commission)) and mark used_fallback_split=true
|
# directly.
|
||||||
# so the audit trail shows we estimated.
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, Optional
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .calculations import calculate_commission
|
|
||||||
from .models import CreateDcaSettlementData, Machine
|
from .models import CreateDcaSettlementData, Machine
|
||||||
|
|
||||||
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound
|
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound
|
||||||
|
|
@ -74,6 +72,34 @@ class SettlementAttributionError(ValueError):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementInvariantError(ValueError):
|
||||||
|
"""A sat-amount or fee-fraction value violates the cross-codebase
|
||||||
|
canonical invariants (see
|
||||||
|
`~/.claude/projects/.../memory/reference_sat_amount_vocabulary.md`).
|
||||||
|
|
||||||
|
Raised by `_assert_sat_invariants`. Caller treats it like
|
||||||
|
SettlementAttributionError — record as rejected, don't distribute.
|
||||||
|
A breach means something upstream (bitSpire, the relay, a buggy
|
||||||
|
consumer) is stamping garbage on Payment.extra; we don't want to
|
||||||
|
quietly silently distribute against corrupt numbers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementMetadataError(ValueError):
|
||||||
|
"""Payment.extra is missing the bitSpire stamp or required fields.
|
||||||
|
|
||||||
|
Raised by `parse_settlement`. Caller records the settlement as
|
||||||
|
'rejected' with the exception message in `error_message`. Operator
|
||||||
|
investigates the ATM that issued the invoice — a bitSpire ATM that
|
||||||
|
landed on a satmachineadmin-managed wallet without stamping the
|
||||||
|
canonical fields is a real upstream bug (lamassu-next side), not a
|
||||||
|
graceful-degradation case. Pre-v2 reverse-derivation from the
|
||||||
|
wire amount + a machine-level fallback rate is no longer supported:
|
||||||
|
the wire-format contract (lamassu-next#44) is that the ATM always
|
||||||
|
stamps `principal_sats` and `fee_sats` explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
||||||
"""Assert that the originating Nostr signer pubkey matches the machine.
|
"""Assert that the originating Nostr signer pubkey matches the machine.
|
||||||
|
|
||||||
|
|
@ -111,49 +137,124 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_sat_invariants(
|
||||||
|
*,
|
||||||
|
tx_type: str,
|
||||||
|
wire_sats: int,
|
||||||
|
principal_sats: int,
|
||||||
|
fee_sats: int,
|
||||||
|
fee_fraction: Optional[float] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Enforce the cross-codebase canonical sat-amount invariants on the
|
||||||
|
parsed settlement values BEFORE building the `CreateDcaSettlementData`.
|
||||||
|
|
||||||
|
Range invariants (all cases):
|
||||||
|
- wire_sats, principal_sats, fee_sats are all non-negative integers.
|
||||||
|
- fee_fraction (if provided) is in [0, 1].
|
||||||
|
|
||||||
|
Sum invariants (direction-specific):
|
||||||
|
- cash_out: wire_sats == principal_sats + fee_sats
|
||||||
|
- cash_in: wire_sats == principal_sats - fee_sats
|
||||||
|
AND fee_sats <= principal_sats
|
||||||
|
(commission cannot exceed the principal in a cash-in;
|
||||||
|
a customer can't owe negative sats)
|
||||||
|
|
||||||
|
The fee_fraction × principal_sats sanity check (≈ fee_sats ±1) is
|
||||||
|
intentionally NOT enforced here — fee_fraction is informational on
|
||||||
|
Payment.extra; the absolute fee_sats stamp is the audit anchor and
|
||||||
|
the source of truth. The two can drift by a few sats due to upstream
|
||||||
|
rounding without indicating corruption. If we ever observe drift
|
||||||
|
>1% of fee_sats we'll add the check.
|
||||||
|
|
||||||
|
Raises SettlementInvariantError with a precise message on any breach.
|
||||||
|
Reference: `reference_sat_amount_vocabulary.md`.
|
||||||
|
"""
|
||||||
|
# Range checks
|
||||||
|
if wire_sats < 0:
|
||||||
|
raise SettlementInvariantError(f"wire_sats must be >= 0, got {wire_sats}")
|
||||||
|
if principal_sats < 0:
|
||||||
|
raise SettlementInvariantError(
|
||||||
|
f"principal_sats must be >= 0, got {principal_sats}"
|
||||||
|
)
|
||||||
|
if fee_sats < 0:
|
||||||
|
raise SettlementInvariantError(f"fee_sats must be >= 0, got {fee_sats}")
|
||||||
|
if fee_fraction is not None and not (0.0 <= fee_fraction <= 1.0):
|
||||||
|
raise SettlementInvariantError(
|
||||||
|
f"fee_fraction must be in [0, 1], got {fee_fraction} "
|
||||||
|
f"(if you see a value >1 the upstream may be stamping percentage "
|
||||||
|
f"instead of fraction — check lamassu-next#? rename status)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sum invariants per direction
|
||||||
|
if tx_type == "cash_out":
|
||||||
|
expected_wire = principal_sats + fee_sats
|
||||||
|
if wire_sats != expected_wire:
|
||||||
|
raise SettlementInvariantError(
|
||||||
|
f"cash-out wire_sats invariant violated: "
|
||||||
|
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
|
||||||
|
f"+ fee_sats({fee_sats}) = {expected_wire}"
|
||||||
|
)
|
||||||
|
elif tx_type == "cash_in":
|
||||||
|
if fee_sats > principal_sats:
|
||||||
|
raise SettlementInvariantError(
|
||||||
|
f"cash-in fee_sats({fee_sats}) cannot exceed "
|
||||||
|
f"principal_sats({principal_sats}) — commission > principal "
|
||||||
|
f"would mean a customer owes negative sats"
|
||||||
|
)
|
||||||
|
expected_wire = principal_sats - fee_sats
|
||||||
|
if wire_sats != expected_wire:
|
||||||
|
raise SettlementInvariantError(
|
||||||
|
f"cash-in wire_sats invariant violated: "
|
||||||
|
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
|
||||||
|
f"- fee_sats({fee_sats}) = {expected_wire}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise SettlementInvariantError(
|
||||||
|
f"unknown tx_type={tx_type!r}; expected 'cash_out' or 'cash_in'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_settlement(
|
def parse_settlement(
|
||||||
machine: Machine,
|
machine: Machine,
|
||||||
payment_hash: str,
|
payment_hash: str,
|
||||||
gross_sats: int,
|
wire_sats: int,
|
||||||
extra: dict,
|
extra: dict,
|
||||||
super_fee_pct: float,
|
super_fee_fraction: float,
|
||||||
) -> Tuple[CreateDcaSettlementData, bool]:
|
) -> CreateDcaSettlementData:
|
||||||
"""Build a CreateDcaSettlementData for an inbound payment landing on
|
"""Build a CreateDcaSettlementData for an inbound payment landing on
|
||||||
`machine`'s wallet.
|
`machine`'s wallet.
|
||||||
|
|
||||||
Returns (data, used_fallback): when `used_fallback` is True, bitSpire
|
Requires bitSpire's canonical Payment.extra stamp (source="bitspire"
|
||||||
didn't populate Payment.extra so we back-derived the split. Caller
|
plus the absolute sat amounts) per aiolabs/lamassu-next#44. Raises
|
||||||
should log this for visibility — once aiolabs/lamassu-next#44 ships,
|
`SettlementMetadataError` on missing/partial stamp — caller records
|
||||||
fallback usage should drop to zero.
|
the settlement as 'rejected' for upstream investigation. Raises
|
||||||
|
`SettlementInvariantError` if the stamped values violate the
|
||||||
|
canonical sat-amount invariants (range + sum, see
|
||||||
|
`_assert_sat_invariants`).
|
||||||
"""
|
"""
|
||||||
if is_bitspire_payment(extra):
|
if not (0.0 <= super_fee_fraction <= 1.0):
|
||||||
data = _parse_extra(machine, payment_hash, gross_sats, extra, super_fee_pct)
|
raise SettlementInvariantError(
|
||||||
return data, False
|
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
|
||||||
logger.warning(
|
)
|
||||||
f"satmachineadmin: settlement on machine {machine.machine_npub[:12]}... "
|
if not is_bitspire_payment(extra):
|
||||||
f"missing bitSpire extra metadata; back-deriving via "
|
raise SettlementMetadataError(
|
||||||
f"fallback_commission_pct={machine.fallback_commission_pct}. "
|
f"Payment.extra missing `source: \"bitspire\"` marker on machine "
|
||||||
f"See aiolabs/lamassu-next#44."
|
f"{machine.machine_npub[:12]}... — invoice did not come through "
|
||||||
)
|
f"a bitSpire ATM, or the ATM firmware is older than "
|
||||||
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct), True
|
f"aiolabs/lamassu-next#44 and didn't stamp the canonical fields"
|
||||||
|
)
|
||||||
|
|
||||||
def _parse_extra(
|
|
||||||
machine: Machine,
|
|
||||||
payment_hash: str,
|
|
||||||
gross_sats: int,
|
|
||||||
extra: dict,
|
|
||||||
super_fee_pct: float,
|
|
||||||
) -> CreateDcaSettlementData:
|
|
||||||
"""Happy path: bitSpire populated Payment.extra per lamassu-next#44."""
|
|
||||||
principal_sats = _coerce_int(extra.get("principal_sats"))
|
principal_sats = _coerce_int(extra.get("principal_sats"))
|
||||||
fee_sats = _coerce_int(extra.get("fee_sats"))
|
fee_sats = _coerce_int(extra.get("fee_sats"))
|
||||||
if principal_sats is None or fee_sats is None:
|
if principal_sats is None or fee_sats is None:
|
||||||
# Missing key fields — shouldn't happen post-#44 but defensive.
|
raise SettlementMetadataError(
|
||||||
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct)
|
f"Payment.extra has source=bitspire but is missing required "
|
||||||
commission_sats = fee_sats
|
f"fields principal_sats={extra.get('principal_sats')!r} or "
|
||||||
platform_fee_sats = round(commission_sats * super_fee_pct)
|
f"fee_sats={extra.get('fee_sats')!r}; the wire-format contract "
|
||||||
operator_fee_sats = commission_sats - platform_fee_sats
|
f"(lamassu-next#44) requires both. Investigate the ATM "
|
||||||
|
f"firmware on machine {machine.machine_npub[:12]}..."
|
||||||
|
)
|
||||||
|
platform_fee_sats = round(fee_sats * super_fee_fraction)
|
||||||
|
operator_fee_sats = fee_sats - platform_fee_sats
|
||||||
exchange_rate = _coerce_float(extra.get("exchange_rate"))
|
exchange_rate = _coerce_float(extra.get("exchange_rate"))
|
||||||
if exchange_rate is None or exchange_rate <= 0:
|
if exchange_rate is None or exchange_rate <= 0:
|
||||||
# Without exchange rate we can't compute fiat. Use 1.0 as a stand-in
|
# Without exchange rate we can't compute fiat. Use 1.0 as a stand-in
|
||||||
|
|
@ -163,66 +264,39 @@ def _parse_extra(
|
||||||
# dispenser ledger (lamassu-next@8318489). It's the cash that
|
# dispenser ledger (lamassu-next@8318489). It's the cash that
|
||||||
# physically entered (cash-in) or exited (cash-out) the machine —
|
# physically entered (cash-in) or exited (cash-out) the machine —
|
||||||
# canonical, not derived. We never recompute it from sats × rate
|
# canonical, not derived. We never recompute it from sats × rate
|
||||||
# downstream: the relationship is't load-bearing (commission lives
|
# downstream: the relationship isn't load-bearing (commission lives
|
||||||
# in BTC today, but the cash side has its own ground truth).
|
# in BTC today, but the cash side has its own ground truth).
|
||||||
fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0
|
fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0
|
||||||
fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code
|
fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code
|
||||||
return CreateDcaSettlementData(
|
tx_type = _coerce_str(extra.get("type")) or "cash_out"
|
||||||
|
data = CreateDcaSettlementData(
|
||||||
machine_id=machine.id,
|
machine_id=machine.id,
|
||||||
payment_hash=payment_hash,
|
payment_hash=payment_hash,
|
||||||
bitspire_event_id=None,
|
bitspire_event_id=None,
|
||||||
bitspire_txid=_coerce_str(extra.get("txid")),
|
bitspire_txid=_coerce_str(extra.get("txid")),
|
||||||
gross_sats=gross_sats,
|
wire_sats=wire_sats,
|
||||||
fiat_amount=fiat_amount,
|
fiat_amount=fiat_amount,
|
||||||
fiat_code=fiat_code,
|
fiat_code=fiat_code,
|
||||||
exchange_rate=exchange_rate,
|
exchange_rate=exchange_rate,
|
||||||
principal_sats=principal_sats,
|
principal_sats=principal_sats,
|
||||||
commission_sats=commission_sats,
|
fee_sats=fee_sats,
|
||||||
platform_fee_sats=platform_fee_sats,
|
platform_fee_sats=platform_fee_sats,
|
||||||
operator_fee_sats=operator_fee_sats,
|
operator_fee_sats=operator_fee_sats,
|
||||||
used_fallback_split=False,
|
tx_type=tx_type,
|
||||||
tx_type=_coerce_str(extra.get("type")) or "cash_out",
|
|
||||||
bills_json=_json_dumps(extra.get("bills")),
|
bills_json=_json_dumps(extra.get("bills")),
|
||||||
cassettes_json=_json_dumps(extra.get("cassettes")),
|
cassettes_json=_json_dumps(extra.get("cassettes")),
|
||||||
)
|
)
|
||||||
|
# Enforce the cross-codebase canonical sat-amount invariants on the
|
||||||
|
# values bitSpire stamped (post-rename: `fee_fraction` is preferred;
|
||||||
def _parse_fallback(
|
# the old `fee_percent` field is deliberately NOT read here because
|
||||||
machine: Machine,
|
# of the 100× misinterpretation risk during the rename window — the
|
||||||
payment_hash: str,
|
# absolute `fee_sats` stamp is the audit anchor and the sum
|
||||||
gross_sats: int,
|
# invariants below catch any garbage at the wire).
|
||||||
super_fee_pct: float,
|
_assert_sat_invariants(
|
||||||
) -> CreateDcaSettlementData:
|
tx_type=data.tx_type,
|
||||||
"""Back-derive the split using the machine's fallback_commission_pct.
|
wire_sats=data.wire_sats,
|
||||||
|
principal_sats=data.principal_sats,
|
||||||
Same formula as the Lamassu integration used:
|
fee_sats=data.fee_sats,
|
||||||
base_amount = round(gross / (1 + commission_pct))
|
fee_fraction=_coerce_float(extra.get("fee_fraction")),
|
||||||
commission = gross - base_amount
|
|
||||||
"""
|
|
||||||
principal_sats, commission_sats, _effective = calculate_commission(
|
|
||||||
crypto_atoms=gross_sats,
|
|
||||||
commission_percentage=machine.fallback_commission_pct,
|
|
||||||
discount=0.0,
|
|
||||||
)
|
|
||||||
platform_fee_sats = round(commission_sats * super_fee_pct)
|
|
||||||
operator_fee_sats = commission_sats - platform_fee_sats
|
|
||||||
# No exchange rate from the wire; leave fiat_amount=0 so it's visibly
|
|
||||||
# incomplete on the operator's reconciliation screen.
|
|
||||||
return CreateDcaSettlementData(
|
|
||||||
machine_id=machine.id,
|
|
||||||
payment_hash=payment_hash,
|
|
||||||
bitspire_event_id=None,
|
|
||||||
bitspire_txid=None,
|
|
||||||
gross_sats=gross_sats,
|
|
||||||
fiat_amount=0.0,
|
|
||||||
fiat_code=machine.fiat_code,
|
|
||||||
exchange_rate=0.0,
|
|
||||||
principal_sats=principal_sats,
|
|
||||||
commission_sats=commission_sats,
|
|
||||||
platform_fee_sats=platform_fee_sats,
|
|
||||||
operator_fee_sats=operator_fee_sats,
|
|
||||||
used_fallback_split=True,
|
|
||||||
tx_type="cash_out",
|
|
||||||
bills_json=None,
|
|
||||||
cassettes_json=None,
|
|
||||||
)
|
)
|
||||||
|
return data
|
||||||
|
|
|
||||||
108
calculations.py
108
calculations.py
|
|
@ -3,52 +3,19 @@ Pure calculation functions for DCA transaction processing.
|
||||||
|
|
||||||
These functions have no external dependencies (no lnbits, no database)
|
These functions have no external dependencies (no lnbits, no database)
|
||||||
and can be easily tested in isolation.
|
and can be easily tested in isolation.
|
||||||
|
|
||||||
|
What's intentionally NOT here (deleted 2026-05-26):
|
||||||
|
- `calculate_commission` (back-derive principal+fee from a gross-with-
|
||||||
|
commission-baked-in wire amount). Lamassu-era reverse-derivation;
|
||||||
|
obsolete since bitSpire stamps `principal_sats` AND `fee_sats`
|
||||||
|
directly on Payment.extra per aiolabs/lamassu-next#44.
|
||||||
|
- `calculate_exchange_rate` (principal / fiat_amount). bitSpire stamps
|
||||||
|
`exchange_rate` directly on Payment.extra too. Not used in production.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
|
||||||
def calculate_commission(
|
|
||||||
crypto_atoms: int,
|
|
||||||
commission_percentage: float,
|
|
||||||
discount: float = 0.0
|
|
||||||
) -> Tuple[int, int, float]:
|
|
||||||
"""
|
|
||||||
Calculate commission split from a Lamassu transaction.
|
|
||||||
|
|
||||||
The crypto_atoms from Lamassu already includes the commission baked in.
|
|
||||||
This function extracts the base amount (for DCA distribution) and
|
|
||||||
commission amount (for commission wallet).
|
|
||||||
|
|
||||||
Formula:
|
|
||||||
effective_commission = commission_percentage * (100 - discount) / 100
|
|
||||||
base_amount = round(crypto_atoms / (1 + effective_commission))
|
|
||||||
commission_amount = crypto_atoms - base_amount
|
|
||||||
|
|
||||||
Args:
|
|
||||||
crypto_atoms: Total sats from Lamassu (includes commission)
|
|
||||||
commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%)
|
|
||||||
discount: Discount percentage on commission (e.g., 10.0 for 10% off)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> calculate_commission(266800, 0.03, 0.0)
|
|
||||||
(259029, 7771, 0.03)
|
|
||||||
"""
|
|
||||||
if commission_percentage > 0:
|
|
||||||
effective_commission = commission_percentage * (100 - discount) / 100
|
|
||||||
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
|
||||||
commission_amount_sats = crypto_atoms - base_crypto_atoms
|
|
||||||
else:
|
|
||||||
effective_commission = 0.0
|
|
||||||
base_crypto_atoms = crypto_atoms
|
|
||||||
commission_amount_sats = 0
|
|
||||||
|
|
||||||
return base_crypto_atoms, commission_amount_sats, effective_commission
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_distribution(
|
def calculate_distribution(
|
||||||
base_amount_sats: int,
|
base_amount_sats: int,
|
||||||
client_balances: Dict[str, float],
|
client_balances: Dict[str, float],
|
||||||
|
|
@ -132,14 +99,15 @@ def calculate_distribution(
|
||||||
|
|
||||||
|
|
||||||
def split_two_stage_commission(
|
def split_two_stage_commission(
|
||||||
commission_sats: int, super_fee_pct: float
|
fee_sats: int, super_fee_fraction: float
|
||||||
) -> Tuple[int, int]:
|
) -> Tuple[int, int]:
|
||||||
"""Stage-1 of the v2 commission split: super takes `super_fee_pct` of the
|
"""Stage-1 of the v2 commission split: super takes `super_fee_fraction`
|
||||||
total commission; the remainder is what the operator's own ruleset acts on.
|
of the total fee; the remainder is what the operator's own ruleset
|
||||||
|
acts on.
|
||||||
|
|
||||||
Returns (platform_fee_sats, operator_fee_sats). Platform is rounded;
|
Returns (platform_fee_sats, operator_fee_sats). Platform is rounded;
|
||||||
operator absorbs the rounding remainder so platform_fee + operator_fee
|
operator absorbs the rounding remainder so platform_fee + operator_fee
|
||||||
== commission_sats exactly.
|
== fee_sats exactly.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> split_two_stage_commission(100, 0.30)
|
>>> split_two_stage_commission(100, 0.30)
|
||||||
|
|
@ -151,23 +119,28 @@ def split_two_stage_commission(
|
||||||
>>> split_two_stage_commission(100, 1.0)
|
>>> split_two_stage_commission(100, 1.0)
|
||||||
(100, 0)
|
(100, 0)
|
||||||
"""
|
"""
|
||||||
if commission_sats <= 0:
|
if not (0.0 <= super_fee_fraction <= 1.0):
|
||||||
|
raise ValueError(
|
||||||
|
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
|
||||||
|
)
|
||||||
|
if fee_sats <= 0:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
platform = round(commission_sats * super_fee_pct)
|
platform = round(fee_sats * super_fee_fraction)
|
||||||
platform = max(0, min(platform, commission_sats))
|
platform = max(0, min(platform, fee_sats))
|
||||||
operator = commission_sats - platform
|
operator = fee_sats - platform
|
||||||
return platform, operator
|
return platform, operator
|
||||||
|
|
||||||
|
|
||||||
def allocate_operator_split_legs(
|
def allocate_operator_split_legs(
|
||||||
operator_fee_sats: int, leg_pcts: list
|
operator_fee_sats: int, leg_fractions: list
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
|
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
|
||||||
across N leg wallets per `leg_pcts` (each in 0..1, sum should equal 1.0).
|
across N leg wallets per `leg_fractions` (each in [0, 1], sum should
|
||||||
|
equal 1.0).
|
||||||
|
|
||||||
The last leg absorbs the rounding remainder so the sum of allocations
|
The last leg absorbs the rounding remainder so the sum of allocations
|
||||||
exactly equals operator_fee_sats (assuming pcts sum to ~1.0). Returns
|
exactly equals operator_fee_sats (assuming fractions sum to ~1.0).
|
||||||
a list of integer sat amounts in the same order as leg_pcts.
|
Returns a list of integer sat amounts in the same order as leg_fractions.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
|
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
|
||||||
|
|
@ -179,33 +152,24 @@ def allocate_operator_split_legs(
|
||||||
>>> allocate_operator_split_legs(0, [0.5, 0.5])
|
>>> allocate_operator_split_legs(0, [0.5, 0.5])
|
||||||
[0, 0]
|
[0, 0]
|
||||||
"""
|
"""
|
||||||
if not leg_pcts:
|
if not leg_fractions:
|
||||||
return []
|
return []
|
||||||
if operator_fee_sats <= 0:
|
if operator_fee_sats <= 0:
|
||||||
return [0] * len(leg_pcts)
|
return [0] * len(leg_fractions)
|
||||||
|
for f in leg_fractions:
|
||||||
|
if not (0.0 <= float(f) <= 1.0):
|
||||||
|
raise ValueError(
|
||||||
|
f"every leg fraction must be in [0, 1], got {f}"
|
||||||
|
)
|
||||||
allocations: list = []
|
allocations: list = []
|
||||||
remaining = operator_fee_sats
|
remaining = operator_fee_sats
|
||||||
for idx, pct in enumerate(leg_pcts):
|
for idx, fraction in enumerate(leg_fractions):
|
||||||
if idx == len(leg_pcts) - 1:
|
if idx == len(leg_fractions) - 1:
|
||||||
allocations.append(remaining)
|
allocations.append(remaining)
|
||||||
else:
|
else:
|
||||||
amount = round(operator_fee_sats * float(pct))
|
amount = round(operator_fee_sats * float(fraction))
|
||||||
allocations.append(amount)
|
allocations.append(amount)
|
||||||
remaining -= amount
|
remaining -= amount
|
||||||
return allocations
|
return allocations
|
||||||
|
|
||||||
|
|
||||||
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
|
|
||||||
"""
|
|
||||||
Calculate exchange rate in sats per fiat unit.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_crypto_atoms: Base amount in sats (after commission)
|
|
||||||
fiat_amount: Fiat amount dispensed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Exchange rate as sats per fiat unit
|
|
||||||
"""
|
|
||||||
if fiat_amount <= 0:
|
|
||||||
return 0.0
|
|
||||||
return base_crypto_atoms / fiat_amount
|
|
||||||
|
|
|
||||||
41
crud.py
41
crud.py
|
|
@ -78,10 +78,9 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
|
||||||
"""
|
"""
|
||||||
INSERT INTO satoshimachine.dca_machines
|
INSERT INTO satoshimachine.dca_machines
|
||||||
(id, operator_user_id, machine_npub, wallet_id, name, location,
|
(id, operator_user_id, machine_npub, wallet_id, name, location,
|
||||||
fiat_code, is_active, fallback_commission_pct, created_at, updated_at)
|
fiat_code, is_active, created_at, updated_at)
|
||||||
VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name,
|
VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name,
|
||||||
:location, :fiat_code, :is_active, :fallback_commission_pct,
|
:location, :fiat_code, :is_active, :created_at, :updated_at)
|
||||||
:created_at, :updated_at)
|
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"id": machine_id,
|
"id": machine_id,
|
||||||
|
|
@ -92,7 +91,6 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
|
||||||
"location": data.location,
|
"location": data.location,
|
||||||
"fiat_code": data.fiat_code,
|
"fiat_code": data.fiat_code,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"fallback_commission_pct": data.fallback_commission_pct,
|
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
},
|
},
|
||||||
|
|
@ -555,14 +553,14 @@ async def create_settlement_idempotent(
|
||||||
"""
|
"""
|
||||||
INSERT INTO satoshimachine.dca_settlements
|
INSERT INTO satoshimachine.dca_settlements
|
||||||
(id, machine_id, payment_hash, bitspire_event_id, bitspire_txid,
|
(id, machine_id, payment_hash, bitspire_event_id, bitspire_txid,
|
||||||
gross_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
|
wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
|
||||||
commission_sats, platform_fee_sats, operator_fee_sats,
|
fee_sats, platform_fee_sats, operator_fee_sats,
|
||||||
used_fallback_split, tx_type, bills_json, cassettes_json,
|
tx_type, bills_json, cassettes_json,
|
||||||
status, error_message, created_at)
|
status, error_message, created_at)
|
||||||
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
|
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
|
||||||
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code,
|
:bitspire_txid, :wire_sats, :fiat_amount, :fiat_code,
|
||||||
:exchange_rate, :principal_sats, :commission_sats,
|
:exchange_rate, :principal_sats, :fee_sats,
|
||||||
:platform_fee_sats, :operator_fee_sats, :used_fallback_split,
|
:platform_fee_sats, :operator_fee_sats,
|
||||||
:tx_type, :bills_json, :cassettes_json, :status,
|
:tx_type, :bills_json, :cassettes_json, :status,
|
||||||
:error_message, :created_at)
|
:error_message, :created_at)
|
||||||
""",
|
""",
|
||||||
|
|
@ -572,15 +570,14 @@ async def create_settlement_idempotent(
|
||||||
"payment_hash": data.payment_hash,
|
"payment_hash": data.payment_hash,
|
||||||
"bitspire_event_id": data.bitspire_event_id,
|
"bitspire_event_id": data.bitspire_event_id,
|
||||||
"bitspire_txid": data.bitspire_txid,
|
"bitspire_txid": data.bitspire_txid,
|
||||||
"gross_sats": data.gross_sats,
|
"wire_sats": data.wire_sats,
|
||||||
"fiat_amount": data.fiat_amount,
|
"fiat_amount": data.fiat_amount,
|
||||||
"fiat_code": data.fiat_code,
|
"fiat_code": data.fiat_code,
|
||||||
"exchange_rate": data.exchange_rate,
|
"exchange_rate": data.exchange_rate,
|
||||||
"principal_sats": data.principal_sats,
|
"principal_sats": data.principal_sats,
|
||||||
"commission_sats": data.commission_sats,
|
"fee_sats": data.fee_sats,
|
||||||
"platform_fee_sats": data.platform_fee_sats,
|
"platform_fee_sats": data.platform_fee_sats,
|
||||||
"operator_fee_sats": data.operator_fee_sats,
|
"operator_fee_sats": data.operator_fee_sats,
|
||||||
"used_fallback_split": data.used_fallback_split,
|
|
||||||
"tx_type": data.tx_type,
|
"tx_type": data.tx_type,
|
||||||
"bills_json": data.bills_json,
|
"bills_json": data.bills_json,
|
||||||
"cassettes_json": data.cassettes_json,
|
"cassettes_json": data.cassettes_json,
|
||||||
|
|
@ -840,9 +837,9 @@ async def reset_settlement_for_retry(
|
||||||
async def apply_partial_dispense(
|
async def apply_partial_dispense(
|
||||||
settlement_id: str,
|
settlement_id: str,
|
||||||
*,
|
*,
|
||||||
new_gross_sats: int,
|
new_wire_sats: int,
|
||||||
new_principal_sats: int,
|
new_principal_sats: int,
|
||||||
new_commission_sats: int,
|
new_fee_sats: int,
|
||||||
new_platform_fee_sats: int,
|
new_platform_fee_sats: int,
|
||||||
new_operator_fee_sats: int,
|
new_operator_fee_sats: int,
|
||||||
new_fiat_amount: float,
|
new_fiat_amount: float,
|
||||||
|
|
@ -858,9 +855,9 @@ async def apply_partial_dispense(
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE satoshimachine.dca_settlements
|
UPDATE satoshimachine.dca_settlements
|
||||||
SET gross_sats = :gross,
|
SET wire_sats = :gross,
|
||||||
principal_sats = :principal,
|
principal_sats = :principal,
|
||||||
commission_sats = :commission,
|
fee_sats = :commission,
|
||||||
platform_fee_sats = :platform,
|
platform_fee_sats = :platform,
|
||||||
operator_fee_sats = :operator,
|
operator_fee_sats = :operator,
|
||||||
fiat_amount = :fiat,
|
fiat_amount = :fiat,
|
||||||
|
|
@ -875,9 +872,9 @@ async def apply_partial_dispense(
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"id": settlement_id,
|
"id": settlement_id,
|
||||||
"gross": new_gross_sats,
|
"gross": new_wire_sats,
|
||||||
"principal": new_principal_sats,
|
"principal": new_principal_sats,
|
||||||
"commission": new_commission_sats,
|
"commission": new_fee_sats,
|
||||||
"platform": new_platform_fee_sats,
|
"platform": new_platform_fee_sats,
|
||||||
"operator": new_operator_fee_sats,
|
"operator": new_operator_fee_sats,
|
||||||
"fiat": new_fiat_amount,
|
"fiat": new_fiat_amount,
|
||||||
|
|
@ -1013,9 +1010,9 @@ async def replace_commission_splits(
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO satoshimachine.dca_commission_splits
|
INSERT INTO satoshimachine.dca_commission_splits
|
||||||
(id, machine_id, operator_user_id, target, label, pct,
|
(id, machine_id, operator_user_id, target, label, fraction,
|
||||||
sort_order, created_at)
|
sort_order, created_at)
|
||||||
VALUES (:id, :machine_id, :uid, :target, :label, :pct,
|
VALUES (:id, :machine_id, :uid, :target, :label, :fraction,
|
||||||
:sort_order, :created_at)
|
:sort_order, :created_at)
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
|
|
@ -1024,7 +1021,7 @@ async def replace_commission_splits(
|
||||||
"uid": operator_user_id,
|
"uid": operator_user_id,
|
||||||
"target": leg.target,
|
"target": leg.target,
|
||||||
"label": leg.label,
|
"label": leg.label,
|
||||||
"pct": leg.pct,
|
"fraction": leg.fraction,
|
||||||
"sort_order": leg.sort_order,
|
"sort_order": leg.sort_order,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -111,32 +111,32 @@ async def _record_skipped_leg(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_partial_dispense_gross(
|
def _resolve_partial_dispense_wire(
|
||||||
settlement: DcaSettlement, data: PartialDispenseData
|
settlement: DcaSettlement, data: PartialDispenseData
|
||||||
) -> int:
|
) -> int:
|
||||||
if data.dispensed_sats is not None:
|
if data.dispensed_sats is not None:
|
||||||
new_gross = int(data.dispensed_sats)
|
new_wire = int(data.dispensed_sats)
|
||||||
elif data.dispensed_fraction is not None:
|
elif data.dispensed_fraction is not None:
|
||||||
new_gross = round(settlement.gross_sats * float(data.dispensed_fraction))
|
new_wire = round(settlement.wire_sats * float(data.dispensed_fraction))
|
||||||
else:
|
else:
|
||||||
raise ValueError("provide one of dispensed_sats or dispensed_fraction")
|
raise ValueError("provide one of dispensed_sats or dispensed_fraction")
|
||||||
if new_gross < 0:
|
if new_wire < 0:
|
||||||
raise ValueError("partial dispense cannot be negative")
|
raise ValueError("partial dispense cannot be negative")
|
||||||
if new_gross > settlement.gross_sats:
|
if new_wire > settlement.wire_sats:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"partial dispense ({new_gross} sats) cannot exceed the original "
|
f"partial dispense ({new_wire} sats) cannot exceed the original "
|
||||||
f"gross ({settlement.gross_sats} sats)"
|
f"wire amount ({settlement.wire_sats} sats)"
|
||||||
)
|
)
|
||||||
return new_gross
|
return new_wire
|
||||||
|
|
||||||
|
|
||||||
def _build_partial_dispense_memo(
|
def _build_partial_dispense_memo(
|
||||||
settlement: DcaSettlement,
|
settlement: DcaSettlement,
|
||||||
data: PartialDispenseData,
|
data: PartialDispenseData,
|
||||||
*,
|
*,
|
||||||
new_gross: int,
|
new_wire: int,
|
||||||
new_principal: int,
|
new_principal: int,
|
||||||
new_commission: int,
|
new_fee: int,
|
||||||
new_platform: int,
|
new_platform: int,
|
||||||
new_operator: int,
|
new_operator: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|
@ -148,13 +148,13 @@ def _build_partial_dispense_memo(
|
||||||
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
return (
|
return (
|
||||||
f"[{ts}] partial dispense applied — {adjust}. "
|
f"[{ts}] partial dispense applied — {adjust}. "
|
||||||
f"Original gross={settlement.gross_sats} "
|
f"Original wire={settlement.wire_sats} "
|
||||||
f"principal={settlement.principal_sats} "
|
f"principal={settlement.principal_sats} "
|
||||||
f"commission={settlement.commission_sats} "
|
f"fee={settlement.fee_sats} "
|
||||||
f"(super_fee={settlement.platform_fee_sats} "
|
f"(super_fee={settlement.platform_fee_sats} "
|
||||||
f"operator_fee={settlement.operator_fee_sats}). "
|
f"operator_fee={settlement.operator_fee_sats}). "
|
||||||
f"New gross={new_gross} principal={new_principal} "
|
f"New wire={new_wire} principal={new_principal} "
|
||||||
f"commission={new_commission} "
|
f"fee={new_fee} "
|
||||||
f"(super_fee={new_platform} operator_fee={new_operator}). "
|
f"(super_fee={new_platform} operator_fee={new_operator}). "
|
||||||
f"Reason: {reason}"
|
f"Reason: {reason}"
|
||||||
)
|
)
|
||||||
|
|
@ -275,10 +275,10 @@ async def apply_partial_dispense_and_redistribute(
|
||||||
|
|
||||||
When a bitSpire dispense fails mid-transaction (e.g., dispenser jam after
|
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
|
6 of 10 bills), the operator confirms the actual amount dispensed and we
|
||||||
re-allocate the split against that partial gross. Sat amounts scale
|
re-allocate the split against that partial wire amount. Sat amounts scale
|
||||||
linearly, preserving the original commission ratio exactly. The two-stage
|
linearly, preserving the original commission ratio exactly. The two-stage
|
||||||
super/operator split also scales by the *original* platform_fee_sats /
|
super/operator split also scales by the *original* platform_fee_sats /
|
||||||
commission_sats ratio rather than re-reading current super_fee_pct —
|
fee_sats ratio rather than re-reading current super_fee_fraction —
|
||||||
this honors the "absolute fields are the source of truth" invariant
|
this honors the "absolute fields are the source of truth" invariant
|
||||||
even when super has changed the global rate since the settlement landed
|
even when super has changed the global rate since the settlement landed
|
||||||
(closes #11 H6).
|
(closes #11 H6).
|
||||||
|
|
@ -296,8 +296,8 @@ async def apply_partial_dispense_and_redistribute(
|
||||||
settlement = await get_settlement(settlement_id)
|
settlement = await get_settlement(settlement_id)
|
||||||
if settlement is None:
|
if settlement is None:
|
||||||
raise ValueError(f"settlement {settlement_id} not found")
|
raise ValueError(f"settlement {settlement_id} not found")
|
||||||
if settlement.gross_sats <= 0:
|
if settlement.wire_sats <= 0:
|
||||||
raise ValueError("cannot partial-dispense a zero-gross settlement")
|
raise ValueError("cannot partial-dispense a zero-wire settlement")
|
||||||
completed = await count_completed_legs_for_settlement(settlement_id)
|
completed = await count_completed_legs_for_settlement(settlement_id)
|
||||||
if completed > 0:
|
if completed > 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -305,33 +305,33 @@ async def apply_partial_dispense_and_redistribute(
|
||||||
"(Lightning payments can't be clawed back)"
|
"(Lightning payments can't be clawed back)"
|
||||||
)
|
)
|
||||||
|
|
||||||
new_gross = _resolve_partial_dispense_gross(settlement, data)
|
new_wire = _resolve_partial_dispense_wire(settlement, data)
|
||||||
# Linear scale preserves the original commission ratio exactly.
|
# Linear scale preserves the original commission ratio exactly.
|
||||||
scale = new_gross / settlement.gross_sats
|
scale = new_wire / settlement.wire_sats
|
||||||
new_commission = round(settlement.commission_sats * scale)
|
new_fee = round(settlement.fee_sats * scale)
|
||||||
new_principal = new_gross - new_commission
|
new_principal = new_wire - new_fee
|
||||||
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
|
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
|
||||||
|
|
||||||
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
|
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
|
||||||
# settlement row — NOT the current super_fee_pct. The contract was
|
# settlement row — NOT the current super_fee_fraction. The contract was
|
||||||
# locked at landing; super raising or lowering the global rate after
|
# locked at landing; super raising or lowering the global rate after
|
||||||
# the fact must not retroactively change this transaction's share.
|
# the fact must not retroactively change this transaction's share.
|
||||||
# Operator absorbs the rounding remainder so platform + operator
|
# Operator absorbs the rounding remainder so platform + operator
|
||||||
# == new_commission exactly.
|
# == new_fee exactly.
|
||||||
if settlement.commission_sats > 0:
|
if settlement.fee_sats > 0:
|
||||||
ratio = settlement.platform_fee_sats / settlement.commission_sats
|
ratio = settlement.platform_fee_sats / settlement.fee_sats
|
||||||
else:
|
else:
|
||||||
ratio = 0.0
|
ratio = 0.0
|
||||||
new_platform = round(new_commission * ratio)
|
new_platform = round(new_fee * ratio)
|
||||||
new_platform = max(0, min(new_platform, new_commission))
|
new_platform = max(0, min(new_platform, new_fee))
|
||||||
new_operator = new_commission - new_platform
|
new_operator = new_fee - new_platform
|
||||||
|
|
||||||
memo = _build_partial_dispense_memo(
|
memo = _build_partial_dispense_memo(
|
||||||
settlement,
|
settlement,
|
||||||
data,
|
data,
|
||||||
new_gross=new_gross,
|
new_wire=new_wire,
|
||||||
new_principal=new_principal,
|
new_principal=new_principal,
|
||||||
new_commission=new_commission,
|
new_fee=new_fee,
|
||||||
new_platform=new_platform,
|
new_platform=new_platform,
|
||||||
new_operator=new_operator,
|
new_operator=new_operator,
|
||||||
)
|
)
|
||||||
|
|
@ -339,9 +339,9 @@ async def apply_partial_dispense_and_redistribute(
|
||||||
await void_open_legs_for_settlement(settlement_id)
|
await void_open_legs_for_settlement(settlement_id)
|
||||||
updated = await apply_partial_dispense(
|
updated = await apply_partial_dispense(
|
||||||
settlement_id,
|
settlement_id,
|
||||||
new_gross_sats=new_gross,
|
new_wire_sats=new_wire,
|
||||||
new_principal_sats=new_principal,
|
new_principal_sats=new_principal,
|
||||||
new_commission_sats=new_commission,
|
new_fee_sats=new_fee,
|
||||||
new_platform_fee_sats=new_platform,
|
new_platform_fee_sats=new_platform,
|
||||||
new_operator_fee_sats=new_operator,
|
new_operator_fee_sats=new_operator,
|
||||||
new_fiat_amount=new_fiat,
|
new_fiat_amount=new_fiat,
|
||||||
|
|
@ -467,7 +467,7 @@ async def _pay_operator_splits(
|
||||||
# Pure allocator handles the rounding rule (last leg absorbs remainder).
|
# Pure allocator handles the rounding rule (last leg absorbs remainder).
|
||||||
leg_amounts = allocate_operator_split_legs(
|
leg_amounts = allocate_operator_split_legs(
|
||||||
settlement.operator_fee_sats,
|
settlement.operator_fee_sats,
|
||||||
[float(leg.pct) for leg in splits],
|
[float(leg.fraction) for leg in splits],
|
||||||
)
|
)
|
||||||
for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)):
|
for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)):
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ async def m001_satmachine_v2_initial(db):
|
||||||
await db.execute(f"""
|
await db.execute(f"""
|
||||||
CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
|
CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
super_fee_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
|
super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
|
||||||
super_fee_wallet_id TEXT,
|
super_fee_wallet_id TEXT,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
);
|
);
|
||||||
|
|
@ -72,7 +72,7 @@ async def m001_satmachine_v2_initial(db):
|
||||||
)
|
)
|
||||||
if not existing:
|
if not existing:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO satoshimachine.super_config (id, super_fee_pct) "
|
"INSERT INTO satoshimachine.super_config (id, super_fee_fraction) "
|
||||||
"VALUES ('default', 0.0000)"
|
"VALUES ('default', 0.0000)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -88,7 +88,6 @@ async def m001_satmachine_v2_initial(db):
|
||||||
location TEXT,
|
location TEXT,
|
||||||
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
fallback_commission_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0500,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
);
|
);
|
||||||
|
|
@ -180,10 +179,10 @@ async def m001_satmachine_v2_initial(db):
|
||||||
# append-only audit memo for partial-dispense + operator notes.
|
# append-only audit memo for partial-dispense + operator notes.
|
||||||
#
|
#
|
||||||
# platform_fee_sats and operator_fee_sats are absolute BIGINT,
|
# platform_fee_sats and operator_fee_sats are absolute BIGINT,
|
||||||
# NOT derived percentages — when the v2 customer-discount engine
|
# NOT derived fractions — when the v2 customer-discount engine
|
||||||
# ships, these two columns are the audit-grade record of who
|
# ships, these two columns are the audit-grade record of who
|
||||||
# forgave what per transaction. Do not collapse them into a single
|
# forgave what per transaction. Do not collapse them into a single
|
||||||
# commission_pct. See plan section "Customer discounts" and #10.
|
# fee_fraction. See plan section "Customer discounts" and #10.
|
||||||
await db.execute(f"""
|
await db.execute(f"""
|
||||||
CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
|
CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|
@ -191,15 +190,14 @@ async def m001_satmachine_v2_initial(db):
|
||||||
payment_hash TEXT NOT NULL UNIQUE,
|
payment_hash TEXT NOT NULL UNIQUE,
|
||||||
bitspire_event_id TEXT,
|
bitspire_event_id TEXT,
|
||||||
bitspire_txid TEXT,
|
bitspire_txid TEXT,
|
||||||
gross_sats BIGINT NOT NULL,
|
wire_sats BIGINT NOT NULL,
|
||||||
fiat_amount DECIMAL(10,2) NOT NULL,
|
fiat_amount DECIMAL(10,2) NOT NULL,
|
||||||
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
||||||
exchange_rate REAL NOT NULL,
|
exchange_rate REAL NOT NULL,
|
||||||
principal_sats BIGINT NOT NULL,
|
principal_sats BIGINT NOT NULL,
|
||||||
commission_sats BIGINT NOT NULL,
|
fee_sats BIGINT NOT NULL,
|
||||||
platform_fee_sats BIGINT NOT NULL,
|
platform_fee_sats BIGINT NOT NULL,
|
||||||
operator_fee_sats BIGINT NOT NULL,
|
operator_fee_sats BIGINT NOT NULL,
|
||||||
used_fallback_split BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
tx_type TEXT NOT NULL,
|
tx_type TEXT NOT NULL,
|
||||||
bills_json TEXT,
|
bills_json TEXT,
|
||||||
cassettes_json TEXT,
|
cassettes_json TEXT,
|
||||||
|
|
@ -217,9 +215,9 @@ async def m001_satmachine_v2_initial(db):
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. dca_commission_splits — operator's rules for distributing the
|
# 7. dca_commission_splits — operator's rules for distributing the
|
||||||
# *remainder* (commission_sats - platform_fee_sats). One row per
|
# *remainder* (fee_sats - platform_fee_sats). One row per
|
||||||
# leg. machine_id=NULL = operator default; non-null = per-machine
|
# leg. machine_id=NULL = operator default; non-null = per-machine
|
||||||
# override. Sum(pct) per (operator, machine) must equal 1.0 —
|
# override. Sum(fraction) per (operator, machine) must equal 1.0 —
|
||||||
# enforced at write-time in crud.py.
|
# enforced at write-time in crud.py.
|
||||||
#
|
#
|
||||||
# `target` accepts any of (splitpayments-style):
|
# `target` accepts any of (splitpayments-style):
|
||||||
|
|
@ -235,7 +233,7 @@ async def m001_satmachine_v2_initial(db):
|
||||||
operator_user_id TEXT NOT NULL,
|
operator_user_id TEXT NOT NULL,
|
||||||
target TEXT NOT NULL,
|
target TEXT NOT NULL,
|
||||||
label TEXT,
|
label TEXT,
|
||||||
pct DECIMAL(10,4) NOT NULL,
|
fraction DECIMAL(10,4) NOT NULL,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
);
|
);
|
||||||
|
|
@ -439,6 +437,76 @@ async def m004_introduce_dca_lp_table(db):
|
||||||
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
|
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
|
||||||
|
|
||||||
|
|
||||||
|
async def m006_rename_to_canonical_sat_vocabulary(db):
|
||||||
|
"""Adopt the cross-codebase canonical sat-amount vocabulary AND drop
|
||||||
|
the now-obsolete Lamassu-era fallback columns, per the decision at
|
||||||
|
memory `reference_sat_amount_vocabulary.md` (2026-05-26):
|
||||||
|
|
||||||
|
Renames:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Drops (Lamassu-era reverse-derivation is obsolete since bitSpire
|
||||||
|
stamps both `principal_sats` AND `fee_sats` directly on
|
||||||
|
Payment.extra per lamassu-next#44 — there's nothing to back-derive):
|
||||||
|
- dca_machines.fallback_commission_pct (was the rate used by the
|
||||||
|
deleted `_parse_fallback` path)
|
||||||
|
- dca_settlements.used_fallback_split (was the per-row marker for
|
||||||
|
that path)
|
||||||
|
|
||||||
|
Same canonical applies on the lamassu-next + atm-tui side; the
|
||||||
|
rename is coordinated via `~/dev/coordination/log.md` (2026-05-26).
|
||||||
|
|
||||||
|
Each step is idempotent — probe for the OLD column; rename/drop only
|
||||||
|
if present; otherwise no-op (covers fresh installs where m001
|
||||||
|
already laid down the canonical schema).
|
||||||
|
|
||||||
|
Why a single migration: all driven by the same decision and any
|
||||||
|
external code wants to see the whole rename + cleanup land at once.
|
||||||
|
"""
|
||||||
|
renames = [
|
||||||
|
("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"),
|
||||||
|
]
|
||||||
|
for table, old_col, new_col in renames:
|
||||||
|
try:
|
||||||
|
await db.fetchone(
|
||||||
|
f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# old column doesn't exist; either rename already landed or
|
||||||
|
# m001 produced the canonical schema directly on fresh install.
|
||||||
|
continue
|
||||||
|
await db.execute(
|
||||||
|
f"ALTER TABLE satoshimachine.{table} "
|
||||||
|
f"RENAME COLUMN {old_col} TO {new_col}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the Lamassu-era fallback columns. Same idempotency pattern.
|
||||||
|
# Try both old (_pct) and new (_fraction) names for the dca_machines
|
||||||
|
# column since an install could be at either rename state.
|
||||||
|
drops = [
|
||||||
|
("dca_machines", "fallback_commission_pct"),
|
||||||
|
("dca_machines", "fallback_commission_fraction"),
|
||||||
|
("dca_settlements", "used_fallback_split"),
|
||||||
|
]
|
||||||
|
for table, col in drops:
|
||||||
|
try:
|
||||||
|
await db.fetchone(
|
||||||
|
f"SELECT {col} FROM satoshimachine.{table} LIMIT 1"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# column doesn't exist; either already dropped or never present.
|
||||||
|
continue
|
||||||
|
await db.execute(
|
||||||
|
f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def m005_lock_deposit_currency_to_machine_fiat_code(db):
|
async def m005_lock_deposit_currency_to_machine_fiat_code(db):
|
||||||
"""Rewrite every `dca_deposits.currency` row to match its joined
|
"""Rewrite every `dca_deposits.currency` row to match its joined
|
||||||
`dca_machines.fiat_code`.
|
`dca_machines.fiat_code`.
|
||||||
|
|
|
||||||
60
models.py
60
models.py
|
|
@ -29,18 +29,6 @@ class CreateMachineData(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
fiat_code: str = "GTQ"
|
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):
|
class Machine(BaseModel):
|
||||||
|
|
@ -52,7 +40,6 @@ class Machine(BaseModel):
|
||||||
location: Optional[str]
|
location: Optional[str]
|
||||||
fiat_code: str
|
fiat_code: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
fallback_commission_pct: float
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
@ -63,15 +50,6 @@ class UpdateMachineData(BaseModel):
|
||||||
fiat_code: Optional[str] = None
|
fiat_code: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
wallet_id: Optional[str] = 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.
|
# platform_fee_sats and operator_fee_sats are absolute audit-grade values.
|
||||||
# Today they equal the contractual split; tomorrow (post-v1 promo engine)
|
# 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)".
|
# 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)
|
payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
|
||||||
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion
|
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion
|
||||||
bitspire_txid: Optional[str] = None
|
bitspire_txid: Optional[str] = None
|
||||||
gross_sats: int
|
wire_sats: int
|
||||||
fiat_amount: float
|
fiat_amount: float
|
||||||
fiat_code: str = "GTQ"
|
fiat_code: str = "GTQ"
|
||||||
exchange_rate: float
|
exchange_rate: float
|
||||||
principal_sats: int
|
principal_sats: int
|
||||||
commission_sats: int
|
fee_sats: int
|
||||||
platform_fee_sats: int
|
platform_fee_sats: int
|
||||||
operator_fee_sats: int
|
operator_fee_sats: int
|
||||||
used_fallback_split: bool = False
|
|
||||||
tx_type: str # 'cash_out' | 'cash_in'
|
tx_type: str # 'cash_out' | 'cash_in'
|
||||||
bills_json: Optional[str] = None
|
bills_json: Optional[str] = None
|
||||||
cassettes_json: Optional[str] = None
|
cassettes_json: Optional[str] = None
|
||||||
|
|
@ -254,15 +231,14 @@ class DcaSettlement(BaseModel):
|
||||||
payment_hash: str
|
payment_hash: str
|
||||||
bitspire_event_id: Optional[str]
|
bitspire_event_id: Optional[str]
|
||||||
bitspire_txid: Optional[str]
|
bitspire_txid: Optional[str]
|
||||||
gross_sats: int
|
wire_sats: int
|
||||||
fiat_amount: float
|
fiat_amount: float
|
||||||
fiat_code: str
|
fiat_code: str
|
||||||
exchange_rate: float
|
exchange_rate: float
|
||||||
principal_sats: int
|
principal_sats: int
|
||||||
commission_sats: int
|
fee_sats: int
|
||||||
platform_fee_sats: int
|
platform_fee_sats: int
|
||||||
operator_fee_sats: int
|
operator_fee_sats: int
|
||||||
used_fallback_split: bool
|
|
||||||
tx_type: str
|
tx_type: str
|
||||||
bills_json: Optional[str]
|
bills_json: Optional[str]
|
||||||
cassettes_json: Optional[str]
|
cassettes_json: Optional[str]
|
||||||
|
|
@ -295,8 +271,8 @@ class DcaSettlement(BaseModel):
|
||||||
# Commission splits — operator-defined remainder allocation per machine.
|
# Commission splits — operator-defined remainder allocation per machine.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# machine_id=NULL means operator's default; non-null means per-machine override.
|
# 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,
|
# Sum of fraction across rows for a (operator_user_id, machine_id) scope must
|
||||||
# enforced at write-time in crud.py.
|
# be 1.0, enforced at write-time in crud.py.
|
||||||
|
|
||||||
|
|
||||||
class CommissionSplitLeg(BaseModel):
|
class CommissionSplitLeg(BaseModel):
|
||||||
|
|
@ -311,7 +287,7 @@ class CommissionSplitLeg(BaseModel):
|
||||||
|
|
||||||
target: str
|
target: str
|
||||||
label: Optional[str] = None
|
label: Optional[str] = None
|
||||||
pct: float
|
fraction: float
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
|
||||||
@validator("target")
|
@validator("target")
|
||||||
|
|
@ -321,10 +297,10 @@ class CommissionSplitLeg(BaseModel):
|
||||||
raise ValueError("target cannot be empty")
|
raise ValueError("target cannot be empty")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@validator("pct")
|
@validator("fraction")
|
||||||
def pct_in_unit_range(cls, v):
|
def fraction_in_unit_range(cls, v):
|
||||||
if v < 0 or v > 1:
|
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)
|
return round(float(v), 4)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -334,7 +310,7 @@ class CommissionSplit(BaseModel):
|
||||||
operator_user_id: str
|
operator_user_id: str
|
||||||
target: str
|
target: str
|
||||||
label: Optional[str]
|
label: Optional[str]
|
||||||
pct: float
|
fraction: float
|
||||||
sort_order: int
|
sort_order: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
@ -351,9 +327,9 @@ class SetCommissionSplitsData(BaseModel):
|
||||||
|
|
||||||
@validator("legs")
|
@validator("legs")
|
||||||
def legs_sum_to_one(cls, v):
|
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:
|
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
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -440,21 +416,21 @@ class TelemetrySnapshot(BaseModel):
|
||||||
|
|
||||||
class SuperConfig(BaseModel):
|
class SuperConfig(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
super_fee_pct: float
|
super_fee_fraction: float
|
||||||
super_fee_wallet_id: Optional[str]
|
super_fee_wallet_id: Optional[str]
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class UpdateSuperConfigData(BaseModel):
|
class UpdateSuperConfigData(BaseModel):
|
||||||
super_fee_pct: Optional[float] = None
|
super_fee_fraction: Optional[float] = None
|
||||||
super_fee_wallet_id: Optional[str] = None
|
super_fee_wallet_id: Optional[str] = None
|
||||||
|
|
||||||
@validator("super_fee_pct")
|
@validator("super_fee_fraction")
|
||||||
def fee_in_unit_range(cls, v):
|
def fee_in_unit_range(cls, v):
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
if v < 0 or v > 1:
|
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)
|
return round(float(v), 4)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ window.app = Vue.createApp({
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
||||||
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
|
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
|
||||||
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
|
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
|
||||||
{
|
{
|
||||||
name: 'error_message',
|
name: 'error_message',
|
||||||
label: 'Error',
|
label: 'Error',
|
||||||
|
|
@ -100,7 +100,7 @@ window.app = Vue.createApp({
|
||||||
superFeeDialog: {
|
superFeeDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
data: {super_fee_pct: 0, super_fee_wallet_id: ''}
|
data: {super_fee_fraction: 0, super_fee_wallet_id: ''}
|
||||||
},
|
},
|
||||||
|
|
||||||
// UI configuration -----------------------------------------------
|
// UI configuration -----------------------------------------------
|
||||||
|
|
@ -111,12 +111,6 @@ window.app = Vue.createApp({
|
||||||
{name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'},
|
{name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'},
|
||||||
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
|
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
|
||||||
{name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'},
|
{name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'},
|
||||||
{
|
|
||||||
name: 'fallback_commission_pct',
|
|
||||||
label: 'Fallback %',
|
|
||||||
field: 'fallback_commission_pct',
|
|
||||||
align: 'right'
|
|
||||||
},
|
|
||||||
{name: 'actions', label: 'Actions', field: 'id', align: 'right'}
|
{name: 'actions', label: 'Actions', field: 'id', align: 'right'}
|
||||||
],
|
],
|
||||||
pagination: {rowsPerPage: 10, sortBy: 'name'}
|
pagination: {rowsPerPage: 10, sortBy: 'name'}
|
||||||
|
|
@ -166,12 +160,12 @@ window.app = Vue.createApp({
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||||
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
|
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
|
||||||
{name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
|
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
|
||||||
{name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'},
|
{name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'},
|
||||||
{
|
{
|
||||||
name: 'commission_sats',
|
name: 'fee_sats',
|
||||||
label: 'Commission',
|
label: 'Fee',
|
||||||
field: 'commission_sats',
|
field: 'fee_sats',
|
||||||
align: 'right'
|
align: 'right'
|
||||||
},
|
},
|
||||||
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
|
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
|
||||||
|
|
@ -332,7 +326,7 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
commissionSum() {
|
commissionSum() {
|
||||||
return this.commissionLegs.reduce(
|
return this.commissionLegs.reduce(
|
||||||
(acc, leg) => acc + (Number(leg.pct) || 0), 0
|
(acc, leg) => acc + (Number(leg.fraction) || 0), 0
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
commissionSumValid() {
|
commissionSumValid() {
|
||||||
|
|
@ -351,7 +345,7 @@ window.app = Vue.createApp({
|
||||||
if (idx === this.commissionLegs.length - 1) {
|
if (idx === this.commissionLegs.length - 1) {
|
||||||
sats = remaining
|
sats = remaining
|
||||||
} else {
|
} else {
|
||||||
sats = Math.round(total * (Number(leg.pct) || 0))
|
sats = Math.round(total * (Number(leg.fraction) || 0))
|
||||||
remaining -= sats
|
remaining -= sats
|
||||||
}
|
}
|
||||||
out.push({label: leg.label, sats})
|
out.push({label: leg.label, sats})
|
||||||
|
|
@ -531,7 +525,7 @@ window.app = Vue.createApp({
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
openSuperFeeDialog() {
|
openSuperFeeDialog() {
|
||||||
this.superFeeDialog.data = {
|
this.superFeeDialog.data = {
|
||||||
super_fee_pct: this.superConfig?.super_fee_pct ?? 0,
|
super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0,
|
||||||
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
|
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
|
||||||
}
|
}
|
||||||
this.superFeeDialog.show = true
|
this.superFeeDialog.show = true
|
||||||
|
|
@ -544,7 +538,7 @@ window.app = Vue.createApp({
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'PUT', SUPER_FEE_PATH, null,
|
'PUT', SUPER_FEE_PATH, null,
|
||||||
{
|
{
|
||||||
super_fee_pct: Number(d.super_fee_pct),
|
super_fee_fraction: Number(d.super_fee_fraction),
|
||||||
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
|
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -565,7 +559,7 @@ window.app = Vue.createApp({
|
||||||
this._downloadCsv(
|
this._downloadCsv(
|
||||||
'machines.csv',
|
'machines.csv',
|
||||||
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
|
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
|
||||||
'is_active', 'fallback_commission_pct', 'created_at'],
|
'is_active', 'created_at'],
|
||||||
this.machines
|
this.machines
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -687,7 +681,6 @@ window.app = Vue.createApp({
|
||||||
location: machine.location || '',
|
location: machine.location || '',
|
||||||
wallet_id: machine.wallet_id,
|
wallet_id: machine.wallet_id,
|
||||||
fiat_code: machine.fiat_code,
|
fiat_code: machine.fiat_code,
|
||||||
fallback_commission_pct: machine.fallback_commission_pct,
|
|
||||||
is_active: machine.is_active
|
is_active: machine.is_active
|
||||||
}
|
}
|
||||||
this.editMachineDialog.show = true
|
this.editMachineDialog.show = true
|
||||||
|
|
@ -706,7 +699,6 @@ window.app = Vue.createApp({
|
||||||
location: d.location,
|
location: d.location,
|
||||||
wallet_id: d.wallet_id,
|
wallet_id: d.wallet_id,
|
||||||
fiat_code: d.fiat_code,
|
fiat_code: d.fiat_code,
|
||||||
fallback_commission_pct: d.fallback_commission_pct,
|
|
||||||
is_active: d.is_active
|
is_active: d.is_active
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1118,7 +1110,7 @@ window.app = Vue.createApp({
|
||||||
target: leg.target || '',
|
target: leg.target || '',
|
||||||
targetKind: this._inferTargetKind(leg.target),
|
targetKind: this._inferTargetKind(leg.target),
|
||||||
label: leg.label || '',
|
label: leg.label || '',
|
||||||
pct: Number(leg.pct) || 0
|
fraction: Number(leg.fraction) || 0
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.commissionLegs = []
|
this.commissionLegs = []
|
||||||
|
|
@ -1140,7 +1132,7 @@ window.app = Vue.createApp({
|
||||||
target: this.walletOptions[0]?.value || '',
|
target: this.walletOptions[0]?.value || '',
|
||||||
targetKind: 'wallet',
|
targetKind: 'wallet',
|
||||||
label: '',
|
label: '',
|
||||||
pct: 0
|
fraction: 0
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1157,7 +1149,7 @@ window.app = Vue.createApp({
|
||||||
legs: this.commissionLegs.map((leg, idx) => ({
|
legs: this.commissionLegs.map((leg, idx) => ({
|
||||||
target: (leg.target || '').toString().trim(),
|
target: (leg.target || '').toString().trim(),
|
||||||
label: leg.label || null,
|
label: leg.label || null,
|
||||||
pct: Number(leg.pct),
|
fraction: Number(leg.fraction),
|
||||||
sort_order: idx
|
sort_order: idx
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -1330,8 +1322,7 @@ window.app = Vue.createApp({
|
||||||
wallet_id: null,
|
wallet_id: null,
|
||||||
name: '',
|
name: '',
|
||||||
location: '',
|
location: '',
|
||||||
fiat_code: 'GTQ',
|
fiat_code: 'GTQ'
|
||||||
fallback_commission_pct: 0.05
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1341,8 +1332,7 @@ window.app = Vue.createApp({
|
||||||
wallet_id: d.wallet_id,
|
wallet_id: d.wallet_id,
|
||||||
name: (d.name || '').trim() || null,
|
name: (d.name || '').trim() || null,
|
||||||
location: (d.location || '').trim() || null,
|
location: (d.location || '').trim() || null,
|
||||||
fiat_code: (d.fiat_code || 'GTQ').trim(),
|
fiat_code: (d.fiat_code || 'GTQ').trim()
|
||||||
fallback_commission_pct: Number(d.fallback_commission_pct ?? 0.05)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
146
tasks.py
146
tasks.py
|
|
@ -3,14 +3,26 @@
|
||||||
# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then
|
# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then
|
||||||
# for each successful inbound payment:
|
# for each successful inbound payment:
|
||||||
# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip.
|
# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip.
|
||||||
# 2. Parses Payment.extra for bitSpire split metadata (post-lamassu-next#44).
|
# 2. Verifies the originating Nostr signer matches the machine identity
|
||||||
# Falls back to machine.fallback_commission_pct if extra is absent.
|
# (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey
|
||||||
# 3. Computes the two-stage split (super_fee first, operator remainder).
|
# stamped by lnbits nostr-transport dispatcher).
|
||||||
# 4. Inserts a dca_settlements row idempotently (keyed by payment_hash).
|
# 3. Parses Payment.extra for bitSpire's canonical split stamp per
|
||||||
# 5. Spawns the distribution processor on a background task so the
|
# aiolabs/lamassu-next#44 (`source: "bitspire"`, principal_sats,
|
||||||
|
# fee_sats, exchange_rate). Raises if the stamp is missing or
|
||||||
|
# garbage (no more Lamassu-era reverse-derivation fallback).
|
||||||
|
# 4. Computes the two-stage split (super_fee first, operator remainder).
|
||||||
|
# 5. Inserts a dca_settlements row idempotently (keyed by payment_hash).
|
||||||
|
# 6. Spawns the distribution processor on a background task so the
|
||||||
# LNbits invoice queue (which serves ALL extensions on the node)
|
# LNbits invoice queue (which serves ALL extensions on the node)
|
||||||
# keeps draining while we move sats. Concurrency is safe because
|
# keeps draining while we move sats. Concurrency is safe because
|
||||||
# process_settlement now uses an optimistic-lock claim (fix bundle 1).
|
# process_settlement now uses an optimistic-lock claim (fix bundle 1).
|
||||||
|
#
|
||||||
|
# Rejection paths (settlement still recorded with status='rejected' for
|
||||||
|
# operator forensics, but distribution is skipped):
|
||||||
|
# - SettlementAttributionError: signer mismatch (G5).
|
||||||
|
# - SettlementMetadataError: Payment.extra missing bitSpire stamp.
|
||||||
|
# - SettlementInvariantError: stamped values violate the canonical
|
||||||
|
# sat-amount invariants (range/sum).
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
@ -20,6 +32,8 @@ from loguru import logger
|
||||||
|
|
||||||
from .bitspire import (
|
from .bitspire import (
|
||||||
SettlementAttributionError,
|
SettlementAttributionError,
|
||||||
|
SettlementInvariantError,
|
||||||
|
SettlementMetadataError,
|
||||||
assert_nostr_attribution,
|
assert_nostr_attribution,
|
||||||
parse_settlement,
|
parse_settlement,
|
||||||
)
|
)
|
||||||
|
|
@ -29,6 +43,7 @@ from .crud import (
|
||||||
get_super_config,
|
get_super_config,
|
||||||
)
|
)
|
||||||
from .distribution import process_settlement
|
from .distribution import process_settlement
|
||||||
|
from .models import CreateDcaSettlementData, Machine
|
||||||
|
|
||||||
LISTENER_NAME = "ext_satmachineadmin"
|
LISTENER_NAME = "ext_satmachineadmin"
|
||||||
|
|
||||||
|
|
@ -64,48 +79,47 @@ async def _handle_payment(payment: Payment) -> None:
|
||||||
if machine is None:
|
if machine is None:
|
||||||
return
|
return
|
||||||
extra = payment.extra or {}
|
extra = payment.extra or {}
|
||||||
|
|
||||||
|
# 1) Attribution FIRST — uses only `extra.nostr_sender_pubkey` (no parse
|
||||||
|
# needed). If this fails, every subsequent field on `extra` is
|
||||||
|
# attacker-controlled and untrustworthy — record a minimal rejected
|
||||||
|
# row with placeholder zeros (don't display unverified split numbers
|
||||||
|
# in the operator dashboard).
|
||||||
|
try:
|
||||||
|
assert_nostr_attribution(machine, extra)
|
||||||
|
except SettlementAttributionError as exc:
|
||||||
|
await _record_rejected(payment, machine, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2) Parse + invariants. parse_settlement enforces the canonical
|
||||||
|
# sat-amount invariants on the bitSpire-stamped numbers (range +
|
||||||
|
# direction-specific sum). Raises SettlementMetadataError if the
|
||||||
|
# stamp is missing, SettlementInvariantError on any range/sum
|
||||||
|
# breach.
|
||||||
super_config = await get_super_config()
|
super_config = await get_super_config()
|
||||||
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
|
super_fee_fraction = (
|
||||||
data, used_fallback = parse_settlement(
|
float(super_config.super_fee_fraction) if super_config else 0.0
|
||||||
machine=machine,
|
|
||||||
payment_hash=payment.payment_hash,
|
|
||||||
gross_sats=payment.sat,
|
|
||||||
extra=extra,
|
|
||||||
super_fee_pct=super_fee_pct,
|
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
data = parse_settlement(
|
||||||
|
machine=machine,
|
||||||
|
payment_hash=payment.payment_hash,
|
||||||
|
wire_sats=payment.sat,
|
||||||
|
extra=extra,
|
||||||
|
super_fee_fraction=super_fee_fraction,
|
||||||
|
)
|
||||||
|
except (SettlementMetadataError, SettlementInvariantError) as exc:
|
||||||
|
await _record_rejected(payment, machine, exc)
|
||||||
|
return
|
||||||
|
|
||||||
# Stamp the originating Nostr event id (the kind-21000 create_invoice
|
# Stamp the originating Nostr event id (the kind-21000 create_invoice
|
||||||
# RPC) onto the row for post-hoc forensics — pairs with the
|
# RPC) onto the row for post-hoc forensics — an auditor can trace
|
||||||
# assert_nostr_attribution check below so an auditor can trace
|
# settlement → RPC event → signing key without trusting our DB.
|
||||||
# settlement -> RPC event -> signing key without trusting our DB.
|
|
||||||
nostr_event_id = extra.get("nostr_event_id")
|
nostr_event_id = extra.get("nostr_event_id")
|
||||||
if isinstance(nostr_event_id, str) and nostr_event_id:
|
if isinstance(nostr_event_id, str) and nostr_event_id:
|
||||||
data.bitspire_event_id = nostr_event_id
|
data.bitspire_event_id = nostr_event_id
|
||||||
|
|
||||||
# Cross-check the signature-verified signer pubkey (stamped by
|
# 3) Insert + distribute.
|
||||||
# LNbits' nostr-transport dispatcher onto Payment.extra) against
|
|
||||||
# the machine identity. Routing today is wallet_id-only with no
|
|
||||||
# cryptographic binding — this restores end-to-end attribution
|
|
||||||
# between "the npub that asked LNbits for the invoice" and "the
|
|
||||||
# machine we're crediting" (aiolabs/satmachineadmin#19, G5).
|
|
||||||
try:
|
|
||||||
assert_nostr_attribution(machine, extra)
|
|
||||||
except SettlementAttributionError as exc:
|
|
||||||
rejected = await create_settlement_idempotent(
|
|
||||||
data, initial_status="rejected", error_message=str(exc)
|
|
||||||
)
|
|
||||||
if rejected is None:
|
|
||||||
logger.error(
|
|
||||||
f"satmachineadmin: failed to insert rejected settlement for "
|
|
||||||
f"payment_hash={payment.payment_hash[:12]}..."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
logger.error(
|
|
||||||
f"satmachineadmin: rejected settlement {rejected.id} "
|
|
||||||
f"(machine={machine.machine_npub[:12]}..., "
|
|
||||||
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
settlement = await create_settlement_idempotent(data, initial_status="pending")
|
settlement = await create_settlement_idempotent(data, initial_status="pending")
|
||||||
if settlement is None:
|
if settlement is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -113,14 +127,13 @@ async def _handle_payment(payment: Payment) -> None:
|
||||||
f"payment_hash={payment.payment_hash[:12]}..."
|
f"payment_hash={payment.payment_hash[:12]}..."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
fb = " (fallback split)" if used_fallback else ""
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"satmachineadmin: landed settlement {settlement.id} for "
|
f"satmachineadmin: landed settlement {settlement.id} for "
|
||||||
f"machine={machine.machine_npub[:12]}... "
|
f"machine={machine.machine_npub[:12]}... "
|
||||||
f"gross={data.gross_sats}sats principal={data.principal_sats}sats "
|
f"wire={data.wire_sats}sats principal={data.principal_sats}sats "
|
||||||
f"commission={data.commission_sats}sats "
|
f"fee={data.fee_sats}sats "
|
||||||
f"(super_fee={data.platform_fee_sats} "
|
f"(super_fee={data.platform_fee_sats} "
|
||||||
f"operator_fee={data.operator_fee_sats}){fb}"
|
f"operator_fee={data.operator_fee_sats})"
|
||||||
)
|
)
|
||||||
# Spawn distribution on a background task so the LNbits invoice queue
|
# Spawn distribution on a background task so the LNbits invoice queue
|
||||||
# (shared across all extensions) keeps draining while we move sats.
|
# (shared across all extensions) keeps draining while we move sats.
|
||||||
|
|
@ -131,3 +144,48 @@ async def _handle_payment(payment: Payment) -> None:
|
||||||
task = asyncio.create_task(process_settlement(settlement.id))
|
task = asyncio.create_task(process_settlement(settlement.id))
|
||||||
_inflight_distributions.add(task)
|
_inflight_distributions.add(task)
|
||||||
task.add_done_callback(_inflight_distributions.discard)
|
task.add_done_callback(_inflight_distributions.discard)
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_rejected(
|
||||||
|
payment: Payment, machine: Machine, exc: Exception
|
||||||
|
) -> None:
|
||||||
|
"""Insert a minimal `dca_settlements` row with `status='rejected'` and
|
||||||
|
the exception message for operator forensics.
|
||||||
|
|
||||||
|
Used for every rejection path (attribution / metadata / invariant).
|
||||||
|
The split fields are zero placeholders — we deliberately do NOT
|
||||||
|
display attacker-supplied numbers in the operator dashboard. The
|
||||||
|
wire amount (`payment.sat`) is the only value LNbits authenticated;
|
||||||
|
everything else from Payment.extra is untrusted in this branch.
|
||||||
|
"""
|
||||||
|
data = CreateDcaSettlementData(
|
||||||
|
machine_id=machine.id,
|
||||||
|
payment_hash=payment.payment_hash,
|
||||||
|
wire_sats=payment.sat,
|
||||||
|
fiat_amount=0.0,
|
||||||
|
fiat_code=machine.fiat_code,
|
||||||
|
exchange_rate=0.0,
|
||||||
|
principal_sats=0,
|
||||||
|
fee_sats=0,
|
||||||
|
platform_fee_sats=0,
|
||||||
|
operator_fee_sats=0,
|
||||||
|
# tx_type is unknown for rejection paths; default to cash_out
|
||||||
|
# (the only direction currently wired). When S8 lands the
|
||||||
|
# listener will branch on tx_type from extra, and this default
|
||||||
|
# gets revisited.
|
||||||
|
tx_type="cash_out",
|
||||||
|
)
|
||||||
|
rejected = await create_settlement_idempotent(
|
||||||
|
data, initial_status="rejected", error_message=str(exc)
|
||||||
|
)
|
||||||
|
if rejected is None:
|
||||||
|
logger.error(
|
||||||
|
f"satmachineadmin: failed to insert rejected settlement for "
|
||||||
|
f"payment_hash={payment.payment_hash[:12]}..."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logger.error(
|
||||||
|
f"satmachineadmin: rejected settlement {rejected.id} "
|
||||||
|
f"(machine={machine.machine_npub[:12]}..., "
|
||||||
|
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,13 @@
|
||||||
<q-banner
|
<q-banner
|
||||||
v-if="superConfig"
|
v-if="superConfig"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
:class="superConfig.super_fee_pct > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
|
:class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar>
|
||||||
<q-icon name="account_balance" :color="superConfig.super_fee_pct > 0 ? 'blue' : 'grey'"></q-icon>
|
<q-icon name="account_balance" :color="superConfig.super_fee_fraction > 0 ? 'blue' : 'grey'"></q-icon>
|
||||||
</template>
|
</template>
|
||||||
<span :style="{fontWeight: 500}">
|
<span :style="{fontWeight: 500}">
|
||||||
LNbits platform fee:
|
LNbits platform fee:
|
||||||
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_pct * 100).toFixed(2) }%</span>
|
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_fraction * 100).toFixed(2) }%</span>
|
||||||
of each transaction's commission.
|
of each transaction's commission.
|
||||||
</span>
|
</span>
|
||||||
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
|
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
|
||||||
|
|
@ -143,12 +143,6 @@
|
||||||
v-text="shortId(props.row.wallet_id)"></code>
|
v-text="shortId(props.row.wallet_id)"></code>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="fiat_code" v-text="props.row.fiat_code"></q-td>
|
<q-td key="fiat_code" v-text="props.row.fiat_code"></q-td>
|
||||||
<q-td key="fallback_commission_pct">
|
|
||||||
<span v-text="(props.row.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
|
|
||||||
<q-tooltip>
|
|
||||||
Used only when bitSpire doesn't supply a per-tx split.
|
|
||||||
</q-tooltip>
|
|
||||||
</q-td>
|
|
||||||
<q-td key="actions" auto-width>
|
<q-td key="actions" auto-width>
|
||||||
<q-btn flat dense round size="sm" icon="visibility"
|
<q-btn flat dense round size="sm" icon="visibility"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -529,13 +523,13 @@
|
||||||
dense outlined></q-input>
|
dense outlined></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-5 col-md-3">
|
<div class="col-5 col-md-3">
|
||||||
<q-input v-model.number="leg.pct"
|
<q-input v-model.number="leg.fraction"
|
||||||
label="% (0..1)"
|
label="% (0..1)"
|
||||||
type="number" step="0.01" min="0" max="1"
|
type="number" step="0.01" min="0" max="1"
|
||||||
dense outlined>
|
dense outlined>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<span :style="{fontSize: '0.75em', opacity: 0.6}"
|
<span :style="{fontSize: '0.75em', opacity: 0.6}"
|
||||||
v-text="((leg.pct || 0) * 100).toFixed(1) + '%'"></span>
|
v-text="((leg.fraction || 0) * 100).toFixed(1) + '%'"></span>
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -630,8 +624,8 @@
|
||||||
<span :style="{fontSize: '0.85em'}"
|
<span :style="{fontSize: '0.85em'}"
|
||||||
v-text="formatTime(props.row.created_at)"></span>
|
v-text="formatTime(props.row.created_at)"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="gross_sats" class="text-right">
|
<q-td key="wire_sats" class="text-right">
|
||||||
<span v-text="formatSats(props.row.gross_sats)"></span>
|
<span v-text="formatSats(props.row.wire_sats)"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="error_message">
|
<q-td key="error_message">
|
||||||
<span :style="{fontSize: '0.85em', opacity: 0.8}"
|
<span :style="{fontSize: '0.85em', opacity: 0.8}"
|
||||||
|
|
@ -792,14 +786,6 @@
|
||||||
dense outlined
|
dense outlined
|
||||||
:rules="[v => !!v || 'Pick a wallet']"></q-select>
|
:rules="[v => !!v || 'Pick a wallet']"></q-select>
|
||||||
|
|
||||||
<q-input
|
|
||||||
v-model.number="addMachineDialog.data.fallback_commission_pct"
|
|
||||||
label="Fallback commission % (decimal: 0.05 = 5%)"
|
|
||||||
hint="Only used if bitSpire doesn't supply a per-tx split (lamassu-next#44)."
|
|
||||||
type="number" step="0.0001" min="0" max="1"
|
|
||||||
class="q-mb-md"
|
|
||||||
dense outlined></q-input>
|
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="addMachineDialog.data.fiat_code"
|
v-model="addMachineDialog.data.fiat_code"
|
||||||
label="Fiat code"
|
label="Fiat code"
|
||||||
|
|
@ -855,12 +841,6 @@
|
||||||
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
|
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
|
||||||
<span v-text="machineDetail.machine.location || '—'"></span>
|
<span v-text="machineDetail.machine.location || '—'"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="text-caption" :style="{opacity: 0.6}">
|
|
||||||
Fallback commission %
|
|
||||||
</div>
|
|
||||||
<span v-text="(machineDetail.machine.fallback_commission_pct * 100).toFixed(2) + '%'"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-separator class="q-mb-md"></q-separator>
|
<q-separator class="q-mb-md"></q-separator>
|
||||||
|
|
@ -893,27 +873,19 @@
|
||||||
<q-td key="status">
|
<q-td key="status">
|
||||||
<q-badge :color="settlementStatusColor(props.row.status)"
|
<q-badge :color="settlementStatusColor(props.row.status)"
|
||||||
:label="props.row.status"></q-badge>
|
:label="props.row.status"></q-badge>
|
||||||
<q-icon v-if="props.row.used_fallback_split"
|
|
||||||
name="warning_amber" color="orange" size="sm"
|
|
||||||
class="q-ml-xs">
|
|
||||||
<q-tooltip>
|
|
||||||
Fallback split — bitSpire didn't supply per-tx
|
|
||||||
net/fee. See lamassu-next#44.
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="created_at">
|
<q-td key="created_at">
|
||||||
<span :style="{fontSize: '0.85em'}"
|
<span :style="{fontSize: '0.85em'}"
|
||||||
v-text="formatTime(props.row.created_at)"></span>
|
v-text="formatTime(props.row.created_at)"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="gross_sats" class="text-right">
|
<q-td key="wire_sats" class="text-right">
|
||||||
<span v-text="formatSats(props.row.gross_sats)"></span>
|
<span v-text="formatSats(props.row.wire_sats)"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="principal_sats" class="text-right">
|
<q-td key="principal_sats" class="text-right">
|
||||||
<span v-text="formatSats(props.row.principal_sats)"></span>
|
<span v-text="formatSats(props.row.principal_sats)"></span>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="commission_sats" class="text-right">
|
<q-td key="fee_sats" class="text-right">
|
||||||
<span v-text="formatSats(props.row.commission_sats)"></span>
|
<span v-text="formatSats(props.row.fee_sats)"></span>
|
||||||
<div :style="{fontSize: '0.75em', opacity: 0.6}">
|
<div :style="{fontSize: '0.75em', opacity: 0.6}">
|
||||||
super
|
super
|
||||||
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
|
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
|
||||||
|
|
@ -996,7 +968,7 @@
|
||||||
<q-icon name="info" color="blue"></q-icon>
|
<q-icon name="info" color="blue"></q-icon>
|
||||||
</template>
|
</template>
|
||||||
Original gross:
|
Original gross:
|
||||||
<b v-text="formatSats(partialDispenseDialog.settlement.gross_sats)"></b>.
|
<b v-text="formatSats(partialDispenseDialog.settlement.wire_sats)"></b>.
|
||||||
Provide what was actually dispensed. Sat amounts will scale linearly,
|
Provide what was actually dispensed. Sat amounts will scale linearly,
|
||||||
the commission split will recompute, and distribution will re-run.
|
the commission split will recompute, and distribution will re-run.
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
@ -1019,7 +991,7 @@
|
||||||
label="Dispensed sats"
|
label="Dispensed sats"
|
||||||
hint="Exact sat amount actually dispensed (≤ original gross)"
|
hint="Exact sat amount actually dispensed (≤ original gross)"
|
||||||
type="number" step="1" min="0"
|
type="number" step="1" min="0"
|
||||||
:max="partialDispenseDialog.settlement.gross_sats"
|
:max="partialDispenseDialog.settlement.wire_sats"
|
||||||
dense outlined></q-input>
|
dense outlined></q-input>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
|
|
@ -1085,7 +1057,7 @@
|
||||||
Operators see this as a read-only banner. Wallet ID is where the
|
Operators see this as a read-only banner. Wallet ID is where the
|
||||||
collected fee lands; typically a wallet you (the super) own.
|
collected fee lands; typically a wallet you (the super) own.
|
||||||
</p>
|
</p>
|
||||||
<q-input v-model.number="superFeeDialog.data.super_fee_pct"
|
<q-input v-model.number="superFeeDialog.data.super_fee_fraction"
|
||||||
label="Fee % (decimal, 0..1)"
|
label="Fee % (decimal, 0..1)"
|
||||||
hint="0.30 = 30% of every operator's commission"
|
hint="0.30 = 30% of every operator's commission"
|
||||||
type="number" step="0.0001" min="0" max="1"
|
type="number" step="0.0001" min="0" max="1"
|
||||||
|
|
@ -1337,10 +1309,6 @@
|
||||||
emit-value map-options
|
emit-value map-options
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
dense outlined></q-select>
|
dense outlined></q-select>
|
||||||
<q-input v-model.number="editMachineDialog.data.fallback_commission_pct"
|
|
||||||
label="Fallback commission %"
|
|
||||||
type="number" step="0.0001" min="0" max="1"
|
|
||||||
class="q-mb-md" dense outlined></q-input>
|
|
||||||
<q-input v-model="editMachineDialog.data.fiat_code"
|
<q-input v-model="editMachineDialog.data.fiat_code"
|
||||||
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
||||||
<q-toggle v-model="editMachineDialog.data.is_active"
|
<q-toggle v-model="editMachineDialog.data.is_active"
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,19 @@
|
||||||
"""
|
"""
|
||||||
Tests for DCA transaction calculations using empirical data.
|
Tests for DCA transaction calculations.
|
||||||
|
|
||||||
These tests verify commission and distribution calculations against
|
Covers the pure-function helpers that survive the 2026-05-26 cleanup:
|
||||||
real Lamassu transaction data to ensure the math is correct.
|
- calculate_distribution (proportional split across LPs by balance)
|
||||||
|
|
||||||
|
The previous test surface for `calculate_commission` and
|
||||||
|
`calculate_exchange_rate` was deleted alongside those functions — the
|
||||||
|
Lamassu-era reverse-derivation is obsolete now that bitSpire stamps
|
||||||
|
`principal_sats` and `fee_sats` directly on Payment.extra.
|
||||||
|
|
||||||
|
Two-stage commission split tests live in `test_two_stage_split.py`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Dict, List, Tuple
|
|
||||||
|
|
||||||
# Import from the parent package (following lnurlp pattern)
|
# Import from the parent package (following lnurlp pattern)
|
||||||
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
|
from ..calculations import calculate_distribution
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# COMMISSION CALCULATION TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestCommissionCalculation:
|
|
||||||
"""Tests for commission calculation logic."""
|
|
||||||
|
|
||||||
# Empirical test cases: (crypto_atoms, commission%, discount%, expected_base, expected_commission)
|
|
||||||
# Formula: base = round(crypto_atoms / (1 + effective_commission))
|
|
||||||
# Where: effective_commission = commission_percentage * (100 - discount) / 100
|
|
||||||
EMPIRICAL_COMMISSION_CASES = [
|
|
||||||
# =============================================================
|
|
||||||
# REAL LAMASSU TRANSACTIONS (extracted from production database)
|
|
||||||
# =============================================================
|
|
||||||
|
|
||||||
# 8.75% commission, no discount - small transaction
|
|
||||||
# 15600 / 1.0875 = 14344.827... → 14345
|
|
||||||
(15600, 0.0875, 0.0, 14345, 1255),
|
|
||||||
|
|
||||||
# 8.75% commission, no discount - large transaction
|
|
||||||
# 309200 / 1.0875 = 284322.298... → 284322
|
|
||||||
(309200, 0.0875, 0.0, 284322, 24878),
|
|
||||||
|
|
||||||
# 5.5% commission, no discount
|
|
||||||
# 309500 / 1.055 = 293364.928... → 293365
|
|
||||||
(309500, 0.055, 0.0, 293365, 16135),
|
|
||||||
|
|
||||||
# 5.5% commission with 100% discount (no commission charged)
|
|
||||||
# effective = 0.055 * (100-100)/100 = 0
|
|
||||||
(292400, 0.055, 100.0, 292400, 0),
|
|
||||||
|
|
||||||
# 5.5% commission with 90% discount
|
|
||||||
# effective = 0.055 * (100-90)/100 = 0.0055
|
|
||||||
# 115000 / 1.0055 = 114370.96... → 114371
|
|
||||||
(115000, 0.055, 90.0, 114371, 629),
|
|
||||||
|
|
||||||
# 5.5% commission, no discount - 1300 GTQ transaction
|
|
||||||
# 205600 / 1.055 = 194881.516... → 194882
|
|
||||||
# Note: This tx showed 0.01 GTQ rounding discrepancy in per-client fiat
|
|
||||||
(205600, 0.055, 0.0, 194882, 10718),
|
|
||||||
|
|
||||||
# =============================================================
|
|
||||||
# SYNTHETIC TEST CASES (edge cases)
|
|
||||||
# =============================================================
|
|
||||||
|
|
||||||
# Zero commission - all goes to base
|
|
||||||
(100000, 0.0, 0.0, 100000, 0),
|
|
||||||
|
|
||||||
# Small amount edge case (1 sat minimum)
|
|
||||||
(100, 0.03, 0.0, 97, 3),
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"crypto_atoms,commission_pct,discount,expected_base,expected_commission",
|
|
||||||
EMPIRICAL_COMMISSION_CASES,
|
|
||||||
ids=[
|
|
||||||
"lamassu_8.75pct_small",
|
|
||||||
"lamassu_8.75pct_large",
|
|
||||||
"lamassu_5.5pct_no_discount",
|
|
||||||
"lamassu_5.5pct_100pct_discount",
|
|
||||||
"lamassu_5.5pct_90pct_discount",
|
|
||||||
"lamassu_5.5pct_1300gtq",
|
|
||||||
"zero_commission",
|
|
||||||
"small_amount_100sats",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
def test_commission_calculation(
|
|
||||||
self,
|
|
||||||
crypto_atoms: int,
|
|
||||||
commission_pct: float,
|
|
||||||
discount: float,
|
|
||||||
expected_base: int,
|
|
||||||
expected_commission: int
|
|
||||||
):
|
|
||||||
"""Test commission calculation against empirical data."""
|
|
||||||
base, commission, _ = calculate_commission(crypto_atoms, commission_pct, discount)
|
|
||||||
|
|
||||||
assert base == expected_base, f"Base amount mismatch: got {base}, expected {expected_base}"
|
|
||||||
assert commission == expected_commission, f"Commission mismatch: got {commission}, expected {expected_commission}"
|
|
||||||
|
|
||||||
# Invariant: base + commission must equal total
|
|
||||||
assert base + commission == crypto_atoms, "Base + commission must equal total crypto_atoms"
|
|
||||||
|
|
||||||
def test_commission_invariant_always_sums_to_total(self):
|
|
||||||
"""Commission + base must always equal the original amount."""
|
|
||||||
test_values = [1, 100, 1000, 10000, 100000, 266800, 1000000]
|
|
||||||
commission_rates = [0.0, 0.01, 0.03, 0.05, 0.10]
|
|
||||||
discounts = [0.0, 10.0, 25.0, 50.0]
|
|
||||||
|
|
||||||
for crypto_atoms in test_values:
|
|
||||||
for comm_rate in commission_rates:
|
|
||||||
for discount in discounts:
|
|
||||||
base, commission, _ = calculate_commission(crypto_atoms, comm_rate, discount)
|
|
||||||
assert base + commission == crypto_atoms, \
|
|
||||||
f"Invariant failed: {base} + {commission} != {crypto_atoms} " \
|
|
||||||
f"(rate={comm_rate}, discount={discount})"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -157,7 +62,6 @@ class TestDistributionCalculation:
|
||||||
|
|
||||||
def test_distribution_invariant_sums_to_total(self):
|
def test_distribution_invariant_sums_to_total(self):
|
||||||
"""Total distributed sats must always equal base amount."""
|
"""Total distributed sats must always equal base amount."""
|
||||||
# Test with various client configurations
|
|
||||||
test_cases = [
|
test_cases = [
|
||||||
{"a": 100.0},
|
{"a": 100.0},
|
||||||
{"a": 100.0, "b": 100.0},
|
{"a": 100.0, "b": 100.0},
|
||||||
|
|
@ -215,156 +119,6 @@ class TestDistributionCalculation:
|
||||||
|
|
||||||
assert distributions == {}
|
assert distributions == {}
|
||||||
|
|
||||||
def test_fiat_round_trip_invariant(self):
|
|
||||||
"""
|
|
||||||
Verify that distributed sats convert back to original fiat amount.
|
|
||||||
|
|
||||||
The sum of each client's fiat equivalent should equal the original
|
|
||||||
fiat amount (within rounding tolerance).
|
|
||||||
"""
|
|
||||||
# Use real Lamassu transaction data
|
|
||||||
test_cases = [
|
|
||||||
# (crypto_atoms, fiat_amount, commission_pct, discount, client_balances)
|
|
||||||
(309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}),
|
|
||||||
(309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}),
|
|
||||||
(292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}),
|
|
||||||
(115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}),
|
|
||||||
# Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients
|
|
||||||
(205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}),
|
|
||||||
]
|
|
||||||
|
|
||||||
for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases:
|
|
||||||
# Calculate commission and base amount
|
|
||||||
base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount)
|
|
||||||
|
|
||||||
# Calculate exchange rate
|
|
||||||
exchange_rate = calculate_exchange_rate(base_sats, fiat_amount)
|
|
||||||
|
|
||||||
# Distribute sats to clients
|
|
||||||
distributions = calculate_distribution(base_sats, client_balances)
|
|
||||||
|
|
||||||
# Convert each client's sats back to fiat
|
|
||||||
total_fiat_distributed = sum(
|
|
||||||
sats / exchange_rate for sats in distributions.values()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should equal original fiat amount (within small rounding tolerance)
|
|
||||||
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
|
|
||||||
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
|
|
||||||
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# EMPIRICAL END-TO-END TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestEmpiricalTransactions:
|
|
||||||
"""
|
|
||||||
End-to-end tests using real Lamassu transaction data.
|
|
||||||
|
|
||||||
Add your empirical test cases here! Each case should include:
|
|
||||||
- Transaction details (crypto_atoms, fiat, commission, discount)
|
|
||||||
- Client balances at time of transaction
|
|
||||||
- Expected distribution outcome
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TODO: Add your empirical data here
|
|
||||||
# Example structure:
|
|
||||||
EMPIRICAL_SCENARIOS = [
|
|
||||||
{
|
|
||||||
"name": "real_tx_266800sats_two_equal_clients",
|
|
||||||
"transaction": {
|
|
||||||
"crypto_atoms": 266800,
|
|
||||||
"fiat_amount": 2000,
|
|
||||||
"commission_percentage": 0.03,
|
|
||||||
"discount": 0.0,
|
|
||||||
},
|
|
||||||
"client_balances": {
|
|
||||||
"client_a": 1000.00, # 50% of total
|
|
||||||
"client_b": 1000.00, # 50% of total
|
|
||||||
},
|
|
||||||
# 266800 / 1.03 = 259029
|
|
||||||
"expected_base_sats": 259029,
|
|
||||||
"expected_commission_sats": 7771,
|
|
||||||
"expected_distributions": {
|
|
||||||
# 259029 / 2 = 129514.5 → both get 129514 or 129515
|
|
||||||
# With banker's rounding: 129514.5 → 129514 (even)
|
|
||||||
# Remainder of 1 sat goes to first client by fractional sort
|
|
||||||
"client_a": 129515,
|
|
||||||
"client_b": 129514,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
# Add more scenarios from your real data!
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"scenario",
|
|
||||||
EMPIRICAL_SCENARIOS,
|
|
||||||
ids=[s["name"] for s in EMPIRICAL_SCENARIOS]
|
|
||||||
)
|
|
||||||
def test_empirical_scenario(self, scenario):
|
|
||||||
"""Test full transaction flow against empirical data."""
|
|
||||||
tx = scenario["transaction"]
|
|
||||||
|
|
||||||
# Calculate commission
|
|
||||||
base, commission, _ = calculate_commission(
|
|
||||||
tx["crypto_atoms"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert base == scenario["expected_base_sats"], \
|
|
||||||
f"Base amount mismatch in {scenario['name']}"
|
|
||||||
assert commission == scenario["expected_commission_sats"], \
|
|
||||||
f"Commission mismatch in {scenario['name']}"
|
|
||||||
|
|
||||||
# Calculate distribution
|
|
||||||
distributions = calculate_distribution(
|
|
||||||
base,
|
|
||||||
scenario["client_balances"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify each client's allocation
|
|
||||||
for client_id, expected_sats in scenario["expected_distributions"].items():
|
|
||||||
actual_sats = distributions.get(client_id, 0)
|
|
||||||
assert actual_sats == expected_sats, \
|
|
||||||
f"Distribution mismatch for {client_id} in {scenario['name']}: " \
|
|
||||||
f"got {actual_sats}, expected {expected_sats}"
|
|
||||||
|
|
||||||
# Verify total distribution equals base
|
|
||||||
assert sum(distributions.values()) == base, \
|
|
||||||
f"Total distribution doesn't match base in {scenario['name']}"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# EDGE CASE TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestEdgeCases:
|
|
||||||
"""Tests for edge cases and boundary conditions."""
|
|
||||||
|
|
||||||
def test_minimum_amount_1_sat(self):
|
|
||||||
"""Test with minimum possible amount (1 sat)."""
|
|
||||||
base, commission, _ = calculate_commission(1, 0.03, 0.0)
|
|
||||||
# With 3% commission on 1 sat, base rounds to 1, commission to 0
|
|
||||||
assert base + commission == 1
|
|
||||||
|
|
||||||
def test_large_transaction(self):
|
|
||||||
"""Test with large transaction (100 BTC worth of sats)."""
|
|
||||||
crypto_atoms = 10_000_000_000 # 100 BTC in sats
|
|
||||||
base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0)
|
|
||||||
|
|
||||||
assert base + commission == crypto_atoms
|
|
||||||
assert commission > 0
|
|
||||||
|
|
||||||
def test_100_percent_discount(self):
|
|
||||||
"""100% discount should result in zero commission."""
|
|
||||||
base, commission, effective = calculate_commission(100000, 0.03, 100.0)
|
|
||||||
|
|
||||||
assert effective == 0.0
|
|
||||||
assert commission == 0
|
|
||||||
assert base == 100000
|
|
||||||
|
|
||||||
def test_many_clients_distribution(self):
|
def test_many_clients_distribution(self):
|
||||||
"""Test distribution with many clients."""
|
"""Test distribution with many clients."""
|
||||||
# 10 clients with varying balances
|
# 10 clients with varying balances
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ def _machine(npub: str) -> Machine:
|
||||||
location=None,
|
location=None,
|
||||||
fiat_code="EUR",
|
fiat_code="EUR",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
fallback_commission_pct=0.05,
|
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
Tests for the v2 two-stage commission split (super first, operator remainder).
|
Tests for the v2 two-stage commission split (super first, operator remainder).
|
||||||
|
|
||||||
The plan calls out a verification scenario explicitly:
|
The plan calls out a verification scenario explicitly:
|
||||||
super_fee_pct=30%, operator split 50/30/20 on a 100-sat commission
|
super_fee_fraction=0.30 (i.e. 30%), operator splits [0.5, 0.3, 0.2] on a
|
||||||
→ super_wallet gets 30, operator_self gets 35, employee 21, maint 14.
|
100-sat fee → super_wallet gets 30, operator legs get 35 / 21 / 14.
|
||||||
|
|
||||||
Also covers the edge cases: super_fee_pct=0 (no super), super_fee_pct=1.0
|
Also covers the edge cases: super_fee_fraction=0.0 (no super takes the
|
||||||
(everything to super), single-leg operator ruleset, zero operator fee.
|
whole fee), super_fee_fraction=1.0 (super takes everything), single-leg
|
||||||
|
operator ruleset, zero operator fee.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -18,7 +19,7 @@ from ..calculations import (
|
||||||
|
|
||||||
|
|
||||||
class TestSplitTwoStageCommission:
|
class TestSplitTwoStageCommission:
|
||||||
"""Stage-1: super takes super_fee_pct of commission; operator gets rest."""
|
"""Stage-1: super takes super_fee_fraction of the fee; operator gets rest."""
|
||||||
|
|
||||||
def test_plan_example_100sats_30pct(self):
|
def test_plan_example_100sats_30pct(self):
|
||||||
platform, operator = split_two_stage_commission(100, 0.30)
|
platform, operator = split_two_stage_commission(100, 0.30)
|
||||||
|
|
@ -33,12 +34,12 @@ class TestSplitTwoStageCommission:
|
||||||
assert operator == 5575 # 7965 - 2390
|
assert operator == 5575 # 7965 - 2390
|
||||||
assert platform + operator == 7965
|
assert platform + operator == 7965
|
||||||
|
|
||||||
def test_super_pct_zero_leaves_all_to_operator(self):
|
def test_super_fraction_zero_leaves_all_to_operator(self):
|
||||||
platform, operator = split_two_stage_commission(7965, 0.0)
|
platform, operator = split_two_stage_commission(7965, 0.0)
|
||||||
assert platform == 0
|
assert platform == 0
|
||||||
assert operator == 7965
|
assert operator == 7965
|
||||||
|
|
||||||
def test_super_pct_one_takes_everything(self):
|
def test_super_fraction_one_takes_everything(self):
|
||||||
platform, operator = split_two_stage_commission(7965, 1.0)
|
platform, operator = split_two_stage_commission(7965, 1.0)
|
||||||
assert platform == 7965
|
assert platform == 7965
|
||||||
assert operator == 0
|
assert operator == 0
|
||||||
|
|
@ -54,13 +55,13 @@ class TestSplitTwoStageCommission:
|
||||||
assert platform == 0
|
assert platform == 0
|
||||||
assert operator == 0
|
assert operator == 0
|
||||||
|
|
||||||
@pytest.mark.parametrize("commission_sats", [1, 7, 100, 7965, 1_000_000])
|
@pytest.mark.parametrize("fee_sats", [1, 7, 100, 7965, 1_000_000])
|
||||||
@pytest.mark.parametrize("super_pct", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
|
@pytest.mark.parametrize("super_fraction", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
|
||||||
def test_invariant_sum_equals_commission(self, commission_sats, super_pct):
|
def test_invariant_sum_equals_commission(self, fee_sats, super_fraction):
|
||||||
platform, operator = split_two_stage_commission(commission_sats, super_pct)
|
platform, operator = split_two_stage_commission(fee_sats, super_fraction)
|
||||||
assert platform + operator == commission_sats
|
assert platform + operator == fee_sats
|
||||||
assert 0 <= platform <= commission_sats
|
assert 0 <= platform <= fee_sats
|
||||||
assert 0 <= operator <= commission_sats
|
assert 0 <= operator <= fee_sats
|
||||||
|
|
||||||
|
|
||||||
class TestAllocateOperatorSplitLegs:
|
class TestAllocateOperatorSplitLegs:
|
||||||
|
|
@ -102,7 +103,7 @@ class TestAllocateOperatorSplitLegs:
|
||||||
assert amounts[2] == 100 - amounts[0] - amounts[1]
|
assert amounts[2] == 100 - amounts[0] - amounts[1]
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"operator_fee,pcts",
|
"operator_fee,fractions",
|
||||||
[
|
[
|
||||||
(1, [0.5, 0.5]),
|
(1, [0.5, 0.5]),
|
||||||
(7, [0.5, 0.3, 0.2]),
|
(7, [0.5, 0.3, 0.2]),
|
||||||
|
|
@ -111,8 +112,8 @@ class TestAllocateOperatorSplitLegs:
|
||||||
(1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
|
(1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_invariant_sum_equals_operator_fee(self, operator_fee, pcts):
|
def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions):
|
||||||
amounts = allocate_operator_split_legs(operator_fee, pcts)
|
amounts = allocate_operator_split_legs(operator_fee, fractions)
|
||||||
assert sum(amounts) == operator_fee
|
assert sum(amounts) == operator_fee
|
||||||
assert all(a >= 0 for a in amounts)
|
assert all(a >= 0 for a in amounts)
|
||||||
|
|
||||||
|
|
@ -121,21 +122,21 @@ class TestEndToEndScenarios:
|
||||||
"""The full two-stage split — super then operator legs — composed."""
|
"""The full two-stage split — super then operator legs — composed."""
|
||||||
|
|
||||||
def test_plan_example_full(self):
|
def test_plan_example_full(self):
|
||||||
# 100 sats commission, super=30%, operator splits 50/30/20.
|
# 100 sats fee, super_fee_fraction=0.30, operator splits [0.5, 0.3, 0.2].
|
||||||
platform, operator = split_two_stage_commission(100, 0.30)
|
platform, operator = split_two_stage_commission(100, 0.30)
|
||||||
legs = allocate_operator_split_legs(operator, [0.5, 0.3, 0.2])
|
legs = allocate_operator_split_legs(operator, [0.5, 0.3, 0.2])
|
||||||
assert platform == 30
|
assert platform == 30
|
||||||
assert legs == [35, 21, 14]
|
assert legs == [35, 21, 14]
|
||||||
assert platform + sum(legs) == 100
|
assert platform + sum(legs) == 100
|
||||||
|
|
||||||
def test_super_pct_zero_full_pipeline(self):
|
def test_super_fraction_zero_full_pipeline(self):
|
||||||
platform, operator = split_two_stage_commission(7965, 0.0)
|
platform, operator = split_two_stage_commission(7965, 0.0)
|
||||||
legs = allocate_operator_split_legs(operator, [1.0])
|
legs = allocate_operator_split_legs(operator, [1.0])
|
||||||
assert platform == 0
|
assert platform == 0
|
||||||
assert legs == [7965]
|
assert legs == [7965]
|
||||||
assert platform + sum(legs) == 7965
|
assert platform + sum(legs) == 7965
|
||||||
|
|
||||||
def test_super_pct_one_full_pipeline(self):
|
def test_super_fraction_one_full_pipeline(self):
|
||||||
platform, operator = split_two_stage_commission(7965, 1.0)
|
platform, operator = split_two_stage_commission(7965, 1.0)
|
||||||
legs = allocate_operator_split_legs(operator, [0.5, 0.5])
|
legs = allocate_operator_split_legs(operator, [0.5, 0.5])
|
||||||
assert platform == 7965
|
assert platform == 7965
|
||||||
|
|
@ -147,27 +148,27 @@ class TestEndToEndScenarios:
|
||||||
class TestPartialDispenseSplitRatio:
|
class TestPartialDispenseSplitRatio:
|
||||||
"""The partial-dispense recompute (H6 fix) must preserve the ORIGINAL
|
"""The partial-dispense recompute (H6 fix) must preserve the ORIGINAL
|
||||||
platform/operator ratio from the landed settlement — NOT re-derive
|
platform/operator ratio from the landed settlement — NOT re-derive
|
||||||
from the current super_fee_pct.
|
from the current super_fee_fraction.
|
||||||
|
|
||||||
These tests cover the math; the actual function lives in distribution.py
|
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
|
and is exercised end-to-end via integration testing. Here we verify the
|
||||||
invariant a future maintainer should never break.
|
invariant a future maintainer should never break.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _recompute(self, original_commission, original_platform_fee, new_commission):
|
def _recompute(self, original_fee, original_platform_fee, new_fee):
|
||||||
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
|
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
|
||||||
if original_commission > 0:
|
if original_fee > 0:
|
||||||
ratio = original_platform_fee / original_commission
|
ratio = original_platform_fee / original_fee
|
||||||
else:
|
else:
|
||||||
ratio = 0.0
|
ratio = 0.0
|
||||||
new_platform = round(new_commission * ratio)
|
new_platform = round(new_fee * ratio)
|
||||||
new_platform = max(0, min(new_platform, new_commission))
|
new_platform = max(0, min(new_platform, new_fee))
|
||||||
new_operator = new_commission - new_platform
|
new_operator = new_fee - new_platform
|
||||||
return new_platform, new_operator
|
return new_platform, new_operator
|
||||||
|
|
||||||
def test_plan_scenario_30pct_lands_then_partial(self):
|
def test_plan_scenario_30pct_lands_then_partial(self):
|
||||||
# Landed at super_fee_pct=30%: 100-sat commission → 30 / 70.
|
# Landed at super_fee_fraction=0.30: 100-sat fee → 30 / 70.
|
||||||
# Partial-dispense to 50% gross → new_commission = 50.
|
# Partial-dispense to 50% gross → new_fee = 50.
|
||||||
# Original ratio (30/100 = 0.30) preserved.
|
# Original ratio (30/100 = 0.30) preserved.
|
||||||
new_platform, new_operator = self._recompute(100, 30, 50)
|
new_platform, new_operator = self._recompute(100, 30, 50)
|
||||||
assert new_platform == 15
|
assert new_platform == 15
|
||||||
|
|
@ -175,9 +176,9 @@ class TestPartialDispenseSplitRatio:
|
||||||
assert new_platform + new_operator == 50
|
assert new_platform + new_operator == 50
|
||||||
|
|
||||||
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
|
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
|
||||||
# Landed at super_fee_pct=30% (commission 7965, platform 2390).
|
# Landed at super_fee_fraction=0.30 (fee 7965, platform 2390).
|
||||||
# Super then raises rate to 50% globally. Operator partial-dispenses
|
# Super then raises rate to 50% globally. Operator partial-dispenses
|
||||||
# to 50% gross → new_commission = 3982 (round(7965 * 0.5)).
|
# to 50% gross → new_fee = 3982 (round(7965 * 0.5)).
|
||||||
# Original ratio (2390/7965 ≈ 0.30) MUST still apply, not 50%.
|
# Original ratio (2390/7965 ≈ 0.30) MUST still apply, not 50%.
|
||||||
new_platform, new_operator = self._recompute(7965, 2390, 3982)
|
new_platform, new_operator = self._recompute(7965, 2390, 3982)
|
||||||
# Expected with original ratio: round(3982 * 0.30006...) = 1195
|
# Expected with original ratio: round(3982 * 0.30006...) = 1195
|
||||||
|
|
@ -187,17 +188,17 @@ class TestPartialDispenseSplitRatio:
|
||||||
# Original platform share was ~30%; preserved within rounding.
|
# Original platform share was ~30%; preserved within rounding.
|
||||||
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
|
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
|
||||||
|
|
||||||
def test_zero_original_commission_yields_zero_platform(self):
|
def test_zero_original_fee_yields_zero_platform(self):
|
||||||
new_platform, new_operator = self._recompute(0, 0, 0)
|
new_platform, new_operator = self._recompute(0, 0, 0)
|
||||||
assert new_platform == 0
|
assert new_platform == 0
|
||||||
assert new_operator == 0
|
assert new_operator == 0
|
||||||
|
|
||||||
def test_invariant_sum_equals_new_commission(self):
|
def test_invariant_sum_equals_new_fee(self):
|
||||||
# Random-ish parameter sweep over realistic values.
|
# Random-ish parameter sweep over realistic values.
|
||||||
cases = [
|
cases = [
|
||||||
(100, 30, 50),
|
(100, 30, 50),
|
||||||
(100, 0, 50), # original platform_fee was 0 (super_pct=0)
|
(100, 0, 50), # original platform_fee was 0 (super_fraction=0)
|
||||||
(100, 100, 50), # original platform_fee was 100 (super_pct=100)
|
(100, 100, 50), # original platform_fee was 100 (super_fraction=100)
|
||||||
(7965, 2390, 3982),
|
(7965, 2390, 3982),
|
||||||
(7965, 7965, 3982),
|
(7965, 7965, 3982),
|
||||||
(1_000_000, 333_333, 250_000),
|
(1_000_000, 333_333, 250_000),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue