From 7b55dc152b771a7d77d0e06c2b2e8c47292165ed Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 21 Jun 2026 17:27:58 +0200 Subject: [PATCH] fix(settlements): process cash-in (outbound) payments, not just cash-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_handle_payment` cash-in branch existed but had never been exercised end-to-end — bitSpire cash-in payouts only started reaching it once the withdraw extension learned to stamp `source=bitspire` on an LNURL-withdraw payout (aiolabs/withdraw#3). With that wired up, the first real cash-in exposed two bugs: 1. `payment.sat` is signed by protocol direction — negative for an outbound (cash-in) payout. It was passed straight to `parse_settlement` as `wire_sats`, which enforces `wire_sats >= 0`, so every cash-in was rejected ("wire_sats must be >= 0, got -75795"). A settlement's `wire_sats` is a magnitude (direction lives in `tx_type`); pass `abs(payment.sat)`. Same in `_record_rejected`. 2. `_record_rejected` hard-coded `tx_type="cash_out"`, so a rejected cash-in showed the wrong direction in the operator dashboard. The parsed tx_type isn't available on the rejection path, but the authenticated protocol direction is — derive it: outbound → cash_in, inbound → cash_out. Verified on the dev stack: a stamped cash-in now lands a `cash_in` settlement (net 75795, principal 82386, fee 6591), pays the super its 3% (2472 sats), and correctly skips the DCA leg (principal stays in the operator's wallet as liquidity from the cash-in customer). --- tasks.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tasks.py b/tasks.py index 4685c84..eb874de 100644 --- a/tasks.py +++ b/tasks.py @@ -130,7 +130,11 @@ async def _handle_payment(payment: Payment) -> None: data = parse_settlement( machine=machine, payment_hash=payment.payment_hash, - wire_sats=payment.sat, + # `payment.sat` is signed by protocol direction (negative for an + # outbound cash-in payout, positive for an inbound cash-out + # receipt). The settlement's `wire_sats` is a magnitude — direction + # is carried separately by `tx_type` — so pass the absolute value. + wire_sats=abs(payment.sat), extra=extra, super_config=super_config, ) @@ -205,7 +209,8 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - data = CreateDcaSettlementData( machine_id=machine.id, payment_hash=payment.payment_hash, - wire_sats=payment.sat, + # Magnitude, not the signed `payment.sat` (negative for outbound). + wire_sats=abs(payment.sat), fiat_amount=0.0, fiat_code=machine.fiat_code, exchange_rate=0.0, @@ -213,11 +218,11 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - 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", + # The parsed tx_type is unavailable on the rejection path, but the + # authenticated protocol direction is: an outbound payment is a + # cash-in, an inbound one a cash-out. Use that so a rejected row shows + # the right direction instead of always reading "cash-out". + tx_type="cash_in" if not payment.is_in else "cash_out", ) rejected = await create_settlement_idempotent( data, initial_status="rejected", error_message=str(exc)