chore: drop non-analytics branch drift

This commit is contained in:
Evan Yang
2026-03-13 17:00:15 +08:00
parent 8a89a38864
commit c5cb562165
11 changed files with 286 additions and 404 deletions

1
logs/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -2,7 +2,6 @@ import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
@@ -27,55 +26,20 @@ logger = get_logger(__name__)
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
admin_sessions: dict[str, int] = {}
ADMIN_SESSION_DURATION = 12 * 60 * 60
ADMIN_SESSION_DURATION = 3600
# Usage analytics remain queryable up to 12 months.
MAX_USAGE_ANALYTICS_HOURS = 365 * 24
def _current_timestamp() -> int:
return int(datetime.now(timezone.utc).timestamp())
def _cleanup_expired_admin_sessions(now_timestamp: int | None = None) -> None:
current_timestamp = (
now_timestamp if now_timestamp is not None else _current_timestamp()
)
expired_tokens = [
token
for token, expiry_timestamp in admin_sessions.items()
if expiry_timestamp <= current_timestamp
]
for token in expired_tokens:
admin_sessions.pop(token, None)
def _raise_unauthorized(detail: str) -> NoReturn:
raise HTTPException(
status_code=401,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
def require_admin_api(request: Request) -> None:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
_raise_unauthorized("Missing bearer token")
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
return
token = auth_header.split(" ", 1)[1].strip()
if not token:
_raise_unauthorized("Missing bearer token")
now_timestamp = _current_timestamp()
expiry_timestamp = admin_sessions.get(token)
if expiry_timestamp is None:
_raise_unauthorized("Invalid session token")
if expiry_timestamp <= now_timestamp:
admin_sessions.pop(token, None)
_raise_unauthorized("Session expired")
_cleanup_expired_admin_sessions(now_timestamp)
raise HTTPException(status_code=403, detail="Unauthorized")
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
@@ -244,10 +208,18 @@ async def admin_login(
raise HTTPException(status_code=401, detail="Invalid password")
token = secrets.token_urlsafe(32)
expiry_timestamp = _current_timestamp() + ADMIN_SESSION_DURATION
expiry_timestamp = (
int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION
)
admin_sessions[token] = expiry_timestamp
_cleanup_expired_admin_sessions()
expired_tokens = [
t
for t, exp in admin_sessions.items()
if exp <= int(datetime.now(timezone.utc).timestamp())
]
for t in expired_tokens:
del admin_sessions[t]
return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION}
@@ -574,6 +546,7 @@ class UpstreamProviderCreate(BaseModel):
api_version: str | None = None
enabled: bool = True
provider_fee: float = 1.01
provider_settings: dict | None = None
class UpstreamProviderUpdate(BaseModel):
@@ -583,6 +556,7 @@ class UpstreamProviderUpdate(BaseModel):
api_version: str | None = None
enabled: bool | None = None
provider_fee: float | None = None
provider_settings: dict | None = None
@admin_router.get("/api/upstream-providers", dependencies=[Depends(require_admin_api)])
@@ -599,6 +573,9 @@ async def get_upstream_providers() -> list[dict[str, object]]:
"api_version": p.api_version,
"enabled": p.enabled,
"provider_fee": p.provider_fee,
"provider_settings": json.loads(p.provider_settings)
if p.provider_settings
else None,
}
for p in providers
]
@@ -628,6 +605,9 @@ async def create_upstream_provider(
api_version=payload.api_version,
enabled=payload.enabled,
provider_fee=payload.provider_fee,
provider_settings=json.dumps(payload.provider_settings)
if payload.provider_settings
else None,
)
session.add(provider)
await session.commit()
@@ -643,6 +623,7 @@ async def create_upstream_provider(
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": payload.provider_settings,
}
@@ -662,6 +643,9 @@ async def get_upstream_provider(provider_id: int) -> dict[str, object]:
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": json.loads(provider.provider_settings)
if provider.provider_settings
else None,
}
@@ -688,6 +672,8 @@ async def update_upstream_provider(
provider.enabled = payload.enabled
if payload.provider_fee is not None:
provider.provider_fee = payload.provider_fee
if payload.provider_settings is not None:
provider.provider_settings = json.dumps(payload.provider_settings)
session.add(provider)
await session.commit()
@@ -703,6 +689,9 @@ async def update_upstream_provider(
"api_version": provider.api_version,
"enabled": provider.enabled,
"provider_fee": provider.provider_fee,
"provider_settings": json.loads(provider.provider_settings)
if provider.provider_settings
else None,
}
@@ -822,8 +811,45 @@ class TopupRequest(BaseModel):
amount: int
class CashuTopupRequest(BaseModel):
cashu_token: str
class TopupTokenRequest(BaseModel):
token: str
@admin_router.post(
"/api/upstream-providers/{provider_id}/topup-token",
dependencies=[Depends(require_admin_api)],
)
async def topup_provider_with_token(
provider_id: int, payload: TopupTokenRequest
) -> dict:
"""Redeem a Cashu token for an upstream provider."""
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
resp = await client.post(
f"{clean_url}/v1/balance/topup",
json={"cashu_token": payload.token},
headers=headers,
)
if resp.status_code == 200:
return {"ok": True, "message": "Token redeemed successfully"}
else:
logger.error(f"Upstream token topup failed: {resp.text}")
try:
error_detail = resp.json()
except Exception:
error_detail = resp.text
return {"ok": False, "message": f"Upstream error: {error_detail}"}
@admin_router.post(
@@ -852,7 +878,49 @@ async def initiate_provider_topup(
f"Initiating top-up for provider {provider_id}",
extra={"amount": payload.amount},
)
# For Routstr providers, we might be doing a Lightning top-up or a direct token transfer
if provider.provider_type == "routstr":
# UI sends sats for Routstr topup
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
# Proxy the request to upstream Routstr
# Use the actual API key from the database
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json={
"amount_sats": int(payload.amount),
"purpose": "topup",
"api_key": provider.api_key,
},
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
)
if resp.status_code == 200:
data = resp.json()
return {
"ok": True,
"topup_data": {
"payment_request": data.get("bolt11"),
"invoice_id": data.get("invoice_id"),
"status": "pending",
},
}
else:
logger.error(f"Upstream topup request failed: {resp.text}")
# Check if it's JSON error
try:
error_detail = resp.json()
except Exception:
error_detail = resp.text
raise HTTPException(
status_code=resp.status_code, detail=error_detail
)
topup_data = await upstream_instance.initiate_topup(payload.amount)
logger.info(
"Top-up initiated successfully",
extra={
@@ -889,61 +957,6 @@ async def initiate_provider_topup(
raise HTTPException(status_code=500, detail=str(e))
@admin_router.post(
"/api/upstream-providers/{provider_id}/topup/token",
dependencies=[Depends(require_admin_api)],
)
async def topup_provider_with_token(
provider_id: int, payload: CashuTopupRequest
) -> dict[str, object]:
"""Top up the upstream provider account with a Cashu token."""
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
cashu_token = payload.cashu_token.strip()
if not cashu_token:
raise HTTPException(status_code=400, detail="Cashu token is required")
if not hasattr(upstream_instance, "topup"):
raise HTTPException(
status_code=400, detail="Provider does not support token top-up"
)
try:
topup_data = await upstream_instance.topup(cashu_token)
if isinstance(topup_data, dict) and topup_data.get("error"):
raise HTTPException(status_code=400, detail=str(topup_data["error"]))
return {
"ok": True,
"topup_data": topup_data if isinstance(topup_data, dict) else {},
"message": "Token redeemed successfully",
}
except NotImplementedError as e:
raise HTTPException(
status_code=400,
detail=f"Provider does not support token top-up: {str(e)}",
)
except HTTPException:
raise
except Exception as e:
logger.error(
f"Failed to top up provider {provider_id} with token: {e}",
extra={"error_type": type(e).__name__},
)
raise HTTPException(status_code=500, detail=str(e))
@admin_router.get(
"/api/upstream-providers/{provider_id}/topup/{invoice_id}/status",
dependencies=[Depends(require_admin_api)],
@@ -958,6 +971,23 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# For Routstr providers, proxy the status check
if provider.provider_type == "routstr":
import httpx
async with httpx.AsyncClient() as client:
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 {},
)
if resp.status_code == 200:
status_data = resp.json()
return {"ok": True, "paid": status_data.get("status") == "paid"}
else:
logger.error(f"Upstream status check failed: {resp.text}")
return {"ok": False, "paid": False}
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
@@ -985,7 +1015,7 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj
dependencies=[Depends(require_admin_api)],
)
async def get_provider_balance(provider_id: int) -> dict[str, object]:
"""Get the current account balance for the upstream provider."""
"""Get the current balance for an upstream provider account."""
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
@@ -993,6 +1023,30 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# For Routstr providers, proxy the balance check
if provider.provider_type == "routstr":
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
resp = await client.get(
f"{clean_url}/v1/balance/info",
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
# Return balance in sats
balance = data.get("balance", 0)
if isinstance(balance, (int, float)):
return {"ok": True, "balance_data": balance // 1000}
return {"ok": True, "balance_data": balance}
else:
logger.error(f"Failed to fetch Routstr balance: {resp.text}")
return {"ok": False, "balance_data": None}
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
@@ -1205,3 +1259,71 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
continue
return {"dates": dates}
@admin_router.post(
"/api/upstream-providers/{provider_id}/routstr/refund",
dependencies=[Depends(require_admin_api)],
)
async def refund_routstr_provider_balance(provider_id: int) -> dict[str, object]:
"""Refund balance from an upstream Routstr provider back to the local wallet."""
from ..upstream.helpers import _instantiate_provider
from ..upstream.routstr import RoutstrUpstreamProvider
async with create_session() as session:
provider_row = await session.get(UpstreamProviderRow, provider_id)
if not provider_row:
raise HTTPException(status_code=404, detail="Provider not found")
if provider_row.provider_type != "routstr":
raise HTTPException(
status_code=400, detail="Refund only supported for Routstr providers"
)
provider = _instantiate_provider(provider_row)
if not isinstance(provider, RoutstrUpstreamProvider):
raise HTTPException(status_code=400, detail="Invalid provider instance")
try:
# Request refund from upstream
data = await provider.refund_balance()
if "error" in data:
# If the upstream returned an OpenAI-style error (like the model unknown error)
# it means the request likely didn't even reach the refund endpoint handler
# but was intercepted by the proxy layer.
error_info = data.get("error", {})
message = (
error_info.get("message")
if isinstance(error_info, dict)
else str(error_info)
)
return {
"ok": False,
"message": f"Upstream refund failed: {message}",
}
token = data.get("token")
if not token:
return {"ok": False, "message": "Upstream did not return a token"}
# Receive token into local wallet
from ..wallet import recieve_token
try:
# Use current wallet to receive
await recieve_token(token)
return {
"ok": True,
"message": "Successfully received refund from upstream provider",
}
except Exception as e:
logger.error(f"Failed to receive refund token: {e}")
return {
"ok": False,
"message": f"Failed to receive refund token: {str(e)}",
"token": token,
}
except Exception as e:
logger.exception(f"Refund failed for provider {provider_id}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,6 +1,5 @@
import asyncio
import math
import time
from typing import TypedDict
from cashu.core.base import Proof, Token
@@ -159,14 +158,6 @@ async def credit_balance(
_wallets: dict[str, Wallet] = {}
_balances_cache_ttl_seconds = 300.0
_balances_cache: dict[
tuple[str, ...], tuple[float, tuple[list["BalanceDetail"], int, int, int]]
] = {}
_balances_refresh_tasks: dict[
tuple[str, ...], asyncio.Task[tuple[list["BalanceDetail"], int, int, int]]
] = {}
_balances_cache_lock = asyncio.Lock()
async def get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
@@ -235,12 +226,6 @@ async def fetch_all_balances(
"""
if units is None:
units = ["sat", "msat"]
units_key = tuple(units)
now = time.time()
cached = _balances_cache.get(units_key)
if cached and cached[0] > now:
return cached[1]
async def fetch_balance(
session: db.AsyncSession, mint_url: str, unit: str
@@ -276,7 +261,6 @@ async def fetch_all_balances(
}
return error_result
async def compute_balances() -> tuple[list[BalanceDetail], int, int, int]:
# Create tasks for all mint/unit combinations
async with db.create_session() as session:
tasks = [
@@ -311,6 +295,7 @@ async def fetch_all_balances(
total_user_balance_sats += user_balance_sats
owner_balance = total_wallet_balance_sats - total_user_balance_sats
return (
balance_details,
total_wallet_balance_sats,
@@ -318,30 +303,6 @@ async def fetch_all_balances(
owner_balance,
)
async with _balances_cache_lock:
now = time.time()
cached = _balances_cache.get(units_key)
if cached and cached[0] > now:
return cached[1]
refresh_task = _balances_refresh_tasks.get(units_key)
if refresh_task is None or refresh_task.done():
refresh_task = asyncio.create_task(compute_balances())
_balances_refresh_tasks[units_key] = refresh_task
result = await refresh_task
async with _balances_cache_lock:
_balances_cache[units_key] = (
time.time() + _balances_cache_ttl_seconds,
result,
)
current_task = _balances_refresh_tasks.get(units_key)
if current_task is refresh_task and refresh_task.done():
_balances_refresh_tasks.pop(units_key, None)
return result
async def periodic_payout() -> None:
if not settings.receive_ln_address:

View File

@@ -264,12 +264,12 @@ async def test_models_endpoint_accept_headers(integration_client: AsyncClient) -
async def test_admin_endpoint_unauthenticated(
integration_client: AsyncClient, db_snapshot: Any
) -> None:
"""Test admin API requires authentication."""
"""Test GET /admin/ endpoint redirects to /"""
await db_snapshot.capture()
response = await integration_client.get("/admin/api/settings")
assert response.status_code == 401
assert response.status_code == 403
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 0

View File

@@ -3,6 +3,7 @@ import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import './globals.css';
import { Providers } from './providers';
import { SuppressHydrationWarning } from '@/components/suppress-hydration-warning';
export const metadata: Metadata = {
title: 'Routstr',
@@ -22,7 +23,9 @@ export default function RootLayout({
<body
className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}
>
<SuppressHydrationWarning>
<Providers>{children}</Providers>
</SuppressHydrationWarning>
</body>
</html>
);

View File

@@ -451,6 +451,7 @@ export default function ProvidersPage() {
data: { api_key: newKey },
});
}}
availableMints={availableMints}
/>
))}
</div>

View File

@@ -66,7 +66,9 @@ function normalizeBaseUrl(url: string): string {
}
export function CheatSheet(): JSX.Element {
const [baseUrl, setBaseUrl] = useState('');
const [baseUrl, setBaseUrl] = useState(() =>
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
);
const [apiKeyInput, setApiKeyInput] = useState('');
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(

View File

@@ -50,6 +50,7 @@ interface ProviderCardProps {
onDeleteModel: (modelId: string) => void;
onOverrideModel: (model: AdminModel) => void;
onUpdateApiKey: (newKey: string) => void;
availableMints: string[];
}
export function ProviderCard({
@@ -69,6 +70,7 @@ export function ProviderCard({
onDeleteModel,
onOverrideModel,
onUpdateApiKey,
availableMints,
}: ProviderCardProps) {
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const hasDetails = Boolean(provider.api_version) || isExpanded;

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import Image from 'next/image';
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface RoutstrCreateKeySectionProps {

View File

@@ -1,202 +0,0 @@
'use client';
import { useCallback, useMemo } from 'react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { ModelRevenueData } from '@/lib/api/services/admin';
import { convertToMsat, formatFromMsat } from '@/lib/currency';
import { useIsMobile } from '@/hooks/use-mobile';
import type { DisplayUnit } from '@/lib/types/units';
interface RevenueByModelTableProps {
models: ModelRevenueData[];
displayUnit: DisplayUnit;
usdPerSat: number | null;
}
function truncateModelName(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 1)}`;
}
export function RevenueByModelTable({
models,
displayUnit,
usdPerSat,
}: RevenueByModelTableProps) {
const isMobile = useIsMobile();
const revenueDisplayUnit: DisplayUnit = useMemo(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
return 'sat';
}
return displayUnit;
}, [displayUnit, usdPerSat]);
const unitLabel = revenueDisplayUnit === 'usd' ? 'USD' : revenueDisplayUnit;
const compactNumber = useMemo(
() =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}),
[]
);
const convertSatsToDisplay = useCallback(
(sats: number): number => {
if (revenueDisplayUnit === 'msat') {
return sats * 1000;
}
if (revenueDisplayUnit === 'usd') {
return sats * (usdPerSat ?? 0);
}
return sats;
},
[revenueDisplayUnit, usdPerSat]
);
const formatAmount = (sats: number) =>
formatFromMsat(convertToMsat(sats, 'sat'), revenueDisplayUnit, usdPerSat);
const formatCompactAmount = (value: number): string => {
const compact = compactNumber.format(value);
if (revenueDisplayUnit === 'usd') {
return `$${compact}`;
}
return `${compact} ${unitLabel}`;
};
const totalCollectedRevenue = models.reduce(
(sum, model) => sum + model.revenue_sats,
0
);
const totalOperationalNet = models.reduce(
(sum, model) => sum + model.net_revenue_sats,
0
);
const chartData = useMemo(
() =>
[...models]
.sort((a, b) => b.revenue_sats - a.revenue_sats)
.slice(0, 12)
.map((model) => ({
model: model.model,
modelLabel: truncateModelName(model.model, isMobile ? 16 : 28),
revenueDisplay: convertSatsToDisplay(model.revenue_sats),
})),
[models, isMobile, convertSatsToDisplay]
);
const chartConfig: ChartConfig = {
revenueDisplay: {
label: 'Revenue',
color: 'var(--chart-1)',
},
};
if (chartData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
</CardHeader>
<CardContent className='text-muted-foreground text-sm'>
No model data available
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className='text-muted-foreground text-sm'>
Total Collected Revenue:{' '}
<span className='text-foreground font-mono font-medium'>
{formatAmount(totalCollectedRevenue)}
</span>
</p>
<p className='text-muted-foreground text-xs'>
Operational Net:{' '}
<span className='text-foreground font-mono'>
{formatAmount(totalOperationalNet)}
</span>
</p>
</CardHeader>
<CardContent>
<ChartContainer className='h-[360px] w-full' config={chartConfig}>
<BarChart
data={chartData}
layout='vertical'
margin={{
top: 8,
right: isMobile ? 8 : 28,
left: isMobile ? 8 : 28,
bottom: 8,
}}
>
<CartesianGrid horizontal={false} className='stroke-muted/30' />
<XAxis
type='number'
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
compactNumber.format(
typeof value === 'number' ? value : Number(value || 0)
)
}
/>
<YAxis
type='category'
dataKey='modelLabel'
tickLine={false}
axisLine={false}
width={isMobile ? 110 : 220}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(label) => String(label)}
formatter={(value, name) => {
const numericValue =
typeof value === 'number' ? value : Number(value || 0);
return (
<div className='grid w-full grid-cols-[minmax(0,1fr)_auto] gap-x-4'>
<span className='text-muted-foreground truncate pr-1'>
{name}
</span>
<span className='text-foreground text-right font-mono font-medium tabular-nums'>
{Number.isFinite(numericValue)
? formatCompactAmount(numericValue)
: '-'}
</span>
</div>
);
}}
/>
}
/>
<Bar
dataKey='revenueDisplay'
name={`Revenue (${unitLabel})`}
fill='var(--color-revenueDisplay)'
radius={[0, 6, 6, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -20,7 +20,7 @@ export const UpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean(),
provider_fee: z.number().optional(),
provider_settings: z.record(z.string(), z.unknown()).optional(),
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
});
export const CreateUpstreamProviderSchema = z.object({
@@ -30,7 +30,7 @@ export const CreateUpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean().default(true),
provider_fee: z.number().optional(),
provider_settings: z.record(z.string(), z.unknown()).optional(),
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
});
export const UpdateUpstreamProviderSchema = z.object({
@@ -40,7 +40,7 @@ export const UpdateUpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean().optional(),
provider_fee: z.number().optional(),
provider_settings: z.record(z.string(), z.unknown()).optional(),
provider_settings: z.record(z.string(), z.any()).nullable().optional(),
});
export const AdminModelPricingSchema = z.object({
@@ -912,26 +912,17 @@ export class AdminService {
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}>(`/admin/api/upstream-providers/${providerId}/topup`, {
amount: amount,
});
}>(`/admin/api/upstream-providers/${providerId}/topup`, { amount });
}
static async topupProviderWithToken(
providerId: number,
cashuToken: string
): Promise<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}> {
return await apiClient.post<{
ok: boolean;
topup_data: Record<string, unknown>;
message: string;
}>(`/admin/api/upstream-providers/${providerId}/topup/token`, {
cashu_token: cashuToken,
});
token: string
): Promise<{ ok: boolean; message?: string }> {
return await apiClient.post<{ ok: boolean; message?: string }>(
`/admin/api/upstream-providers/${providerId}/topup-token`,
{ token }
);
}
static async checkTopupStatus(