diff --git a/bitspire.py b/bitspire.py index 9c60432..fa5620b 100644 --- a/bitspire.py +++ b/bitspire.py @@ -146,9 +146,9 @@ def _parse_extra( super_fee_pct: float, ) -> CreateDcaSettlementData: """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")) - 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. return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct) commission_sats = fee_sats @@ -170,7 +170,7 @@ def _parse_extra( fiat_amount=fiat_amount, fiat_code=fiat_code, exchange_rate=exchange_rate, - net_sats=net_sats, + principal_sats=principal_sats, commission_sats=commission_sats, platform_fee_sats=platform_fee_sats, operator_fee_sats=operator_fee_sats, @@ -193,7 +193,7 @@ def _parse_fallback( base_amount = round(gross / (1 + commission_pct)) commission = gross - base_amount """ - net_sats, commission_sats, _effective = calculate_commission( + principal_sats, commission_sats, _effective = calculate_commission( crypto_atoms=gross_sats, commission_percentage=machine.fallback_commission_pct, discount=0.0, @@ -211,7 +211,7 @@ def _parse_fallback( fiat_amount=0.0, fiat_code=machine.fiat_code, exchange_rate=0.0, - net_sats=net_sats, + principal_sats=principal_sats, commission_sats=commission_sats, platform_fee_sats=platform_fee_sats, operator_fee_sats=operator_fee_sats, diff --git a/crud.py b/crud.py index 8fd40d3..53b3fe3 100644 --- a/crud.py +++ b/crud.py @@ -442,13 +442,13 @@ async def create_settlement_idempotent( """ INSERT INTO satoshimachine.dca_settlements (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, used_fallback_split, tx_type, bills_json, cassettes_json, status, error_message, created_at) VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id, :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, :tx_type, :bills_json, :cassettes_json, :status, :error_message, :created_at) @@ -463,7 +463,7 @@ async def create_settlement_idempotent( "fiat_amount": data.fiat_amount, "fiat_code": data.fiat_code, "exchange_rate": data.exchange_rate, - "net_sats": data.net_sats, + "principal_sats": data.principal_sats, "commission_sats": data.commission_sats, "platform_fee_sats": data.platform_fee_sats, "operator_fee_sats": data.operator_fee_sats, @@ -728,7 +728,7 @@ async def apply_partial_dispense( settlement_id: str, *, new_gross_sats: int, - new_net_sats: int, + new_principal_sats: int, new_commission_sats: int, new_platform_fee_sats: int, new_operator_fee_sats: int, @@ -746,7 +746,7 @@ async def apply_partial_dispense( """ UPDATE satoshimachine.dca_settlements SET gross_sats = :gross, - net_sats = :net, + principal_sats = :principal, commission_sats = :commission, platform_fee_sats = :platform, operator_fee_sats = :operator, @@ -763,7 +763,7 @@ async def apply_partial_dispense( { "id": settlement_id, "gross": new_gross_sats, - "net": new_net_sats, + "principal": new_principal_sats, "commission": new_commission_sats, "platform": new_platform_fee_sats, "operator": new_operator_fee_sats, diff --git a/distribution.py b/distribution.py index 4d8c2b2..8e953a4 100644 --- a/distribution.py +++ b/distribution.py @@ -10,7 +10,7 @@ # Leg order: # 1. super_fee — platform_fee_sats → super_fee_wallet_id (if set) # 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 # (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]) logger.info( - f"distribution: skipped {leg_type} leg " - f"({amount_sats} sats) — {reason}" + f"distribution: skipped {leg_type} leg " f"({amount_sats} sats) — {reason}" ) @@ -134,7 +133,7 @@ def _build_partial_dispense_memo( data: PartialDispenseData, *, new_gross: int, - new_net: int, + new_principal: int, new_commission: int, new_platform: int, new_operator: int, @@ -147,11 +146,13 @@ def _build_partial_dispense_memo( ts = datetime.now(timezone.utc).isoformat(timespec="seconds") return ( 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"(super_fee={settlement.platform_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"Reason: {reason}" ) @@ -177,14 +178,10 @@ async def settle_lp_balance( raise ValueError(f"client {client.id} balance not available") remaining = float(summary.remaining_balance) if remaining <= 0: - raise ValueError( - f"client {client.id} has no remaining balance to settle" - ) + raise ValueError(f"client {client.id} has no remaining balance to settle") # Resolve fiat amount: explicit if given (capped at remaining), else full. - requested = ( - float(data.amount_fiat) if data.amount_fiat is not None else remaining - ) + requested = float(data.amount_fiat) if data.amount_fiat is not None else remaining amount_fiat = round(min(requested, remaining), 2) if amount_fiat <= 0: 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. scale = new_gross / settlement.gross_sats 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) # 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, data, new_gross=new_gross, - new_net=new_net, + new_principal=new_principal, new_commission=new_commission, new_platform=new_platform, new_operator=new_operator, @@ -331,7 +328,7 @@ async def apply_partial_dispense_and_redistribute( updated = await apply_partial_dispense( settlement_id, new_gross_sats=new_gross, - new_net_sats=new_net, + new_principal_sats=new_principal, new_commission_sats=new_commission, new_platform_fee_sats=new_platform, 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"machine {settlement.machine_id}" ) - await mark_settlement_status( - settlement_id, "errored", "machine missing" - ) + await mark_settlement_status(settlement_id, "errored", "machine missing") return super_config = await get_super_config() errors: List[str] = [] @@ -390,9 +385,7 @@ async def process_settlement(settlement_id: str) -> None: errors.append(f"unexpected: {exc}") if errors: - await mark_settlement_status( - settlement_id, "errored", "; ".join(errors)[:512] - ) + await mark_settlement_status(settlement_id, "errored", "; ".join(errors)[:512]) else: 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 super needs to configure their wallet before they can collect. await _record_skipped_leg( - settlement, machine, + settlement, + machine, leg_type="super_fee", amount_sats=settlement.platform_fee_sats, reason="super_fee_wallet_id not configured by LNbits super", @@ -445,12 +439,11 @@ async def _pay_operator_splits( ) -> None: if settlement.operator_fee_sats <= 0: return - splits = await get_effective_commission_splits( - machine.operator_user_id, machine.id - ) + splits = await get_effective_commission_splits(machine.operator_user_id, machine.id) if not splits: await _record_skipped_leg( - settlement, machine, + settlement, + machine, leg_type="operator_split", amount_sats=settlement.operator_fee_sats, reason=( @@ -492,17 +485,18 @@ async def _pay_dca_distributions( machine: Machine, errors: List[str], ) -> None: - if settlement.net_sats <= 0: + if settlement.principal_sats <= 0: return if settlement.exchange_rate <= 0: # Fallback path with no exchange rate (bitSpire Payment.extra absent). # Without a rate we can't compute fiat balances → can't compute - # proportional shares → leave net_sats in the machine wallet for - # manual reconciliation. Audit row makes the strand visible. + # proportional shares → leave principal_sats in the machine wallet + # for manual reconciliation. Audit row makes the strand visible. await _record_skipped_leg( - settlement, machine, + settlement, + machine, leg_type="dca", - amount_sats=settlement.net_sats, + amount_sats=settlement.principal_sats, reason=( "no exchange_rate on settlement (bitSpire fallback path; " "see aiolabs/lamassu-next#44)" @@ -512,9 +506,10 @@ async def _pay_dca_distributions( clients = await get_flow_mode_clients_for_machine(machine.id) if not clients: await _record_skipped_leg( - settlement, machine, + settlement, + machine, leg_type="dca", - amount_sats=settlement.net_sats, + amount_sats=settlement.principal_sats, reason="no active flow-mode LPs registered at this machine", ) return @@ -527,9 +522,10 @@ async def _pay_dca_distributions( client_balances[client.id] = summary.remaining_balance if not client_balances: await _record_skipped_leg( - settlement, machine, + settlement, + machine, leg_type="dca", - amount_sats=settlement.net_sats, + amount_sats=settlement.principal_sats, reason=( "no LP has remaining-fiat-balance > 0 — all confirmed deposits " "already paid out" @@ -539,7 +535,7 @@ async def _pay_dca_distributions( # Compute proportional sat allocations, then cap each at the client's # remaining-fiat-balance-in-sats (the v1 sync-mismatch safeguard). raw_allocations = calculate_distribution( - base_amount_sats=settlement.net_sats, + base_amount_sats=settlement.principal_sats, client_balances=client_balances, ) capped_allocations: dict[str, int] = {} @@ -565,9 +561,7 @@ async def _pay_one_dca_leg( if amount_sats <= 0: return amount_fiat = round(amount_sats / float(settlement.exchange_rate), 2) - memo = ( - f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}" - ) + memo = f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}" dca_leg = await _pay_internal( settlement=settlement, machine=machine, @@ -654,9 +648,7 @@ async def _attempt_autoforward( "satmachine_destination": address, }, ) - await update_payment_status( - leg.id, "completed", paid.payment_hash, None - ) + await update_payment_status(leg.id, "completed", paid.payment_hash, None) logger.info( f"distribution: autoforward {amount_sats} sats from client " f"{client.id} → {address} OK" @@ -716,9 +708,7 @@ async def _pay_split_leg( "satmachine_destination": target, } try: - ln_target = ( - LnAddress(target) if "@" in target else target - ) + ln_target = LnAddress(target) if "@" in target else target bolt11 = await get_pr_from_lnurl( lnurl=ln_target, amount_msat=amount_sats * 1000, @@ -740,9 +730,7 @@ async def _pay_split_leg( f"distribution: operator_split (LNURL/LN-addr) FAILED " f"target={target} settlement={settlement.id}: {exc}" ) - await update_payment_status( - leg_row.id, "failed", None, str(exc)[:512] - ) + await update_payment_status(leg_row.id, "failed", None, str(exc)[:512]) errors.append(f"operator_split→{target}: {exc}") return leg_row @@ -750,6 +738,7 @@ async def _pay_split_leg( resolved_wallet_id = target try: from lnbits.core.crud.wallets import get_wallet_for_key + wallet = await get_wallet_for_key(target) if wallet is not None: resolved_wallet_id = wallet.id @@ -828,9 +817,7 @@ async def _pay_internal( tag=tag, extra=extra, ) - await update_payment_status( - leg_row.id, "completed", paid.payment_hash, None - ) + await update_payment_status(leg_row.id, "completed", paid.payment_hash, None) return leg_row except Exception as exc: logger.error( diff --git a/migrations.py b/migrations.py index d485aa7..2a69b7e 100644 --- a/migrations.py +++ b/migrations.py @@ -59,16 +59,14 @@ async def m001_satmachine_v2_initial(db): await db.execute(f"DROP TABLE IF EXISTS satoshimachine.{table}") # 2. super_config — singleton (id='default') with platform-fee config. - await db.execute( - f""" + await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.super_config ( id TEXT PRIMARY KEY, super_fee_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0000, super_fee_wallet_id TEXT, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ - ) + """) existing = await db.fetchone( "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 # operator. wallet_id UNIQUE prevents the IDOR funds-theft vector. - await db.execute( - f""" + await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_machines ( id TEXT PRIMARY KEY, 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}, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ - ) + """) await db.execute( "CREATE INDEX IF NOT EXISTS dca_machines_operator_idx " "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 # can hold positions at many machines (and many operators) on the # same LNbits instance. - await db.execute( - f""" + await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients ( id TEXT PRIMARY KEY, 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}, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ - ) + """) await db.execute( "CREATE UNIQUE INDEX IF NOT EXISTS dca_clients_machine_user_uq " "ON dca_clients (machine_id, user_id)" ) await db.execute( - "CREATE INDEX IF NOT EXISTS dca_clients_user_idx " - "ON dca_clients (user_id)" + "CREATE INDEX IF NOT EXISTS dca_clients_user_idx " "ON dca_clients (user_id)" ) # 5. dca_deposits — fiat the operator (or super) records against an LP # at a machine. creator_user_id preserves audit trail. - await db.execute( - f""" + await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_deposits ( id TEXT PRIMARY KEY, 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}, confirmed_at TIMESTAMP ); - """ - ) + """) await db.execute( "CREATE INDEX IF NOT EXISTS dca_deposits_client_idx " "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 # forgave what per transaction. Do not collapse them into a single # commission_pct. See plan section "Customer discounts" and #10. - await db.execute( - f""" + await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements ( id TEXT PRIMARY KEY, machine_id TEXT NOT NULL, @@ -182,7 +172,7 @@ async def m001_satmachine_v2_initial(db): fiat_amount DECIMAL(10,2) NOT NULL, fiat_code TEXT NOT NULL DEFAULT 'GTQ', exchange_rate REAL NOT NULL, - net_sats BIGINT NOT NULL, + principal_sats BIGINT NOT NULL, commission_sats BIGINT NOT NULL, platform_fee_sats BIGINT NOT NULL, operator_fee_sats BIGINT NOT NULL, @@ -197,8 +187,7 @@ async def m001_satmachine_v2_initial(db): notes TEXT, processing_claim TEXT ); - """ - ) + """) await db.execute( "CREATE INDEX IF NOT EXISTS dca_settlements_machine_idx " "ON dca_settlements (machine_id, created_at DESC)" @@ -216,8 +205,7 @@ async def m001_satmachine_v2_initial(db): # - Lightning address (user@domain) # - LNURL string (bech32 LNURL...) # Resolution lives in distribution._pay_one_split_leg. - await db.execute( - f""" + await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits ( id TEXT PRIMARY KEY, machine_id TEXT, @@ -228,8 +216,7 @@ async def m001_satmachine_v2_initial(db): sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ - ) + """) await db.execute( "CREATE INDEX IF NOT EXISTS dca_commission_splits_lookup_idx " "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 | # autoforward | refund. status enum: pending | completed | failed | # voided | skipped | refunded. - await db.execute( - f""" + await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_payments ( id TEXT PRIMARY KEY, settlement_id TEXT, @@ -259,8 +245,7 @@ async def m001_satmachine_v2_initial(db): error_message TEXT, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ - ) + """) await db.execute( "CREATE INDEX IF NOT EXISTS dca_payments_client_idx " "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 # (name, location, geo, fees, limits, denominations, version) are # nullable until that upstream issue lands. Ingest opportunistically. - await db.execute( - """ + await db.execute(""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_telemetry ( machine_id TEXT PRIMARY KEY, beacon_cash_in BOOLEAN, @@ -300,8 +284,7 @@ async def m001_satmachine_v2_initial(db): telemetry_json TEXT, telemetry_received_at TIMESTAMP ); - """ - ) + """) 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 " "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" + ) diff --git a/models.py b/models.py index fe652b4..aeb07fb 100644 --- a/models.py +++ b/models.py @@ -29,9 +29,9 @@ class CreateMachineData(BaseModel): name: Optional[str] = None location: Optional[str] = None fiat_code: str = "GTQ" - # Used only when bitSpire's settlement event omits net_sats/fee_sats - # in Payment.extra (older bitSpire or edge cases). See plan's - # lamassu-next informational issue #1. + # 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") @@ -192,7 +192,7 @@ class CreateDcaSettlementData(BaseModel): fiat_amount: float fiat_code: str = "GTQ" exchange_rate: float - net_sats: int + principal_sats: int commission_sats: int platform_fee_sats: int operator_fee_sats: int @@ -212,7 +212,7 @@ class DcaSettlement(BaseModel): fiat_amount: float fiat_code: str exchange_rate: float - net_sats: int + principal_sats: int commission_sats: int platform_fee_sats: int operator_fee_sats: int diff --git a/static/js/index.js b/static/js/index.js index 33c58c6..966c1f7 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -164,7 +164,7 @@ window.app = Vue.createApp({ {name: 'status', label: 'Status', field: 'status', align: 'left'}, {name: 'created_at', label: 'Time', field: 'created_at', align: 'left'}, {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', label: 'Commission', diff --git a/tasks.py b/tasks.py index 6bd8f13..d19a95d 100644 --- a/tasks.py +++ b/tasks.py @@ -117,7 +117,7 @@ async def _handle_payment(payment: Payment) -> None: logger.info( f"satmachineadmin: landed settlement {settlement.id} for " 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"(super_fee={data.platform_fee_sats} " f"operator_fee={data.operator_fee_sats}){fb}" diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 2bdf02c..728e8c2 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -913,8 +913,8 @@ - - + +