mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
add apikey refund tracking
This commit is contained in:
@@ -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
@@ -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={
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user