add apikey refund tracking

This commit is contained in:
9qeklajc
2026-04-10 15:34:19 +02:00
parent 0f0f8c40bf
commit 85a1ea4b4c
6 changed files with 424 additions and 134 deletions
@@ -0,0 +1,36 @@
"""add source to cashu_transactions
Revision ID: c3d4e5f6a7b8
Revises: b1c2d3e4f5a6
Create Date: 2026-04-10 00:00:00.000000
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "c3d4e5f6a7b8"
down_revision = "b1c2d3e4f5a6"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("cashu_transactions")]
if "source" not in columns:
op.add_column(
"cashu_transactions",
sa.Column(
"source",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="x-cashu",
),
)
def downgrade() -> None:
op.drop_column("cashu_transactions", "source")
+32 -1
View File
@@ -10,7 +10,13 @@ from pydantic import BaseModel
from sqlmodel import select from sqlmodel import select
from .auth import get_billing_key, validate_bearer_key from .auth import get_billing_key, validate_bearer_key
from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session from .core.db import (
ApiKey,
AsyncSession,
CashuTransaction,
get_session,
store_cashu_transaction,
)
from .core.logging import get_logger from .core.logging import get_logger
from .core.settings import settings from .core.settings import settings
from .lightning import lightning_router from .lightning import lightning_router
@@ -310,6 +316,17 @@ async def refund_wallet_endpoint(
else: else:
result["msats"] = str(remaining_balance_msats) result["msats"] = str(remaining_balance_msats)
if "token" in result:
logger.info(
"refund_wallet_endpoint: cashu token issued",
extra={
"path": "/v1/wallet/refund",
"token": result["token"],
"amount": remaining_balance,
"currency": key.refund_currency or "sat",
},
)
except HTTPException: except HTTPException:
# Re-raise HTTP exceptions (like 400 for balance too small) # Re-raise HTTP exceptions (like 400 for balance too small)
raise raise
@@ -334,6 +351,20 @@ async def refund_wallet_endpoint(
session.add(key) session.add(key)
await session.commit() await session.commit()
if "token" in result:
try:
await store_cashu_transaction(
token=result["token"],
amount=remaining_balance,
unit=key.refund_currency or "sat",
mint_url=key.refund_mint_url,
typ="out",
collected=False,
source="apikey",
)
except Exception:
pass # store_cashu_transaction already logs
logger.info( logger.info(
"refund_wallet_endpoint: refund successful", "refund_wallet_endpoint: refund successful",
extra={ extra={
+18
View File
@@ -10,6 +10,7 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.util.exc import CommandError from alembic.util.exc import CommandError
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio.engine import create_async_engine from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update from sqlmodel import Field, Relationship, SQLModel, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -154,6 +155,10 @@ class CashuTransaction(SQLModel, table=True): # type: ignore
) )
collected: bool = Field(default=False) collected: bool = Field(default=False)
swept: bool = Field(default=False) swept: bool = Field(default=False)
source: str = Field(
default="x-cashu",
description="Payment source: x-cashu or apikey",
)
async def store_cashu_transaction( async def store_cashu_transaction(
@@ -165,6 +170,7 @@ async def store_cashu_transaction(
request_id: str | None = None, request_id: str | None = None,
collected: bool = False, collected: bool = False,
created_at: int | None = None, created_at: int | None = None,
source: str = "x-cashu",
) -> None: ) -> None:
try: try:
async with create_session() as session: async with create_session() as session:
@@ -177,6 +183,7 @@ async def store_cashu_transaction(
request_id=request_id, request_id=request_id,
collected=collected, collected=collected,
created_at=created_at or int(time.time()), created_at=created_at or int(time.time()),
source=source,
) )
session.add(tx) session.add(tx)
await session.commit() await session.commit()
@@ -331,6 +338,17 @@ def run_migrations() -> None:
command.stamp(alembic_cfg, "head") command.stamp(alembic_cfg, "head")
else: else:
raise raise
except OperationalError as e:
if "duplicate column name" in str(e).lower():
logger.warning(
"Migration hit a column that already exists (likely added via "
"create_all on another branch). Stamping to current head.",
extra={"error": str(e)},
)
_clear_alembic_version()
command.stamp(alembic_cfg, "head")
else:
raise
logger.info("Database migrations completed successfully") logger.info("Database migrations completed successfully")
+136 -2
View File
@@ -1,11 +1,11 @@
import json import json
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from routstr.balance import refund_wallet_endpoint from routstr.balance import refund_wallet_endpoint
from routstr.core.db import CashuTransaction from routstr.core.db import ApiKey, CashuTransaction
def _make_cashu_tx( def _make_cashu_tx(
@@ -114,3 +114,137 @@ async def test_refund_x_cashu_swept_raises_410() -> None:
) )
assert exc_info.value.status_code == 410 assert exc_info.value.status_code == 410
# ---------------------------------------------------------------------------
# source field defaults
# ---------------------------------------------------------------------------
def test_cashu_transaction_source_defaults_to_x_cashu() -> None:
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat")
assert tx.source == "x-cashu"
def test_cashu_transaction_source_can_be_apikey() -> None:
tx = CashuTransaction(token="cashuAtest", amount=100, unit="msat", source="apikey")
assert tx.source == "apikey"
# ---------------------------------------------------------------------------
# apikey-based refund: token logging and CashuTransaction storage
# ---------------------------------------------------------------------------
def _make_api_key(
balance: int = 5000,
refund_currency: str | None = "sat",
refund_mint_url: str | None = "https://mint.example.com",
refund_address: str | None = None,
parent_key_hash: str | None = None,
) -> ApiKey:
key = ApiKey(hashed_key="testhash")
key.balance = balance
key.reserved_balance = 0
key.refund_currency = refund_currency
key.refund_mint_url = refund_mint_url
key.refund_address = refund_address
key.parent_key_hash = parent_key_hash
key.total_spent = 0
key.total_requests = 0
return key
@pytest.mark.asyncio
async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
refund_token = "cashuArefund_apikey_token"
session = MagicMock()
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
):
result = await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
assert isinstance(result, dict)
assert result["token"] == refund_token
mock_store.assert_awaited_once()
call_kwargs = mock_store.call_args.kwargs
assert call_kwargs["source"] == "apikey"
assert call_kwargs["token"] == refund_token
assert call_kwargs["typ"] == "out"
@pytest.mark.asyncio
async def test_apikey_refund_logs_token() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
refund_token = "cashuAlogged_token"
session = MagicMock()
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger") as mock_logger,
):
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
calls = [str(c) for c in mock_logger.info.call_args_list]
assert any("cashu token issued" in c for c in calls)
@pytest.mark.asyncio
async def test_apikey_refund_log_includes_path() -> None:
key = _make_api_key(balance=5000, refund_currency="sat")
refund_token = "cashuApath_token"
session = MagicMock()
session.add = MagicMock()
session.commit = AsyncMock()
with (
patch("routstr.balance.validate_bearer_key", AsyncMock(return_value=key)),
patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger") as mock_logger,
):
await refund_wallet_endpoint(
authorization="Bearer sk-testhash",
x_cashu=None,
session=session,
)
# Find the "cashu token issued" call and verify extra contains the path
token_issued_calls = [
c for c in mock_logger.info.call_args_list
if c.args and "cashu token issued" in c.args[0]
]
assert len(token_issued_calls) == 1
extra = token_issued_calls[0].kwargs.get("extra", {})
assert extra.get("path") == "/v1/wallet/refund"
+201 -131
View File
@@ -22,6 +22,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Table, Table,
TableBody, TableBody,
@@ -47,6 +48,8 @@ import {
Copy, Copy,
Check, Check,
Receipt, Receipt,
Key,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import { AdminService, type Transaction } from '@/lib/api/services/admin'; import { AdminService, type Transaction } from '@/lib/api/services/admin';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -54,6 +57,118 @@ import { toast } from 'sonner';
const STORAGE_KEY = 'routstr-transaction-filters'; const STORAGE_KEY = 'routstr-transaction-filters';
function TransactionTable({
transactions,
copiedId,
onCopy,
getStatusBadge,
}: {
transactions: Transaction[];
copiedId: string | null;
onCopy: (text: string, id: string) => void;
getStatusBadge: (tx: Transaction) => React.ReactNode;
}) {
if (transactions.length === 0) {
return (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Receipt className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No transactions found</EmptyTitle>
<EmptyDescription>
Try adjusting your filters or check back later.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
return (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Request ID</TableHead>
<TableHead>Mint</TableHead>
<TableHead>Date</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>
<div className='flex items-center gap-2'>
{tx.type === 'in' ? (
<ArrowDownLeft className='h-4 w-4 text-green-500' />
) : (
<ArrowUpRight className='h-4 w-4 text-blue-500' />
)}
<span className='capitalize'>{tx.type}</span>
</div>
</TableCell>
<TableCell className='font-mono'>
{tx.amount} {tx.unit}
</TableCell>
<TableCell>{getStatusBadge(tx)}</TableCell>
<TableCell>
{tx.request_id ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[150px] truncate font-mono'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() => onCopy(tx.request_id!, tx.id + '-req')}
>
{copiedId === tx.id + '-req' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'>—</span>
)}
</TableCell>
<TableCell>
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
<span className='truncate'>{tx.mint_url}</span>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
);
}
export default function TransactionsPage() { export default function TransactionsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [type, setType] = useState<string>('all'); const [type, setType] = useState<string>('all');
@@ -145,12 +260,41 @@ export default function TransactionsPage() {
.filter(Boolean) .filter(Boolean)
.join(' • '); .join(' • ');
const xcashuTxs =
data?.transactions.filter((tx) => !tx.source || tx.source === 'x-cashu') ??
[];
const apikeyTxs =
data?.transactions.filter((tx) => tx.source === 'apikey') ?? [];
const renderCardContent = (txs: Transaction[]) => {
if (isLoading) {
return (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={`tx-loading-${index}`}
className='h-16 w-full rounded-lg'
/>
))}
</div>
);
}
return (
<TransactionTable
transactions={txs}
copiedId={copiedId}
onCopy={copyToClipboard}
getStatusBadge={getStatusBadge}
/>
);
};
return ( return (
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'> <AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
<div className='space-y-6'> <div className='space-y-6'>
<PageHeader <PageHeader
title='X-Cashu Transactions' title='Cashu Transactions'
description='View all incoming and outgoing X-Cashu token transactions.' description='View all incoming and outgoing Cashu token transactions.'
actions={ actions={
<Button <Button
onClick={() => refetch()} onClick={() => refetch()}
@@ -228,138 +372,64 @@ export default function TransactionsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card> <Tabs defaultValue='x-cashu'>
<CardHeader> <TabsList className='mb-4'>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'> <TabsTrigger value='x-cashu' className='flex items-center gap-2'>
<CardTitle>Transaction History</CardTitle> <Zap className='h-4 w-4' />
X-Cashu
{data && ( {data && (
<Badge variant='secondary'> <Badge variant='secondary' className='ml-1'>
{data.transactions.length} entries {xcashuTxs.length}
</Badge> </Badge>
)} )}
</div> </TabsTrigger>
{hasActiveFilters && ( <TabsTrigger value='apikey' className='flex items-center gap-2'>
<CardDescription> <Key className='h-4 w-4' />
Showing transactions filtered by {activeFilterDescription} API Key Refunds
</CardDescription> {data && (
)} <Badge variant='secondary' className='ml-1'>
</CardHeader> {apikeyTxs.length}
<CardContent className='overflow-hidden'> </Badge>
{isLoading ? ( )}
<div className='space-y-2'> </TabsTrigger>
{Array.from({ length: 8 }).map((_, index) => ( </TabsList>
<Skeleton
key={`tx-loading-${index}`} <TabsContent value='x-cashu'>
className='h-16 w-full rounded-lg' <Card>
/> <CardHeader>
))} <div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
</div> <CardTitle>X-Cashu Transaction History</CardTitle>
) : data?.transactions && data.transactions.length > 0 ? ( {hasActiveFilters && (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'> <CardDescription>
<Table> Filtered by {activeFilterDescription}
<TableHeader> </CardDescription>
<TableRow> )}
<TableHead>Type</TableHead> </div>
<TableHead>Amount</TableHead> </CardHeader>
<TableHead>Status</TableHead> <CardContent className='overflow-hidden'>
<TableHead>Request ID</TableHead> {renderCardContent(xcashuTxs)}
<TableHead>Mint</TableHead> </CardContent>
<TableHead>Date</TableHead> </Card>
<TableHead className='text-right'>Actions</TableHead> </TabsContent>
</TableRow>
</TableHeader> <TabsContent value='apikey'>
<TableBody> <Card>
{data.transactions.map((tx) => ( <CardHeader>
<TableRow key={tx.id}> <div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<TableCell> <CardTitle>API Key Refund History</CardTitle>
<div className='flex items-center gap-2'> {hasActiveFilters && (
{tx.type === 'in' ? ( <CardDescription>
<ArrowDownLeft className='h-4 w-4 text-green-500' /> Filtered by {activeFilterDescription}
) : ( </CardDescription>
<ArrowUpRight className='h-4 w-4 text-blue-500' /> )}
)} </div>
<span className='capitalize'>{tx.type}</span> </CardHeader>
</div> <CardContent className='overflow-hidden'>
</TableCell> {renderCardContent(apikeyTxs)}
<TableCell className='font-mono'> </CardContent>
{tx.amount} {tx.unit} </Card>
</TableCell> </TabsContent>
<TableCell>{getStatusBadge(tx)}</TableCell> </Tabs>
<TableCell>
{tx.request_id ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[150px] truncate font-mono'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() =>
copyToClipboard(
tx.request_id!,
tx.id + '-req'
)
}
>
{copiedId === tx.id + '-req' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'>
—
</span>
)}
</TableCell>
<TableCell>
<div className='flex max-w-[150px] items-center gap-1 truncate text-xs'>
<span className='truncate'>{tx.mint_url}</span>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
copyToClipboard(tx.token, tx.id + '-token')
}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Receipt className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No transactions found</EmptyTitle>
<EmptyDescription>
Try adjusting your filters or check back later.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</CardContent>
</Card>
</div> </div>
</AppPageShell> </AppPageShell>
); );
+1
View File
@@ -1135,6 +1135,7 @@ export interface Transaction {
created_at: number; created_at: number;
collected: boolean; collected: boolean;
swept: boolean; swept: boolean;
source: 'x-cashu' | 'apikey';
} }
export interface TransactionsResponse { export interface TransactionsResponse {