diff --git a/apps/dashboard/src/@/api/team-billing.ts b/apps/dashboard/src/@/api/team-billing.ts index f6c7ffbae38..7b682018f63 100644 --- a/apps/dashboard/src/@/api/team-billing.ts +++ b/apps/dashboard/src/@/api/team-billing.ts @@ -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; +} diff --git a/apps/dashboard/src/@/api/team-subscription.ts b/apps/dashboard/src/@/api/team-subscription.ts new file mode 100644 index 00000000000..53b4b4b78cb --- /dev/null +++ b/apps/dashboard/src/@/api/team-subscription.ts @@ -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; + } +} diff --git a/apps/dashboard/src/@/constants/env.ts b/apps/dashboard/src/@/constants/env.ts index d00df291eb6..faf219c8263 100644 --- a/apps/dashboard/src/@/constants/env.ts +++ b/apps/dashboard/src/@/constants/env.ts @@ -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; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/subscribe/README.md b/apps/dashboard/src/app/team/[team_slug]/(team)/billing/README.md similarity index 100% rename from apps/dashboard/src/app/team/[team_slug]/(team)/subscribe/README.md rename to apps/dashboard/src/app/team/[team_slug]/(team)/billing/README.md diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/billing/manage/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/billing/manage/page.tsx new file mode 100644 index 00000000000..db8205972c2 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/billing/manage/page.tsx @@ -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; +} + +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
Invalid Params
; + } + case 401: { + return
User not authenticated
; + } + case 403: { + return
User not allowed to subscribe
; + } + + // default case + default: { + // todo handle this better + notFound(); + } + } +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/subscribe/[sku]/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/billing/subscribe/[sku]/page.tsx similarity index 100% rename from apps/dashboard/src/app/team/[team_slug]/(team)/subscribe/[sku]/page.tsx rename to apps/dashboard/src/app/team/[team_slug]/(team)/billing/subscribe/[sku]/page.tsx diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx new file mode 100644 index 00000000000..bb8a2e3818f --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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 ( +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx index 9bce539e99b..80f271f28b9 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx @@ -1,38 +1,70 @@ import type { Team } from "@/api/team"; +import { + type TeamSubscription, + parseThirdwebSKU, +} from "@/api/team-subscription"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { TrackedLinkTW } from "@/components/ui/tracked-link"; -import type { - Account, - UsageBillableByService, -} from "@3rdweb-sdk/react/hooks/useApi"; +import { differenceInDays, isAfter } from "date-fns"; import { format } from "date-fns/format"; import { CircleAlertIcon } from "lucide-react"; -import { ManageBillingButton } from "../../../../../../../../components/settings/Account/Billing/ManageButton"; +import Link from "next/link"; import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan"; export function PlanInfoCard(props: { - account: Account; - accountUsage: UsageBillableByService; + subscriptions: TeamSubscription[]; team: Team; }) { - const { account, accountUsage, team } = props; + const { subscriptions, team } = props; const validPlan = getValidTeamPlan(team); const isActualFreePlan = team.billingPlan === "free"; + + const planSub = subscriptions.find( + (subscription) => subscription.type === "PLAN", + ); + + // considers hours, mins ... etc as well + const trialEndsInFuture = + planSub?.trialEnd && isAfter(new Date(planSub.trialEnd), new Date()); + + const trialEndsAfterDays = planSub?.trialEnd + ? differenceInDays(new Date(planSub.trialEnd), new Date()) + : 0; + return (
-

- {validPlan} Plan -

+
+
+

+ {validPlan} Plan +

+ {trialEndsInFuture && Trial} +
+ {trialEndsAfterDays > 0 && ( +

+ Your trial ends in {trialEndsAfterDays} days +

+ )} +
+
+ +
{isActualFreePlan && (
- + {/* manage team billing */} +