Skip to content

Commit 4027a3f

Browse files
committed
[Dashboard] Add detailed usage breakdown and billing preview
1 parent 5c4c49b commit 4027a3f

File tree

8 files changed

+315
-502
lines changed

8 files changed

+315
-502
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import "server-only";
2+
3+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
4+
import { getAuthToken } from "../../../app/(app)/api/lib/getAuthToken";
5+
6+
type LineItem = {
7+
quantity: number;
8+
amountUsdCents: number;
9+
unitAmountUsdCents: string;
10+
description: string;
11+
};
12+
13+
export type UsageCategory = {
14+
category: string;
15+
unitName: string;
16+
lineItems: LineItem[];
17+
};
18+
19+
type UsageApiResponse = {
20+
result: UsageCategory[];
21+
};
22+
23+
export async function getBilledUsage(teamSlug: string) {
24+
const authToken = await getAuthToken();
25+
if (!authToken) {
26+
throw new Error("No auth token found");
27+
}
28+
const response = await fetch(
29+
new URL(
30+
`/v1/teams/${teamSlug}/billing/billed-usage`,
31+
NEXT_PUBLIC_THIRDWEB_API_HOST,
32+
),
33+
{
34+
headers: {
35+
Authorization: `Bearer ${authToken}`,
36+
},
37+
},
38+
);
39+
if (!response.ok) {
40+
throw new Error("Failed to fetch billed usage");
41+
}
42+
return response.json() as Promise<UsageApiResponse>;
43+
}

apps/dashboard/src/@/api/usage/rpc.ts

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,58 +2,6 @@ import "server-only";
22
import { unstable_cache } from "next/cache";
33
import { ANALYTICS_SERVICE_URL } from "../../constants/server-envs";
44

5-
export type RPCUsageDataItem = {
6-
date: string;
7-
usageType: "included" | "overage" | "rate-limit";
8-
count: string;
9-
};
10-
11-
export const fetchRPCUsage = unstable_cache(
12-
async (params: {
13-
teamId: string;
14-
projectId?: string;
15-
authToken: string;
16-
from: string;
17-
to: string;
18-
period: "day" | "week" | "month" | "year" | "all";
19-
}) => {
20-
const analyticsEndpoint = ANALYTICS_SERVICE_URL;
21-
const url = new URL(`${analyticsEndpoint}/v2/rpc/usage-types`);
22-
url.searchParams.set("teamId", params.teamId);
23-
if (params.projectId) {
24-
url.searchParams.set("projectId", params.projectId);
25-
}
26-
url.searchParams.set("from", params.from);
27-
url.searchParams.set("to", params.to);
28-
url.searchParams.set("period", params.period);
29-
30-
const res = await fetch(url, {
31-
headers: {
32-
Authorization: `Bearer ${params.authToken}`,
33-
},
34-
});
35-
36-
if (!res.ok) {
37-
const error = await res.text();
38-
return {
39-
ok: false as const,
40-
error: error,
41-
};
42-
}
43-
44-
const resData = await res.json();
45-
46-
return {
47-
ok: true as const,
48-
data: resData.data as RPCUsageDataItem[],
49-
};
50-
},
51-
["rpc-usage"],
52-
{
53-
revalidate: 60 * 60, // 1 hour
54-
},
55-
);
56-
575
type Last24HoursRPCUsageApiResponse = {
586
peakRate: {
597
date: string;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { UsageCategory } from "@/api/usage/billing-preview";
2+
import {
3+
Card,
4+
CardContent,
5+
CardDescription,
6+
CardFooter,
7+
CardHeader,
8+
CardTitle,
9+
} from "@/components/ui/card";
10+
import {
11+
Table,
12+
TableBody,
13+
TableCell,
14+
TableHead,
15+
TableHeader,
16+
TableRow,
17+
} from "@/components/ui/table";
18+
19+
interface UsageCategoryDetailsProps {
20+
category: UsageCategory;
21+
}
22+
23+
export function UsageCategoryDetails({ category }: UsageCategoryDetailsProps) {
24+
const categoryTotalCents = category.lineItems.reduce(
25+
(sum, item) => sum + item.amountUsdCents,
26+
0,
27+
);
28+
29+
// filter out any lines with 0 quantity
30+
const filteredLineItems = category.lineItems
31+
.filter((item) => item.quantity > 0)
32+
// sort the line items with no quantity to the bottom
33+
.sort((a, b) => {
34+
if (a.quantity === 0) {
35+
return 1;
36+
}
37+
if (b.quantity === 0) {
38+
return -1;
39+
}
40+
return 0;
41+
});
42+
43+
return (
44+
<Card className="overflow-hidden">
45+
<CardHeader>
46+
<CardTitle className="text-lg">{category.category}</CardTitle>
47+
<CardDescription className="text-sm">
48+
Unit of Measure: {category.unitName}
49+
</CardDescription>
50+
</CardHeader>
51+
<CardContent className="p-0">
52+
<Table>
53+
<TableHeader className="bg-transparent">
54+
<TableRow>
55+
<TableHead className="w-[45%] pl-6">Description</TableHead>
56+
<TableHead className="text-right">Quantity</TableHead>
57+
<TableHead className="text-right">Unit Price</TableHead>
58+
<TableHead className="pr-6 text-right">Amount</TableHead>
59+
</TableRow>
60+
</TableHeader>
61+
<TableBody>
62+
{filteredLineItems.length > 0 ? (
63+
filteredLineItems.map((item, index) => (
64+
<TableRow
65+
key={`${item.description}_${index}`}
66+
className="hover:bg-accent"
67+
>
68+
<TableCell className="py-3 pl-6 font-medium">
69+
{item.description}
70+
</TableCell>
71+
<TableCell className="py-3 text-right">
72+
{item.quantity.toLocaleString()}
73+
</TableCell>
74+
<TableCell className="py-3 text-right">
75+
{formatPrice(item.unitAmountUsdCents, {
76+
isUnitPrice: true,
77+
inCents: true,
78+
})}
79+
</TableCell>
80+
<TableCell className="py-3 pr-6 text-right">
81+
{formatPrice(item.amountUsdCents, { inCents: true })}
82+
</TableCell>
83+
</TableRow>
84+
))
85+
) : (
86+
<TableRow>
87+
<TableCell
88+
colSpan={4}
89+
className="h-24 pl-6 text-center text-muted-foreground"
90+
>
91+
No usage during this period.
92+
</TableCell>
93+
</TableRow>
94+
)}
95+
</TableBody>
96+
</Table>
97+
</CardContent>
98+
{categoryTotalCents > 0 && (
99+
<CardFooter className="flex justify-end p-4 pr-6 ">
100+
<div className="font-semibold text-md">
101+
Subtotal: {formatPrice(categoryTotalCents, { inCents: true })}
102+
</div>
103+
</CardFooter>
104+
)}
105+
</Card>
106+
);
107+
}
108+
109+
// Currency Formatting Helper
110+
export function formatPrice(
111+
value: number | string,
112+
options?: { isUnitPrice?: boolean; inCents?: boolean },
113+
) {
114+
const { isUnitPrice = false, inCents = true } = options || {};
115+
const numericValue =
116+
typeof value === "string" ? Number.parseFloat(value) : value;
117+
118+
if (Number.isNaN(numericValue)) {
119+
return "N/A";
120+
}
121+
122+
const amountInDollars = inCents ? numericValue / 100 : numericValue;
123+
124+
return amountInDollars.toLocaleString("en-US", {
125+
style: "currency",
126+
currency: "USD",
127+
minimumFractionDigits: 2,
128+
maximumFractionDigits: isUnitPrice ? 10 : 2, // Allow more precision for unit prices
129+
});
130+
}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,16 +200,19 @@ export function PlanInfoCardUI(props: {
200200
</div>
201201
</div>
202202
) : (
203-
<BillingInfo subscriptions={subscriptions} />
203+
<BillingInfo
204+
subscriptions={subscriptions}
205+
teamSlug={props.team.slug}
206+
/>
204207
)}
205208
</div>
206209

207210
{props.team.billingPlan !== "free" && (
208211
<div className="flex flex-col gap-4 border-t p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
209212
<p className="text-muted-foreground text-sm">
210213
<span>
211-
Adjust your plan here to avoid unnecessary charges.{" "}
212-
<br className="max-sm:hidden" /> For more details, See{" "}
214+
Adjust your plan to avoid unnecessary charges.{" "}
215+
<br className="max-sm:hidden" /> For more details, see{" "}
213216
</span>
214217
<span>
215218
<UnderlineLink
@@ -268,8 +271,10 @@ export function PlanInfoCardUI(props: {
268271

269272
function BillingInfo({
270273
subscriptions,
274+
teamSlug,
271275
}: {
272276
subscriptions: TeamSubscription[];
277+
teamSlug: string;
273278
}) {
274279
const planSubscription = subscriptions.find(
275280
(subscription) => subscription.type === "PLAN",
@@ -282,15 +287,28 @@ function BillingInfo({
282287
return (
283288
<div>
284289
{planSubscription && (
285-
<SubscriptionOverview subscription={planSubscription} />
290+
<SubscriptionOverview title="Plan" subscription={planSubscription} />
286291
)}
287292

288293
{usageSubscription && (
289294
<>
290295
<Separator className="my-4" />
291296
<SubscriptionOverview
292297
subscription={usageSubscription}
293-
title="On-Demand Charges"
298+
title={
299+
<div className="flex items-center">
300+
<h5 className="mr-1 font-medium text-base">Usage</h5>{" "}
301+
<span className="text-muted-foreground text-sm">
302+
-{" "}
303+
<Link
304+
className="hover:underline"
305+
href={`/team/${teamSlug}/~/usage`}
306+
>
307+
View Breakdown
308+
</Link>
309+
</span>
310+
</div>
311+
}
294312
/>
295313
</>
296314
)}
@@ -300,17 +318,20 @@ function BillingInfo({
300318

301319
function SubscriptionOverview(props: {
302320
subscription: TeamSubscription;
303-
title?: string;
321+
title?: string | React.ReactNode;
304322
}) {
305323
const { subscription } = props;
306324

307325
return (
308326
<div>
309327
<div className="flex items-center justify-between gap-6">
310328
<div>
311-
{props.title && (
312-
<h5 className="font-medium text-base">{props.title}</h5>
313-
)}
329+
{props.title &&
330+
(typeof props.title === "string" ? (
331+
<h5 className="font-medium text-base">{props.title}</h5>
332+
) : (
333+
props.title
334+
))}
314335
<p className="text-muted-foreground text-sm">
315336
{format(
316337
new Date(props.subscription.currentPeriodStart),

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/usage/layout.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
2+
import { Button } from "@/components/ui/button";
3+
import Link from "next/link";
24

35
export default async function Layout(props: {
46
children: React.ReactNode;
@@ -10,10 +12,22 @@ export default async function Layout(props: {
1012
return (
1113
<div className="flex grow flex-col">
1214
<div className="border-border border-b py-10">
13-
<div className="container">
15+
<div className="container flex flex-row justify-between">
1416
<h1 className="font-semibold text-3xl tracking-tight lg:px-2">
1517
Usage
1618
</h1>
19+
<div className="flex items-center gap-2">
20+
<Button variant="outline" asChild>
21+
<Link href={`/team/${params.team_slug}/~/settings/billing`}>
22+
Billing Settings
23+
</Link>
24+
</Button>
25+
<Button variant="outline" asChild>
26+
<Link href={`/team/${params.team_slug}/~/settings/invoices`}>
27+
Invoice History
28+
</Link>
29+
</Button>
30+
</div>
1731
</div>
1832
</div>
1933
<SidebarLayout

0 commit comments

Comments
 (0)