mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
2 Commits
token-erro
...
enforce-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0206ff01a0 | ||
|
|
5620564643 |
@@ -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")
|
||||
@@ -10,7 +10,13 @@ from pydantic import BaseModel
|
||||
from sqlmodel import select
|
||||
|
||||
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.settings import settings
|
||||
from .lightning import lightning_router
|
||||
@@ -310,6 +316,17 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
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:
|
||||
# Re-raise HTTP exceptions (like 400 for balance too small)
|
||||
raise
|
||||
@@ -334,6 +351,20 @@ async def refund_wallet_endpoint(
|
||||
session.add(key)
|
||||
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(
|
||||
"refund_wallet_endpoint: refund successful",
|
||||
extra={
|
||||
|
||||
@@ -10,6 +10,7 @@ from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.util.exc import CommandError
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -154,6 +155,10 @@ class CashuTransaction(SQLModel, table=True): # type: ignore
|
||||
)
|
||||
collected: 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(
|
||||
@@ -165,6 +170,7 @@ async def store_cashu_transaction(
|
||||
request_id: str | None = None,
|
||||
collected: bool = False,
|
||||
created_at: int | None = None,
|
||||
source: str = "x-cashu",
|
||||
) -> None:
|
||||
try:
|
||||
async with create_session() as session:
|
||||
@@ -177,6 +183,7 @@ async def store_cashu_transaction(
|
||||
request_id=request_id,
|
||||
collected=collected,
|
||||
created_at=created_at or int(time.time()),
|
||||
source=source,
|
||||
)
|
||||
session.add(tx)
|
||||
await session.commit()
|
||||
@@ -331,6 +338,17 @@ def run_migrations() -> None:
|
||||
command.stamp(alembic_cfg, "head")
|
||||
else:
|
||||
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")
|
||||
|
||||
|
||||
@@ -182,11 +182,16 @@ def _row_to_model(
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
(
|
||||
parsed_pricing.max_prompt_cost,
|
||||
parsed_pricing.max_completion_cost,
|
||||
parsed_pricing.max_cost,
|
||||
) = _calculate_usd_max_costs(model)
|
||||
max_prompt, max_completion, max_cost = _calculate_usd_max_costs(model)
|
||||
parsed_pricing = Pricing(
|
||||
**{
|
||||
**parsed_pricing.dict(),
|
||||
"max_prompt_cost": max_prompt,
|
||||
"max_completion_cost": max_completion,
|
||||
"max_cost": max_cost,
|
||||
}
|
||||
)
|
||||
model = Model(**{**model.dict(), "pricing": parsed_pricing, "sats_pricing": None})
|
||||
|
||||
try:
|
||||
sats_to_usd = sats_usd_price()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routstr.balance import refund_wallet_endpoint
|
||||
from routstr.core.db import CashuTransaction
|
||||
from routstr.core.db import ApiKey, CashuTransaction
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
Receipt,
|
||||
Key,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { AdminService, type Transaction } from '@/lib/api/services/admin';
|
||||
import { format } from 'date-fns';
|
||||
@@ -54,6 +57,118 @@ import { toast } from 'sonner';
|
||||
|
||||
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() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [type, setType] = useState<string>('all');
|
||||
@@ -145,12 +260,41 @@ export default function TransactionsPage() {
|
||||
.filter(Boolean)
|
||||
.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 (
|
||||
<AppPageShell contentClassName='mx-auto w-full max-w-5xl overflow-x-hidden'>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='X-Cashu Transactions'
|
||||
description='View all incoming and outgoing X-Cashu token transactions.'
|
||||
title='Cashu Transactions'
|
||||
description='View all incoming and outgoing Cashu token transactions.'
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
@@ -228,138 +372,64 @@ export default function TransactionsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>Transaction History</CardTitle>
|
||||
<Tabs defaultValue='x-cashu'>
|
||||
<TabsList className='mb-4'>
|
||||
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
|
||||
<Zap className='h-4 w-4' />
|
||||
X-Cashu
|
||||
{data && (
|
||||
<Badge variant='secondary'>
|
||||
{data.transactions.length} entries
|
||||
<Badge variant='secondary' className='ml-1'>
|
||||
{xcashuTxs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Showing transactions filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`tx-loading-${index}`}
|
||||
className='h-16 w-full rounded-lg'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : data?.transactions && data.transactions.length > 0 ? (
|
||||
<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>
|
||||
{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>
|
||||
{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>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='apikey' className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
API Key Refunds
|
||||
{data && (
|
||||
<Badge variant='secondary' className='ml-1'>
|
||||
{apikeyTxs.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='x-cashu'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>X-Cashu Transaction History</CardTitle>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{renderCardContent(xcashuTxs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='apikey'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<CardTitle>API Key Refund History</CardTitle>
|
||||
{hasActiveFilters && (
|
||||
<CardDescription>
|
||||
Filtered by {activeFilterDescription}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='overflow-hidden'>
|
||||
{renderCardContent(apikeyTxs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
|
||||
@@ -1135,6 +1135,7 @@ export interface Transaction {
|
||||
created_at: number;
|
||||
collected: boolean;
|
||||
swept: boolean;
|
||||
source: 'x-cashu' | 'apikey';
|
||||
}
|
||||
|
||||
export interface TransactionsResponse {
|
||||
|
||||
Reference in New Issue
Block a user