From d04a91ce09fe51ce87482bb44da2c3c5649774a6 Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Fri, 30 May 2025 00:30:51 -0700 Subject: [PATCH] [Dashboard] Add crypto top-up functionality for account credits --- .../dashboard/src/@/actions/stripe-actions.ts | 13 + .../checkout/[team_slug]/[sku]/page.tsx | 30 ++- .../src/app/(app)/(stripe)/utils/billing.ts | 36 +++ .../credit-balance-section.client.tsx | 248 ++++++++++++++++++ .../(team)/~/settings/billing/page.tsx | 68 +++-- .../settings/Account/Billing/index.tsx | 50 ---- 6 files changed, 374 insertions(+), 71 deletions(-) create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx delete mode 100644 apps/dashboard/src/components/settings/Account/Billing/index.tsx diff --git a/apps/dashboard/src/@/actions/stripe-actions.ts b/apps/dashboard/src/@/actions/stripe-actions.ts index bb34a0b8f14..bb2aeb7b374 100644 --- a/apps/dashboard/src/@/actions/stripe-actions.ts +++ b/apps/dashboard/src/@/actions/stripe-actions.ts @@ -59,3 +59,16 @@ export async function getTeamInvoices( throw new Error("Failed to fetch billing history"); } } + +async function getStripeCustomer(customerId: string) { + return await getStripe().customers.retrieve(customerId); +} + +export async function getStripeBalance(customerId: string) { + const customer = await getStripeCustomer(customerId); + if (customer.deleted) { + return 0; + } + // Stripe returns a positive balance for credits, so we need to multiply by -1 to get the actual balance (as long as the balance is not 0) + return customer.balance === 0 ? 0 : customer.balance * -1; +} diff --git a/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx b/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx index d170aab563d..039f4844f66 100644 --- a/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx +++ b/apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx @@ -1,16 +1,44 @@ import type { ProductSKU } from "@/lib/billing"; import { redirect } from "next/navigation"; import { StripeRedirectErrorPage } from "../../../_components/StripeRedirectErrorPage"; -import { getBillingCheckoutUrl } from "../../../utils/billing"; +import { + getBillingCheckoutUrl, + getCryptoTopupUrl, +} from "../../../utils/billing"; export default async function CheckoutPage(props: { params: Promise<{ team_slug: string; sku: string; }>; + searchParams: Promise<{ + amount?: string; + }>; }) { const params = await props.params; + // special case for crypto topup + if (params.sku === "topup") { + const amountUSD = Number.parseInt( + (await props.searchParams).amount || "10", + ); + if (Number.isNaN(amountUSD)) { + return ; + } + const topupUrl = await getCryptoTopupUrl({ + teamSlug: params.team_slug, + amountUSD, + }); + if (!topupUrl) { + // TODO: make a better error page + return ( + + ); + } + redirect(topupUrl); + return null; + } + const billingUrl = await getBillingCheckoutUrl({ teamSlug: params.team_slug, sku: decodeURIComponent(params.sku) as Exclude, diff --git a/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts b/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts index db209505a28..35ffc479e62 100644 --- a/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts +++ b/apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts @@ -120,3 +120,39 @@ function getAbsoluteStripeRedirectUrl() { url.pathname = "/stripe-redirect"; return url.toString(); } + +export async function getCryptoTopupUrl(options: { + teamSlug: string; + amountUSD: number; +}): Promise { + const token = await getAuthToken(); + if (!token) { + return undefined; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/crypto-top-up`, + { + method: "POST", + body: JSON.stringify({ + amountUSD: options.amountUSD, + }), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!res.ok) { + return undefined; + } + + const json = await res.json(); + + if (!json.result) { + return undefined; + } + + return json.result as string; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx new file mode 100644 index 00000000000..2843273dbe3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ArrowRightIcon, DollarSignIcon } from "lucide-react"; +import Link from "next/link"; +import { Suspense, use, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +const predefinedAmounts = [ + { value: "25", label: "$25" }, + { value: "100", label: "$100" }, + { value: "500", label: "$500" }, + { value: "1000", label: "$1,000" }, +] as const; + +interface CreditBalanceSectionProps { + balancePromise: Promise; + teamSlug: string; +} + +export function CreditBalanceSection({ + balancePromise, + teamSlug, +}: CreditBalanceSectionProps) { + const [selectedAmount, setSelectedAmount] = useState( + predefinedAmounts[0].value, + ); + + return ( + + + + Credit Balance + + + Your credit balance automatically applies to all invoices before your + default payment method is charged. + + + + {/* Current Balance */} + }> + }> + + + + + +
+

Top Up Credits

+

+ Add credits to your account for future billing cycles. Credits are + non-refundable and do not expire. +

+
+ +
+ {/* Amount Selection */} +
+ + + {predefinedAmounts.map((amount) => ( +
+ + +
+ ))} +
+
+ + {/* Top-up Summary and Button */} +
+ + } + > + + } + > + + + + + +
+
+
+
+ ); +} + +function CurrentBalance({ + balancePromise, +}: { balancePromise: Promise }) { + const currentBalance = use(balancePromise); + + return ( +
+
+ + Current Credit Balance +
+ {formatUsd(currentBalance)} +
+ ); +} + +function CurrentBalanceSkeleton() { + return ( +
+
+ + Current Credit Balance +
+ +
+ ); +} +function CurrentBalanceErrorBoundary() { + return ( +
+
+ + Current Credit Balance +
+ + Failed to load current credit balance, please try again later. + +
+ ); +} + +function TopUpSummary({ + selectedAmount, + currentBalancePromise, +}: { + selectedAmount: string; + currentBalancePromise: Promise; +}) { + const currentBalance = use(currentBalancePromise); + + return ( +
+

Summary

+
+ Top-up amount: + {formatUsd(Number(selectedAmount))} +
+
+ New balance: + + {formatUsd(currentBalance + Number(selectedAmount))} + +
+
+ ); +} + +function TopUpSummarySkeleton({ + selectedAmount, +}: { + selectedAmount: string; +}) { + return ( +
+

Summary

+
+ Top-up amount: + {formatUsd(Number(selectedAmount))} +
+
+ New balance: + +
+
+ ); +} + +function TopUpSummaryErrorBoundary({ + selectedAmount, +}: { + selectedAmount: string; +}) { + return ( +
+

Summary

+
+ Top-up amount: + {formatUsd(Number(selectedAmount))} +
+
+ New balance: + + Unable to calculate + +
+
+ ); +} + +// utils +function formatUsd(amount: number) { + return amount.toLocaleString("en-US", { + style: "currency", + currency: "USD", + }); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx index d91ceaf0cfc..809069a4f84 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/page.tsx @@ -1,10 +1,14 @@ +import { getStripeBalance } from "@/actions/stripe-actions"; import { type Team, getTeamBySlug } from "@/api/team"; import { getTeamSubscriptions } from "@/api/team-subscription"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { Billing } from "components/settings/Account/Billing"; import { redirect } from "next/navigation"; +import { CreditsInfoCard } from "../../../../../../../../components/settings/Account/Billing/PlanCard"; +import { Coupons } from "../../../../../../../../components/settings/Account/Billing/SubscriptionCoupons/Coupons"; import { getValidAccount } from "../../../../../../account/settings/getAccount"; import { getAuthToken } from "../../../../../../api/lib/getAuthToken"; +import { PlanInfoCardClient } from "./components/PlanInfoCard.client"; +import { CreditBalanceSection } from "./components/credit-balance-section.client"; export default async function Page(props: { params: Promise<{ @@ -13,10 +17,13 @@ export default async function Page(props: { searchParams: Promise<{ showPlans?: string | string[]; highlight?: string | string[]; + showCreditBalance?: string | string[]; }>; }) { - const params = await props.params; - const searchParams = await props.searchParams; + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]); const pagePath = `/team/${params.team_slug}/settings/billing`; const [account, team, authToken] = await Promise.all([ @@ -31,11 +38,6 @@ export default async function Page(props: { const subscriptions = await getTeamSubscriptions(team.slug); - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: team.id, - }); - if (!subscriptions) { return (
@@ -44,18 +46,44 @@ export default async function Page(props: { ); } + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: team.id, + }); + + const highlightPlan = + typeof searchParams.highlight === "string" + ? (searchParams.highlight as Team["billingPlan"]) + : undefined; + + const validPayment = + team.billingStatus === "validPayment" || team.billingStatus === "pastDue"; + return ( - +
+
+ +
+ + {/* Credit Balance Section */} + {searchParams.showCreditBalance === "true" && team.stripeCustomerId && ( + + )} + + + +
); } diff --git a/apps/dashboard/src/components/settings/Account/Billing/index.tsx b/apps/dashboard/src/components/settings/Account/Billing/index.tsx deleted file mode 100644 index d46d3717281..00000000000 --- a/apps/dashboard/src/components/settings/Account/Billing/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { Team } from "@/api/team"; -import type { TeamSubscription } from "@/api/team-subscription"; -import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; -import type { ThirdwebClient } from "thirdweb"; -import { PlanInfoCardClient } from "../../../../app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client"; -import { CreditsInfoCard } from "./PlanCard"; -import { Coupons } from "./SubscriptionCoupons/Coupons"; - -// TODO - move this in app router folder in other pr - -interface BillingProps { - team: Team; - subscriptions: TeamSubscription[]; - twAccount: Account; - client: ThirdwebClient; - openPlanSheetButtonByDefault: boolean; - highlightPlan: Team["billingPlan"] | undefined; -} - -export const Billing: React.FC = ({ - team, - subscriptions, - twAccount, - client, - openPlanSheetButtonByDefault, - highlightPlan, -}) => { - const validPayment = - team.billingStatus === "validPayment" || team.billingStatus === "pastDue"; - - return ( -
-
- -
- - - -
- ); -};