diff --git a/crud.py b/crud.py index 7c3d6db..33f2459 100644 --- a/crud.py +++ b/crud.py @@ -410,24 +410,24 @@ async def delete_deposit(deposit_id: str) -> None: async def create_settlement_idempotent( data: CreateDcaSettlementData, ) -> Optional[DcaSettlement]: - """Insert a settlement keyed by bitspire_event_id. Returns the inserted row - on first sight; returns the existing row if the event_id was already seen - (subscription replay, relay double-delivery). The UNIQUE constraint on - bitspire_event_id is the source of truth.""" - existing = await get_settlement_by_event_id(data.bitspire_event_id) + """Insert a settlement keyed by payment_hash. Returns the inserted row on + first sight; returns the existing row if the payment_hash was already seen + (subscription replay, dispatcher double-fire). The UNIQUE constraint on + payment_hash is the source of truth.""" + existing = await get_settlement_by_payment_hash(data.payment_hash) if existing is not None: return existing settlement_id = urlsafe_short_hash() await db.execute( """ INSERT INTO satoshimachine.dca_settlements - (id, machine_id, bitspire_event_id, bitspire_txid, payment_hash, + (id, machine_id, payment_hash, bitspire_event_id, bitspire_txid, gross_sats, fiat_amount, fiat_code, exchange_rate, net_sats, commission_sats, platform_fee_sats, operator_fee_sats, used_fallback_split, tx_type, bills_json, cassettes_json, status, created_at) - VALUES (:id, :machine_id, :bitspire_event_id, :bitspire_txid, - :payment_hash, :gross_sats, :fiat_amount, :fiat_code, + VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id, + :bitspire_txid, :gross_sats, :fiat_amount, :fiat_code, :exchange_rate, :net_sats, :commission_sats, :platform_fee_sats, :operator_fee_sats, :used_fallback_split, :tx_type, :bills_json, :cassettes_json, :status, :created_at) @@ -435,9 +435,9 @@ async def create_settlement_idempotent( { "id": settlement_id, "machine_id": data.machine_id, + "payment_hash": data.payment_hash, "bitspire_event_id": data.bitspire_event_id, "bitspire_txid": data.bitspire_txid, - "payment_hash": data.payment_hash, "gross_sats": data.gross_sats, "fiat_amount": data.fiat_amount, "fiat_code": data.fiat_code, @@ -465,15 +465,15 @@ async def get_settlement(settlement_id: str) -> Optional[DcaSettlement]: ) -async def get_settlement_by_event_id( - bitspire_event_id: str, +async def get_settlement_by_payment_hash( + payment_hash: str, ) -> Optional[DcaSettlement]: return await db.fetchone( """ SELECT * FROM satoshimachine.dca_settlements - WHERE bitspire_event_id = :eid + WHERE payment_hash = :hash """, - {"eid": bitspire_event_id}, + {"hash": payment_hash}, DcaSettlement, ) diff --git a/migrations.py b/migrations.py index 1c06203..c9588c0 100644 --- a/migrations.py +++ b/migrations.py @@ -290,20 +290,28 @@ async def m005_satmachine_v2_overhaul(db): "ON satoshimachine.dca_deposits (client_id, created_at DESC)" ) - # dca_settlements — idempotency table for bitSpire kind-21000 events. - # CRITICAL: platform_fee_sats and operator_fee_sats are stored as absolute BIGINT - # (not as a derived percentage). Today this is just the contractual split. Once - # the v2 promotion engine ships, the two values diverge when discounts fire and - # this row is the only audit-grade record of who forgave what. Do not collapse - # them into a single commission_pct field. See plan section "Customer discounts". + # dca_settlements — idempotency table for bitSpire-driven settlements. + # The natural unique key is payment_hash (every LN invoice has a globally + # unique hash; subscription replays / dispatcher double-fires dedup via the + # UNIQUE constraint). bitspire_event_id is reserved for a future path where + # we subscribe to raw Nostr events directly (kind-30078/30079 ingestion + # uses dca_telemetry; bitspire_event_id is kept here for future bookkeeping + # if we ever bypass the LNbits Payment system). + # + # CRITICAL: platform_fee_sats and operator_fee_sats are stored as absolute + # BIGINT (not a derived percentage). Today this is just the contractual + # split. Once the v2 promotion engine ships, the two values diverge when + # discounts fire and this row is the only audit-grade record of who forgave + # what. Do not collapse them into a single commission_pct field. See plan + # section "Customer discounts". await db.execute( f""" CREATE TABLE satoshimachine.dca_settlements ( id TEXT PRIMARY KEY, machine_id TEXT NOT NULL, - bitspire_event_id TEXT NOT NULL UNIQUE, + payment_hash TEXT NOT NULL UNIQUE, + bitspire_event_id TEXT, bitspire_txid TEXT, - payment_hash TEXT NOT NULL, gross_sats BIGINT NOT NULL, fiat_amount DECIMAL(10,2) NOT NULL, fiat_code TEXT NOT NULL DEFAULT 'GTQ', @@ -327,10 +335,7 @@ async def m005_satmachine_v2_overhaul(db): "CREATE INDEX dca_settlements_machine_idx " "ON satoshimachine.dca_settlements (machine_id, created_at DESC)" ) - await db.execute( - "CREATE INDEX dca_settlements_payment_hash_idx " - "ON satoshimachine.dca_settlements (payment_hash)" - ) + # payment_hash UNIQUE already creates a lookup index — no extra index needed. # dca_commission_splits — operator's rules for distributing the *remainder* # of each commission (commission_sats - platform_fee_sats). One row per leg. diff --git a/models.py b/models.py index ced9f8d..a92cade 100644 --- a/models.py +++ b/models.py @@ -185,9 +185,9 @@ class UpdateDepositStatusData(BaseModel): class CreateDcaSettlementData(BaseModel): machine_id: str - bitspire_event_id: str # nostr event id — the idempotency key + payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table) + bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion bitspire_txid: Optional[str] = None - payment_hash: str gross_sats: int fiat_amount: float fiat_code: str = "GTQ" @@ -205,9 +205,9 @@ class CreateDcaSettlementData(BaseModel): class DcaSettlement(BaseModel): id: str machine_id: str - bitspire_event_id: str - bitspire_txid: Optional[str] payment_hash: str + bitspire_event_id: Optional[str] + bitspire_txid: Optional[str] gross_sats: int fiat_amount: float fiat_code: str