Skip to content

Commit 082d963

Browse files
authored
[Dashboard] Add crypto payment option for invoices (#7270)
1 parent d4bc8e1 commit 082d963

File tree

6 files changed

+126
-44
lines changed

6 files changed

+126
-44
lines changed

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

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { StripeRedirectErrorPage } from "../../../_components/StripeRedirectErro
44
import {
55
getBillingCheckoutUrl,
66
getCryptoTopupUrl,
7+
getInvoicePaymentUrl,
78
} from "../../../utils/billing";
89

910
export default async function CheckoutPage(props: {
@@ -13,44 +14,65 @@ export default async function CheckoutPage(props: {
1314
}>;
1415
searchParams: Promise<{
1516
amount?: string;
17+
invoice_id?: string;
1618
}>;
1719
}) {
1820
const params = await props.params;
1921

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" />
22+
switch (params.sku) {
23+
case "topup": {
24+
const amountUSD = Number.parseInt(
25+
(await props.searchParams).amount || "10",
3626
);
27+
if (Number.isNaN(amountUSD)) {
28+
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
29+
}
30+
const topupUrl = await getCryptoTopupUrl({
31+
teamSlug: params.team_slug,
32+
amountUSD,
33+
});
34+
if (!topupUrl) {
35+
// TODO: make a better error page
36+
return (
37+
<StripeRedirectErrorPage errorMessage="Failed to load topup page" />
38+
);
39+
}
40+
redirect(topupUrl);
41+
break;
3742
}
38-
redirect(topupUrl);
39-
return null;
40-
}
43+
case "invoice": {
44+
const invoiceId = (await props.searchParams).invoice_id;
45+
if (!invoiceId) {
46+
return <StripeRedirectErrorPage errorMessage="Invalid invoice ID" />;
47+
}
48+
const invoice = await getInvoicePaymentUrl({
49+
teamSlug: params.team_slug,
50+
invoiceId,
51+
});
52+
if (!invoice) {
53+
return (
54+
<StripeRedirectErrorPage errorMessage="Failed to load invoice payment page" />
55+
);
56+
}
57+
redirect(invoice);
58+
break;
59+
}
60+
default: {
61+
const billingUrl = await getBillingCheckoutUrl({
62+
teamSlug: params.team_slug,
63+
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
64+
});
4165

42-
const billingUrl = await getBillingCheckoutUrl({
43-
teamSlug: params.team_slug,
44-
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
45-
});
66+
if (!billingUrl) {
67+
return (
68+
<StripeRedirectErrorPage errorMessage="Failed to load checkout page" />
69+
);
70+
}
4671

47-
if (!billingUrl) {
48-
return (
49-
<StripeRedirectErrorPage errorMessage="Failed to load checkout page" />
50-
);
72+
redirect(billingUrl);
73+
break;
74+
}
5175
}
5276

53-
redirect(billingUrl);
54-
5577
return null;
5678
}

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,10 @@ export async function getCryptoTopupUrl(options: {
131131
}
132132

133133
const res = await fetch(
134-
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/crypto-top-up`,
134+
new URL(
135+
`/v1/teams/${options.teamSlug}/checkout/crypto-top-up`,
136+
NEXT_PUBLIC_THIRDWEB_API_HOST,
137+
),
135138
{
136139
method: "POST",
137140
body: JSON.stringify({
@@ -156,3 +159,41 @@ export async function getCryptoTopupUrl(options: {
156159

157160
return json.result as string;
158161
}
162+
163+
export async function getInvoicePaymentUrl(options: {
164+
teamSlug: string;
165+
invoiceId: string;
166+
}): Promise<string | undefined> {
167+
const token = await getAuthToken();
168+
if (!token) {
169+
return undefined;
170+
}
171+
const res = await fetch(
172+
new URL(
173+
`/v1/teams/${options.teamSlug}/checkout/crypto-pay-invoice`,
174+
NEXT_PUBLIC_THIRDWEB_API_HOST,
175+
),
176+
{
177+
method: "POST",
178+
body: JSON.stringify({
179+
invoiceId: options.invoiceId,
180+
}),
181+
headers: {
182+
"Content-Type": "application/json",
183+
Authorization: `Bearer ${token}`,
184+
},
185+
},
186+
);
187+
188+
if (!res.ok) {
189+
return undefined;
190+
}
191+
192+
const json = await res.json();
193+
194+
if (!json.result) {
195+
return undefined;
196+
}
197+
198+
return json.result as string;
199+
}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/billing/components/credit-balance-section.client.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ArrowRightIcon, DollarSignIcon } from "lucide-react";
1616
import Link from "next/link";
1717
import { Suspense, use, useState } from "react";
1818
import { ErrorBoundary } from "react-error-boundary";
19+
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
1920

2021
const predefinedAmounts = [
2122
{ value: "25", label: "$25" },
@@ -119,7 +120,8 @@ export function CreditBalanceSection({
119120
prefetch={false}
120121
target="_blank"
121122
>
122-
Top Up Credits
123+
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
124+
Top Up With Crypto
123125
<ArrowRightIcon className="ml-2 h-4 w-4" />
124126
</Link>
125127
</Button>

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export default async function Page(props: {
1717
searchParams: Promise<{
1818
showPlans?: string | string[];
1919
highlight?: string | string[];
20-
showCreditBalance?: string | string[];
2120
}>;
2221
}) {
2322
const [params, searchParams] = await Promise.all([
@@ -71,7 +70,7 @@ export default async function Page(props: {
7170
</div>
7271

7372
{/* Credit Balance Section */}
74-
{searchParams.showCreditBalance === "true" && team.stripeCustomerId && (
73+
{team.stripeCustomerId && (
7574
<CreditBalanceSection
7675
teamSlug={team.slug}
7776
balancePromise={getStripeBalance(team.stripeCustomerId)}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/invoices/components/billing-history.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ import {
1818
DownloadIcon,
1919
ReceiptIcon,
2020
} from "lucide-react";
21+
import Link from "next/link";
2122
import { useQueryState } from "nuqs";
2223
import { useTransition } from "react";
2324
import type Stripe from "stripe";
25+
import { ThirdwebMiniLogo } from "../../../../../../../components/ThirdwebMiniLogo";
2426
import { searchParams } from "../search-params";
2527

2628
export function BillingHistory(props: {
29+
teamSlug: string;
2730
invoices: Stripe.Invoice[];
2831
status: "all" | "past_due" | "open";
2932
hasMore: boolean;
@@ -61,7 +64,7 @@ export function BillingHistory(props: {
6164
// we treate "uncollectible" as unpaid
6265
case "uncollectible": {
6366
// if the invoice due date is in the past, we want to display it as past due
64-
if (invoice.due_date && invoice.due_date < Date.now()) {
67+
if (invoice.due_date && invoice.due_date * 1000 < Date.now()) {
6568
return <Badge variant="destructive">Past Due</Badge>;
6669
}
6770
return <Badge variant="outline">Open</Badge>;
@@ -122,19 +125,33 @@ export function BillingHistory(props: {
122125
<TableCell>{getStatusBadge(invoice)}</TableCell>
123126
<TableCell className="text-right">
124127
<div className="flex justify-end space-x-2">
125-
{invoice.status === "open" &&
126-
invoice.hosted_invoice_url && (
128+
{invoice.status === "open" && (
129+
<>
130+
{/* always show the crypto payment button */}
127131
<Button variant="default" size="sm" asChild>
128-
<a
129-
href={invoice.hosted_invoice_url}
132+
<Link
130133
target="_blank"
131-
rel="noopener noreferrer"
134+
href={`/checkout/${props.teamSlug}/invoice?invoice_id=${invoice.id}`}
132135
>
133-
<CreditCardIcon className="mr-2 h-4 w-4 text-muted-foreground" />
134-
Pay Now
135-
</a>
136+
<ThirdwebMiniLogo className="mr-2 h-4 w-4" />
137+
Pay with crypto
138+
</Link>
136139
</Button>
137-
)}
140+
{/* if we have a hosted invoice url, show that */}
141+
{invoice.hosted_invoice_url && (
142+
<Button variant="outline" size="sm" asChild>
143+
<a
144+
href={invoice.hosted_invoice_url}
145+
target="_blank"
146+
rel="noopener noreferrer"
147+
>
148+
<CreditCardIcon className="mr-2 h-4 w-4" />
149+
Pay with Card
150+
</a>
151+
</Button>
152+
)}
153+
</>
154+
)}
138155

139156
{invoice.invoice_pdf && (
140157
<Button variant="ghost" size="sm" asChild>
@@ -143,7 +160,7 @@ export function BillingHistory(props: {
143160
target="_blank"
144161
rel="noopener noreferrer"
145162
>
146-
<DownloadIcon className="mr-2 h-4 w-4 text-muted-foreground" />
163+
<DownloadIcon className="mr-2 h-4 w-4 " />
147164
PDF
148165
</a>
149166
</Button>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export default async function Page(props: {
4949
<BillingFilter />
5050
</div>
5151
<BillingHistory
52+
teamSlug={params.team_slug}
5253
invoices={invoices.data}
5354
hasMore={invoices.has_more}
5455
// fall back to "all" if the status is not set

0 commit comments

Comments
 (0)