refactor(v2): rename net_sats → principal_sats for semantic clarity

`net` is financial-accounting ambiguous (net of what?). In the
bitSpire/DCA context this column is specifically the principal the
operator distributes to LPs (gross − commission), not a generic net
amount. Renaming locally before any bitSpire firmware locks the
wire-level name; lamassu-next#44 should adopt the same name.

Scope:
- migrations.py: m003 ALTER TABLE … RENAME COLUMN, idempotent probe
  pattern matching m002. Also updates the m001 canonical schema so
  fresh installs land on the new column directly.
- models.py: `CreateDcaSettlementData.principal_sats` /
  `DcaSettlement.principal_sats`. Field-doc comment updated.
- bitspire.py: both happy path and fallback path return
  `principal_sats=…`. Reads `extra.get("principal_sats")` from the
  bitSpire payload (lamassu-next#44 should follow this rename).
- crud.py: INSERT column list + `apply_partial_dispense(
  new_principal_sats=…)` keyword.
- distribution.py: every `settlement.net_sats` → `settlement.
  principal_sats`; partial-dispense memo + helper signatures updated;
  the leg-order docblock at the top reads "principal_sats".
- tasks.py: landed-settlement log line.
- static/js/index.js: settlements-table column `principal_sats` with
  label "Principal (→ LPs)".
- templates/satmachineadmin/index.html: q-td key + binding.

All 86 unit tests still pass. No backwards-compat shim — v2-bitspire
isn't released; the rename is a clean break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-15 23:21:32 +02:00
commit 1feaba80ed
8 changed files with 97 additions and 106 deletions

View file

@ -146,9 +146,9 @@ def _parse_extra(
super_fee_pct: float, super_fee_pct: float,
) -> CreateDcaSettlementData: ) -> CreateDcaSettlementData:
"""Happy path: bitSpire populated Payment.extra per lamassu-next#44.""" """Happy path: bitSpire populated Payment.extra per lamassu-next#44."""
net_sats = _coerce_int(extra.get("net_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 net_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. # Missing key fields — shouldn't happen post-#44 but defensive.
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct) return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct)
commission_sats = fee_sats commission_sats = fee_sats
@ -170,7 +170,7 @@ def _parse_extra(
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
fiat_code=fiat_code, fiat_code=fiat_code,
exchange_rate=exchange_rate, exchange_rate=exchange_rate,
net_sats=net_sats, principal_sats=principal_sats,
commission_sats=commission_sats, commission_sats=commission_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,
@ -193,7 +193,7 @@ def _parse_fallback(
base_amount = round(gross / (1 + commission_pct)) base_amount = round(gross / (1 + commission_pct))
commission = gross - base_amount commission = gross - base_amount
""" """
net_sats, commission_sats, _effective = calculate_commission( principal_sats, commission_sats, _effective = calculate_commission(
crypto_atoms=gross_sats, crypto_atoms=gross_sats,
commission_percentage=machine.fallback_commission_pct, commission_percentage=machine.fallback_commission_pct,
discount=0.0, discount=0.0,
@ -211,7 +211,7 @@ def _parse_fallback(
fiat_amount=0.0, fiat_amount=0.0,
fiat_code=machine.fiat_code, fiat_code=machine.fiat_code,
exchange_rate=0.0, exchange_rate=0.0,
net_sats=net_sats, principal_sats=principal_sats,
commission_sats=commission_sats, commission_sats=commission_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,

12
crud.py
View file

@ -442,13 +442,13 @@ 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, net_sats, gross_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
commission_sats, platform_fee_sats, operator_fee_sats, commission_sats, platform_fee_sats, operator_fee_sats,
used_fallback_split, tx_type, bills_json, cassettes_json, used_fallback_split, 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, :gross_sats, :fiat_amount, :fiat_code,
:exchange_rate, :net_sats, :commission_sats, :exchange_rate, :principal_sats, :commission_sats,
:platform_fee_sats, :operator_fee_sats, :used_fallback_split, :platform_fee_sats, :operator_fee_sats, :used_fallback_split,
:tx_type, :bills_json, :cassettes_json, :status, :tx_type, :bills_json, :cassettes_json, :status,
:error_message, :created_at) :error_message, :created_at)
@ -463,7 +463,7 @@ async def create_settlement_idempotent(
"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,
"net_sats": data.net_sats, "principal_sats": data.principal_sats,
"commission_sats": data.commission_sats, "commission_sats": data.commission_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,
@ -728,7 +728,7 @@ async def apply_partial_dispense(
settlement_id: str, settlement_id: str,
*, *,
new_gross_sats: int, new_gross_sats: int,
new_net_sats: int, new_principal_sats: int,
new_commission_sats: int, new_commission_sats: int,
new_platform_fee_sats: int, new_platform_fee_sats: int,
new_operator_fee_sats: int, new_operator_fee_sats: int,
@ -746,7 +746,7 @@ async def apply_partial_dispense(
""" """
UPDATE satoshimachine.dca_settlements UPDATE satoshimachine.dca_settlements
SET gross_sats = :gross, SET gross_sats = :gross,
net_sats = :net, principal_sats = :principal,
commission_sats = :commission, commission_sats = :commission,
platform_fee_sats = :platform, platform_fee_sats = :platform,
operator_fee_sats = :operator, operator_fee_sats = :operator,
@ -763,7 +763,7 @@ async def apply_partial_dispense(
{ {
"id": settlement_id, "id": settlement_id,
"gross": new_gross_sats, "gross": new_gross_sats,
"net": new_net_sats, "principal": new_principal_sats,
"commission": new_commission_sats, "commission": new_commission_sats,
"platform": new_platform_fee_sats, "platform": new_platform_fee_sats,
"operator": new_operator_fee_sats, "operator": new_operator_fee_sats,

View file

@ -10,7 +10,7 @@
# Leg order: # Leg order:
# 1. super_fee — platform_fee_sats → super_fee_wallet_id (if set) # 1. super_fee — platform_fee_sats → super_fee_wallet_id (if set)
# 2. operator_split — operator_fee_sats split per operator's rules # 2. operator_split — operator_fee_sats split per operator's rules
# 3. dca — net_sats distributed proportionally to active LPs, # 3. dca — principal_sats distributed proportionally to active LPs,
# each leg capped at the LP's remaining fiat balance # each leg capped at the LP's remaining fiat balance
# (preserves the v1 sync-mismatch fix from PR #2) # (preserves the v1 sync-mismatch fix from PR #2)
# #
@ -105,8 +105,7 @@ async def _record_skipped_leg(
) )
await update_payment_status(leg.id, "skipped", None, reason[:512]) await update_payment_status(leg.id, "skipped", None, reason[:512])
logger.info( logger.info(
f"distribution: skipped {leg_type} leg " f"distribution: skipped {leg_type} leg " f"({amount_sats} sats) — {reason}"
f"({amount_sats} sats) — {reason}"
) )
@ -134,7 +133,7 @@ def _build_partial_dispense_memo(
data: PartialDispenseData, data: PartialDispenseData,
*, *,
new_gross: int, new_gross: int,
new_net: int, new_principal: int,
new_commission: int, new_commission: int,
new_platform: int, new_platform: int,
new_operator: int, new_operator: int,
@ -147,11 +146,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} net={settlement.net_sats} " f"Original gross={settlement.gross_sats} "
f"principal={settlement.principal_sats} "
f"commission={settlement.commission_sats} " f"commission={settlement.commission_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} net={new_net} commission={new_commission} " f"New gross={new_gross} principal={new_principal} "
f"commission={new_commission} "
f"(super_fee={new_platform} operator_fee={new_operator}). " f"(super_fee={new_platform} operator_fee={new_operator}). "
f"Reason: {reason}" f"Reason: {reason}"
) )
@ -177,14 +178,10 @@ async def settle_lp_balance(
raise ValueError(f"client {client.id} balance not available") raise ValueError(f"client {client.id} balance not available")
remaining = float(summary.remaining_balance) remaining = float(summary.remaining_balance)
if remaining <= 0: if remaining <= 0:
raise ValueError( raise ValueError(f"client {client.id} has no remaining balance to settle")
f"client {client.id} has no remaining balance to settle"
)
# Resolve fiat amount: explicit if given (capped at remaining), else full. # Resolve fiat amount: explicit if given (capped at remaining), else full.
requested = ( requested = float(data.amount_fiat) if data.amount_fiat is not None else remaining
float(data.amount_fiat) if data.amount_fiat is not None else remaining
)
amount_fiat = round(min(requested, remaining), 2) amount_fiat = round(min(requested, remaining), 2)
if amount_fiat <= 0: if amount_fiat <= 0:
raise ValueError("computed settlement amount is zero") raise ValueError("computed settlement amount is zero")
@ -300,7 +297,7 @@ async def apply_partial_dispense_and_redistribute(
# Linear scale preserves the original commission ratio exactly. # Linear scale preserves the original commission ratio exactly.
scale = new_gross / settlement.gross_sats scale = new_gross / settlement.gross_sats
new_commission = round(settlement.commission_sats * scale) new_commission = round(settlement.commission_sats * scale)
new_net = new_gross - new_commission new_principal = new_gross - new_commission
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
@ -321,7 +318,7 @@ async def apply_partial_dispense_and_redistribute(
settlement, settlement,
data, data,
new_gross=new_gross, new_gross=new_gross,
new_net=new_net, new_principal=new_principal,
new_commission=new_commission, new_commission=new_commission,
new_platform=new_platform, new_platform=new_platform,
new_operator=new_operator, new_operator=new_operator,
@ -331,7 +328,7 @@ async def apply_partial_dispense_and_redistribute(
updated = await apply_partial_dispense( updated = await apply_partial_dispense(
settlement_id, settlement_id,
new_gross_sats=new_gross, new_gross_sats=new_gross,
new_net_sats=new_net, new_principal_sats=new_principal,
new_commission_sats=new_commission, new_commission_sats=new_commission,
new_platform_fee_sats=new_platform, new_platform_fee_sats=new_platform,
new_operator_fee_sats=new_operator, new_operator_fee_sats=new_operator,
@ -374,9 +371,7 @@ async def process_settlement(settlement_id: str) -> None:
f"distribution: settlement {settlement_id} references missing " f"distribution: settlement {settlement_id} references missing "
f"machine {settlement.machine_id}" f"machine {settlement.machine_id}"
) )
await mark_settlement_status( await mark_settlement_status(settlement_id, "errored", "machine missing")
settlement_id, "errored", "machine missing"
)
return return
super_config = await get_super_config() super_config = await get_super_config()
errors: List[str] = [] errors: List[str] = []
@ -390,9 +385,7 @@ async def process_settlement(settlement_id: str) -> None:
errors.append(f"unexpected: {exc}") errors.append(f"unexpected: {exc}")
if errors: if errors:
await mark_settlement_status( await mark_settlement_status(settlement_id, "errored", "; ".join(errors)[:512])
settlement_id, "errored", "; ".join(errors)[:512]
)
else: else:
await mark_settlement_status(settlement_id, "processed", None) await mark_settlement_status(settlement_id, "processed", None)
@ -415,7 +408,8 @@ async def _pay_super_fee(
# the sats in the machine wallet and record a skipped audit row. # the sats in the machine wallet and record a skipped audit row.
# The super needs to configure their wallet before they can collect. # The super needs to configure their wallet before they can collect.
await _record_skipped_leg( await _record_skipped_leg(
settlement, machine, settlement,
machine,
leg_type="super_fee", leg_type="super_fee",
amount_sats=settlement.platform_fee_sats, amount_sats=settlement.platform_fee_sats,
reason="super_fee_wallet_id not configured by LNbits super", reason="super_fee_wallet_id not configured by LNbits super",
@ -445,12 +439,11 @@ async def _pay_operator_splits(
) -> None: ) -> None:
if settlement.operator_fee_sats <= 0: if settlement.operator_fee_sats <= 0:
return return
splits = await get_effective_commission_splits( splits = await get_effective_commission_splits(machine.operator_user_id, machine.id)
machine.operator_user_id, machine.id
)
if not splits: if not splits:
await _record_skipped_leg( await _record_skipped_leg(
settlement, machine, settlement,
machine,
leg_type="operator_split", leg_type="operator_split",
amount_sats=settlement.operator_fee_sats, amount_sats=settlement.operator_fee_sats,
reason=( reason=(
@ -492,17 +485,18 @@ async def _pay_dca_distributions(
machine: Machine, machine: Machine,
errors: List[str], errors: List[str],
) -> None: ) -> None:
if settlement.net_sats <= 0: if settlement.principal_sats <= 0:
return return
if settlement.exchange_rate <= 0: if settlement.exchange_rate <= 0:
# Fallback path with no exchange rate (bitSpire Payment.extra absent). # Fallback path with no exchange rate (bitSpire Payment.extra absent).
# Without a rate we can't compute fiat balances → can't compute # Without a rate we can't compute fiat balances → can't compute
# proportional shares → leave net_sats in the machine wallet for # proportional shares → leave principal_sats in the machine wallet
# manual reconciliation. Audit row makes the strand visible. # for manual reconciliation. Audit row makes the strand visible.
await _record_skipped_leg( await _record_skipped_leg(
settlement, machine, settlement,
machine,
leg_type="dca", leg_type="dca",
amount_sats=settlement.net_sats, amount_sats=settlement.principal_sats,
reason=( reason=(
"no exchange_rate on settlement (bitSpire fallback path; " "no exchange_rate on settlement (bitSpire fallback path; "
"see aiolabs/lamassu-next#44)" "see aiolabs/lamassu-next#44)"
@ -512,9 +506,10 @@ async def _pay_dca_distributions(
clients = await get_flow_mode_clients_for_machine(machine.id) clients = await get_flow_mode_clients_for_machine(machine.id)
if not clients: if not clients:
await _record_skipped_leg( await _record_skipped_leg(
settlement, machine, settlement,
machine,
leg_type="dca", leg_type="dca",
amount_sats=settlement.net_sats, amount_sats=settlement.principal_sats,
reason="no active flow-mode LPs registered at this machine", reason="no active flow-mode LPs registered at this machine",
) )
return return
@ -527,9 +522,10 @@ async def _pay_dca_distributions(
client_balances[client.id] = summary.remaining_balance client_balances[client.id] = summary.remaining_balance
if not client_balances: if not client_balances:
await _record_skipped_leg( await _record_skipped_leg(
settlement, machine, settlement,
machine,
leg_type="dca", leg_type="dca",
amount_sats=settlement.net_sats, amount_sats=settlement.principal_sats,
reason=( reason=(
"no LP has remaining-fiat-balance > 0 — all confirmed deposits " "no LP has remaining-fiat-balance > 0 — all confirmed deposits "
"already paid out" "already paid out"
@ -539,7 +535,7 @@ async def _pay_dca_distributions(
# Compute proportional sat allocations, then cap each at the client's # Compute proportional sat allocations, then cap each at the client's
# remaining-fiat-balance-in-sats (the v1 sync-mismatch safeguard). # remaining-fiat-balance-in-sats (the v1 sync-mismatch safeguard).
raw_allocations = calculate_distribution( raw_allocations = calculate_distribution(
base_amount_sats=settlement.net_sats, base_amount_sats=settlement.principal_sats,
client_balances=client_balances, client_balances=client_balances,
) )
capped_allocations: dict[str, int] = {} capped_allocations: dict[str, int] = {}
@ -565,9 +561,7 @@ async def _pay_one_dca_leg(
if amount_sats <= 0: if amount_sats <= 0:
return return
amount_fiat = round(amount_sats / float(settlement.exchange_rate), 2) amount_fiat = round(amount_sats / float(settlement.exchange_rate), 2)
memo = ( memo = f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}"
f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}"
)
dca_leg = await _pay_internal( dca_leg = await _pay_internal(
settlement=settlement, settlement=settlement,
machine=machine, machine=machine,
@ -654,9 +648,7 @@ async def _attempt_autoforward(
"satmachine_destination": address, "satmachine_destination": address,
}, },
) )
await update_payment_status( await update_payment_status(leg.id, "completed", paid.payment_hash, None)
leg.id, "completed", paid.payment_hash, None
)
logger.info( logger.info(
f"distribution: autoforward {amount_sats} sats from client " f"distribution: autoforward {amount_sats} sats from client "
f"{client.id}{address} OK" f"{client.id}{address} OK"
@ -716,9 +708,7 @@ async def _pay_split_leg(
"satmachine_destination": target, "satmachine_destination": target,
} }
try: try:
ln_target = ( ln_target = LnAddress(target) if "@" in target else target
LnAddress(target) if "@" in target else target
)
bolt11 = await get_pr_from_lnurl( bolt11 = await get_pr_from_lnurl(
lnurl=ln_target, lnurl=ln_target,
amount_msat=amount_sats * 1000, amount_msat=amount_sats * 1000,
@ -740,9 +730,7 @@ async def _pay_split_leg(
f"distribution: operator_split (LNURL/LN-addr) FAILED " f"distribution: operator_split (LNURL/LN-addr) FAILED "
f"target={target} settlement={settlement.id}: {exc}" f"target={target} settlement={settlement.id}: {exc}"
) )
await update_payment_status( await update_payment_status(leg_row.id, "failed", None, str(exc)[:512])
leg_row.id, "failed", None, str(exc)[:512]
)
errors.append(f"operator_split→{target}: {exc}") errors.append(f"operator_split→{target}: {exc}")
return leg_row return leg_row
@ -750,6 +738,7 @@ async def _pay_split_leg(
resolved_wallet_id = target resolved_wallet_id = target
try: try:
from lnbits.core.crud.wallets import get_wallet_for_key from lnbits.core.crud.wallets import get_wallet_for_key
wallet = await get_wallet_for_key(target) wallet = await get_wallet_for_key(target)
if wallet is not None: if wallet is not None:
resolved_wallet_id = wallet.id resolved_wallet_id = wallet.id
@ -828,9 +817,7 @@ async def _pay_internal(
tag=tag, tag=tag,
extra=extra, extra=extra,
) )
await update_payment_status( await update_payment_status(leg_row.id, "completed", paid.payment_hash, None)
leg_row.id, "completed", paid.payment_hash, None
)
return leg_row return leg_row
except Exception as exc: except Exception as exc:
logger.error( logger.error(

View file

@ -59,16 +59,14 @@ async def m001_satmachine_v2_initial(db):
await db.execute(f"DROP TABLE IF EXISTS satoshimachine.{table}") await db.execute(f"DROP TABLE IF EXISTS satoshimachine.{table}")
# 2. super_config — singleton (id='default') with platform-fee config. # 2. super_config — singleton (id='default') with platform-fee config.
await db.execute( await db.execute(f"""
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_pct 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}
); );
""" """)
)
existing = await db.fetchone( existing = await db.fetchone(
"SELECT id FROM satoshimachine.super_config WHERE id = 'default'" "SELECT id FROM satoshimachine.super_config WHERE id = 'default'"
) )
@ -80,8 +78,7 @@ async def m001_satmachine_v2_initial(db):
# 3. dca_machines — one row per bitSpire ATM, owned by exactly one # 3. dca_machines — one row per bitSpire ATM, owned by exactly one
# operator. wallet_id UNIQUE prevents the IDOR funds-theft vector. # operator. wallet_id UNIQUE prevents the IDOR funds-theft vector.
await db.execute( await db.execute(f"""
f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_machines ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_machines (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
operator_user_id TEXT NOT NULL, operator_user_id TEXT NOT NULL,
@ -95,8 +92,7 @@ async def m001_satmachine_v2_initial(db):
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}
); );
""" """)
)
await db.execute( await db.execute(
"CREATE INDEX IF NOT EXISTS dca_machines_operator_idx " "CREATE INDEX IF NOT EXISTS dca_machines_operator_idx "
"ON dca_machines (operator_user_id)" "ON dca_machines (operator_user_id)"
@ -109,8 +105,7 @@ async def m001_satmachine_v2_initial(db):
# 4. dca_clients — LP registrations scoped per (machine, user). An LP # 4. dca_clients — LP registrations scoped per (machine, user). An LP
# can hold positions at many machines (and many operators) on the # can hold positions at many machines (and many operators) on the
# same LNbits instance. # same LNbits instance.
await db.execute( await db.execute(f"""
f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL, machine_id TEXT NOT NULL,
@ -125,21 +120,18 @@ async def m001_satmachine_v2_initial(db):
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}
); );
""" """)
)
await db.execute( await db.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS dca_clients_machine_user_uq " "CREATE UNIQUE INDEX IF NOT EXISTS dca_clients_machine_user_uq "
"ON dca_clients (machine_id, user_id)" "ON dca_clients (machine_id, user_id)"
) )
await db.execute( await db.execute(
"CREATE INDEX IF NOT EXISTS dca_clients_user_idx " "CREATE INDEX IF NOT EXISTS dca_clients_user_idx " "ON dca_clients (user_id)"
"ON dca_clients (user_id)"
) )
# 5. dca_deposits — fiat the operator (or super) records against an LP # 5. dca_deposits — fiat the operator (or super) records against an LP
# at a machine. creator_user_id preserves audit trail. # at a machine. creator_user_id preserves audit trail.
await db.execute( await db.execute(f"""
f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_deposits ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_deposits (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
client_id TEXT NOT NULL, client_id TEXT NOT NULL,
@ -152,8 +144,7 @@ async def m001_satmachine_v2_initial(db):
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
confirmed_at TIMESTAMP confirmed_at TIMESTAMP
); );
""" """)
)
await db.execute( await db.execute(
"CREATE INDEX IF NOT EXISTS dca_deposits_client_idx " "CREATE INDEX IF NOT EXISTS dca_deposits_client_idx "
"ON dca_deposits (client_id, created_at DESC)" "ON dca_deposits (client_id, created_at DESC)"
@ -170,8 +161,7 @@ async def m001_satmachine_v2_initial(db):
# 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. # commission_pct. See plan section "Customer discounts" and #10.
await db.execute( await db.execute(f"""
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,
machine_id TEXT NOT NULL, machine_id TEXT NOT NULL,
@ -182,7 +172,7 @@ async def m001_satmachine_v2_initial(db):
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,
net_sats BIGINT NOT NULL, principal_sats BIGINT NOT NULL,
commission_sats BIGINT NOT NULL, commission_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,
@ -197,8 +187,7 @@ async def m001_satmachine_v2_initial(db):
notes TEXT, notes TEXT,
processing_claim TEXT processing_claim TEXT
); );
""" """)
)
await db.execute( await db.execute(
"CREATE INDEX IF NOT EXISTS dca_settlements_machine_idx " "CREATE INDEX IF NOT EXISTS dca_settlements_machine_idx "
"ON dca_settlements (machine_id, created_at DESC)" "ON dca_settlements (machine_id, created_at DESC)"
@ -216,8 +205,7 @@ async def m001_satmachine_v2_initial(db):
# - Lightning address (user@domain) # - Lightning address (user@domain)
# - LNURL string (bech32 LNURL...) # - LNURL string (bech32 LNURL...)
# Resolution lives in distribution._pay_one_split_leg. # Resolution lives in distribution._pay_one_split_leg.
await db.execute( await db.execute(f"""
f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
machine_id TEXT, machine_id TEXT,
@ -228,8 +216,7 @@ async def m001_satmachine_v2_initial(db):
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}
); );
""" """)
)
await db.execute( await db.execute(
"CREATE INDEX IF NOT EXISTS dca_commission_splits_lookup_idx " "CREATE INDEX IF NOT EXISTS dca_commission_splits_lookup_idx "
"ON dca_commission_splits (operator_user_id, machine_id)" "ON dca_commission_splits (operator_user_id, machine_id)"
@ -239,8 +226,7 @@ async def m001_satmachine_v2_initial(db):
# discriminator: dca | super_fee | operator_split | settlement | # discriminator: dca | super_fee | operator_split | settlement |
# autoforward | refund. status enum: pending | completed | failed | # autoforward | refund. status enum: pending | completed | failed |
# voided | skipped | refunded. # voided | skipped | refunded.
await db.execute( await db.execute(f"""
f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_payments ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_payments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
settlement_id TEXT, settlement_id TEXT,
@ -259,8 +245,7 @@ async def m001_satmachine_v2_initial(db):
error_message TEXT, error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
""" """)
)
await db.execute( await db.execute(
"CREATE INDEX IF NOT EXISTS dca_payments_client_idx " "CREATE INDEX IF NOT EXISTS dca_payments_client_idx "
"ON dca_payments (client_id, created_at DESC)" "ON dca_payments (client_id, created_at DESC)"
@ -280,8 +265,7 @@ async def m001_satmachine_v2_initial(db):
# only cash_in/cash_out/cash_level/fiat/model — post-#43 fields # only cash_in/cash_out/cash_level/fiat/model — post-#43 fields
# (name, location, geo, fees, limits, denominations, version) are # (name, location, geo, fees, limits, denominations, version) are
# nullable until that upstream issue lands. Ingest opportunistically. # nullable until that upstream issue lands. Ingest opportunistically.
await db.execute( await db.execute("""
"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_telemetry ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_telemetry (
machine_id TEXT PRIMARY KEY, machine_id TEXT PRIMARY KEY,
beacon_cash_in BOOLEAN, beacon_cash_in BOOLEAN,
@ -300,8 +284,7 @@ async def m001_satmachine_v2_initial(db):
telemetry_json TEXT, telemetry_json TEXT,
telemetry_received_at TIMESTAMP telemetry_received_at TIMESTAMP
); );
""" """)
)
async def m002_rename_commission_split_wallet_id_to_target(db): async def m002_rename_commission_split_wallet_id_to_target(db):
@ -332,3 +315,24 @@ async def m002_rename_commission_split_wallet_id_to_target(db):
"ALTER TABLE satoshimachine.dca_commission_splits " "ALTER TABLE satoshimachine.dca_commission_splits "
"RENAME COLUMN wallet_id TO target" "RENAME COLUMN wallet_id TO target"
) )
async def m003_rename_settlements_net_sats_to_principal_sats(db):
"""Rename `dca_settlements.net_sats` → `principal_sats` for clarity.
"Net" in financial accounting is overloaded (net of what?). In the
bitSpire/DCA context this column is specifically the principal the
operator distributes to LPs (gross commission), not a generic
"net" amount. Renaming locally before any bitSpire firmware locks
the wire-level name; lamassu-next#44 should adopt the same name.
Idempotent: probes for the old `net_sats` column. If present, rename.
"""
try:
await db.fetchone("SELECT net_sats FROM satoshimachine.dca_settlements LIMIT 1")
except Exception:
return
await db.execute(
"ALTER TABLE satoshimachine.dca_settlements "
"RENAME COLUMN net_sats TO principal_sats"
)

View file

@ -29,9 +29,9 @@ 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 net_sats/fee_sats # Used only when bitSpire's settlement event omits principal_sats/
# in Payment.extra (older bitSpire or edge cases). See plan's # fee_sats in Payment.extra (older bitSpire or edge cases). See
# lamassu-next informational issue #1. # plan's lamassu-next informational issue #1.
fallback_commission_pct: float = 0.05 fallback_commission_pct: float = 0.05
@validator("fallback_commission_pct") @validator("fallback_commission_pct")
@ -192,7 +192,7 @@ class CreateDcaSettlementData(BaseModel):
fiat_amount: float fiat_amount: float
fiat_code: str = "GTQ" fiat_code: str = "GTQ"
exchange_rate: float exchange_rate: float
net_sats: int principal_sats: int
commission_sats: int commission_sats: int
platform_fee_sats: int platform_fee_sats: int
operator_fee_sats: int operator_fee_sats: int
@ -212,7 +212,7 @@ class DcaSettlement(BaseModel):
fiat_amount: float fiat_amount: float
fiat_code: str fiat_code: str
exchange_rate: float exchange_rate: float
net_sats: int principal_sats: int
commission_sats: int commission_sats: int
platform_fee_sats: int platform_fee_sats: int
operator_fee_sats: int operator_fee_sats: int

View file

@ -164,7 +164,7 @@ window.app = Vue.createApp({
{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: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'},
{name: 'net_sats', label: 'Net (→ LPs)', field: 'net_sats', align: 'right'}, {name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'},
{ {
name: 'commission_sats', name: 'commission_sats',
label: 'Commission', label: 'Commission',

View file

@ -117,7 +117,7 @@ async def _handle_payment(payment: Payment) -> None:
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 net={data.net_sats}sats " f"gross={data.gross_sats}sats principal={data.principal_sats}sats "
f"commission={data.commission_sats}sats " f"commission={data.commission_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}){fb}"

View file

@ -913,8 +913,8 @@
<q-td key="gross_sats" class="text-right"> <q-td key="gross_sats" class="text-right">
<span v-text="formatSats(props.row.gross_sats)"></span> <span v-text="formatSats(props.row.gross_sats)"></span>
</q-td> </q-td>
<q-td key="net_sats" class="text-right"> <q-td key="principal_sats" class="text-right">
<span v-text="formatSats(props.row.net_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="commission_sats" class="text-right">
<span v-text="formatSats(props.row.commission_sats)"></span> <span v-text="formatSats(props.row.commission_sats)"></span>