Skip to content

Commit 1aea7bd

Browse files
authored
fix(billing): clear quota items on plan upgrade and improve paid plan usage UX (#498)
* fix(billing): clear quota items on plan upgrade and improve paid plan usage UX - sync-usage: return excess=false for non-free orgs so API clears leftover quota enforcement items (Redis keys + DB records) after upgrading from free - top-nav: only show red warning dot and alarming bar colors for free plan orgs; paid plan orgs with overage link to usage page instead of billing - usage page: no red/amber colors for paid plans; show per-metric overage quantity and estimated cost via new get-upcoming-invoice edge function - New edge function: get-upcoming-invoice calculates overage from local usage data + Stripe metered subscription prices * fix(dashboard): avoid synchronous setState in effect for lint compliance
1 parent bcc2ddf commit 1aea7bd

5 files changed

Lines changed: 423 additions & 15 deletions

File tree

dashboard/app/org/[id]/usage/usage-page-client.tsx

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"use client";
22

3-
import { useEffect } from "react";
3+
import { useEffect, useState, useMemo } from "react";
44

55
import { useTopNavStore } from "@/stores/top-nav";
66
import { usePlanStore } from "@/stores/plan";
77
import { Organization } from "@/types";
88
import { OrganizationUsageData } from "@/lib/supabase/operations/organizations";
9+
import {
10+
getUpcomingInvoice,
11+
type OverageLineItem,
12+
} from "@/lib/supabase/operations/prices";
913
import { formatBytes } from "@/lib/utils";
1014
import {
1115
Card,
@@ -29,18 +33,46 @@ function getProgressColor(percentage: number): string {
2933
return "";
3034
}
3135

36+
function formatCurrency(amountCents: number, currency: string): string {
37+
return new Intl.NumberFormat("en-US", {
38+
style: "currency",
39+
currency: currency.toUpperCase(),
40+
minimumFractionDigits: 2,
41+
}).format(amountCents / 100);
42+
}
43+
3244
interface UsageMetric {
3345
label: string;
3446
description: string;
3547
current: number;
3648
max: number;
49+
metricKey?: string;
3750
formatValue?: (v: number) => string;
3851
}
3952

40-
function UsageCard({ metric }: { metric: UsageMetric }) {
41-
const percentage = metric.max > 0 ? Math.min((metric.current / metric.max) * 100, 100) : 0;
53+
interface OverageInfo {
54+
quantity: number;
55+
amount: number;
56+
unitAmount: number;
57+
currency: string;
58+
}
59+
60+
function UsageCard({
61+
metric,
62+
isFreePlan,
63+
overage,
64+
}: {
65+
metric: UsageMetric;
66+
isFreePlan: boolean;
67+
overage?: OverageInfo;
68+
}) {
69+
const percentage =
70+
metric.max > 0
71+
? Math.min((metric.current / metric.max) * 100, 100)
72+
: 0;
4273
const format = metric.formatValue || ((v: number) => v.toLocaleString());
43-
const colorClass = getProgressColor(percentage);
74+
// Paid plans use pay-per-use overage — no alarming colors
75+
const colorClass = isFreePlan ? getProgressColor(percentage) : "";
4476

4577
return (
4678
<Card>
@@ -53,7 +85,8 @@ function UsageCard({ metric }: { metric: UsageMetric }) {
5385
</CardDescription>
5486
</div>
5587
<span className="text-sm text-muted-foreground tabular-nums">
56-
{format(metric.current)} / {metric.max > 0 ? format(metric.max) : "∞"}
88+
{format(metric.current)} /{" "}
89+
{metric.max > 0 ? format(metric.max) : "∞"}
5790
</span>
5891
</div>
5992
</CardHeader>
@@ -64,13 +97,24 @@ function UsageCard({ metric }: { metric: UsageMetric }) {
6497
style={{ width: `${percentage}%` }}
6598
/>
6699
</div>
67-
{percentage >= 90 && metric.max > 0 && (
68-
<p className="text-xs text-red-500 mt-2">
100+
{isFreePlan && percentage >= 90 && metric.max > 0 && (
101+
<p className="text-xs mt-2 text-red-500">
69102
{percentage >= 100
70103
? "Quota exceeded. Upgrade your plan to continue."
71104
: "Approaching quota limit."}
72105
</p>
73106
)}
107+
{/* Overage details for paid plans */}
108+
{!isFreePlan && overage && overage.quantity > 0 && (
109+
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
110+
<span>
111+
Overage: {overage.quantity.toLocaleString()} units
112+
</span>
113+
<span className="tabular-nums font-medium">
114+
{formatCurrency(overage.amount, overage.currency)}
115+
</span>
116+
</div>
117+
)}
74118
</CardContent>
75119
</Card>
76120
);
@@ -85,6 +129,17 @@ export function UsagePageClient({
85129
const { getPriceByProduct, getPlanDisplayName: getPlanDisplayNameFromPrice } =
86130
usePlanStore();
87131

132+
const isFreePlan =
133+
currentOrganization.plan === "free" || !currentOrganization.plan;
134+
135+
// Overage data for paid plans
136+
const [overageData, setOverageData] = useState<{
137+
lineItems: OverageLineItem[];
138+
totalOverage: number;
139+
currency: string;
140+
} | null>(null);
141+
const [overageLoading, setOverageLoading] = useState(!isFreePlan && !!currentOrganization.id);
142+
88143
useEffect(() => {
89144
initialize({
90145
title: "",
@@ -100,6 +155,44 @@ export function UsagePageClient({
100155
};
101156
}, [currentOrganization, allOrganizations, initialize, setHasSidebar]);
102157

158+
// Fetch overage data for paid plans
159+
useEffect(() => {
160+
if (isFreePlan || !currentOrganization.id) return;
161+
162+
let cancelled = false;
163+
164+
getUpcomingInvoice(currentOrganization.id).then((result) => {
165+
if (cancelled) return;
166+
if (result.data) {
167+
setOverageData({
168+
lineItems: result.data.line_items,
169+
totalOverage: result.data.total_overage,
170+
currency: result.data.currency,
171+
});
172+
}
173+
setOverageLoading(false);
174+
});
175+
176+
return () => {
177+
cancelled = true;
178+
};
179+
}, [isFreePlan, currentOrganization.id]);
180+
181+
// Build overage lookup by metric_key
182+
const overageByMetric = useMemo(() => {
183+
if (!overageData) return new Map<string, OverageInfo>();
184+
const map = new Map<string, OverageInfo>();
185+
for (const item of overageData.lineItems) {
186+
map.set(item.metric_key, {
187+
quantity: item.quantity,
188+
amount: item.amount,
189+
unitAmount: item.unit_amount,
190+
currency: overageData.currency,
191+
});
192+
}
193+
return map;
194+
}, [overageData]);
195+
103196
const getPlanDisplayName = (plan: string | undefined) => {
104197
if (!plan || plan === "free") return "Free Plan";
105198
const priceInfo = getPriceByProduct(plan);
@@ -118,12 +211,14 @@ export function UsagePageClient({
118211
description: "Total agent tasks created this billing period",
119212
current: usage.current_task,
120213
max: limits.max_task,
214+
metricKey: "current_task",
121215
},
122216
{
123217
label: "Storage",
124218
description: "Total storage used across all projects",
125219
current: usage.current_storage,
126220
max: limits.max_storage,
221+
metricKey: "current_storage",
127222
formatValue: formatBytes,
128223
},
129224
];
@@ -166,8 +261,48 @@ export function UsagePageClient({
166261

167262
{/* Usage Metrics */}
168263
{metrics.map((metric) => (
169-
<UsageCard key={metric.label} metric={metric} />
264+
<UsageCard
265+
key={metric.label}
266+
metric={metric}
267+
isFreePlan={isFreePlan}
268+
overage={
269+
metric.metricKey
270+
? overageByMetric.get(metric.metricKey)
271+
: undefined
272+
}
273+
/>
170274
))}
275+
276+
{/* Overage Summary for paid plans */}
277+
{!isFreePlan && overageData && overageData.totalOverage > 0 && (
278+
<Card>
279+
<CardContent>
280+
<div className="flex items-center justify-between">
281+
<div>
282+
<p className="text-sm font-medium">
283+
Estimated Overage This Period
284+
</p>
285+
<p className="text-xs text-muted-foreground mt-0.5">
286+
Usage beyond included quota, billed at period end
287+
</p>
288+
</div>
289+
<span className="text-lg font-semibold tabular-nums">
290+
{formatCurrency(
291+
overageData.totalOverage,
292+
overageData.currency
293+
)}
294+
</span>
295+
</div>
296+
</CardContent>
297+
</Card>
298+
)}
299+
300+
{/* Loading indicator for overage */}
301+
{!isFreePlan && overageLoading && (
302+
<p className="text-xs text-muted-foreground text-center">
303+
Loading overage details...
304+
</p>
305+
)}
171306
</div>
172307
</div>
173308
);

dashboard/components/top-nav.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,11 @@ function UsageIndicator({ className }: { className?: string }) {
328328
};
329329
}, [fetched, user]);
330330

331-
// Check if any org has critical usage (>=90%)
331+
// Check if any FREE plan org has critical usage (>=90%)
332+
// Paid plans use pay-per-use overage, so no warning needed
332333
const hasWarning = React.useMemo(() => {
333334
return usageData.some((org) => {
335+
if (org.plan !== "free") return false;
334336
const metrics = [
335337
{ current: org.usage.current_task, max: org.limits.max_task },
336338
{ current: org.usage.current_storage, max: org.limits.max_storage },
@@ -341,7 +343,9 @@ function UsageIndicator({ className }: { className?: string }) {
341343
});
342344
}, [usageData]);
343345

344-
const getBarColor = (percentage: number) => {
346+
const getBarColor = (percentage: number, plan: string) => {
347+
// Paid plans use pay-per-use overage — no alarming colors needed
348+
if (plan !== "free") return "bg-primary";
345349
if (percentage >= 90) return "bg-red-500";
346350
if (percentage >= 70) return "bg-amber-500";
347351
return "bg-primary";
@@ -390,21 +394,32 @@ function UsageIndicator({ className }: { className?: string }) {
390394
: 0;
391395
const maxPct = Math.max(taskPct, storagePct);
392396
const encodedId = encodeId(org.orgId);
397+
// For paid plans, check if any metric exceeds included quota
398+
const hasOverage =
399+
org.plan !== "free" &&
400+
((org.limits.max_task > 0 &&
401+
org.usage.current_task > org.limits.max_task) ||
402+
(org.limits.max_storage > 0 &&
403+
org.usage.current_storage > org.limits.max_storage));
393404

394405
return (
395406
<button
396407
key={org.orgId}
397408
className="w-full px-3 py-2.5 text-left hover:bg-muted/50 transition-colors border-b last:border-b-0 cursor-pointer"
398409
onClick={() => {
399-
router.push(`/org/${encodedId}/billing`);
410+
router.push(
411+
hasOverage
412+
? `/org/${encodedId}/usage`
413+
: `/org/${encodedId}/billing`
414+
);
400415
}}
401416
>
402417
<div className="flex items-center justify-between mb-1.5">
403418
<div className="flex items-center gap-1.5 min-w-0">
404419
<span className="text-sm font-medium truncate">
405420
{org.orgName}
406421
</span>
407-
{maxPct >= 90 && (
422+
{org.plan === "free" && maxPct >= 90 && (
408423
<span className="h-1.5 w-1.5 rounded-full bg-red-500 shrink-0" />
409424
)}
410425
</div>
@@ -423,7 +438,7 @@ function UsageIndicator({ className }: { className?: string }) {
423438
</div>
424439
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
425440
<div
426-
className={`h-full rounded-full transition-all ${getBarColor(taskPct)}`}
441+
className={`h-full rounded-full transition-all ${getBarColor(taskPct, org.plan)}`}
427442
style={{ width: `${taskPct}%` }}
428443
/>
429444
</div>
@@ -441,11 +456,18 @@ function UsageIndicator({ className }: { className?: string }) {
441456
</div>
442457
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
443458
<div
444-
className={`h-full rounded-full transition-all ${getBarColor(storagePct)}`}
459+
className={`h-full rounded-full transition-all ${getBarColor(storagePct, org.plan)}`}
445460
style={{ width: `${storagePct}%` }}
446461
/>
447462
</div>
448463
</div>
464+
{/* Overage hint for paid plans */}
465+
{hasOverage && (
466+
<div className="mt-2 text-xs text-muted-foreground flex items-center gap-1">
467+
<span>Overage detected — view details</span>
468+
<ExternalLink className="h-2.5 w-2.5" />
469+
</div>
470+
)}
449471
</button>
450472
);
451473
})

dashboard/lib/supabase/operations/prices.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,59 @@ interface DeleteCustomerResponse {
381381
/**
382382
* Delete Stripe customer and cancel subscriptions when organization is deleted
383383
*/
384+
// ============ Upcoming Invoice (Overage) ============
385+
386+
export interface OverageLineItem {
387+
meter: string;
388+
metric_key: string;
389+
description: string;
390+
quantity: number;
391+
unit_amount: number; // in cents
392+
amount: number; // in cents
393+
}
394+
395+
export interface UpcomingInvoiceData {
396+
line_items: OverageLineItem[];
397+
total_overage: number; // in cents
398+
currency: string;
399+
}
400+
401+
export interface GetUpcomingInvoiceResult {
402+
data?: UpcomingInvoiceData;
403+
error?: string;
404+
}
405+
406+
export async function getUpcomingInvoice(
407+
organizationId: string
408+
): Promise<GetUpcomingInvoiceResult> {
409+
try {
410+
const supabase = await createClient();
411+
const { data, error } = await supabase.functions.invoke(
412+
"get-upcoming-invoice",
413+
{
414+
body: { organization_id: organizationId },
415+
}
416+
);
417+
418+
if (error) {
419+
console.error("Error fetching upcoming invoice:", error);
420+
return { error: error.message || "Failed to fetch upcoming invoice" };
421+
}
422+
423+
return { data: data as UpcomingInvoiceData };
424+
} catch (error) {
425+
console.error("Error fetching upcoming invoice:", error);
426+
return {
427+
error:
428+
error instanceof Error
429+
? error.message
430+
: "Failed to fetch upcoming invoice",
431+
};
432+
}
433+
}
434+
435+
// ============ Delete Customer ============
436+
384437
export async function deleteCustomer(
385438
organizationId: string
386439
): Promise<DeleteCustomerResult> {

0 commit comments

Comments
 (0)