Skip to content

Commit 907f19a

Browse files
committed
[Dashboard] Add crypto top-up functionality for account credits
1 parent 913b243 commit 907f19a

File tree

6 files changed

+262
-15
lines changed

6 files changed

+262
-15
lines changed

apps/dashboard/src/@/actions/stripe-actions.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,15 @@ export async function getTeamInvoices(
5959
throw new Error("Failed to fetch billing history");
6060
}
6161
}
62+
63+
async function getStripeCustomer(customerId: string) {
64+
return await getStripe().customers.retrieve(customerId);
65+
}
66+
67+
export async function getStripeBalance(customerId: string) {
68+
const customer = await getStripeCustomer(customerId);
69+
if (customer.deleted) {
70+
return 0;
71+
}
72+
return customer.balance;
73+
}

apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,44 @@
11
import type { ProductSKU } from "@/lib/billing";
22
import { redirect } from "next/navigation";
33
import { StripeRedirectErrorPage } from "../../../_components/StripeRedirectErrorPage";
4-
import { getBillingCheckoutUrl } from "../../../utils/billing";
4+
import {
5+
getBillingCheckoutUrl,
6+
getCryptoTopupUrl,
7+
} from "../../../utils/billing";
58

69
export default async function CheckoutPage(props: {
710
params: Promise<{
811
team_slug: string;
912
sku: string;
1013
}>;
14+
searchParams: Promise<{
15+
amount?: string;
16+
}>;
1117
}) {
1218
const params = await props.params;
1319

20+
// special case for crypto topup
21+
if (params.sku === "topup") {
22+
const amountUSD = Number.parseInt(
23+
(await props.searchParams).amount || "10",
24+
);
25+
if (Number.isNaN(amountUSD)) {
26+
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
27+
}
28+
const topupUrl = await getCryptoTopupUrl({
29+
teamSlug: params.team_slug,
30+
amountUSD,
31+
});
32+
if (!topupUrl) {
33+
// TODO: make a better error page
34+
return (
35+
<StripeRedirectErrorPage errorMessage="Failed to load topup page" />
36+
);
37+
}
38+
redirect(topupUrl);
39+
return null;
40+
}
41+
1442
const billingUrl = await getBillingCheckoutUrl({
1543
teamSlug: params.team_slug,
1644
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,

apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,39 @@ function getAbsoluteStripeRedirectUrl() {
120120
url.pathname = "/stripe-redirect";
121121
return url.toString();
122122
}
123+
124+
export async function getCryptoTopupUrl(options: {
125+
teamSlug: string;
126+
amountUSD: number;
127+
}): Promise<string | undefined> {
128+
const token = await getAuthToken();
129+
if (!token) {
130+
return undefined;
131+
}
132+
133+
const res = await fetch(
134+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/crypto-top-up`,
135+
{
136+
method: "POST",
137+
body: JSON.stringify({
138+
amountUSD: options.amountUSD,
139+
}),
140+
headers: {
141+
"Content-Type": "application/json",
142+
Authorization: `Bearer ${token}`,
143+
},
144+
},
145+
);
146+
147+
if (!res.ok) {
148+
return undefined;
149+
}
150+
151+
const json = await res.json();
152+
153+
if (!json.result) {
154+
return undefined;
155+
}
156+
157+
return json.result as string;
158+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
} from "@/components/ui/card";
11+
import { Label } from "@/components/ui/label";
12+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
13+
import { Separator } from "@/components/ui/separator";
14+
import { ArrowRightIcon, DollarSignIcon } from "lucide-react";
15+
import Link from "next/link";
16+
import { useState } from "react";
17+
18+
const predefinedAmounts = [
19+
{ value: "25", label: "$25" },
20+
{ value: "100", label: "$100" },
21+
{ value: "500", label: "$500" },
22+
{ value: "1000", label: "$1,000" },
23+
] as const;
24+
25+
interface CreditTopupSectionProps {
26+
currentBalance: number;
27+
teamSlug: string;
28+
}
29+
30+
export default function CreditTopupSection({
31+
currentBalance = 0.0,
32+
teamSlug,
33+
}: CreditTopupSectionProps) {
34+
const [selectedAmount, setSelectedAmount] = useState<string>(
35+
predefinedAmounts[0].value,
36+
);
37+
38+
return (
39+
<Card className="w-full">
40+
<CardHeader>
41+
<CardTitle className="flex items-center gap-2">
42+
Credit Balance
43+
</CardTitle>
44+
<CardDescription className="mt-2">
45+
Your credit balance automatically applies to all of your subscription
46+
charges before your default payment method is charged.
47+
</CardDescription>
48+
</CardHeader>
49+
<CardContent className="space-y-6">
50+
{/* Current Balance */}
51+
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
52+
<div className="flex items-center gap-2">
53+
<DollarSignIcon className="h-4 w-4 text-muted-foreground" />
54+
<span className="font-medium text-sm">Current Credit Balance</span>
55+
</div>
56+
<span className="font-semibold text-lg">
57+
{formatUsd(currentBalance)}
58+
</span>
59+
</div>
60+
61+
<Separator />
62+
<div className="space-y-2">
63+
<h3 className="font-medium text-lg">Top Up Credits</h3>
64+
<p className="text-muted-foreground text-sm">
65+
Add credits to your account for future billing cycles. Credits are
66+
non-refundable and do not expire.
67+
</p>
68+
</div>
69+
70+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
71+
{/* Amount Selection */}
72+
<div className="space-y-4 lg:col-span-2">
73+
<Label className="font-medium text-base">Select Amount</Label>
74+
<RadioGroup
75+
value={selectedAmount}
76+
onValueChange={setSelectedAmount}
77+
className="grid grid-cols-4 gap-3"
78+
>
79+
{predefinedAmounts.map((amount) => (
80+
<div key={amount.value}>
81+
<RadioGroupItem
82+
value={amount.value}
83+
id={amount.value}
84+
className="peer sr-only"
85+
/>
86+
<Label
87+
htmlFor={amount.value}
88+
className="flex cursor-pointer flex-col items-center justify-center rounded-md border-2 border-muted bg-popover p-4 transition-colors hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
89+
>
90+
<span className="font-semibold text-lg">
91+
{amount.label}
92+
</span>
93+
</Label>
94+
</div>
95+
))}
96+
</RadioGroup>
97+
</div>
98+
99+
{/* Top-up Summary and Button */}
100+
<div className="space-y-4">
101+
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
102+
<h3 className="font-medium text-sm">Summary</h3>
103+
<div className="flex justify-between text-sm">
104+
<span>Top-up amount:</span>
105+
<span className="font-medium">
106+
{formatUsd(Number(selectedAmount))}
107+
</span>
108+
</div>
109+
<div className="flex justify-between text-sm">
110+
<span>New balance:</span>
111+
<span className="font-medium">
112+
{formatUsd(currentBalance + Number(selectedAmount))}
113+
</span>
114+
</div>
115+
</div>
116+
117+
<Button asChild className="w-full" size="lg">
118+
<Link
119+
href={`/checkout/${teamSlug}/topup?amount=${selectedAmount}`}
120+
prefetch={false}
121+
target="_blank"
122+
>
123+
Top Up Credits
124+
<ArrowRightIcon className="ml-2 h-4 w-4" />
125+
</Link>
126+
</Button>
127+
</div>
128+
</div>
129+
</CardContent>
130+
</Card>
131+
);
132+
}
133+
134+
function formatUsd(amount: number) {
135+
return amount.toLocaleString("en-US", {
136+
style: "currency",
137+
currency: "USD",
138+
});
139+
}

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

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import { getStripeBalance } from "@/actions/stripe-actions";
12
import { type Team, getTeamBySlug } from "@/api/team";
23
import { getTeamSubscriptions } from "@/api/team-subscription";
34
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
4-
import { Billing } from "components/settings/Account/Billing";
55
import { redirect } from "next/navigation";
6+
import { CreditsInfoCard } from "../../../../../../../../components/settings/Account/Billing/PlanCard";
7+
import { Coupons } from "../../../../../../../../components/settings/Account/Billing/SubscriptionCoupons/Coupons";
68
import { getValidAccount } from "../../../../../../account/settings/getAccount";
79
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
10+
import { PlanInfoCardClient } from "./components/PlanInfoCard.client";
11+
import CreditTopupSection from "./components/top-up-section.client";
812

913
export default async function Page(props: {
1014
params: Promise<{
@@ -29,7 +33,10 @@ export default async function Page(props: {
2933
redirect("/team");
3034
}
3135

32-
const subscriptions = await getTeamSubscriptions(team.slug);
36+
const [subscriptions, stripeBalance] = await Promise.all([
37+
getTeamSubscriptions(team.slug),
38+
team.stripeCustomerId ? getStripeBalance(team.stripeCustomerId) : 0,
39+
]);
3340

3441
const client = getClientThirdwebClient({
3542
jwt: authToken,
@@ -44,18 +51,40 @@ export default async function Page(props: {
4451
);
4552
}
4653

54+
const highlightPlan =
55+
typeof searchParams.highlight === "string"
56+
? (searchParams.highlight as Team["billingPlan"])
57+
: undefined;
58+
59+
const openPlanSheetButtonByDefault = searchParams.showPlans === "true";
60+
61+
const validPayment =
62+
team.billingStatus === "validPayment" || team.billingStatus === "pastDue";
63+
4764
return (
48-
<Billing
49-
highlightPlan={
50-
typeof searchParams.highlight === "string"
51-
? (searchParams.highlight as Team["billingPlan"])
52-
: undefined
53-
}
54-
openPlanSheetButtonByDefault={searchParams.showPlans === "true"}
55-
team={team}
56-
subscriptions={subscriptions}
57-
twAccount={account}
58-
client={client}
59-
/>
65+
<div className="flex flex-col gap-12">
66+
<div>
67+
<PlanInfoCardClient
68+
team={team}
69+
subscriptions={subscriptions}
70+
openPlanSheetButtonByDefault={openPlanSheetButtonByDefault}
71+
highlightPlan={highlightPlan}
72+
/>
73+
</div>
74+
75+
<CreditTopupSection
76+
// stripe treats the balance as negative when it is due to the customer (to the customer this is a "positive" balance)
77+
// we also need to divide by 100 to get the balance in USD (it is returned in USD cents)
78+
currentBalance={stripeBalance === 0 ? 0 : stripeBalance / -100}
79+
teamSlug={team.slug}
80+
/>
81+
82+
<CreditsInfoCard
83+
twAccount={account}
84+
client={client}
85+
teamSlug={team.slug}
86+
/>
87+
<Coupons teamId={team.id} isPaymentSetup={validPayment} />
88+
</div>
6089
);
6190
}

apps/dashboard/src/components/settings/Account/Billing/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { TeamSubscription } from "@/api/team-subscription";
33
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
44
import type { ThirdwebClient } from "thirdweb";
55
import { PlanInfoCardClient } from "../../../../app/(app)/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client";
6+
import CreditTopupSection from "../../../../app/(app)/team/[team_slug]/(team)/~/settings/billing/components/top-up-section.client";
67
import { CreditsInfoCard } from "./PlanCard";
78
import { Coupons } from "./SubscriptionCoupons/Coupons";
89

@@ -39,6 +40,8 @@ export const Billing: React.FC<BillingProps> = ({
3940
/>
4041
</div>
4142

43+
<CreditTopupSection currentBalance={0} teamSlug={team.slug} />
44+
4245
<CreditsInfoCard
4346
twAccount={twAccount}
4447
client={client}

0 commit comments

Comments
 (0)