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
37 changes: 37 additions & 0 deletions apps/dashboard/src/@/api/team-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,40 @@ export async function getStripeCheckoutLink(slug: string, sku: string) {
link: null,
} as const;
}

export async function getStripeBillingPortalLink(slug: string) {
const token = await getAuthToken();

if (!token) {
return {
status: 401,
link: null,
};
}

const res = await fetch(
`${API_SERVER_URL}/v1/teams/${slug}/checkout/create-session-link`,
{
method: "POST",
body: JSON.stringify({
redirectTo: getAbsoluteUrlFromPath(
`/team/${slug}/~/settings/billing`,
).toString(),
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (res.ok) {
return {
status: 200,
link: (await res.json())?.result as string,
} as const;
}
return {
status: res.status,
link: null,
} as const;
}
107 changes: 107 additions & 0 deletions apps/dashboard/src/@/api/team-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import { API_SERVER_URL } from "../constants/env";

// keep in line with product SKUs in the backend
type ProductSKU =
| "plan:starter"
| "plan:growth"
| "plan:custom"
| "product:ecosystem_wallets"
| "product:engine_standard"
| "product:engine_premium"
| "usage:storage"
| "usage:in_app_wallet"
| "usage:aa_sponsorship"
| "usage:aa_sponsorship_op_grant"
| null;

type InvoiceLine = {
// amount for this line item
amount: number;
// statement descriptor
description: string | null;
// the thirdweb product sku or null if it is not recognized
thirdwebSku: ProductSKU | null;
};

type Invoice = {
// total amount excluding tax
amount: number | null;
// the ISO currency code (e.g. USD)
currency: string;
// the line items on the invoice
lines: InvoiceLine[];
};

export type TeamSubscription = {
id: string;
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
status:
| "incomplete"
| "incomplete_expired"
| "trialing"
| "active"
| "past_due"
| "canceled"
| "unpaid"
| "paused";
currentPeriodStart: string;
currentPeriodEnd: string;
trialStart: string | null;
trialEnd: string | null;
upcomingInvoice: Invoice;
};

export async function getTeamSubscriptions(slug: string) {
const token = await getAuthToken();

if (!token) {
return null;
}

const teamRes = await fetch(
`${API_SERVER_URL}/v1/teams/${slug}/subscriptions`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);

if (teamRes.ok) {
return (await teamRes.json())?.result as TeamSubscription[];
}
return null;
}

// util fn:

export function parseThirdwebSKU(sku: ProductSKU) {
if (!sku) {
return null;
}
switch (sku) {
case "plan:starter":
return "Starter Plan";
case "plan:growth":
return "Growth Plan";
case "plan:custom":
return "Custom Plan";
case "product:ecosystem_wallets":
return "Ecosystem Wallets";
case "product:engine_standard":
return "Engine Standard";
case "product:engine_premium":
return "Engine Premium";
case "usage:storage":
return "Storage";
case "usage:in_app_wallet":
return "In-App Wallet";
case "usage:aa_sponsorship":
return "AA Sponsorship";
case "usage:aa_sponsorship_op_grant":
return "AA Sponsorship Op Grant";
default:
return null;
}
}
5 changes: 3 additions & 2 deletions apps/dashboard/src/@/constants/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ export function getAbsoluteUrlFromPath(path: string) {
const url = new URL(
isProd
? "https://thirdweb.com"
: `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}` ||
"https://thirdweb-dev.com",
: (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
: "http://localhost:3000") || "https://thirdweb-dev.com",
);

url.pathname = path;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { getStripeBillingPortalLink } from "@/api/team-billing";
import { RedirectType, notFound, redirect } from "next/navigation";

interface PageParams {
team_slug: string;
}

interface PageProps {
params: Promise<PageParams>;
}

export default async function TeamBillingPortalLink(props: PageProps) {
const params = await props.params;
// get the stripe checkout link for the team + sku from the API
// this returns a status code and a link (if success)
// 200: success
// 400: invalid params
// 401: user not authenticated
// 403: user not allowed to subscribe (not admin)
// 500: something random else went wrong
const { link, status } = await getStripeBillingPortalLink(params.team_slug);

console.log("status", status);

if (link) {
// we want to REPLACE so when the user navigates BACK the do not end up back here but on the previous page
redirect(link, RedirectType.replace);
}

switch (status) {
case 400: {
return <div>Invalid Params</div>;
}
case 401: {
return <div>User not authenticated</div>;
}
case 403: {
return <div>User not allowed to subscribe</div>;
}

// default case
default: {
// todo handle this better
notFound();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Meta, StoryObj } from "@storybook/react";
import { addDays } from "date-fns";
import {
createDashboardAccountStub,
teamStub,
teamSubscriptionsStub,
} from "stories/stubs";
import {
BadgeContainer,
mobileViewport,
} from "../../../../../../../../stories/utils";
import { PlanInfoCard } from "./PlanInfoCard";

const meta = {
title: "Billing/PlanInfoCard",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Desktop: Story = {
args: {},
};

export const Mobile: Story = {
args: {},
parameters: {
viewport: mobileViewport("iphone14"),
},
};

function Story() {
const team = teamStub("foo", "growth");
const zeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth");
const trialPlanZeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth", {
trialEnd: addDays(new Date(), 7).toISOString(),
});

const subsWith1Usage = teamSubscriptionsStub("plan:growth", {
usage: {
storage: {
amount: 10000,
quantity: 4,
},
},
});

const subsWith4Usage = teamSubscriptionsStub("plan:growth", {
usage: {
storage: {
amount: 10000,
quantity: 4,
},
aaSponsorshipAmount: {
amount: 7500,
quantity: 4,
},
aaSponsorshipOpGrantAmount: {
amount: 2500,
quantity: 4,
},
inAppWalletAmount: {
amount: 40000,
quantity: 100,
},
},
});

const account = createDashboardAccountStub("foo");

return (
<div className="container flex max-w-[1130px] flex-col gap-12 lg:p-10">
<BadgeContainer label="On-demand Subscriptions with 0 usage">
<PlanInfoCard
team={team}
subscriptions={zeroUsageOnDemandSubs}
account={account}
/>
</BadgeContainer>

<BadgeContainer label="Trial Plan - On-demand Subscriptions with 0 usage">
<PlanInfoCard
team={team}
subscriptions={trialPlanZeroUsageOnDemandSubs}
account={account}
/>
</BadgeContainer>

<BadgeContainer label="On-demand Subscriptions with 1 usage">
<PlanInfoCard
team={team}
subscriptions={subsWith1Usage}
account={account}
/>
</BadgeContainer>

<BadgeContainer label="On-demand Subscriptions with 4 usage">
<PlanInfoCard
team={team}
subscriptions={subsWith4Usage}
account={account}
/>
</BadgeContainer>
</div>
);
}
Loading