diff --git a/crud.py b/crud.py index 00e0d96..3f3d69e 100644 --- a/crud.py +++ b/crud.py @@ -157,7 +157,7 @@ async def get_pending_tpos_payments() -> list[TposPayment]: return await db.fetchall( """ SELECT * FROM tpos.payments - WHERE paid = false AND onchain_address IS NOT NULL + WHERE paid = false AND status = 'pending' AND onchain_address IS NOT NULL ORDER BY created_at ASC """, model=TposPayment, diff --git a/migrations.py b/migrations.py index f43f2f3..83e89ef 100644 --- a/migrations.py +++ b/migrations.py @@ -324,3 +324,12 @@ async def m025_add_tabs_integration_settings(db: Database): await db.execute(""" ALTER TABLE tpos.pos ADD tabs_allow_create BOOLEAN DEFAULT false; """) + + +async def m026_add_onchain_payment_status(db: Database): + await db.execute(""" + ALTER TABLE tpos.payments ADD status TEXT NOT NULL DEFAULT 'pending'; + """) + await db.execute(""" + UPDATE tpos.payments SET status = 'paid' WHERE paid = true; + """) diff --git a/models.py b/models.py index b80adb4..59f2fed 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from enum import Enum from time import time from typing import Any, Literal @@ -161,12 +162,30 @@ class Tpos(TposClean, BaseModel): tip_wallet: str | None = None +class TposPaymentStatus(str, Enum): + PENDING = "pending" + PAID = "paid" + EXPIRED = "expired" + UNDERPAID = "underpaid" + ABANDONED = "abandoned" + + +TERMINAL_PAYMENT_STATUSES = frozenset( + { + TposPaymentStatus.EXPIRED, + TposPaymentStatus.UNDERPAID, + TposPaymentStatus.ABANDONED, + } +) + + class TposPayment(BaseModel): id: str tpos_id: str payment_hash: str amount: int = 0 paid: bool = False + status: TposPaymentStatus = TposPaymentStatus.PENDING payment_method: str | None = None onchain_address: str | None = None onchain_wallet_id: str | None = None diff --git a/static/js/tpos.js b/static/js/tpos.js index eef2c94..a41bd33 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -1426,6 +1426,24 @@ window.app = Vue.createApp({ this.promptPrintType(paymentHash) } }, + stopTerminalPayment(paymentHash, status) { + const ws = this.paymentWsByHash[paymentHash] + if (ws) ws.close() + delete this.paymentWsByHash[paymentHash] + if (this.invoiceDialog.data?.payment_hash !== paymentHash) return + if (this.invoiceDialog.paymentChecker) { + clearInterval(this.invoiceDialog.paymentChecker) + this.invoiceDialog.paymentChecker = null + } + this.invoiceDialog.show = false + Quasar.Notify.create({ + type: 'warning', + message: + status === 'underpaid' + ? 'Onchain payment expired with insufficient funds. Reconcile the received amount manually in Watchonly.' + : 'Onchain payment expired. Check received funds manually in Watchonly.' + }) + }, startPaymentChecker(paymentHash) { if (this.invoiceDialog.paymentChecker) { clearInterval(this.invoiceDialog.paymentChecker) @@ -1440,6 +1458,10 @@ window.app = Vue.createApp({ clearInterval(this.invoiceDialog.paymentChecker) this.invoiceDialog.paymentChecker = null this.finalizeSuccessfulPayment(paymentHash) + } else if ( + ['expired', 'underpaid', 'abandoned'].includes(data.status) + ) { + this.stopTerminalPayment(paymentHash, data.status) } } catch (error) { console.warn('TPoS payment status check failed:', error) @@ -1465,6 +1487,10 @@ window.app = Vue.createApp({ } this.finalizeSuccessfulPayment(paymentHash) ws.close() + } else if ( + ['expired', 'underpaid', 'abandoned'].includes(payment.status) + ) { + this.stopTerminalPayment(paymentHash, payment.status) } } ws.onerror = err => { diff --git a/tasks.py b/tasks.py index bf27e42..64eb13f 100644 --- a/tasks.py +++ b/tasks.py @@ -19,6 +19,7 @@ get_tpos_payment_by_hash, update_tpos_payment, ) +from .models import TposPaymentStatus from .services import ensure_tpos_tabs_access from .services_inventory import deduct_inventory_stock from .services_onchain import fetch_onchain_balance @@ -42,6 +43,17 @@ async def poll_onchain_payments(): if not tpos_payment.onchain_address or not tpos_payment.mempool_endpoint: continue try: + payment = await get_standalone_payment( + tpos_payment.payment_hash, incoming=True + ) + if payment and payment.success: + await on_invoice_paid(payment) + continue + expired = ( + not payment + or payment.status == PaymentState.FAILED.value + or payment.is_expired + ) balance = await fetch_onchain_balance( tpos_payment.mempool_endpoint, tpos_payment.onchain_address ) @@ -56,12 +68,31 @@ async def poll_onchain_payments(): tpos_payment.balance != settled_balance or tpos_payment.pending != unconfirmed_balance ) + previous_status = tpos_payment.status tpos_payment.balance = settled_balance tpos_payment.pending = unconfirmed_balance + received = confirmed_balance + unconfirmed_balance settled = settled_balance >= tpos_payment.amount if settled: tpos_payment.payment_method = "onchain" - if changed or settled: + if not payment: + # Funds arrived but the standalone invoice is gone: + # record a terminal state for manual reconciliation. + tpos_payment.status = TposPaymentStatus.ABANDONED + elif expired: + if received == 0: + tpos_payment.status = ( + TposPaymentStatus.EXPIRED + if payment + else TposPaymentStatus.ABANDONED + ) + elif received < tpos_payment.amount: + tpos_payment.status = TposPaymentStatus.UNDERPAID + elif not payment: + # Full amount received (unconfirmed) but the standalone + # invoice is gone: cannot settle, reconcile manually. + tpos_payment.status = TposPaymentStatus.ABANDONED + if changed or settled or tpos_payment.status != previous_status: await update_tpos_payment(tpos_payment) await websocket_updater( tpos_payment.payment_hash, @@ -72,13 +103,21 @@ async def poll_onchain_payments(): "onchain_balance": tpos_payment.balance, "onchain_pending": tpos_payment.pending, "payment_method": tpos_payment.payment_method, + "status": tpos_payment.status, } ), ) - if settled: + if settled and payment: await settle_onchain_tpos_payment(tpos_payment) except Exception as exc: - logger.warning(f"tpos: onchain polling failed: {exc}") + logger.warning( + "tpos: onchain polling failed for payment_hash={} address={} " + "({}): {}", + tpos_payment.payment_hash, + tpos_payment.onchain_address, + type(exc).__name__, + exc, + ) await asyncio.sleep(10) @@ -94,6 +133,7 @@ async def on_invoice_paid(payment: Payment) -> None: tpos_payment = await get_tpos_payment_by_hash(payment.payment_hash) if tpos_payment and not tpos_payment.paid: tpos_payment.paid = True + tpos_payment.status = TposPaymentStatus.PAID tpos_payment.payment_method = payment_method await update_tpos_payment(tpos_payment) diff --git a/tests/test_api.py b/tests/test_api.py index 4f3f3e2..1a9795f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,7 @@ import asyncio import json +from types import SimpleNamespace +from unittest.mock import AsyncMock from uuid import uuid4 import pytest @@ -33,6 +35,7 @@ import tpos.views_wrapper as views_wrapper # type: ignore[import] from tpos.crud import ( # type: ignore[import] create_tpos_payment, + get_pending_tpos_payments, get_tpos, get_tpos_payment_by_hash, update_tpos, @@ -750,6 +753,7 @@ def messages_for(hash_): "onchain_balance": 20, "onchain_pending": 20, "payment_method": None, + "status": "pending", } assert payment_hash not in settle_calls @@ -765,6 +769,7 @@ def messages_for(hash_): "onchain_balance": 42, "onchain_pending": 0, "payment_method": "onchain", + "status": "pending", } assert settle_calls.count(payment_hash) == 1 @@ -775,6 +780,83 @@ def messages_for(hash_): assert tpos_payment.paid is False +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payment", "balance", "zero_conf", "expected_status"), + [ + ( + SimpleNamespace( + status=PaymentState.PENDING, is_expired=True, success=False + ), + {"confirmed": 0, "unconfirmed": 0}, + True, + "expired", + ), + ( + SimpleNamespace(status=PaymentState.FAILED, is_expired=True, success=False), + {"confirmed": 20, "unconfirmed": 0}, + True, + "underpaid", + ), + ( + SimpleNamespace(status=PaymentState.FAILED, is_expired=True, success=False), + {"confirmed": 0, "unconfirmed": 42}, + False, + "pending", + ), + (None, {"confirmed": 0, "unconfirmed": 0}, True, "abandoned"), + (None, {"confirmed": 20, "unconfirmed": 0}, True, "underpaid"), + ( + SimpleNamespace( + status=PaymentState.PENDING, is_expired=True, success=False + ), + RuntimeError("provider down"), + True, + "pending", + ), + ], +) +async def test_expired_onchain_payment_states( + monkeypatch, payment, balance, zero_conf, expected_status +): + tpos_payment = TposPayment( + id=uuid4().hex, + tpos_id=uuid4().hex, + payment_hash=uuid4().hex, + amount=42, + onchain_address="bc1qexpired", + mempool_endpoint="https://mempool.example", + onchain_zero_conf=zero_conf, + ) + await create_tpos_payment(tpos_payment) + + async def stop_polling(_seconds): + raise RuntimeError("stop") + + get_payment = AsyncMock(return_value=payment) + fetch_balance = AsyncMock(return_value=balance) + websocket = AsyncMock() + if isinstance(balance, Exception): + fetch_balance.side_effect = balance + monkeypatch.setattr(tpos_tasks, "get_standalone_payment", get_payment) + monkeypatch.setattr(tpos_tasks, "fetch_onchain_balance", fetch_balance) + monkeypatch.setattr(tpos_tasks, "websocket_updater", websocket) + monkeypatch.setattr(tpos_tasks.asyncio, "sleep", stop_polling) + + with pytest.raises(RuntimeError, match="stop"): + await tpos_tasks.poll_onchain_payments() + + saved = await get_tpos_payment_by_hash(tpos_payment.payment_hash) + assert saved is not None + assert saved.status.value == expected_status + pending_hashes = { + payment.payment_hash for payment in await get_pending_tpos_payments() + } + assert (tpos_payment.payment_hash in pending_hashes) is ( + expected_status == "pending" + ) + + @pytest.mark.asyncio async def test_tpos_rejects_invalid_tab_flows(client: AsyncClient): _user, wallet = await _user_with_tabs("invalidtabsuser") diff --git a/views_payments.py b/views_payments.py index ff2af05..ef7afd9 100644 --- a/views_payments.py +++ b/views_payments.py @@ -22,6 +22,7 @@ ) from .helpers import inventory_tags_to_list from .models import ( + TERMINAL_PAYMENT_STATUSES, CreateTposInvoice, InventorySale, PayLnurlWData, @@ -385,8 +386,15 @@ async def api_tpos_check_invoice( raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." ) + tpos_payment = await get_tpos_payment_by_hash(payment_hash) payment = await get_standalone_payment(payment_hash, incoming=True) if not payment: + if ( + tpos_payment + and tpos_payment.tpos_id == tpos_id + and tpos_payment.status in TERMINAL_PAYMENT_STATUSES + ): + return {"paid": False, "status": tpos_payment.status.value} raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." ) @@ -394,11 +402,18 @@ async def api_tpos_check_invoice( raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." ) - tpos_payment = await get_tpos_payment_by_hash(payment_hash) - if extra: return _build_receipt_data(tpos, payment, tpos_payment).to_api_dict() - return {"paid": payment.success or bool(tpos_payment and tpos_payment.paid)} + response: dict[str, bool | str] = { + "paid": payment.success or bool(tpos_payment and tpos_payment.paid), + } + if ( + tpos_payment + and tpos_payment.onchain_address + and tpos_payment.status in TERMINAL_PAYMENT_STATUSES + ): + response["status"] = tpos_payment.status.value + return response @tpos_payments_router.post(