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. +

+
+ ); + } + + return ( +
+
+ + + + Invoice + Date + Amount + Status + Actions + + + + {props.invoices.map((invoice) => ( + + #{invoice.number} + {formatDate(invoice.created)} + + {invoice.status === "paid" + ? formatCurrency(invoice.amount_paid, invoice.currency) + : formatCurrency(invoice.amount_due, invoice.currency)} + + {getStatusBadge(invoice)} + +
+ {invoice.status === "open" && + invoice.hosted_invoice_url && ( + + )} + + {invoice.invoice_pdf && ( + + )} +
+
+
+ ))} +
+
+
+ + {/* Pagination Controls */} +
+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/page.tsx new file mode 100644 index 00000000000..11d39b9b289 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/page.tsx @@ -0,0 +1,56 @@ +import { getTeamInvoices } from "@/actions/stripe-actions"; +import { getTeamBySlug } from "@/api/team"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { redirect } from "next/navigation"; +import type { SearchParams } from "nuqs/server"; +import { getValidAccount } from "../../../../../../account/settings/getAccount"; +import { BillingHistory } from "./components/billing-history"; +import { searchParamLoader } from "./search-params"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + }>; + searchParams: Promise; +}) { + const [params, searchParams] = await Promise.all([ + props.params, + searchParamLoader(props.searchParams), + ]); + + const pagePath = `/team/${params.team_slug}/settings/invoices`; + + const [, team] = await Promise.all([ + // only called to verify login status etc + getValidAccount(pagePath), + getTeamBySlug(params.team_slug), + ]); + + if (!team) { + redirect("/team"); + } + + const invoices = await getTeamInvoices(team, { + cursor: searchParams.cursor ?? undefined, + }); + + return ( + + + Invoice History + + View your past invoices and payment history + + + + + + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/search-params.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/search-params.ts new file mode 100644 index 00000000000..c43f7b8cb26 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/search-params.ts @@ -0,0 +1,7 @@ +import { createLoader, parseAsString } from "nuqs/server"; + +export const searchParams = { + cursor: parseAsString, +}; + +export const searchParamLoader = createLoader(searchParams); diff --git a/apps/dashboard/src/stories/stubs.ts b/apps/dashboard/src/stories/stubs.ts index e83d578bf80..0eaa2d227c0 100644 --- a/apps/dashboard/src/stories/stubs.ts +++ b/apps/dashboard/src/stories/stubs.ts @@ -55,6 +55,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team { "relayer", "chainsaw", ], + stripeCustomerId: "cus_1234567890", }; return team; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91360192b3c..b4efec5e2f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: spdx-correct: specifier: ^3.2.0 version: 3.2.0 + stripe: + specifier: 17.7.0 + version: 17.7.0 swagger-ui-react: specifier: ^5.20.1 version: 5.20.1(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -13720,6 +13723,10 @@ packages: resolution: {integrity: sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==} engines: {node: '>=14.16'} + stripe@17.7.0: + resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + engines: {node: '>=12.*'} + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -33190,6 +33197,11 @@ snapshots: strip-json-comments@5.0.1: {} + stripe@17.7.0: + dependencies: + '@types/node': 22.13.10 + qs: 6.14.0 + strnum@1.1.2: {} structured-headers@0.4.1: {}