add transaction view

This commit is contained in:
9qeklajc
2026-03-11 23:39:22 +01:00
parent f56ba92ae8
commit 2c2124952f
11 changed files with 503 additions and 60 deletions
@@ -1,4 +1,4 @@
"""add cashu_refunds table
"""add cashu_transactions table
Revision ID: a776ca70e5fe
Revises: 614c0a740e68
@@ -10,25 +10,33 @@ import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = 'a776ca70e5fe'
down_revision = '614c0a740e68'
revision = "a776ca70e5fe"
down_revision = "614c0a740e68"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'cashu_refunds',
sa.Column('payment_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('refund_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('mint_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.Integer(), nullable=False),
sa.Column('collected', sa.Boolean(), nullable=False),
sa.Column('swept', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('payment_token_hash'),
"cashu_transactions",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("amount", sa.Integer(), nullable=False),
sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"type",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
server_default="out",
),
sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.Integer(), nullable=False),
sa.Column("collected", sa.Boolean(), nullable=False),
sa.Column("swept", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table('cashu_refunds')
op.drop_table("cashu_transactions")
+3 -3
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel
from sqlmodel import select
from .auth import get_billing_key, validate_bearer_key
from .core.db import ApiKey, AsyncSession, CashuRefund, get_session
from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session
from .core.logging import get_logger
from .core.settings import settings
from .lightning import lightning_router
@@ -407,7 +407,7 @@ async def get_cashu_refund(
session: AsyncSession = Depends(get_session),
) -> dict:
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
result = await session.get(CashuRefund, payment_token_hash)
result = await session.get(CashuTransaction, payment_token_hash)
if result is None:
raise HTTPException(status_code=404, detail="Refund not found")
if result.swept:
@@ -416,7 +416,7 @@ async def get_cashu_refund(
session.add(result)
await session.commit()
return {
"refund_token": result.refund_token,
"refund_token": result.token,
"amount": result.amount,
"unit": result.unit,
}
+53 -4
View File
@@ -16,7 +16,7 @@ from ..wallet import (
send_token,
slow_filter_spend_proofs,
)
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
from .db import ApiKey, CashuTransaction, ModelRow, UpstreamProviderRow, create_session
from .log_manager import log_manager
from .logging import get_logger
from .settings import SettingsService, settings
@@ -747,7 +747,9 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
)
db_model_ids = {model.id for model in db_models}
filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids]
filtered_remote_models = [
m for m in upstream_models if m.id not in db_model_ids
]
return {
"provider": {
@@ -893,7 +895,9 @@ async def initiate_provider_topup(
"purpose": "topup",
"api_key": provider.api_key,
},
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
headers={"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {},
)
if resp.status_code == 200:
@@ -977,7 +981,9 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
clean_url = provider.base_url.rstrip("/")
resp = await client.get(
f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status",
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
headers={"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {},
)
if resp.status_code == 200:
status_data = resp.json()
@@ -1208,6 +1214,49 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
return {"dates": dates}
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
async def get_transactions_api(
type: str | None = None,
status: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict:
async with create_session() as session:
from sqlmodel import col
stmt = select(CashuTransaction)
if type:
stmt = stmt.where(CashuTransaction.type == type)
if status:
if status == "collected":
stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712
elif status == "swept":
stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712
elif status == "pending":
stmt = stmt.where(
not CashuTransaction.collected,
CashuTransaction.swept == False, # noqa: E712
)
if search:
search_pattern = f"%{search}%"
stmt = stmt.where(
(col(CashuTransaction.id).like(search_pattern))
| (col(CashuTransaction.token).like(search_pattern))
| (col(CashuTransaction.request_id).like(search_pattern))
)
stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit)
results = await session.exec(stmt)
transactions = results.all()
return {
"transactions": [tx.dict() for tx in transactions],
"total": len(transactions),
}
@admin_router.post(
"/api/upstream-providers/{provider_id}/routstr/refund",
dependencies=[Depends(require_admin_api)],
+47 -20
View File
@@ -7,6 +7,7 @@ from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from alembic.util.exc import CommandError
from sqlalchemy import UniqueConstraint
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, Relationship, SQLModel, func, select, update
@@ -128,19 +129,19 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
class CashuRefund(SQLModel, table=True): # type: ignore
__tablename__ = "cashu_refunds"
class CashuTransaction(SQLModel, table=True): # type: ignore
__tablename__ = "cashu_transactions"
payment_token_hash: str = Field(
id: str = Field(
primary_key=True,
description="SHA-256 hash of the original x-cashu payment token",
description="SHA-256 hash of the x-cashu token or unique identifier",
)
refund_token: str = Field(description="Serialized Cashu refund token")
amount: int = Field(description="Refund amount in the token's unit")
token: str = Field(description="Serialized Cashu token")
amount: int = Field(description="Amount in the token's unit")
unit: str = Field(description="Token unit (sat or msat)")
mint_url: str | None = Field(
default=None, description="Mint URL for the refund token"
)
mint_url: str | None = Field(default=None, description="Mint URL for the token")
type: str = Field(default="out", description="Transaction type: in or out")
request_id: str | None = Field(default=None, description="Associated request ID")
created_at: int = Field(
default_factory=lambda: int(time.time()),
description="Unix timestamp",
@@ -149,28 +150,32 @@ class CashuRefund(SQLModel, table=True): # type: ignore
swept: bool = Field(default=False)
async def store_cashu_refund(
payment_token_hash: str,
refund_token: str,
async def store_cashu_transaction(
id: str,
token: str,
amount: int,
unit: str,
mint_url: str | None = None,
type: str = "out",
request_id: str | None = None,
) -> None:
try:
async with create_session() as session:
refund = CashuRefund(
payment_token_hash=payment_token_hash,
refund_token=refund_token,
tx = CashuTransaction(
id=id,
token=token,
amount=amount,
unit=unit,
mint_url=mint_url,
type=type,
request_id=request_id,
)
session.add(refund)
session.add(tx)
await session.commit()
except Exception as e:
logger.warning(
"Failed to store cashu refund",
extra={"error": str(e), "payment_token_hash": payment_token_hash},
"Failed to store cashu transaction",
extra={"error": str(e), "id": id, "type": type},
)
@@ -273,6 +278,17 @@ def fix_cashu_migrations() -> None:
logger.warning(f"Could not check/fix Cashu database {db_file}: {e}")
def _clear_alembic_version() -> None:
"""Clear the alembic_version table so stamp/upgrade can proceed."""
sync_url = DATABASE_URL.replace("+aiosqlite", "")
from sqlalchemy import create_engine, text
eng = create_engine(sync_url)
with eng.begin() as conn:
conn.execute(text("DELETE FROM alembic_version"))
eng.dispose()
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
try:
@@ -294,8 +310,19 @@ def run_migrations() -> None:
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
command.upgrade(alembic_cfg, "head")
try:
command.upgrade(alembic_cfg, "head")
except CommandError as e:
if "Can't locate revision" in str(e):
logger.warning(
"Database stamped with unknown revision (likely from another branch). "
"Re-stamping to current head.",
extra={"error": str(e)},
)
_clear_alembic_version()
command.stamp(alembic_cfg, "head")
else:
raise
logger.info("Database migrations completed successfully")
+5 -2
View File
@@ -238,9 +238,10 @@ class SettingsService:
except Exception:
db_json = {}
valid_fields = set(env_resolved.dict().keys())
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
{k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
)
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
@@ -306,8 +307,10 @@ class SettingsService:
raise RuntimeError("Settings row missing")
(data_str,) = row
data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str)
valid_fields = set(settings.dict().keys())
# Update in-place
for k, v in data.items():
setattr(settings, k, v)
if k in valid_fields:
setattr(settings, k, v)
cls._current = settings
return settings
+20 -9
View File
@@ -21,7 +21,7 @@ from ..core.db import (
AsyncSession,
UpstreamProviderRow,
create_session,
store_cashu_refund,
store_cashu_transaction,
)
from ..core.exceptions import UpstreamError
from ..payment.cost_calculation import (
@@ -1725,15 +1725,16 @@ class BaseUpstreamProvider:
if payment_token_hash:
try:
await store_cashu_refund(
payment_token_hash=payment_token_hash,
refund_token=refund_token,
await store_cashu_transaction(
id=payment_token_hash,
token=refund_token,
amount=amount,
unit=unit,
mint_url=mint,
type="out",
)
except Exception:
pass # store_cashu_refund already logs
pass # store_cashu_transaction already logs
return refund_token
except Exception as e:
@@ -2033,8 +2034,13 @@ class BaseUpstreamProvider:
response.headers["X-Cashu"] = refund_token
if payment_token_hash:
try:
await store_cashu_refund(
payment_token_hash, refund_token, emergency_refund, unit, mint
await store_cashu_transaction(
id=payment_token_hash,
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
type="out",
)
except Exception:
pass
@@ -2899,8 +2905,13 @@ class BaseUpstreamProvider:
response.headers["X-Cashu"] = refund_token
if payment_token_hash:
try:
await store_cashu_refund(
payment_token_hash, refund_token, emergency_refund, unit, mint
await store_cashu_transaction(
id=payment_token_hash,
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
type="out",
)
except Exception:
pass
+25 -8
View File
@@ -1,4 +1,5 @@
import asyncio
import hashlib
import math
import time
from typing import TypedDict
@@ -35,6 +36,21 @@ async def recieve_token(
wallet.verify_proofs_dleq(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
# Store incoming transaction
try:
token_hash = hashlib.sha256(token.encode()).hexdigest()
await db.store_cashu_transaction(
id=token_hash,
token=token,
amount=token_obj.amount,
unit=token_obj.unit,
mint_url=token_obj.mint,
type="in",
)
except Exception:
pass
return token_obj.amount, token_obj.unit, token_obj.mint
@@ -359,23 +375,24 @@ async def periodic_refund_sweep() -> None:
try:
cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds
async with db.create_session() as session:
stmt = select(db.CashuRefund).where(
db.CashuRefund.collected == False, # noqa: E712
db.CashuRefund.swept == False, # noqa: E712
db.CashuRefund.created_at < cutoff,
stmt = select(db.CashuTransaction).where(
db.CashuTransaction.type == "out",
db.CashuTransaction.collected == False, # noqa: E712
db.CashuTransaction.swept == False, # noqa: E712
db.CashuTransaction.created_at < cutoff,
)
results = await session.exec(stmt)
refunds = results.all()
for refund in refunds:
try:
await recieve_token(refund.refund_token)
await recieve_token(refund.token)
refund.swept = True
session.add(refund)
logger.info(
"Swept uncollected refund",
extra={
"payment_token_hash": refund.payment_token_hash,
"id": refund.id,
"amount": refund.amount,
"unit": refund.unit,
},
@@ -388,14 +405,14 @@ async def periodic_refund_sweep() -> None:
logger.info(
"Refund already spent (client collected), marking swept",
extra={
"payment_token_hash": refund.payment_token_hash,
"id": refund.id,
},
)
else:
logger.warning(
"Failed to sweep refund",
extra={
"payment_token_hash": refund.payment_token_hash,
"id": refund.id,
"error": str(e),
},
)
+285
View File
@@ -0,0 +1,285 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import {
RefreshCw,
Search,
ArrowDownLeft,
ArrowUpRight,
Copy,
Check,
} from 'lucide-react';
import { AdminService, type Transaction } from '@/lib/api/services/admin';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
export default function TransactionsPage() {
const [search, setSearch] = useState('');
const [type, setType] = useState<string>('all');
const [status, setStatus] = useState<string>('all');
const [copiedId, setCopiedId] = useState<string | null>(null);
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['transactions', type, status, search],
queryFn: () =>
AdminService.getTransactions(
type === 'all' ? undefined : type,
status === 'all' ? undefined : status,
search || undefined,
100
),
});
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedId(null), 2000);
};
const getStatusBadge = (tx: Transaction) => {
if (tx.swept)
return (
<Badge
variant='outline'
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
>
Swept
</Badge>
);
if (tx.collected)
return (
<Badge
variant='outline'
className='border-green-500/20 bg-green-500/10 text-green-500'
>
Collected
</Badge>
);
return (
<Badge
variant='outline'
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
>
Pending
</Badge>
);
};
return (
<AppPageShell contentClassName='mx-auto w-full max-w-7xl'>
<div className='space-y-6'>
<PageHeader
title='Transactions'
description='View all incoming and outgoing X-Cashu token transactions.'
actions={
<Button
onClick={() => refetch()}
variant='outline'
size='sm'
disabled={isRefetching}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
}
/>
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
<div className='md:col-span-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder='Search by ID, token or request ID...'
className='pl-8'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue placeholder='Type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Types</SelectItem>
<SelectItem value='in'>Incoming (Payments)</SelectItem>
<SelectItem value='out'>Outgoing (Refunds)</SelectItem>
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder='Status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Statuses</SelectItem>
<SelectItem value='pending'>Pending</SelectItem>
<SelectItem value='collected'>Collected</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardHeader>
<CardTitle>Transaction History</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-12 w-full' />
))}
</div>
) : (
<ScrollArea className='h-[600px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>ID / Request ID</TableHead>
<TableHead>Mint</TableHead>
<TableHead>Date</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.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>
<div className='flex flex-col gap-1'>
<div className='text-muted-foreground flex items-center gap-1 text-xs'>
<span className='max-w-[100px] truncate font-mono'>
{tx.id}
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() =>
copyToClipboard(tx.id, tx.id + '-id')
}
>
{copiedId === tx.id + '-id' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
{tx.request_id && (
<div className='text-primary flex items-center gap-1 text-xs'>
<span className='max-w-[100px] truncate'>
{tx.request_id}
</span>
<Button
variant='ghost'
size='icon'
className='text-primary 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>
)}
</div>
</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'>
{formatDistanceToNow(tx.created_at * 1000, {
addSuffix: true,
})}
</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>
))}
{data?.transactions.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className='text-muted-foreground py-10 text-center'
>
No transactions found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
</AppPageShell>
);
}
+2
View File
@@ -13,6 +13,7 @@ import {
ServerIcon,
SettingsIcon,
WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react';
import Image from 'next/image';
import { toast } from 'sonner';
@@ -41,6 +42,7 @@ const NAV_ITEMS = [
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
{ title: 'Models', url: '/models', icon: DatabaseIcon },
{ title: 'Providers', url: '/providers', icon: ServerIcon },
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
] as const;
+6
View File
@@ -9,6 +9,7 @@ import {
ServerIcon,
SettingsIcon,
WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
@@ -45,6 +46,11 @@ const data = {
url: '/balances',
icon: WalletIcon,
},
{
title: 'Transactions',
url: '/transactions',
icon: ArrowRightLeftIcon,
},
{
title: 'Logs',
url: '/logs',
+35
View File
@@ -869,6 +869,23 @@ export class AdminService {
);
}
static async getTransactions(
type?: string,
status?: string,
search?: string,
limit: number = 100
): Promise<TransactionsResponse> {
const params = new URLSearchParams();
if (type) params.append('type', type);
if (status) params.append('status', status);
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<TransactionsResponse>(
`/admin/api/transactions?${params.toString()}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
@@ -1042,3 +1059,21 @@ export interface LogResponse {
search: string | null;
limit: number;
}
export interface Transaction {
id: string;
token: string;
amount: number;
unit: string;
mint_url: string;
type: 'in' | 'out';
request_id?: string;
created_at: number;
collected: boolean;
swept: boolean;
}
export interface TransactionsResponse {
transactions: Transaction[];
total: number;
}