Skip to content

Commit ab0d330

Browse files
authored
add Stripe integration for team invoices (#6475)
1 parent f54a0cb commit ab0d330

File tree

14 files changed

+378
-27
lines changed

14 files changed

+378
-27
lines changed

apps/dashboard/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,6 @@ ANALYTICS_SERVICE_URL=""
102102

103103
# Required for Nebula Chat
104104
NEXT_PUBLIC_NEBULA_URL=""
105+
106+
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
107+
STRIPE_SECRET_KEY=""

apps/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"shiki": "1.27.0",
9595
"sonner": "2.0.1",
9696
"spdx-correct": "^3.2.0",
97+
"stripe": "17.7.0",
9798
"swagger-ui-react": "^5.20.1",
9899
"tailwind-merge": "^2.6.0",
99100
"tailwindcss-animate": "^1.0.7",

apps/dashboard/src/@/actions/billing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use server";
2-
32
import "server-only";
3+
44
import { API_SERVER_URL } from "@/constants/env";
55
import { getAuthToken } from "../../app/api/lib/getAuthToken";
66
import type { ProductSKU } from "../lib/billing";
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import "server-only";
2+
3+
import Stripe from "stripe";
4+
import type { Team } from "../api/team";
5+
6+
let existingStripe: Stripe | undefined;
7+
8+
function getStripe() {
9+
if (!existingStripe) {
10+
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
11+
12+
if (!STRIPE_SECRET_KEY) {
13+
throw new Error("STRIPE_SECRET_KEY is not set");
14+
}
15+
16+
existingStripe = new Stripe(STRIPE_SECRET_KEY, {
17+
apiVersion: "2025-02-24.acacia",
18+
});
19+
}
20+
21+
return existingStripe;
22+
}
23+
24+
export async function getTeamInvoices(
25+
team: Team,
26+
options?: { cursor?: string },
27+
) {
28+
try {
29+
const customerId = team.stripeCustomerId;
30+
31+
if (!customerId) {
32+
throw new Error("No customer ID found");
33+
}
34+
35+
// Get the list of invoices for the customer
36+
const invoices = await getStripe().invoices.list({
37+
customer: customerId,
38+
limit: 10,
39+
starting_after: options?.cursor,
40+
});
41+
42+
return invoices;
43+
} catch (error) {
44+
console.error("Error fetching billing history:", error);
45+
46+
// If the error is that the customer doesn't exist, return an empty array
47+
// instead of throwing an error
48+
if (
49+
error instanceof Stripe.errors.StripeError &&
50+
error.message.includes("No such customer")
51+
) {
52+
return {
53+
data: [],
54+
has_more: false,
55+
};
56+
}
57+
58+
throw new Error("Failed to fetch billing history");
59+
}
60+
}

apps/dashboard/src/@/api/team.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
33
import type { TeamResponse } from "@thirdweb-dev/service-utils";
44
import { getAuthToken } from "../../app/api/lib/getAuthToken";
55

6-
export type Team = TeamResponse;
6+
export type Team = TeamResponse & { stripeCustomerId: string | null };
7+
78
export async function getTeamBySlug(slug: string) {
89
const token = await getAuthToken();
910

apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"use client";
22

33
import type { GetBillingPortalUrlAction } from "@/actions/billing";
4-
import { BillingPortalButton } from "@/components/billing";
54
import { Spinner } from "@/components/ui/Spinner/Spinner";
5+
import { Button } from "@/components/ui/button";
66
import { useDashboardRouter } from "@/lib/DashboardRouter";
77
import { cn } from "@/lib/utils";
8+
import Link from "next/link";
89
import { useTransition } from "react";
910
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
1011

@@ -14,7 +15,6 @@ function BillingAlertBanner(props: {
1415
teamSlug: string;
1516
variant: "error" | "warning";
1617
ctaLabel: string;
17-
getBillingPortalUrl: GetBillingPortalUrlAction;
1818
}) {
1919
const router = useDashboardRouter();
2020
const [isPending, startTransition] = useTransition();
@@ -44,22 +44,21 @@ function BillingAlertBanner(props: {
4444

4545
<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
4646
<p className="mt-1 mb-4 text-sm">{props.description}</p>
47-
<BillingPortalButton
48-
buttonProps={{
49-
size: "sm",
50-
className: cn(
51-
"gap-2",
52-
props.variant === "warning" &&
53-
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
54-
props.variant === "error" &&
55-
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
56-
),
57-
}}
58-
teamSlug={props.teamSlug}
59-
getBillingPortalUrl={props.getBillingPortalUrl}
47+
48+
<Button
49+
asChild
50+
className={cn(
51+
"gap-2",
52+
props.variant === "warning" &&
53+
"border border-yellow-600 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-100 dark:hover:bg-yellow-800",
54+
props.variant === "error" &&
55+
"border border-red-600 bg-red-100 text-red-800 hover:bg-red-200 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800",
56+
)}
6057
>
61-
{props.ctaLabel}
62-
</BillingPortalButton>
58+
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
59+
{props.ctaLabel}
60+
</Link>
61+
</Button>
6362
</div>
6463
);
6564
}
@@ -73,7 +72,6 @@ export function PastDueBannerUI(props: {
7372
ctaLabel="View Invoices"
7473
variant="warning"
7574
title="Unpaid Invoices"
76-
getBillingPortalUrl={props.getBillingPortalUrl}
7775
description={
7876
<>
7977
You have unpaid invoices. Service may be suspended if not paid
@@ -94,7 +92,6 @@ export function ServiceCutOffBannerUI(props: {
9492
ctaLabel="Pay Now"
9593
variant="error"
9694
title="Service Suspended"
97-
getBillingPortalUrl={props.getBillingPortalUrl}
9895
description={
9996
<>
10097
Your service has been suspended due to unpaid invoices. Pay now to

apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/SettingsLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function SettingsLayout(props: {
5555
/>
5656
<div
5757
className={cn(
58-
"flex grow flex-col",
58+
"flex max-w-full grow flex-col",
5959
// if showing full nav on mobile - hide the page content
6060
showFullNavOnMobile && "max-sm:hidden",
6161
)}

apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/_components/sidebar/getTeamSettingsLinks.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ export function getTeamSettingsLinks(teamSlug: string) {
1212
group: "team",
1313
},
1414
{
15-
name: "Members",
16-
href: `${prefix}/members`,
15+
name: "Billing",
16+
href: `${prefix}/billing`,
1717
group: "team",
1818
},
1919
{
20-
name: "Billing",
21-
href: `${prefix}/billing`,
20+
name: "Invoices",
21+
href: `${prefix}/invoices`,
22+
group: "team",
23+
},
24+
{
25+
name: "Members",
26+
href: `${prefix}/members`,
2227
group: "team",
2328
},
2429
{

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { TrackedLinkTW } from "@/components/ui/tracked-link";
99
import { differenceInDays, isAfter } from "date-fns";
1010
import { format } from "date-fns/format";
1111
import { CircleAlertIcon } from "lucide-react";
12+
import Link from "next/link";
1213
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";
1314

1415
export function PlanInfoCard(props: {
@@ -53,7 +54,14 @@ export function PlanInfoCard(props: {
5354
)}
5455
</div>
5556

56-
<div className="flex flex-row gap-2">
57+
<div className="flex flex-row items-center gap-2">
58+
{/* go to invoices page */}
59+
<Button asChild variant="outline">
60+
<Link href={`/team/${team.slug}/~/settings/invoices`}>
61+
View Invoices
62+
</Link>
63+
</Button>
64+
5765
{/* manage team billing */}
5866
<BillingPortalButton
5967
teamSlug={team.slug}

0 commit comments

Comments
 (0)