diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example
index 2b945e30c70..04e0f385e60 100644
--- a/apps/dashboard/.env.example
+++ b/apps/dashboard/.env.example
@@ -102,3 +102,6 @@ ANALYTICS_SERVICE_URL=""
# Required for Nebula Chat
NEXT_PUBLIC_NEBULA_URL=""
+
+# required for billing parts of the dashboard (team -> settings -> billing / invoices)
+STRIPE_SECRET_KEY=""
\ No newline at end of file
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index aa673f0be67..6cc4658b675 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -94,6 +94,7 @@
"shiki": "1.27.0",
"sonner": "2.0.1",
"spdx-correct": "^3.2.0",
+ "stripe": "17.7.0",
"swagger-ui-react": "^5.20.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
diff --git a/apps/dashboard/src/@/actions/billing.ts b/apps/dashboard/src/@/actions/billing.ts
index 7acc0baa66e..965d6f1993f 100644
--- a/apps/dashboard/src/@/actions/billing.ts
+++ b/apps/dashboard/src/@/actions/billing.ts
@@ -1,6 +1,6 @@
"use server";
-
import "server-only";
+
import { API_SERVER_URL } from "@/constants/env";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";
diff --git a/apps/dashboard/src/@/actions/stripe-actions.ts b/apps/dashboard/src/@/actions/stripe-actions.ts
new file mode 100644
index 00000000000..ed88ae191b5
--- /dev/null
+++ b/apps/dashboard/src/@/actions/stripe-actions.ts
@@ -0,0 +1,60 @@
+import "server-only";
+
+import Stripe from "stripe";
+import type { Team } from "../api/team";
+
+let existingStripe: Stripe | undefined;
+
+function getStripe() {
+ if (!existingStripe) {
+ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
+
+ if (!STRIPE_SECRET_KEY) {
+ throw new Error("STRIPE_SECRET_KEY is not set");
+ }
+
+ existingStripe = new Stripe(STRIPE_SECRET_KEY, {
+ apiVersion: "2025-02-24.acacia",
+ });
+ }
+
+ return existingStripe;
+}
+
+export async function getTeamInvoices(
+ team: Team,
+ options?: { cursor?: string },
+) {
+ try {
+ const customerId = team.stripeCustomerId;
+
+ if (!customerId) {
+ throw new Error("No customer ID found");
+ }
+
+ // Get the list of invoices for the customer
+ const invoices = await getStripe().invoices.list({
+ customer: customerId,
+ limit: 10,
+ starting_after: options?.cursor,
+ });
+
+ return invoices;
+ } catch (error) {
+ console.error("Error fetching billing history:", error);
+
+ // If the error is that the customer doesn't exist, return an empty array
+ // instead of throwing an error
+ if (
+ error instanceof Stripe.errors.StripeError &&
+ error.message.includes("No such customer")
+ ) {
+ return {
+ data: [],
+ has_more: false,
+ };
+ }
+
+ throw new Error("Failed to fetch billing history");
+ }
+}
diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts
index d4516c6c78c..b0c862090d4 100644
--- a/apps/dashboard/src/@/api/team.ts
+++ b/apps/dashboard/src/@/api/team.ts
@@ -3,7 +3,8 @@ import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
import type { TeamResponse } from "@thirdweb-dev/service-utils";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
-export type Team = TeamResponse;
+export type Team = TeamResponse & { stripeCustomerId: string | null };
+
export async function getTeamBySlug(slug: string) {
const token = await getAuthToken();
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx
index 9d60fc4e133..0146d147498 100644
--- a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx
+++ b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/BillingAlertBannersUI.tsx
@@ -1,10 +1,11 @@
"use client";
import type { GetBillingPortalUrlAction } from "@/actions/billing";
-import { BillingPortalButton } from "@/components/billing";
import { Spinner } from "@/components/ui/Spinner/Spinner";
+import { Button } from "@/components/ui/button";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
+import Link from "next/link";
import { useTransition } from "react";
import { useStripeRedirectEvent } from "../../../../stripe-redirect/stripeRedirectChannel";
@@ -14,7 +15,6 @@ function BillingAlertBanner(props: {
teamSlug: string;
variant: "error" | "warning";
ctaLabel: string;
- getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
const router = useDashboardRouter();
const [isPending, startTransition] = useTransition();
@@ -44,22 +44,21 @@ function BillingAlertBanner(props: {
{props.title}
{props.description}
-
- {props.ctaLabel}
-
+
+ {props.ctaLabel}
+
+
);
}
@@ -73,7 +72,6 @@ export function PastDueBannerUI(props: {
ctaLabel="View Invoices"
variant="warning"
title="Unpaid Invoices"
- getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
You have unpaid invoices. Service may be suspended if not paid
@@ -94,7 +92,6 @@ export function ServiceCutOffBannerUI(props: {
ctaLabel="Pay Now"
variant="error"
title="Service Suspended"
- getBillingPortalUrl={props.getBillingPortalUrl}
description={
<>
Your service has been suspended due to unpaid invoices. Pay now to
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/SettingsLayout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/SettingsLayout.tsx
index 351e7afa142..d20d6cef9e2 100644
--- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/SettingsLayout.tsx
+++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/SettingsLayout.tsx
@@ -55,7 +55,7 @@ export function SettingsLayout(props: {
/>
-
+
+ {/* go to invoices page */}
+
+
{/* manage team billing */}
i.id));
+ const [isLoading, startTransition] = useTransition();
+ const [cursor, setCursor] = useQueryState(
+ "cursor",
+ searchParams.cursor.withOptions({
+ startTransition,
+ history: "push",
+ shallow: false,
+ }),
+ );
+
+ const formatCurrency = (amount: number, currency: string) => {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: currency.toUpperCase(),
+ }).format(amount / 100);
+ };
+
+ const formatDate = (timestamp: number) => {
+ return new Date(timestamp * 1000).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ };
+
+ const getStatusBadge = (invoice: Stripe.Invoice) => {
+ switch (invoice.status) {
+ case "paid":
+ return (
+
+ Paid
+
+ );
+ case "open":
+ // we treate "uncollectible" as unpaid
+ case "uncollectible": {
+ // if the invoice due date is in the past, we want to display it as past due
+ if (invoice.due_date && invoice.due_date < Date.now()) {
+ return (
+
+ Past Due
+
+ );
+ }
+ return Open;
+ }
+ case "void":
+ return Void;
+
+ default:
+ return Unknown;
+ }
+ };
+
+ if (props.invoices.length === 0) {
+ return (
+
+
+
No billing history
+
+ Your invoice history will appear here once you have made payments.
+