feat: show Lightning payouts in transaction history

This commit is contained in:
9qeklajc
2026-09-21 16:44:43 +02:00
parent 465e9804ad
commit e1ded1dfe3
14 changed files with 381 additions and 47 deletions
@@ -0,0 +1,25 @@
"""Add direction to lightning_invoices
Revision ID: e4c7a1b9d520
Revises: 3a0fbd387f10
Create Date: 2026-09-20 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "e4c7a1b9d520"
down_revision = "3a0fbd387f10"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"lightning_invoices",
sa.Column("direction", sa.String(), nullable=False, server_default="in"),
)
def downgrade() -> None:
op.drop_column("lightning_invoices", "direction")
+3
View File
@@ -2051,6 +2051,7 @@ async def get_transactions_api(
async def get_lightning_invoices_api(
status: str | None = None,
purpose: str | None = None,
direction: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
@@ -2063,6 +2064,8 @@ async def get_lightning_invoices_api(
base = base.where(LightningInvoice.status == status)
if purpose:
base = base.where(LightningInvoice.purpose == purpose)
if direction:
base = base.where(LightningInvoice.direction == direction)
if search:
pattern = f"%{search}%"
base = base.where(
+57 -2
View File
@@ -509,14 +509,17 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
status: str = Field(
default="pending",
description=(
"pending, settlement_pending, paid, expired, cancelled, "
"pending, settlement_pending, paid, failed, expired, cancelled, "
"reconciliation_required"
),
)
api_key_hash: str | None = Field(
default=None, description="Associated API key hash for topup operations"
)
purpose: str = Field(description="create or topup")
direction: str = Field(
default="in", description="in for incoming invoices, out for payouts"
)
purpose: str = Field(description="create, topup or payout")
mint_url: str | None = Field(
default=None,
description="Mint URL where the quote was created (fallback tracking)",
@@ -1005,6 +1008,58 @@ async def complete_routstr_fee_payout(
return result.rowcount == 1
async def record_lightning_payout(
session: AsyncSession,
*,
quote_id: str,
bolt11: str,
amount_sats: int,
mint_url: str,
destination: str,
) -> None:
"""Record a dispatched payout so it shows up in the Lightning history."""
session.add(
LightningInvoice(
id=uuid.uuid4().hex,
bolt11=bolt11,
amount_sats=amount_sats,
description=f"Payout to {destination}",
payment_hash=quote_id,
status="pending",
direction="out",
purpose="payout",
mint_url=mint_url,
# Payouts settle or fail at the mint; they never expire on our side.
expires_at=int(time.time()),
)
)
await session.commit()
async def settle_lightning_payout(
session: AsyncSession,
quote_id: str,
*,
status: str,
amount_sats: int | None = None,
) -> None:
result = await session.exec(
select(LightningInvoice)
.where(col(LightningInvoice.payment_hash) == quote_id)
.where(col(LightningInvoice.direction) == "out")
)
payout = result.first()
if payout is None:
return
payout.status = status
if status == "paid":
payout.paid_at = int(time.time())
if amount_sats is not None:
payout.amount_sats = amount_sats
session.add(payout)
await session.commit()
async def total_user_liability(db_session: AsyncSession) -> int:
"""Return all outstanding user funds in millisatoshis.
+7 -1
View File
@@ -942,6 +942,7 @@ async def _expire_overdue_invoices(now: int) -> int:
expired = await expiry_session.exec( # type: ignore[call-overload]
update(LightningInvoice)
.where(
col(LightningInvoice.direction) == "in",
col(LightningInvoice.status) == "pending",
col(LightningInvoice.expires_at) < now,
)
@@ -959,13 +960,17 @@ async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) ->
logger.info("Expired overdue invoices", extra={"invoice_count": swept})
settling = await session.exec(
select(LightningInvoice)
.where(col(LightningInvoice.status) == "settlement_pending")
.where(
col(LightningInvoice.direction) == "in",
col(LightningInvoice.status) == "settlement_pending",
)
.order_by(col(LightningInvoice.created_at))
.limit(INVOICE_WATCH_BATCH_LIMIT // 2)
)
unpaid = await session.exec(
select(LightningInvoice)
.where(
col(LightningInvoice.direction) == "in",
col(LightningInvoice.status) == "pending",
col(LightningInvoice.expires_at) >= now,
)
@@ -975,6 +980,7 @@ async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) ->
recoverable = await session.exec(
select(LightningInvoice)
.where(
col(LightningInvoice.direction) == "in",
col(LightningInvoice.status) == "expired",
col(LightningInvoice.expires_at) > now - INVOICE_EXPIRY_GRACE_SECONDS,
)
+2 -2
View File
@@ -325,7 +325,7 @@ async def raw_send_to_lnurl(
unit: str,
amount: int | None = None,
*,
on_melt_quote: Callable[[str], Awaitable[None]] | None = None,
on_melt_quote: Callable[[str, str], Awaitable[None]] | None = None,
) -> int:
"""Send funds to an LNURL address.
@@ -413,7 +413,7 @@ async def raw_send_to_lnurl(
raise LNURLError("Cashu melt fees exceed the requested gross amount")
if on_melt_quote is not None:
await on_melt_quote(melt_quote_resp.quote)
await on_melt_quote(melt_quote_resp.quote, bolt11_invoice)
assert selected_proofs is not None
proofs = selected_proofs
+101 -11
View File
@@ -35,7 +35,7 @@ from .mint import (
mint_cooldown_remaining,
run_mint_operation,
)
from .payment.lnurl import raw_send_to_lnurl
from .payment.lnurl import MeltOutcomeAmbiguousError, raw_send_to_lnurl
# cashu 0.20.x passes the `proxies` kwarg httpx removed in 0.28; see the module
# docstring. Installed at import so no mint call can run before the patch.
@@ -1540,6 +1540,27 @@ async def fetch_all_balances(
)
async def _settle_payout_history(
quote_id: str, *, status: str, amount_sats: int | None = None
) -> None:
"""Best-effort history update after the external payment outcome is known."""
try:
async with db.create_session() as session:
await db.settle_lightning_payout(
session, quote_id, status=status, amount_sats=amount_sats
)
except Exception as e:
logger.error(
"Failed to update Lightning payout history",
extra={
"error": str(e),
"error_type": type(e).__name__,
"quote_id": quote_id,
"status": status,
},
)
async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
"""Send only conservatively proven owner funds for one wallet."""
try:
@@ -1588,13 +1609,51 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
else _sats_to_msats(settings.min_payout_sat)
)
if available_balance > min_amount:
amount_received = await raw_send_to_lnurl(
wallet,
proofs,
settings.receive_ln_address,
unit,
amount=available_balance,
)
payout_quote_id: str | None = None
async def record_payout(quote_id: str, bolt11: str) -> None:
nonlocal payout_quote_id
payout_quote_id = quote_id
async with db.create_session() as session:
await db.record_lightning_payout(
session,
quote_id=quote_id,
bolt11=bolt11,
amount_sats=(
available_balance
if unit == "sat"
else _msats_to_sats(available_balance)
),
mint_url=mint_url,
destination=settings.receive_ln_address,
)
try:
amount_received = await raw_send_to_lnurl(
wallet,
proofs,
settings.receive_ln_address,
unit,
amount=available_balance,
on_melt_quote=record_payout,
)
except Exception as e:
if payout_quote_id is not None:
await _settle_payout_history(
payout_quote_id,
status=(
"reconciliation_required"
if isinstance(e, MeltOutcomeAmbiguousError)
else "failed"
),
)
raise
if payout_quote_id is not None:
await _settle_payout_history(
payout_quote_id,
status="paid",
amount_sats=_msats_to_sats(amount_received),
)
logger.info(
"Payout sent successfully",
extra={
@@ -1866,6 +1925,7 @@ async def periodic_routstr_fee_payout() -> None:
payout_unit,
)
if completed:
await _settle_payout_history(payout_quote_id, status="paid")
logger.info(
"Routstr fee payout reconciled as paid",
extra={"payout_quote_id": payout_quote_id},
@@ -1880,6 +1940,9 @@ async def periodic_routstr_fee_payout() -> None:
payout_unit,
)
if restored:
await _settle_payout_history(
payout_quote_id, status="failed"
)
logger.warning(
"Routstr fee payout reconciled as unpaid and restored for retry",
extra={"payout_quote_id": payout_quote_id},
@@ -1910,7 +1973,7 @@ async def periodic_routstr_fee_payout() -> None:
attempt_quote_id: str | None = None
async def checkpoint_quote(quote_id: str) -> None:
async def checkpoint_quote(quote_id: str, bolt11: str) -> None:
nonlocal attempt_quote_id
async with db.create_session() as session:
checkpointed = await db.reset_routstr_fee(
@@ -1923,6 +1986,15 @@ async def periodic_routstr_fee_payout() -> None:
if not checkpointed:
raise _RoutstrFeePayoutAlreadyClaimed
attempt_quote_id = quote_id
async with db.create_session() as session:
await db.record_lightning_payout(
session,
quote_id=quote_id,
bolt11=bolt11,
amount_sats=accumulated_sats,
mint_url=settings.primary_mint,
destination=ROUTSTR_LN_ADDRESS,
)
try:
amount_received = await raw_send_to_lnurl(
@@ -1949,6 +2021,12 @@ async def periodic_routstr_fee_payout() -> None:
extra={"payout_in_progress_msats": paid_msats},
exc_info=isinstance(e, Exception),
)
async with db.create_session() as session:
await db.settle_lightning_payout(
session,
attempt_quote_id,
status="reconciliation_required",
)
if not isinstance(e, Exception):
raise
continue
@@ -1969,6 +2047,9 @@ async def periodic_routstr_fee_payout() -> None:
extra={"payout_in_progress_msats": paid_msats},
exc_info=isinstance(e, Exception),
)
await _settle_payout_history(
attempt_quote_id, status="reconciliation_required"
)
if not isinstance(e, Exception):
raise
continue
@@ -1977,8 +2058,17 @@ async def periodic_routstr_fee_payout() -> None:
"Routstr fee payout sent but checkpoint was not completed; awaiting quote reconciliation",
extra={"payout_in_progress_msats": paid_msats},
)
await _settle_payout_history(
attempt_quote_id, status="reconciliation_required"
)
continue
await _settle_payout_history(
attempt_quote_id,
status="paid",
amount_sats=_msats_to_sats(amount_received),
)
logger.info(
"Routstr fee payout sent",
extra={
@@ -1995,8 +2085,8 @@ async def periodic_routstr_fee_payout() -> None:
def _quote_callback(
notify: Callable[[str, str], Awaitable[None]], mint: str
) -> Callable[[str], Awaitable[None]]:
async def callback(quote_id: str) -> None:
) -> Callable[[str, str], Awaitable[None]]:
async def callback(quote_id: str, _bolt11: str) -> None:
await notify(quote_id, mint)
return callback
@@ -17,8 +17,10 @@ import pytest
from cashu.core.base import Proof
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core import db
from routstr.core.db import ApiKey, LightningInvoice
from routstr.lightning import _create_api_key_record
@@ -81,6 +83,41 @@ async def test_invoice_persists_validity_date(
assert stored.validity_date == expiry
@pytest.mark.asyncio
async def test_outgoing_payout_history_is_recorded_and_settled(
integration_session: AsyncSession,
) -> None:
await db.record_lightning_payout(
integration_session,
quote_id="payout-quote",
bolt11="lnbc1payout",
amount_sats=1_000,
mint_url="https://mint.test",
destination="owner@example.com",
)
result = await integration_session.exec(
select(LightningInvoice).where(LightningInvoice.payment_hash == "payout-quote")
)
payout = result.one()
assert payout.direction == "out"
assert payout.purpose == "payout"
assert payout.status == "pending"
assert payout.mint_url == "https://mint.test"
await db.settle_lightning_payout(
integration_session,
"payout-quote",
status="paid",
amount_sats=995,
)
await integration_session.refresh(payout)
assert payout.status == "paid"
assert payout.amount_sats == 995
assert payout.paid_at is not None
# ---------------------------------------------------------------------------
# Propagation to ApiKey
# ---------------------------------------------------------------------------
+12 -9
View File
@@ -58,7 +58,8 @@ async def test_invoice_read_transaction_closes_before_external_mint_io(
return wallet
with patch(
"routstr.lightning.get_wallet", side_effect=get_wallet_without_open_db_transaction
"routstr.lightning.get_wallet",
side_effect=get_wallet_without_open_db_transaction,
):
await check_invoice_payment(stored, integration_session)
@@ -191,9 +192,7 @@ async def test_failed_final_commit_rolls_back_claim_and_credit_for_retry(
assert unchanged.balance == 100_000
async with AsyncSession(integration_engine, expire_on_commit=False) as retry:
settled, _ = await _finalize_invoice_settlement(
snapshot, retry, 1_700_000_001
)
settled, _ = await _finalize_invoice_settlement(snapshot, retry, 1_700_000_001)
assert settled
async with AsyncSession(integration_engine, expire_on_commit=False) as verify:
@@ -302,9 +301,7 @@ async def test_expiry_cas_cannot_overwrite_concurrent_paid_invoice(
assert result.rowcount == 1
await paid.commit()
expired = await _expire_invoice_if_authoritatively_unpaid(
stale, caller, True
)
expired = await _expire_invoice_if_authoritatively_unpaid(stale, caller, True)
assert expired is False
assert stale.status == "paid"
@@ -381,8 +378,9 @@ async def test_sweep_expires_only_overdue_pending_invoices(
overdue = _lightning_invoice(expires_at=now - 1)
fresh = _lightning_invoice(expires_at=now + 3600)
settling = _lightning_invoice(expires_at=now - 1, status="settlement_pending")
outgoing = _lightning_invoice(expires_at=now - 1, direction="out")
async with AsyncSession(integration_engine, expire_on_commit=False) as seed:
seed.add_all([overdue, fresh, settling])
seed.add_all([overdue, fresh, settling, outgoing])
await seed.commit()
await _expire_overdue_invoices(now)
@@ -392,6 +390,7 @@ async def test_sweep_expires_only_overdue_pending_invoices(
(overdue, "expired"),
(fresh, "pending"),
(settling, "settlement_pending"),
(outgoing, "pending"),
):
stored = await verify.get(LightningInvoice, invoice.id)
assert stored is not None
@@ -409,8 +408,11 @@ async def test_watch_batch_expires_overdue_invoices_and_keeps_settling_rows(
settling = _lightning_invoice(
expires_at=now - 86_400, created_at=now - 86_400, status="settlement_pending"
)
outgoing = _lightning_invoice(
expires_at=now + 3600, created_at=now, direction="out"
)
async with AsyncSession(integration_engine, expire_on_commit=False) as seed:
seed.add_all([overdue, fresh, settling])
seed.add_all([overdue, fresh, settling, outgoing])
await seed.commit()
polled: list[str] = []
@@ -425,6 +427,7 @@ async def test_watch_batch_expires_overdue_invoices_and_keeps_settling_rows(
assert fresh.id in polled
assert settling.id in polled
assert outgoing.id not in polled
async with AsyncSession(integration_engine, expire_on_commit=False) as verify:
stored = await verify.get(LightningInvoice, overdue.id)
+37 -10
View File
@@ -1,5 +1,5 @@
import asyncio
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Generator
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
@@ -29,6 +29,21 @@ def _session_context(session: Mock) -> _SessionContext:
return _SessionContext(session)
@pytest.fixture(autouse=True)
def _mock_lightning_payout_history() -> Generator[
tuple[AsyncMock, AsyncMock], None, None
]:
with (
patch(
"routstr.wallet.db.record_lightning_payout", new_callable=AsyncMock
) as record,
patch(
"routstr.wallet.db.settle_lightning_payout", new_callable=AsyncMock
) as settle,
):
yield record, settle
@pytest.mark.asyncio
async def test_fee_payout_checkpoint_is_atomic_and_durable() -> None:
engine = create_async_engine("sqlite+aiosqlite://")
@@ -178,7 +193,7 @@ async def test_fee_payout_prepares_wallet_then_checkpoints_before_sending() -> N
async def send(*_args: object, **kwargs: object) -> int:
checkpoint_quote = kwargs["on_melt_quote"]
await checkpoint_quote("quote-1") # type: ignore[operator]
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
events.append("send")
return 5
@@ -256,7 +271,7 @@ async def test_fee_payout_lost_checkpoint_race_does_not_send() -> None:
async def send(*_args: object, **kwargs: object) -> int:
checkpoint_quote = kwargs["on_melt_quote"]
await checkpoint_quote("quote-1") # type: ignore[operator]
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
await dispatched()
return 5
@@ -289,7 +304,9 @@ async def test_fee_payout_lost_checkpoint_race_does_not_send() -> None:
@pytest.mark.asyncio
async def test_fee_payout_finalizes_a_paid_unresolved_quote_without_resending() -> None:
async def test_fee_payout_finalizes_a_paid_unresolved_quote_without_resending(
_mock_lightning_payout_history: tuple[AsyncMock, AsyncMock],
) -> None:
session = Mock()
fee = SimpleNamespace(
accumulated_msats=10_000,
@@ -331,10 +348,14 @@ async def test_fee_payout_finalizes_a_paid_unresolved_quote_without_resending()
)
restore.assert_not_awaited()
send.assert_not_awaited()
_, settle = _mock_lightning_payout_history
settle.assert_awaited_once_with(session, "quote-1", status="paid", amount_sats=None)
@pytest.mark.asyncio
async def test_fee_payout_restores_only_an_unpaid_quote_and_retries() -> None:
async def test_fee_payout_restores_only_an_unpaid_quote_and_retries(
_mock_lightning_payout_history: tuple[AsyncMock, AsyncMock],
) -> None:
session = Mock()
unresolved_fee = SimpleNamespace(
accumulated_msats=10_000,
@@ -351,7 +372,9 @@ async def test_fee_payout_restores_only_an_unpaid_quote_and_retries() -> None:
)
async def send(*_args: object, **kwargs: object) -> int:
await kwargs["on_melt_quote"]("quote-2") # type: ignore[index,operator]
await kwargs["on_melt_quote"]( # type: ignore[index,operator]
"quote-2", "lnbc1payout"
)
return 15
with (
@@ -398,6 +421,8 @@ async def test_fee_payout_restores_only_an_unpaid_quote_and_retries() -> None:
session, 15_000, "quote-2", wallet.settings.primary_mint, "sat"
)
raw_send.assert_awaited_once()
_, settle = _mock_lightning_payout_history
settle.assert_any_await(session, "quote-1", status="failed", amount_sats=None)
@pytest.mark.asyncio
@@ -579,7 +604,7 @@ async def test_fee_payout_keeps_checkpoint_when_send_outcome_is_unknown() -> Non
async def send(*_args: object, **kwargs: object) -> int:
checkpoint_quote = kwargs["on_melt_quote"]
await checkpoint_quote("quote-1") # type: ignore[operator]
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
raise TimeoutError("unknown outcome")
with (
@@ -620,7 +645,7 @@ async def test_fee_payout_cancellation_during_send_alerts_and_propagates() -> No
async def cancel_send(*_args: object, **kwargs: object) -> int:
checkpoint_quote = kwargs["on_melt_quote"]
await checkpoint_quote("quote-1") # type: ignore[operator]
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
raise asyncio.CancelledError
with (
@@ -666,6 +691,8 @@ async def test_fee_payout_completion_failures_use_sent_checkpoint_alert(
side_effect=[
_session_context(session),
_session_context(session),
_session_context(session),
RuntimeError("pool unavailable"),
RuntimeError("pool unavailable"),
]
)
@@ -675,7 +702,7 @@ async def test_fee_payout_completion_failures_use_sent_checkpoint_alert(
async def send(*_args: object, **kwargs: object) -> int:
checkpoint_quote = kwargs["on_melt_quote"]
await checkpoint_quote("quote-1") # type: ignore[operator]
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
return 5
with (
@@ -726,7 +753,7 @@ async def test_fee_payout_releases_db_connection_during_send(tmp_path: object) -
async def send(*_args: object, **kwargs: object) -> int:
assert engine.pool.checkedout() == 0 # type: ignore[attr-defined]
checkpoint_quote = kwargs["on_melt_quote"]
await checkpoint_quote("quote-1") # type: ignore[operator]
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
assert engine.pool.checkedout() == 0 # type: ignore[attr-defined]
return 5
@@ -192,7 +192,7 @@ async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion
assert paid == 485_000
assert wallet.melt_quote.await_count == 2
checkpoint.assert_awaited_once_with("q2")
checkpoint.assert_awaited_once_with("q2", "lnbc1...")
wallet.select_to_send.assert_not_called()
selected = wallet.melt.await_args.kwargs["proofs"]
assert sum(proof.amount for proof in selected) == 500
@@ -358,9 +358,7 @@ def _patch_getaddrinfo(ip: str) -> Any:
loop = MagicMock()
loop.getaddrinfo = fake_getaddrinfo
return patch.object(
lnurl_module.asyncio, "get_running_loop", return_value=loop
)
return patch.object(lnurl_module.asyncio, "get_running_loop", return_value=loop)
@pytest.mark.asyncio
+2 -1
View File
@@ -319,8 +319,9 @@ async def test_raw_send_to_lnurl_checkpoints_quote_before_melt_dispatch() -> Non
wallet, proofs = _wallet()
events: list[str] = []
async def checkpoint(quote_id: str) -> None:
async def checkpoint(quote_id: str, bolt11: str) -> None:
assert quote_id == "q"
assert bolt11 == "lnbc1..."
events.append("checkpoint")
async def melt(**_kwargs: object) -> MagicMock:
+28 -3
View File
@@ -13,7 +13,7 @@ Covers two regressions from the auto-payout / primary-mint audit
from collections.abc import Callable, Coroutine
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
@@ -53,11 +53,20 @@ def _one_cycle_sleep() -> Callable[[float], Coroutine[Any, Any, None]]:
@pytest.mark.asyncio
async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> None:
"""primary_mint absent from cashu_mints is still paid out."""
"""primary_mint absent from cashu_mints is paid out and recorded."""
from routstr.core.settings import settings
get_wallet = AsyncMock(return_value=MagicMock())
raw_send = AsyncMock(return_value=1000)
record_payout = AsyncMock()
settle_payout = AsyncMock()
async def send(*args: object, **kwargs: object) -> int:
await kwargs["on_melt_quote"]( # type: ignore[index,operator]
"quote-1", "lnbc1payout"
)
return 1_000_000
raw_send = AsyncMock(side_effect=send)
with (
patch.object(settings, "cashu_mints", []),
@@ -84,6 +93,8 @@ async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> Non
"routstr.wallet.db.total_user_liability",
AsyncMock(return_value=0),
),
patch("routstr.wallet.db.record_lightning_payout", record_payout),
patch("routstr.wallet.db.settle_lightning_payout", settle_payout),
patch("routstr.wallet.raw_send_to_lnurl", raw_send),
):
with pytest.raises(_LoopBreak):
@@ -92,6 +103,20 @@ async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> Non
processed = {call.args[0] for call in get_wallet.await_args_list}
assert processed == {"http://primary:3338"}
assert raw_send.await_count >= 1
record_payout.assert_awaited_once_with(
ANY,
quote_id="quote-1",
bolt11="lnbc1payout",
amount_sats=100_000,
mint_url="http://primary:3338",
destination="owner@ln.tld",
)
settle_payout.assert_awaited_once_with(
ANY,
"quote-1",
status="paid",
amount_sats=1_000,
)
@pytest.mark.asyncio
+56 -2
View File
@@ -264,7 +264,8 @@ function LightningInvoiceTable({
</EmptyMedia>
<EmptyTitle>No invoices found</EmptyTitle>
<EmptyDescription>
Lightning invoices created via /lightning/invoice will show here.
Lightning invoices created via /lightning/invoice and payouts sent
to your Lightning address will show here.
</EmptyDescription>
</EmptyHeader>
</Empty>
@@ -290,6 +291,33 @@ function LightningInvoiceTable({
Expired
</Badge>
);
if (status === 'failed')
return (
<Badge
variant='outline'
className='border-red-500/20 bg-red-500/10 text-red-500'
>
Failed
</Badge>
);
if (status === 'settlement_pending')
return (
<Badge
variant='outline'
className='border-amber-500/20 bg-amber-500/10 text-amber-500'
>
Settling
</Badge>
);
if (status === 'reconciliation_required')
return (
<Badge
variant='outline'
className='border-amber-500/20 bg-amber-500/10 text-amber-500'
>
Reconciling
</Badge>
);
if (status === 'cancelled')
return (
<Badge
@@ -315,6 +343,7 @@ function LightningInvoiceTable({
<Table>
<TableHeader>
<TableRow>
<TableHead>Direction</TableHead>
<TableHead>Purpose</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
@@ -328,6 +357,11 @@ function LightningInvoiceTable({
<TableBody>
{invoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell>
<Badge variant='outline' className='capitalize'>
{inv.direction === 'out' ? 'Sent' : 'Received'}
</Badge>
</TableCell>
<TableCell>
<span className='capitalize'>{inv.purpose}</span>
</TableCell>
@@ -514,15 +548,27 @@ export default function TransactionsPage() {
placeholderData: keepPreviousData,
});
const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled'];
const LIGHTNING_STATUSES = [
'pending',
'settlement_pending',
'paid',
'failed',
'expired',
'cancelled',
'reconciliation_required',
];
const lightningStatusParam = LIGHTNING_STATUSES.includes(status)
? status
: undefined;
const lightningDirectionParam = ['in', 'out'].includes(type)
? type
: undefined;
const lightningQuery = useQuery({
queryKey: [
'lightning-invoices',
lightningStatusParam,
lightningDirectionParam,
searchParam,
lightningPage,
],
@@ -530,6 +576,7 @@ export default function TransactionsPage() {
AdminService.getLightningInvoices(
lightningStatusParam,
undefined,
lightningDirectionParam,
searchParam,
PAGE_SIZE,
lightningPage * PAGE_SIZE
@@ -727,6 +774,13 @@ export default function TransactionsPage() {
<SelectItem value='collected'>Collected</SelectItem>
<SelectItem value='swept'>Swept</SelectItem>
<SelectItem value='paid'>Paid (Lightning)</SelectItem>
<SelectItem value='settlement_pending'>
Settling (Lightning)
</SelectItem>
<SelectItem value='failed'>Failed (Lightning)</SelectItem>
<SelectItem value='reconciliation_required'>
Reconciling (Lightning)
</SelectItem>
<SelectItem value='expired'>Expired (Lightning)</SelectItem>
<SelectItem value='cancelled'>
Cancelled (Lightning)
+12 -2
View File
@@ -938,6 +938,7 @@ export class AdminService {
static async getLightningInvoices(
status?: string,
purpose?: string,
direction?: string,
search?: string,
limit: number = 50,
offset: number = 0
@@ -945,6 +946,7 @@ export class AdminService {
const params = new URLSearchParams();
if (status) params.append('status', status);
if (purpose) params.append('purpose', purpose);
if (direction) params.append('direction', direction);
if (search) params.append('search', search);
params.append('limit', limit.toString());
params.append('offset', offset.toString());
@@ -1304,9 +1306,17 @@ export interface LightningInvoice {
amount_sats: number;
description: string;
payment_hash: string;
status: 'pending' | 'paid' | 'expired' | 'cancelled';
status:
| 'pending'
| 'settlement_pending'
| 'paid'
| 'failed'
| 'expired'
| 'cancelled'
| 'reconciliation_required';
api_key_hash: string | null;
purpose: 'create' | 'topup';
direction: 'in' | 'out';
purpose: 'create' | 'topup' | 'payout';
created_at: number;
expires_at: number;
paid_at: number | null;