feat: carve out billing page and show credit ledger

This commit is contained in:
Abhishek Kumar 2026-06-11 18:13:30 +05:30
parent e33fec17db
commit fde84387f2
13 changed files with 995 additions and 285 deletions

View file

@ -55,6 +55,10 @@ from api.services.configuration.registry import (
ServiceProviders,
ServiceType,
)
from api.services.organization_context import (
OrganizationContextResponse,
get_organization_context,
)
from api.services.organization_preferences import (
get_organization_preferences,
upsert_organization_preferences,
@ -129,6 +133,12 @@ class TelephonyConfigWarningsResponse(BaseModel):
telnyx_missing_webhook_public_key_count: int
@router.get("/context", response_model=OrganizationContextResponse)
async def get_current_organization_context(user: UserModel = Depends(get_user)):
"""Return organization-scoped configuration signals owned by Dograh."""
return await get_organization_context(user)
@router.get(
"/telephony-providers/metadata",
response_model=TelephonyProvidersMetadataResponse,

View file

@ -1,6 +1,6 @@
import json
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
@ -47,6 +47,36 @@ class MPSCreditPurchaseUrlResponse(BaseModel):
checkout_url: str
class MPSBillingAccountResponse(BaseModel):
id: int
organization_id: int
billing_mode: str
cached_balance_credits: float
currency: str
class MPSCreditLedgerEntryResponse(BaseModel):
id: int
entry_type: str
origin: Optional[str] = None
credits_delta: float
balance_after: float
amount_minor: Optional[int] = None
amount_currency: Optional[str] = None
payment_order_id: Optional[int] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
created_at: str
class MPSBillingCreditsResponse(BaseModel):
billing_version: Literal["legacy", "v2"]
total_credits_used: float = 0.0
remaining_credits: float = 0.0
total_quota: float = 0.0
account: Optional[MPSBillingAccountResponse] = None
ledger_entries: List[MPSCreditLedgerEntryResponse] = Field(default_factory=list)
class WorkflowRunUsageResponse(BaseModel):
id: int
workflow_id: int
@ -149,6 +179,102 @@ async def get_mps_credits(user: UserModel = Depends(get_user)):
raise HTTPException(status_code=500, detail=str(e))
async def _uses_mps_billing_v2(user: UserModel, organization_id: int) -> bool:
resolved = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=organization_id,
)
return (
resolved.source == "organization_v2"
and resolved.effective.managed_service_version == 2
)
async def _legacy_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse:
if DEPLOYMENT_MODE == "oss":
usage = await mps_service_key_client.get_usage_by_created_by(
str(user.provider_id)
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
usage = await mps_service_key_client.get_usage_by_organization(
user.selected_organization_id
)
total_used = float(usage.get("total_credits_used", 0.0))
total_remaining = float(usage.get("remaining_credits", 0.0))
return MPSBillingCreditsResponse(
billing_version="legacy",
total_credits_used=total_used,
remaining_credits=total_remaining,
total_quota=total_used + total_remaining,
)
@router.get("/billing/credits", response_model=MPSBillingCreditsResponse)
async def get_billing_credits(
limit: int = Query(50, ge=1, le=100),
user: UserModel = Depends(get_user),
):
"""Return legacy MPS credits or v2 billing ledger details for the org."""
try:
if DEPLOYMENT_MODE == "oss" or not user.selected_organization_id:
return await _legacy_mps_credits_response(user)
organization_id = user.selected_organization_id
if not await _uses_mps_billing_v2(user, organization_id):
return await _legacy_mps_credits_response(user)
ledger = await mps_service_key_client.get_credit_ledger(
organization_id=organization_id,
limit=limit,
created_by=str(user.provider_id),
)
account = ledger.get("account") or {}
ledger_entries = ledger.get("ledger_entries") or []
balance = float(account.get("cached_balance_credits") or 0.0)
total_debits = sum(
abs(float(entry.get("credits_delta") or 0.0))
for entry in ledger_entries
if float(entry.get("credits_delta") or 0.0) < 0
)
return MPSBillingCreditsResponse(
billing_version="v2",
total_credits_used=total_debits,
remaining_credits=balance,
total_quota=balance + total_debits,
account=MPSBillingAccountResponse(
id=int(account["id"]),
organization_id=int(account["organization_id"]),
billing_mode=str(account["billing_mode"]),
cached_balance_credits=balance,
currency=str(account.get("currency") or "USD"),
),
ledger_entries=[
MPSCreditLedgerEntryResponse(
id=int(entry["id"]),
entry_type=str(entry["entry_type"]),
origin=entry.get("origin"),
credits_delta=float(entry.get("credits_delta") or 0.0),
balance_after=float(entry.get("balance_after") or 0.0),
amount_minor=entry.get("amount_minor"),
amount_currency=entry.get("amount_currency"),
payment_order_id=entry.get("payment_order_id"),
metadata=entry.get("metadata") or {},
created_at=str(entry["created_at"]),
)
for entry in ledger_entries
],
)
except HTTPException:
raise
except Exception as exc:
logger.error(f"Failed to fetch billing credits: {exc}")
raise HTTPException(status_code=500, detail=str(exc))
@router.post(
"/usage/mps-credits/purchase-url",
response_model=MPSCreditPurchaseUrlResponse,
@ -185,9 +311,9 @@ async def create_mps_credit_purchase_url(
session = await mps_service_key_client.create_credit_purchase_url(
organization_id=organization_id,
created_by=str(user.provider_id),
return_url=f"{UI_APP_URL.rstrip('/')}/reports",
return_url=f"{UI_APP_URL.rstrip('/')}/billing",
billing_details={
"source": "dograh_reports",
"source": "dograh_billing",
"dograh_user_id": str(user.id),
"dograh_provider_id": str(user.provider_id),
},

View file

@ -390,6 +390,36 @@ class MPSServiceKeyClient:
response=response,
)
async def get_credit_ledger(
self,
organization_id: int,
limit: int = 50,
created_by: Optional[str] = None,
) -> dict:
"""Get the MPS v2 billing account balance and recent credit ledger."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/billing/accounts/{organization_id}/ledger",
params={"limit": limit},
headers=self._get_headers(
organization_id=organization_id,
created_by=created_by,
),
)
if response.status_code == 200:
return response.json()
logger.error(
"Failed to get MPS credit ledger: "
f"{response.status_code} - {response.text}"
)
raise httpx.HTTPStatusError(
f"Failed to get MPS credit ledger: {response.text}",
request=response.request,
response=response,
)
async def create_correlation_id(
self,
*,

View file

@ -0,0 +1,50 @@
from typing import Literal, Optional
from pydantic import BaseModel
from api.db import db_client
from api.db.models import UserModel
from api.services.configuration.ai_model_configuration import (
get_resolved_ai_model_configuration,
)
class OrganizationModelServicesContext(BaseModel):
config_source: Literal["organization_v2", "legacy_user_v1", "empty"]
has_model_configuration_v2: bool
managed_service_version: Optional[int] = None
uses_managed_service_v2: bool
class OrganizationContextResponse(BaseModel):
organization_id: Optional[int] = None
organization_provider_id: Optional[str] = None
model_services: OrganizationModelServicesContext
async def get_organization_context(user: UserModel) -> OrganizationContextResponse:
organization_id = user.selected_organization_id
organization = (
await db_client.get_organization_by_id(organization_id)
if organization_id
else None
)
resolved = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=organization_id,
)
managed_service_version = resolved.effective.managed_service_version
return OrganizationContextResponse(
organization_id=organization_id,
organization_provider_id=organization.provider_id if organization else None,
model_services=OrganizationModelServicesContext(
config_source=resolved.source,
has_model_configuration_v2=resolved.source == "organization_v2",
managed_service_version=managed_service_version,
uses_managed_service_v2=(
resolved.source == "organization_v2" and managed_service_version == 2
),
),
)

267
ui/src/app/billing/page.tsx Normal file
View file

@ -0,0 +1,267 @@
"use client";
import { CircleDollarSign, CreditCard, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost, getBillingCreditsApiV1OrganizationsBillingCreditsGet } from "@/client/sdk.gen";
import type { MpsBillingCreditsResponse } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAppConfig } from "@/context/AppConfigContext";
import { useOrgConfig } from "@/context/OrgConfigContext";
import { useAuth } from "@/lib/auth";
const formatCredits = (value: number | null | undefined) => (
(value ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
})
);
const formatAmount = (amountMinor?: number | null, currency?: string | null) => {
if (amountMinor == null) {
return "-";
}
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency || "USD",
}).format(amountMinor / 100);
};
const formatDate = (value: string) => (
new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
);
export default function BillingPage() {
const auth = useAuth();
const { config } = useAppConfig();
const { orgContext, loading: orgConfigLoading } = useOrgConfig();
const [credits, setCredits] = useState<MpsBillingCreditsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [purchasing, setPurchasing] = useState(false);
const isManagedServiceV2 = Boolean(orgContext?.model_services.uses_managed_service_v2);
const isBillingV2 = isManagedServiceV2 && credits?.billing_version === "v2";
const canPurchaseCredits = isManagedServiceV2 && config?.deploymentMode !== "oss";
const totalQuota = credits?.total_quota ?? 0;
const remainingCredits = credits?.remaining_credits ?? 0;
const usedCredits = credits?.total_credits_used ?? 0;
const usagePercent = totalQuota > 0 ? Math.min(100, Math.round((usedCredits / totalQuota) * 100)) : 0;
const ledgerEntries = useMemo(() => credits?.ledger_entries ?? [], [credits?.ledger_entries]);
const fetchCredits = useCallback(async ({ silent = false }: { silent?: boolean } = {}) => {
if (auth.loading) {
return;
}
if (!auth.isAuthenticated) {
setLoading(false);
return;
}
if (silent) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
const response = await getBillingCreditsApiV1OrganizationsBillingCreditsGet({
query: { limit: 50 },
});
if (response.error) {
throw new Error("Failed to fetch billing credits");
}
setCredits(response.data ?? null);
} catch (error) {
console.error("Failed to fetch billing credits:", error);
toast.error("Failed to fetch billing credits");
} finally {
setLoading(false);
setRefreshing(false);
}
}, [auth.isAuthenticated, auth.loading]);
useEffect(() => {
fetchCredits();
}, [fetchCredits]);
const handleRefresh = () => {
fetchCredits({ silent: true });
};
const handlePurchaseCredits = async () => {
if (!canPurchaseCredits) {
return;
}
setPurchasing(true);
try {
const response = await createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost();
const checkoutUrl = response.data?.checkout_url;
if (!checkoutUrl) {
throw new Error("Missing checkout URL");
}
window.location.href = checkoutUrl;
} catch (error) {
console.error("Failed to create credit purchase URL:", error);
toast.error("Failed to open checkout");
setPurchasing(false);
}
};
if (loading || orgConfigLoading) {
return (
<div className="container mx-auto p-6 space-y-6">
<div className="space-y-2">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-5 w-96 max-w-full" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-36 rounded-lg" />
<Skeleton className="h-36 rounded-lg" />
</div>
<Skeleton className="h-80 rounded-lg" />
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">Billing</h1>
<p className="text-muted-foreground">
Credits, balance, and account usage for your organization.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
{canPurchaseCredits && (
<Button onClick={handlePurchaseCredits} disabled={purchasing}>
<CreditCard className="h-4 w-4 mr-2" />
{purchasing ? "Opening..." : "Add Credits"}
</Button>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardDescription>{isBillingV2 ? "Credit balance" : "Credits remaining"}</CardDescription>
<CardTitle className="flex items-center gap-2 text-3xl">
<CircleDollarSign className="h-6 w-6 text-muted-foreground" />
{formatCredits(remainingCredits)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">1 credit = 1 cent</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Credits used</CardDescription>
<CardTitle className="text-3xl">{formatCredits(usedCredits)}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{isBillingV2 ? "Recent ledger debit total" : "Current allocation usage"}
</p>
</CardContent>
</Card>
</div>
{isBillingV2 ? (
<Card>
<CardHeader>
<CardTitle>Credit Ledger</CardTitle>
<CardDescription>Recent grants, purchases, and usage debits.</CardDescription>
</CardHeader>
<CardContent>
{ledgerEntries.length > 0 ? (
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Date</TableHead>
<TableHead>Type</TableHead>
<TableHead>Origin</TableHead>
<TableHead className="text-right">Delta</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ledgerEntries.map((entry) => {
const delta = entry.credits_delta ?? 0;
return (
<TableRow key={entry.id}>
<TableCell>{formatDate(entry.created_at)}</TableCell>
<TableCell className="capitalize">{entry.entry_type.replaceAll("_", " ")}</TableCell>
<TableCell>{entry.origin || "-"}</TableCell>
<TableCell className={`text-right font-medium ${delta >= 0 ? "text-green-600" : "text-destructive"}`}>
{delta >= 0 ? "+" : ""}
{formatCredits(delta)}
</TableCell>
<TableCell className="text-right">{formatCredits(entry.balance_after)}</TableCell>
<TableCell className="text-right">
{formatAmount(entry.amount_minor, entry.amount_currency)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
No ledger entries yet
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Credit Usage</CardTitle>
<CardDescription>Current legacy MPS credit allocation.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Progress value={usagePercent} />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{usagePercent}% used</span>
<span>{formatCredits(remainingCredits)} of {formatCredits(totalQuota)} remaining</span>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -12,8 +12,8 @@ import SpinLoader from "@/components/SpinLoader";
import { Toaster } from "@/components/ui/sonner";
import { AppConfigProvider } from "@/context/AppConfigContext";
import { OnboardingProvider } from "@/context/OnboardingContext";
import { OrgConfigProvider } from "@/context/OrgConfigContext";
import { TelephonyConfigWarningsProvider } from "@/context/TelephonyConfigWarningsContext";
import { UserConfigProvider } from "@/context/UserConfigContext";
import { AuthProvider } from "@/lib/auth";
@ -65,7 +65,7 @@ export default function RootLayout({
<AuthProvider>
<AppConfigProvider>
<Suspense fallback={<SpinLoader />}>
<UserConfigProvider>
<OrgConfigProvider>
<TelephonyConfigWarningsProvider>
<OnboardingProvider>
<PostHogIdentify />
@ -76,7 +76,7 @@ export default function RootLayout({
<ChatwootWidget />
</OnboardingProvider>
</TelephonyConfigWarningsProvider>
</UserConfigProvider>
</OrgConfigProvider>
</Suspense>
</AppConfigProvider>
</AuthProvider>

View file

@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
import { toast } from 'sonner';
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
import { CallTypeCell } from '@/components/CallTypeCell';
import { DailyUsageTable } from '@/components/DailyUsageTable';
import { FilterBuilder } from '@/components/filters/FilterBuilder';
@ -15,7 +15,6 @@ import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPrevie
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
@ -39,10 +38,6 @@ export default function UsagePage() {
const { organizationPricing } = useUserConfig();
const auth = useAuth();
// MPS credits state
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
// Usage history state
const [usageHistory, setUsageHistory] = useState<UsageHistoryResponse | null>(null);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
@ -78,21 +73,6 @@ export default function UsagePage() {
const [preferencesLoading, setPreferencesLoading] = useState(true);
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
// Fetch MPS credits
const fetchMpsCredits = useCallback(async () => {
if (!auth.isAuthenticated) return;
try {
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
if (response.data) {
setMpsCredits(response.data);
}
} catch (error) {
console.error('Failed to fetch MPS credits:', error);
} finally {
setIsLoadingCredits(false);
}
}, [auth.isAuthenticated]);
// Translate the FilterBuilder state into the query-param shape the
// backend expects. Shared between the listing fetch and the CSV export
// so they stay in lockstep.
@ -251,10 +231,9 @@ export default function UsagePage() {
// Initial load - fetch when auth becomes available
useEffect(() => {
if (auth.isAuthenticated) {
fetchMpsCredits();
fetchUsageHistory(currentPage, appliedFilters);
}
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory, fetchMpsCredits]);
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory]);
// Fetch daily usage when organizationPricing becomes available
useEffect(() => {
@ -428,46 +407,6 @@ export default function UsagePage() {
</div>
</div>
{/* MPS Credits Card */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Dograh Model Credits</CardTitle>
<CardDescription>
These track usage of Dograh models using Dograh Service Keys.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingCredits ? (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-1/4"></div>
<div className="h-8 bg-muted rounded"></div>
<div className="h-4 bg-muted rounded w-1/3"></div>
</div>
) : mpsCredits ? (
<div className="space-y-4">
<div className="flex justify-between items-baseline">
<div>
<p className="text-2xl font-bold">
{mpsCredits.total_credits_used.toFixed(2)} <span className="text-lg font-normal text-muted-foreground">/ {mpsCredits.total_quota.toFixed(2)}</span>
</p>
<p className="text-sm text-muted-foreground">Credits Used</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
<p className="text-sm text-muted-foreground">Remaining</p>
</div>
</div>
{mpsCredits.total_quota > 0 && (
<Progress value={(mpsCredits.total_credits_used / mpsCredits.total_quota) * 100} className="h-3" />
)}
</div>
) : (
<p className="text-muted-foreground">No Dograh service keys configured. Set up a service key in your model configuration to see usage.</p>
)}
</CardContent>
</Card>
{/* Daily Usage Table - Only for paid organizations */}
{organizationPricing?.price_per_second_usd && (
<div className="mb-6">
@ -535,9 +474,9 @@ export default function UsagePage() {
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Date</TableHead>
<TableHead className="font-semibold text-right">Duration</TableHead>
<TableHead className="font-semibold text-right">
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'}
</TableHead>
{organizationPricing?.price_per_second_usd && (
<TableHead className="font-semibold text-right">Cost (USD)</TableHead>
)}
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
@ -574,12 +513,14 @@ export default function UsagePage() {
<TableCell className="text-right">
{formatDuration(run.call_duration_seconds)}
</TableCell>
<TableCell className="text-right font-medium">
{organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null
? `$${run.charge_usd.toFixed(2)}`
: run.dograh_token_usage.toLocaleString()
}
</TableCell>
{organizationPricing?.price_per_second_usd && (
<TableCell className="text-right font-medium">
{run.charge_usd !== undefined && run.charge_usd !== null
? `$${run.charge_usd.toFixed(2)}`
: '-'
}
</TableCell>
)}
<TableCell>
<MediaPreviewButton
recordingUrl={run.recording_url}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3107,6 +3107,117 @@ export type LoginRequest = {
password: string;
};
/**
* MPSBillingAccountResponse
*/
export type MpsBillingAccountResponse = {
/**
* Id
*/
id: number;
/**
* Organization Id
*/
organization_id: number;
/**
* Billing Mode
*/
billing_mode: string;
/**
* Cached Balance Credits
*/
cached_balance_credits: number;
/**
* Currency
*/
currency: string;
};
/**
* MPSBillingCreditsResponse
*/
export type MpsBillingCreditsResponse = {
/**
* Billing Version
*/
billing_version: 'legacy' | 'v2';
/**
* Total Credits Used
*/
total_credits_used?: number;
/**
* Remaining Credits
*/
remaining_credits?: number;
/**
* Total Quota
*/
total_quota?: number;
account?: MpsBillingAccountResponse | null;
/**
* Ledger Entries
*/
ledger_entries?: Array<MpsCreditLedgerEntryResponse>;
};
/**
* MPSCreditLedgerEntryResponse
*/
export type MpsCreditLedgerEntryResponse = {
/**
* Id
*/
id: number;
/**
* Entry Type
*/
entry_type: string;
/**
* Origin
*/
origin?: string | null;
/**
* Credits Delta
*/
credits_delta: number;
/**
* Balance After
*/
balance_after: number;
/**
* Amount Minor
*/
amount_minor?: number | null;
/**
* Amount Currency
*/
amount_currency?: string | null;
/**
* Payment Order Id
*/
payment_order_id?: number | null;
/**
* Metadata
*/
metadata?: {
[key: string]: unknown;
};
/**
* Created At
*/
created_at: string;
};
/**
* MPSCreditPurchaseUrlResponse
*/
export type MpsCreditPurchaseUrlResponse = {
/**
* Checkout Url
*/
checkout_url: string;
};
/**
* MPSCreditsResponse
*/
@ -3618,6 +3729,43 @@ export type OrganizationAiModelConfigurationV2 = {
byok?: ByokaiModelConfiguration | null;
};
/**
* OrganizationContextResponse
*/
export type OrganizationContextResponse = {
/**
* Organization Id
*/
organization_id?: number | null;
/**
* Organization Provider Id
*/
organization_provider_id?: string | null;
model_services: OrganizationModelServicesContext;
};
/**
* OrganizationModelServicesContext
*/
export type OrganizationModelServicesContext = {
/**
* Config Source
*/
config_source: 'organization_v2' | 'legacy_user_v1' | 'empty';
/**
* Has Model Configuration V2
*/
has_model_configuration_v2: boolean;
/**
* Managed Service Version
*/
managed_service_version?: number | null;
/**
* Uses Managed Service V2
*/
uses_managed_service_v2: boolean;
};
/**
* OrganizationPreferences
*/
@ -9750,6 +9898,45 @@ export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = {
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses];
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/organizations/context';
};
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetError = GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors];
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses = {
/**
* Successful Response
*/
200: OrganizationContextResponse;
};
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponse = GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses];
export type GetTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGetData = {
body?: never;
headers?: {
@ -11269,6 +11456,89 @@ export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses = {
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses];
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: {
/**
* Limit
*/
limit?: number;
};
url: '/api/v1/organizations/billing/credits';
};
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetError = GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors];
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses = {
/**
* Successful Response
*/
200: MpsBillingCreditsResponse;
};
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponse = GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses];
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/organizations/usage/mps-credits/purchase-url';
};
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostError = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors];
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses = {
/**
* Successful Response
*/
200: MpsCreditPurchaseUrlResponse;
};
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponse = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses];
export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
body?: never;
headers?: {

View file

@ -136,6 +136,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [
url: "/usage",
icon: TrendingUp,
},
{
title: "Billing",
url: "/billing",
icon: CircleDollarSign,
},
{
title: "Reports",
url: "/reports",

View file

@ -0,0 +1,192 @@
'use client';
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { client } from '@/client/client.gen';
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
import type { OrganizationContextResponse, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
import { setupAuthInterceptor } from '@/lib/apiClient';
import type { AuthUser } from '@/lib/auth';
import { useAuth } from '@/lib/auth';
interface TeamPermission {
id: string;
}
interface OrganizationPricing {
price_per_second_usd: number | null;
currency: string;
billing_enabled: boolean;
}
interface OrgConfigContextType {
orgContext: OrganizationContextResponse | null;
userConfig: UserConfigurationRequestResponseSchema | null;
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
loading: boolean;
error: Error | null;
refreshConfig: () => Promise<void>;
permissions: TeamPermission[];
user: AuthUser | null;
organizationPricing: OrganizationPricing | null;
}
const OrgConfigContext = createContext<OrgConfigContextType | null>(null);
const pricingFromUserConfig = (
userConfig: UserConfigurationRequestResponseSchema,
): OrganizationPricing | null => {
if (!userConfig.organization_pricing) {
return null;
}
return {
price_per_second_usd: userConfig.organization_pricing.price_per_second_usd as number | null,
currency: (userConfig.organization_pricing.currency as string) || 'USD',
billing_enabled: (userConfig.organization_pricing.billing_enabled as boolean) || false,
};
};
export function OrgConfigProvider({ children }: { children: ReactNode }) {
const [orgContext, setOrgContext] = useState<OrganizationContextResponse | null>(null);
const [userConfig, setUserConfig] = useState<UserConfigurationRequestResponseSchema | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
const auth = useAuth();
const authRef = useRef(auth);
authRef.current = auth;
const hasFetchedConfig = useRef(false);
const hasFetchedPermissions = useRef(false);
if (!auth.loading && auth.isAuthenticated) {
setupAuthInterceptor(client, auth.getAccessToken);
}
useEffect(() => {
if (auth.loading || hasFetchedPermissions.current) {
return;
}
hasFetchedPermissions.current = true;
const fetchPermissions = async () => {
const currentAuth = authRef.current;
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
const selectedTeam = currentAuth.getSelectedTeam();
if (selectedTeam) {
try {
const perms = await currentAuth.listPermissions(selectedTeam);
setPermissions(Array.isArray(perms) ? perms : []);
} catch {
setPermissions([]);
}
} else {
setPermissions([]);
}
} else {
setPermissions([{ id: 'admin' }]);
}
};
fetchPermissions();
}, [auth.loading, auth.provider]);
const fetchConfig = useCallback(async () => {
const currentAuth = authRef.current;
if (!currentAuth.isAuthenticated) {
return;
}
setLoading(true);
try {
const [orgContextResponse, userConfigResponse] = await Promise.all([
getCurrentOrganizationContextApiV1OrganizationsContextGet(),
getUserConfigurationsApiV1UserConfigurationsUserGet(),
]);
if (orgContextResponse.data) {
setOrgContext(orgContextResponse.data);
}
if (userConfigResponse.data) {
setUserConfig(userConfigResponse.data);
setOrganizationPricing(pricingFromUserConfig(userConfigResponse.data));
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch organization configuration'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
return;
}
hasFetchedConfig.current = true;
fetchConfig();
}, [auth.loading, auth.isAuthenticated, fetchConfig]);
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
body: {
...userConfig,
...userConfigRequest,
} as UserConfigurationRequestResponseSchema,
});
if (response.error) {
let msg = 'Failed to save user configuration';
const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail;
if (typeof detail === 'string') {
msg = detail;
} else if (Array.isArray(detail)) {
msg = detail
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
.join('\n');
}
throw new Error(msg);
}
if (response.data) {
setUserConfig(response.data);
setOrganizationPricing(pricingFromUserConfig(response.data));
}
}, [userConfig]);
const refreshConfig = useCallback(async () => {
await fetchConfig();
}, [fetchConfig]);
return (
<OrgConfigContext.Provider
value={{
orgContext,
userConfig,
saveUserConfig,
loading,
error,
refreshConfig,
permissions,
user: auth.user,
organizationPricing,
}}
>
{children}
</OrgConfigContext.Provider>
);
}
export function useOrgConfig() {
const context = useContext(OrgConfigContext);
if (!context) {
throw new Error('useOrgConfig must be used within an OrgConfigProvider');
}
return context;
}

View file

@ -1,205 +1,3 @@
'use client';
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { client } from '@/client/client.gen';
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
import { setupAuthInterceptor } from '@/lib/apiClient';
import type { AuthUser } from '@/lib/auth';
import { useAuth } from '@/lib/auth';
interface TeamPermission {
id: string;
}
interface OrganizationPricing {
price_per_second_usd: number | null;
currency: string;
billing_enabled: boolean;
}
interface UserConfigContextType {
userConfig: UserConfigurationRequestResponseSchema | null;
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
loading: boolean;
error: Error | null;
refreshConfig: () => Promise<void>;
permissions: TeamPermission[];
user: AuthUser | null;
organizationPricing: OrganizationPricing | null;
}
const UserConfigContext = createContext<UserConfigContextType | null>(null);
export function UserConfigProvider({ children }: { children: ReactNode }) {
const [userConfig, setUserConfig] = useState<UserConfigurationRequestResponseSchema | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
const auth = useAuth();
// Store auth functions in refs to avoid dependency issues
const authRef = useRef(auth);
authRef.current = auth;
// Track initialization
const hasFetchedConfig = useRef(false);
const hasFetchedPermissions = useRef(false);
// Register the auth interceptor synchronously during render (not in useEffect)
// so it's in place before any child effects fire API calls.
// setupAuthInterceptor is idempotent — safe for strict mode double-renders.
if (!auth.loading && auth.isAuthenticated) {
setupAuthInterceptor(client, auth.getAccessToken);
}
// Fetch permissions once when auth is ready
useEffect(() => {
if (auth.loading || hasFetchedPermissions.current) {
return;
}
hasFetchedPermissions.current = true;
const fetchPermissions = async () => {
const currentAuth = authRef.current;
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
const selectedTeam = currentAuth.getSelectedTeam();
if (selectedTeam) {
try {
const perms = await currentAuth.listPermissions(selectedTeam);
setPermissions(Array.isArray(perms) ? perms : []);
} catch {
setPermissions([]);
}
} else {
setPermissions([]);
}
} else {
setPermissions([{ id: 'admin' }]);
}
};
fetchPermissions();
}, [auth.loading, auth.provider]);
// Fetch user config once when auth is ready
useEffect(() => {
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
return;
}
hasFetchedConfig.current = true;
const fetchUserConfig = async () => {
setLoading(true);
try {
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet();
if (response.data) {
setUserConfig(response.data);
if (response.data.organization_pricing) {
setOrganizationPricing({
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
currency: response.data.organization_pricing.currency as string || 'USD',
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
});
} else {
setOrganizationPricing(null);
}
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
} finally {
setLoading(false);
}
};
fetchUserConfig();
}, [auth.loading, auth.isAuthenticated]);
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
body: {
...userConfig,
...userConfigRequest
} as UserConfigurationRequestResponseSchema,
});
if (response.error) {
let msg = 'Failed to save user configuration';
const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail;
if (typeof detail === 'string') {
msg = detail;
} else if (Array.isArray(detail)) {
msg = detail
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
.join('\n');
}
throw new Error(msg);
}
setUserConfig(response.data!);
if (response.data?.organization_pricing) {
setOrganizationPricing({
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
currency: response.data.organization_pricing.currency as string || 'USD',
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
});
}
}, [userConfig]);
const refreshConfig = useCallback(async () => {
const currentAuth = authRef.current;
if (!currentAuth.isAuthenticated) return;
setLoading(true);
try {
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet();
if (response.data) {
setUserConfig(response.data);
if (response.data.organization_pricing) {
setOrganizationPricing({
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
currency: response.data.organization_pricing.currency as string || 'USD',
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
});
}
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
} finally {
setLoading(false);
}
}, []);
return (
<UserConfigContext.Provider
value={{
userConfig,
saveUserConfig,
loading,
error,
refreshConfig,
permissions,
user: auth.user,
organizationPricing,
}}
>
{children}
</UserConfigContext.Provider>
);
}
export function useUserConfig() {
const context = useContext(UserConfigContext);
if (!context) {
throw new Error('useUserConfig must be used within a UserConfigProvider');
}
return context;
}
export { OrgConfigProvider as UserConfigProvider, useOrgConfig as useUserConfig } from './OrgConfigContext';