mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
feat: show Lightning payouts in transaction history
This commit is contained in:
@@ -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")
|
||||||
@@ -2051,6 +2051,7 @@ async def get_transactions_api(
|
|||||||
async def get_lightning_invoices_api(
|
async def get_lightning_invoices_api(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
purpose: str | None = None,
|
purpose: str | None = None,
|
||||||
|
direction: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -2063,6 +2064,8 @@ async def get_lightning_invoices_api(
|
|||||||
base = base.where(LightningInvoice.status == status)
|
base = base.where(LightningInvoice.status == status)
|
||||||
if purpose:
|
if purpose:
|
||||||
base = base.where(LightningInvoice.purpose == purpose)
|
base = base.where(LightningInvoice.purpose == purpose)
|
||||||
|
if direction:
|
||||||
|
base = base.where(LightningInvoice.direction == direction)
|
||||||
if search:
|
if search:
|
||||||
pattern = f"%{search}%"
|
pattern = f"%{search}%"
|
||||||
base = base.where(
|
base = base.where(
|
||||||
|
|||||||
+57
-2
@@ -509,14 +509,17 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
|||||||
status: str = Field(
|
status: str = Field(
|
||||||
default="pending",
|
default="pending",
|
||||||
description=(
|
description=(
|
||||||
"pending, settlement_pending, paid, expired, cancelled, "
|
"pending, settlement_pending, paid, failed, expired, cancelled, "
|
||||||
"reconciliation_required"
|
"reconciliation_required"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
api_key_hash: str | None = Field(
|
api_key_hash: str | None = Field(
|
||||||
default=None, description="Associated API key hash for topup operations"
|
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(
|
mint_url: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Mint URL where the quote was created (fallback tracking)",
|
description="Mint URL where the quote was created (fallback tracking)",
|
||||||
@@ -1005,6 +1008,58 @@ async def complete_routstr_fee_payout(
|
|||||||
return result.rowcount == 1
|
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:
|
async def total_user_liability(db_session: AsyncSession) -> int:
|
||||||
"""Return all outstanding user funds in millisatoshis.
|
"""Return all outstanding user funds in millisatoshis.
|
||||||
|
|
||||||
|
|||||||
@@ -942,6 +942,7 @@ async def _expire_overdue_invoices(now: int) -> int:
|
|||||||
expired = await expiry_session.exec( # type: ignore[call-overload]
|
expired = await expiry_session.exec( # type: ignore[call-overload]
|
||||||
update(LightningInvoice)
|
update(LightningInvoice)
|
||||||
.where(
|
.where(
|
||||||
|
col(LightningInvoice.direction) == "in",
|
||||||
col(LightningInvoice.status) == "pending",
|
col(LightningInvoice.status) == "pending",
|
||||||
col(LightningInvoice.expires_at) < now,
|
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})
|
logger.info("Expired overdue invoices", extra={"invoice_count": swept})
|
||||||
settling = await session.exec(
|
settling = await session.exec(
|
||||||
select(LightningInvoice)
|
select(LightningInvoice)
|
||||||
.where(col(LightningInvoice.status) == "settlement_pending")
|
.where(
|
||||||
|
col(LightningInvoice.direction) == "in",
|
||||||
|
col(LightningInvoice.status) == "settlement_pending",
|
||||||
|
)
|
||||||
.order_by(col(LightningInvoice.created_at))
|
.order_by(col(LightningInvoice.created_at))
|
||||||
.limit(INVOICE_WATCH_BATCH_LIMIT // 2)
|
.limit(INVOICE_WATCH_BATCH_LIMIT // 2)
|
||||||
)
|
)
|
||||||
unpaid = await session.exec(
|
unpaid = await session.exec(
|
||||||
select(LightningInvoice)
|
select(LightningInvoice)
|
||||||
.where(
|
.where(
|
||||||
|
col(LightningInvoice.direction) == "in",
|
||||||
col(LightningInvoice.status) == "pending",
|
col(LightningInvoice.status) == "pending",
|
||||||
col(LightningInvoice.expires_at) >= now,
|
col(LightningInvoice.expires_at) >= now,
|
||||||
)
|
)
|
||||||
@@ -975,6 +980,7 @@ async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) ->
|
|||||||
recoverable = await session.exec(
|
recoverable = await session.exec(
|
||||||
select(LightningInvoice)
|
select(LightningInvoice)
|
||||||
.where(
|
.where(
|
||||||
|
col(LightningInvoice.direction) == "in",
|
||||||
col(LightningInvoice.status) == "expired",
|
col(LightningInvoice.status) == "expired",
|
||||||
col(LightningInvoice.expires_at) > now - INVOICE_EXPIRY_GRACE_SECONDS,
|
col(LightningInvoice.expires_at) > now - INVOICE_EXPIRY_GRACE_SECONDS,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ async def raw_send_to_lnurl(
|
|||||||
unit: str,
|
unit: str,
|
||||||
amount: int | None = None,
|
amount: int | None = None,
|
||||||
*,
|
*,
|
||||||
on_melt_quote: Callable[[str], Awaitable[None]] | None = None,
|
on_melt_quote: Callable[[str, str], Awaitable[None]] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Send funds to an LNURL address.
|
"""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")
|
raise LNURLError("Cashu melt fees exceed the requested gross amount")
|
||||||
|
|
||||||
if on_melt_quote is not None:
|
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
|
assert selected_proofs is not None
|
||||||
proofs = selected_proofs
|
proofs = selected_proofs
|
||||||
|
|||||||
+101
-11
@@ -35,7 +35,7 @@ from .mint import (
|
|||||||
mint_cooldown_remaining,
|
mint_cooldown_remaining,
|
||||||
run_mint_operation,
|
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
|
# 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.
|
# 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:
|
async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
|
||||||
"""Send only conservatively proven owner funds for one wallet."""
|
"""Send only conservatively proven owner funds for one wallet."""
|
||||||
try:
|
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)
|
else _sats_to_msats(settings.min_payout_sat)
|
||||||
)
|
)
|
||||||
if available_balance > min_amount:
|
if available_balance > min_amount:
|
||||||
amount_received = await raw_send_to_lnurl(
|
payout_quote_id: str | None = None
|
||||||
wallet,
|
|
||||||
proofs,
|
async def record_payout(quote_id: str, bolt11: str) -> None:
|
||||||
settings.receive_ln_address,
|
nonlocal payout_quote_id
|
||||||
unit,
|
payout_quote_id = quote_id
|
||||||
amount=available_balance,
|
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(
|
logger.info(
|
||||||
"Payout sent successfully",
|
"Payout sent successfully",
|
||||||
extra={
|
extra={
|
||||||
@@ -1866,6 +1925,7 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
payout_unit,
|
payout_unit,
|
||||||
)
|
)
|
||||||
if completed:
|
if completed:
|
||||||
|
await _settle_payout_history(payout_quote_id, status="paid")
|
||||||
logger.info(
|
logger.info(
|
||||||
"Routstr fee payout reconciled as paid",
|
"Routstr fee payout reconciled as paid",
|
||||||
extra={"payout_quote_id": payout_quote_id},
|
extra={"payout_quote_id": payout_quote_id},
|
||||||
@@ -1880,6 +1940,9 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
payout_unit,
|
payout_unit,
|
||||||
)
|
)
|
||||||
if restored:
|
if restored:
|
||||||
|
await _settle_payout_history(
|
||||||
|
payout_quote_id, status="failed"
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Routstr fee payout reconciled as unpaid and restored for retry",
|
"Routstr fee payout reconciled as unpaid and restored for retry",
|
||||||
extra={"payout_quote_id": payout_quote_id},
|
extra={"payout_quote_id": payout_quote_id},
|
||||||
@@ -1910,7 +1973,7 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
|
|
||||||
attempt_quote_id: str | None = 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
|
nonlocal attempt_quote_id
|
||||||
async with db.create_session() as session:
|
async with db.create_session() as session:
|
||||||
checkpointed = await db.reset_routstr_fee(
|
checkpointed = await db.reset_routstr_fee(
|
||||||
@@ -1923,6 +1986,15 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
if not checkpointed:
|
if not checkpointed:
|
||||||
raise _RoutstrFeePayoutAlreadyClaimed
|
raise _RoutstrFeePayoutAlreadyClaimed
|
||||||
attempt_quote_id = quote_id
|
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:
|
try:
|
||||||
amount_received = await raw_send_to_lnurl(
|
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},
|
extra={"payout_in_progress_msats": paid_msats},
|
||||||
exc_info=isinstance(e, Exception),
|
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):
|
if not isinstance(e, Exception):
|
||||||
raise
|
raise
|
||||||
continue
|
continue
|
||||||
@@ -1969,6 +2047,9 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
extra={"payout_in_progress_msats": paid_msats},
|
extra={"payout_in_progress_msats": paid_msats},
|
||||||
exc_info=isinstance(e, Exception),
|
exc_info=isinstance(e, Exception),
|
||||||
)
|
)
|
||||||
|
await _settle_payout_history(
|
||||||
|
attempt_quote_id, status="reconciliation_required"
|
||||||
|
)
|
||||||
if not isinstance(e, Exception):
|
if not isinstance(e, Exception):
|
||||||
raise
|
raise
|
||||||
continue
|
continue
|
||||||
@@ -1977,8 +2058,17 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
"Routstr fee payout sent but checkpoint was not completed; awaiting quote reconciliation",
|
"Routstr fee payout sent but checkpoint was not completed; awaiting quote reconciliation",
|
||||||
extra={"payout_in_progress_msats": paid_msats},
|
extra={"payout_in_progress_msats": paid_msats},
|
||||||
)
|
)
|
||||||
|
await _settle_payout_history(
|
||||||
|
attempt_quote_id, status="reconciliation_required"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
await _settle_payout_history(
|
||||||
|
attempt_quote_id,
|
||||||
|
status="paid",
|
||||||
|
amount_sats=_msats_to_sats(amount_received),
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Routstr fee payout sent",
|
"Routstr fee payout sent",
|
||||||
extra={
|
extra={
|
||||||
@@ -1995,8 +2085,8 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
|
|
||||||
def _quote_callback(
|
def _quote_callback(
|
||||||
notify: Callable[[str, str], Awaitable[None]], mint: str
|
notify: Callable[[str, str], Awaitable[None]], mint: str
|
||||||
) -> Callable[[str], Awaitable[None]]:
|
) -> Callable[[str, str], Awaitable[None]]:
|
||||||
async def callback(quote_id: str) -> None:
|
async def callback(quote_id: str, _bolt11: str) -> None:
|
||||||
await notify(quote_id, mint)
|
await notify(quote_id, mint)
|
||||||
|
|
||||||
return callback
|
return callback
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import pytest
|
|||||||
from cashu.core.base import Proof
|
from cashu.core.base import Proof
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.core import db
|
||||||
from routstr.core.db import ApiKey, LightningInvoice
|
from routstr.core.db import ApiKey, LightningInvoice
|
||||||
from routstr.lightning import _create_api_key_record
|
from routstr.lightning import _create_api_key_record
|
||||||
|
|
||||||
@@ -81,6 +83,41 @@ async def test_invoice_persists_validity_date(
|
|||||||
assert stored.validity_date == expiry
|
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
|
# Propagation to ApiKey
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ async def test_invoice_read_transaction_closes_before_external_mint_io(
|
|||||||
return wallet
|
return wallet
|
||||||
|
|
||||||
with patch(
|
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)
|
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
|
assert unchanged.balance == 100_000
|
||||||
|
|
||||||
async with AsyncSession(integration_engine, expire_on_commit=False) as retry:
|
async with AsyncSession(integration_engine, expire_on_commit=False) as retry:
|
||||||
settled, _ = await _finalize_invoice_settlement(
|
settled, _ = await _finalize_invoice_settlement(snapshot, retry, 1_700_000_001)
|
||||||
snapshot, retry, 1_700_000_001
|
|
||||||
)
|
|
||||||
assert settled
|
assert settled
|
||||||
|
|
||||||
async with AsyncSession(integration_engine, expire_on_commit=False) as verify:
|
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
|
assert result.rowcount == 1
|
||||||
await paid.commit()
|
await paid.commit()
|
||||||
|
|
||||||
expired = await _expire_invoice_if_authoritatively_unpaid(
|
expired = await _expire_invoice_if_authoritatively_unpaid(stale, caller, True)
|
||||||
stale, caller, True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert expired is False
|
assert expired is False
|
||||||
assert stale.status == "paid"
|
assert stale.status == "paid"
|
||||||
@@ -381,8 +378,9 @@ async def test_sweep_expires_only_overdue_pending_invoices(
|
|||||||
overdue = _lightning_invoice(expires_at=now - 1)
|
overdue = _lightning_invoice(expires_at=now - 1)
|
||||||
fresh = _lightning_invoice(expires_at=now + 3600)
|
fresh = _lightning_invoice(expires_at=now + 3600)
|
||||||
settling = _lightning_invoice(expires_at=now - 1, status="settlement_pending")
|
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:
|
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 seed.commit()
|
||||||
|
|
||||||
await _expire_overdue_invoices(now)
|
await _expire_overdue_invoices(now)
|
||||||
@@ -392,6 +390,7 @@ async def test_sweep_expires_only_overdue_pending_invoices(
|
|||||||
(overdue, "expired"),
|
(overdue, "expired"),
|
||||||
(fresh, "pending"),
|
(fresh, "pending"),
|
||||||
(settling, "settlement_pending"),
|
(settling, "settlement_pending"),
|
||||||
|
(outgoing, "pending"),
|
||||||
):
|
):
|
||||||
stored = await verify.get(LightningInvoice, invoice.id)
|
stored = await verify.get(LightningInvoice, invoice.id)
|
||||||
assert stored is not None
|
assert stored is not None
|
||||||
@@ -409,8 +408,11 @@ async def test_watch_batch_expires_overdue_invoices_and_keeps_settling_rows(
|
|||||||
settling = _lightning_invoice(
|
settling = _lightning_invoice(
|
||||||
expires_at=now - 86_400, created_at=now - 86_400, status="settlement_pending"
|
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:
|
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 seed.commit()
|
||||||
|
|
||||||
polled: list[str] = []
|
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 fresh.id in polled
|
||||||
assert settling.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:
|
async with AsyncSession(integration_engine, expire_on_commit=False) as verify:
|
||||||
stored = await verify.get(LightningInvoice, overdue.id)
|
stored = await verify.get(LightningInvoice, overdue.id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator, Generator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
@@ -29,6 +29,21 @@ def _session_context(session: Mock) -> _SessionContext:
|
|||||||
return _SessionContext(session)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_fee_payout_checkpoint_is_atomic_and_durable() -> None:
|
async def test_fee_payout_checkpoint_is_atomic_and_durable() -> None:
|
||||||
engine = create_async_engine("sqlite+aiosqlite://")
|
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:
|
async def send(*_args: object, **kwargs: object) -> int:
|
||||||
checkpoint_quote = kwargs["on_melt_quote"]
|
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")
|
events.append("send")
|
||||||
return 5
|
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:
|
async def send(*_args: object, **kwargs: object) -> int:
|
||||||
checkpoint_quote = kwargs["on_melt_quote"]
|
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()
|
await dispatched()
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
@@ -289,7 +304,9 @@ async def test_fee_payout_lost_checkpoint_race_does_not_send() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
session = Mock()
|
||||||
fee = SimpleNamespace(
|
fee = SimpleNamespace(
|
||||||
accumulated_msats=10_000,
|
accumulated_msats=10_000,
|
||||||
@@ -331,10 +348,14 @@ async def test_fee_payout_finalizes_a_paid_unresolved_quote_without_resending()
|
|||||||
)
|
)
|
||||||
restore.assert_not_awaited()
|
restore.assert_not_awaited()
|
||||||
send.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
|
@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()
|
session = Mock()
|
||||||
unresolved_fee = SimpleNamespace(
|
unresolved_fee = SimpleNamespace(
|
||||||
accumulated_msats=10_000,
|
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:
|
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
|
return 15
|
||||||
|
|
||||||
with (
|
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"
|
session, 15_000, "quote-2", wallet.settings.primary_mint, "sat"
|
||||||
)
|
)
|
||||||
raw_send.assert_awaited_once()
|
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
|
@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:
|
async def send(*_args: object, **kwargs: object) -> int:
|
||||||
checkpoint_quote = kwargs["on_melt_quote"]
|
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")
|
raise TimeoutError("unknown outcome")
|
||||||
|
|
||||||
with (
|
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:
|
async def cancel_send(*_args: object, **kwargs: object) -> int:
|
||||||
checkpoint_quote = kwargs["on_melt_quote"]
|
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
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -666,6 +691,8 @@ async def test_fee_payout_completion_failures_use_sent_checkpoint_alert(
|
|||||||
side_effect=[
|
side_effect=[
|
||||||
_session_context(session),
|
_session_context(session),
|
||||||
_session_context(session),
|
_session_context(session),
|
||||||
|
_session_context(session),
|
||||||
|
RuntimeError("pool unavailable"),
|
||||||
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:
|
async def send(*_args: object, **kwargs: object) -> int:
|
||||||
checkpoint_quote = kwargs["on_melt_quote"]
|
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
|
return 5
|
||||||
|
|
||||||
with (
|
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:
|
async def send(*_args: object, **kwargs: object) -> int:
|
||||||
assert engine.pool.checkedout() == 0 # type: ignore[attr-defined]
|
assert engine.pool.checkedout() == 0 # type: ignore[attr-defined]
|
||||||
checkpoint_quote = kwargs["on_melt_quote"]
|
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]
|
assert engine.pool.checkedout() == 0 # type: ignore[attr-defined]
|
||||||
return 5
|
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 paid == 485_000
|
||||||
assert wallet.melt_quote.await_count == 2
|
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()
|
wallet.select_to_send.assert_not_called()
|
||||||
selected = wallet.melt.await_args.kwargs["proofs"]
|
selected = wallet.melt.await_args.kwargs["proofs"]
|
||||||
assert sum(proof.amount for proof in selected) == 500
|
assert sum(proof.amount for proof in selected) == 500
|
||||||
@@ -358,9 +358,7 @@ def _patch_getaddrinfo(ip: str) -> Any:
|
|||||||
|
|
||||||
loop = MagicMock()
|
loop = MagicMock()
|
||||||
loop.getaddrinfo = fake_getaddrinfo
|
loop.getaddrinfo = fake_getaddrinfo
|
||||||
return patch.object(
|
return patch.object(lnurl_module.asyncio, "get_running_loop", return_value=loop)
|
||||||
lnurl_module.asyncio, "get_running_loop", return_value=loop
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -319,8 +319,9 @@ async def test_raw_send_to_lnurl_checkpoints_quote_before_melt_dispatch() -> Non
|
|||||||
wallet, proofs = _wallet()
|
wallet, proofs = _wallet()
|
||||||
events: list[str] = []
|
events: list[str] = []
|
||||||
|
|
||||||
async def checkpoint(quote_id: str) -> None:
|
async def checkpoint(quote_id: str, bolt11: str) -> None:
|
||||||
assert quote_id == "q"
|
assert quote_id == "q"
|
||||||
|
assert bolt11 == "lnbc1..."
|
||||||
events.append("checkpoint")
|
events.append("checkpoint")
|
||||||
|
|
||||||
async def melt(**_kwargs: object) -> MagicMock:
|
async def melt(**_kwargs: object) -> MagicMock:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Covers two regressions from the auto-payout / primary-mint audit
|
|||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -53,11 +53,20 @@ def _one_cycle_sleep() -> Callable[[float], Coroutine[Any, Any, None]]:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> None:
|
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
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
get_wallet = AsyncMock(return_value=MagicMock())
|
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 (
|
with (
|
||||||
patch.object(settings, "cashu_mints", []),
|
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",
|
"routstr.wallet.db.total_user_liability",
|
||||||
AsyncMock(return_value=0),
|
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),
|
patch("routstr.wallet.raw_send_to_lnurl", raw_send),
|
||||||
):
|
):
|
||||||
with pytest.raises(_LoopBreak):
|
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}
|
processed = {call.args[0] for call in get_wallet.await_args_list}
|
||||||
assert processed == {"http://primary:3338"}
|
assert processed == {"http://primary:3338"}
|
||||||
assert raw_send.await_count >= 1
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -264,7 +264,8 @@ function LightningInvoiceTable({
|
|||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>No invoices found</EmptyTitle>
|
<EmptyTitle>No invoices found</EmptyTitle>
|
||||||
<EmptyDescription>
|
<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>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
@@ -290,6 +291,33 @@ function LightningInvoiceTable({
|
|||||||
Expired
|
Expired
|
||||||
</Badge>
|
</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')
|
if (status === 'cancelled')
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -315,6 +343,7 @@ function LightningInvoiceTable({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead>Direction</TableHead>
|
||||||
<TableHead>Purpose</TableHead>
|
<TableHead>Purpose</TableHead>
|
||||||
<TableHead>Amount</TableHead>
|
<TableHead>Amount</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
@@ -328,6 +357,11 @@ function LightningInvoiceTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{invoices.map((inv) => (
|
{invoices.map((inv) => (
|
||||||
<TableRow key={inv.id}>
|
<TableRow key={inv.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{inv.direction === 'out' ? 'Sent' : 'Received'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className='capitalize'>{inv.purpose}</span>
|
<span className='capitalize'>{inv.purpose}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -514,15 +548,27 @@ export default function TransactionsPage() {
|
|||||||
placeholderData: keepPreviousData,
|
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)
|
const lightningStatusParam = LIGHTNING_STATUSES.includes(status)
|
||||||
? status
|
? status
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const lightningDirectionParam = ['in', 'out'].includes(type)
|
||||||
|
? type
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const lightningQuery = useQuery({
|
const lightningQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'lightning-invoices',
|
'lightning-invoices',
|
||||||
lightningStatusParam,
|
lightningStatusParam,
|
||||||
|
lightningDirectionParam,
|
||||||
searchParam,
|
searchParam,
|
||||||
lightningPage,
|
lightningPage,
|
||||||
],
|
],
|
||||||
@@ -530,6 +576,7 @@ export default function TransactionsPage() {
|
|||||||
AdminService.getLightningInvoices(
|
AdminService.getLightningInvoices(
|
||||||
lightningStatusParam,
|
lightningStatusParam,
|
||||||
undefined,
|
undefined,
|
||||||
|
lightningDirectionParam,
|
||||||
searchParam,
|
searchParam,
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
lightningPage * PAGE_SIZE
|
lightningPage * PAGE_SIZE
|
||||||
@@ -727,6 +774,13 @@ export default function TransactionsPage() {
|
|||||||
<SelectItem value='collected'>Collected</SelectItem>
|
<SelectItem value='collected'>Collected</SelectItem>
|
||||||
<SelectItem value='swept'>Swept</SelectItem>
|
<SelectItem value='swept'>Swept</SelectItem>
|
||||||
<SelectItem value='paid'>Paid (Lightning)</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='expired'>Expired (Lightning)</SelectItem>
|
||||||
<SelectItem value='cancelled'>
|
<SelectItem value='cancelled'>
|
||||||
Cancelled (Lightning)
|
Cancelled (Lightning)
|
||||||
|
|||||||
@@ -938,6 +938,7 @@ export class AdminService {
|
|||||||
static async getLightningInvoices(
|
static async getLightningInvoices(
|
||||||
status?: string,
|
status?: string,
|
||||||
purpose?: string,
|
purpose?: string,
|
||||||
|
direction?: string,
|
||||||
search?: string,
|
search?: string,
|
||||||
limit: number = 50,
|
limit: number = 50,
|
||||||
offset: number = 0
|
offset: number = 0
|
||||||
@@ -945,6 +946,7 @@ export class AdminService {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (status) params.append('status', status);
|
if (status) params.append('status', status);
|
||||||
if (purpose) params.append('purpose', purpose);
|
if (purpose) params.append('purpose', purpose);
|
||||||
|
if (direction) params.append('direction', direction);
|
||||||
if (search) params.append('search', search);
|
if (search) params.append('search', search);
|
||||||
params.append('limit', limit.toString());
|
params.append('limit', limit.toString());
|
||||||
params.append('offset', offset.toString());
|
params.append('offset', offset.toString());
|
||||||
@@ -1304,9 +1306,17 @@ export interface LightningInvoice {
|
|||||||
amount_sats: number;
|
amount_sats: number;
|
||||||
description: string;
|
description: string;
|
||||||
payment_hash: string;
|
payment_hash: string;
|
||||||
status: 'pending' | 'paid' | 'expired' | 'cancelled';
|
status:
|
||||||
|
| 'pending'
|
||||||
|
| 'settlement_pending'
|
||||||
|
| 'paid'
|
||||||
|
| 'failed'
|
||||||
|
| 'expired'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'reconciliation_required';
|
||||||
api_key_hash: string | null;
|
api_key_hash: string | null;
|
||||||
purpose: 'create' | 'topup';
|
direction: 'in' | 'out';
|
||||||
|
purpose: 'create' | 'topup' | 'payout';
|
||||||
created_at: number;
|
created_at: number;
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
paid_at: number | null;
|
paid_at: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user