Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
""")
19 changes: 19 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions static/js/tpos.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 => {
Expand Down
46 changes: 43 additions & 3 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand All @@ -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,
Expand All @@ -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)


Expand All @@ -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)

Expand Down
82 changes: 82 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4

import pytest
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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")
Expand Down
21 changes: 18 additions & 3 deletions views_payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)
from .helpers import inventory_tags_to_list
from .models import (
TERMINAL_PAYMENT_STATUSES,
CreateTposInvoice,
InventorySale,
PayLnurlWData,
Expand Down Expand Up @@ -385,20 +386,34 @@ 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."
)
if payment.extra.get("tag") != "tpos":
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(
Expand Down
Loading