feat(phase-4): WS6 — Billing & Usage Metering (Stripe, free tier enforcement)

- DB migration 023: tenant_subscriptions and usage_events tables
- UsageMeteringMiddleware: in-memory counters, 60s flush to DB via UPSERT
- FreeTierEnforcementMiddleware: 10 agents / 1,000 calls/day limits, Redis cache
- UsageService: getDailyUsage and getActiveAgentCount
- BillingService: Stripe checkout sessions, webhook verification, subscription status
- POST /billing/checkout, POST /billing/webhook, GET /billing/usage endpoints
- BILLING_ENABLED=false disables enforcement without breaking metering
- Dashboard: Usage tab with Free Tier/Pro badges and metric cards
- 19 unit tests passing across billing services and middleware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-04-02 10:51:36 +00:00
parent fefbf1e3ea
commit 26a56f84e1
18 changed files with 1647 additions and 17 deletions

View File

@@ -0,0 +1,192 @@
import * as React from 'react';
import { useAuth } from '@/lib/auth';
import { TokenManager } from '@sentryagent/idp-sdk';
/** Shape of the GET /api/v1/billing/usage response. */
interface UsageResponse {
tenantId: string;
date: string;
apiCalls: number;
agentCount: number;
subscriptionStatus: string;
currentPeriodEnd: string | null;
stripeSubscriptionId: string | null;
}
type LoadState = 'idle' | 'loading' | 'success' | 'error';
interface UsageState {
loadState: LoadState;
data: UsageResponse | null;
errorMessage: string | null;
}
const initialState: UsageState = {
loadState: 'idle',
data: null,
errorMessage: null,
};
/**
* Fetches the current usage summary from the API using the stored credentials.
*
* @param baseUrl - The API base URL.
* @param clientId - The agent client ID.
* @param clientSecret - The agent client secret.
* @returns The usage response from the server.
*/
async function fetchUsage(
baseUrl: string,
clientId: string,
clientSecret: string,
): Promise<UsageResponse> {
const tokenManager = new TokenManager(
baseUrl,
clientId,
clientSecret,
'agents:read',
);
const token = await tokenManager.getToken();
const response = await fetch(`${baseUrl}/api/v1/billing/usage`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`Failed to fetch usage data (HTTP ${response.status})`);
}
return response.json() as Promise<UsageResponse>;
}
/** Badge shown for the tenant's subscription tier. */
function SubscriptionBadge({ status }: { status: string }): React.JSX.Element {
const isPro = status !== 'free';
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
isPro
? 'bg-brand-100 text-brand-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{isPro ? 'Pro' : 'Free Tier'}
</span>
);
}
/** A single metric card with label and value. */
function MetricCard({ label, value }: { label: string; value: string | number }): React.JSX.Element {
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<p className="text-sm font-medium text-slate-500">{label}</p>
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
</div>
);
}
/**
* Displays the current tenant's usage summary:
* - API calls today
* - Active agent count
* - Subscription status (Free Tier / Pro)
*
* Fetches GET /api/v1/billing/usage with the current Bearer token.
* Handles loading state and error state gracefully.
*/
export function UsagePanel(): React.JSX.Element {
const { credentials } = useAuth();
const [state, setState] = React.useState<UsageState>(initialState);
const loadUsage = React.useCallback(async (): Promise<void> => {
if (!credentials) return;
setState((prev) => ({ ...prev, loadState: 'loading', errorMessage: null }));
try {
const data = await fetchUsage(
credentials.baseUrl,
credentials.clientId,
credentials.clientSecret,
);
setState({ loadState: 'success', data, errorMessage: null });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error occurred.';
setState({ loadState: 'error', data: null, errorMessage: message });
}
}, [credentials]);
React.useEffect(() => {
void loadUsage();
}, [loadUsage]);
const isLoading = state.loadState === 'loading' || state.loadState === 'idle';
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">Usage &amp; Billing</h1>
<button
onClick={() => { void loadUsage(); }}
disabled={isLoading}
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 disabled:opacity-40"
>
Refresh
</button>
</div>
{/* Error state */}
{state.loadState === 'error' && (
<div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{state.errorMessage ?? 'Failed to load usage data.'}
</div>
)}
{/* Loading skeleton */}
{isLoading && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 animate-pulse">
{[1, 2, 3].map((i) => (
<div key={i} className="h-28 rounded-xl border border-slate-200 bg-slate-100" />
))}
</div>
)}
{/* Data */}
{state.loadState === 'success' && state.data !== null && (
<>
<div className="mb-4 flex items-center gap-3">
<p className="text-sm text-slate-500">
Showing usage for <strong>{state.data.date}</strong>
</p>
<SubscriptionBadge status={state.data.subscriptionStatus} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<MetricCard label="API Calls Today" value={state.data.apiCalls.toLocaleString()} />
<MetricCard label="Active Agents" value={state.data.agentCount.toLocaleString()} />
<MetricCard label="Plan" value={state.data.subscriptionStatus === 'free' ? 'Free Tier' : 'Pro'} />
</div>
{state.data.subscriptionStatus === 'free' && (
<div className="mt-6 rounded-xl border border-brand-200 bg-brand-50 p-5">
<p className="text-sm font-medium text-brand-800">
You are on the Free Tier limited to 10 agents and 1,000 API calls/day.
</p>
<p className="mt-1 text-sm text-brand-700">
Upgrade to Pro for unlimited agents and API calls.
</p>
</div>
)}
{state.data.currentPeriodEnd !== null && (
<p className="mt-4 text-xs text-slate-400">
Current period ends:{' '}
{new Date(state.data.currentPeriodEnd).toLocaleDateString()}
</p>
)}
</>
)}
</div>
);
}

View File

@@ -12,6 +12,7 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard/agents', label: 'Agents' },
{ to: '/dashboard/audit', label: 'Audit Log' },
{ to: '/dashboard/health', label: 'Health' },
{ to: '/dashboard/usage', label: 'Usage' },
];
/**