Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/@/actions/billing.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
60 changes: 60 additions & 0 deletions apps/dashboard/src/@/actions/stripe-actions.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
3 changes: 2 additions & 1 deletion apps/dashboard/src/@/api/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,7 +15,6 @@ function BillingAlertBanner(props: {
teamSlug: string;
variant: "error" | "warning";
ctaLabel: string;
getBillingPortalUrl: GetBillingPortalUrlAction;
}) {
const router = useDashboardRouter();
const [isPending, startTransition] = useTransition();
Expand Down Expand Up @@ -44,22 +44,21 @@ function BillingAlertBanner(props: {

<h3 className="font-semibold text-xl tracking-tight">{props.title}</h3>
<p className="mt-1 mb-4 text-sm">{props.description}</p>
<BillingPortalButton
buttonProps={{
size: "sm",
className: cn(
"gap-2",
props.variant === "warning" &&
"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",
props.variant === "error" &&
"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",
),
}}
teamSlug={props.teamSlug}
getBillingPortalUrl={props.getBillingPortalUrl}

<Button
asChild
className={cn(
"gap-2",
props.variant === "warning" &&
"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",
props.variant === "error" &&
"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",
)}
>
{props.ctaLabel}
</BillingPortalButton>
<Link href={`/team/${props.teamSlug}/~/settings/invoices`}>
{props.ctaLabel}
</Link>
</Button>
</div>
);
}
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function SettingsLayout(props: {
/>
<div
className={cn(
"flex grow flex-col",
"flex max-w-full grow flex-col",
// if showing full nav on mobile - hide the page content
showFullNavOnMobile && "max-sm:hidden",
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ export function getTeamSettingsLinks(teamSlug: string) {
group: "team",
},
{
name: "Members",
href: `${prefix}/members`,
name: "Billing",
href: `${prefix}/billing`,
group: "team",
},
{
name: "Billing",
href: `${prefix}/billing`,
name: "Invoices",
href: `${prefix}/invoices`,
group: "team",
},
{
name: "Members",
href: `${prefix}/members`,
group: "team",
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { TrackedLinkTW } from "@/components/ui/tracked-link";
import { differenceInDays, isAfter } from "date-fns";
import { format } from "date-fns/format";
import { CircleAlertIcon } from "lucide-react";
import Link from "next/link";
import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan";

export function PlanInfoCard(props: {
Expand Down Expand Up @@ -53,7 +54,14 @@ export function PlanInfoCard(props: {
)}
</div>

<div className="flex flex-row gap-2">
<div className="flex flex-row items-center gap-2">
{/* go to invoices page */}
<Button asChild variant="outline">
<Link href={`/team/${team.slug}/~/settings/invoices`}>
View Invoices
</Link>
</Button>

{/* manage team billing */}
<BillingPortalButton
teamSlug={team.slug}
Expand Down
Loading