B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,14 @@
'use client';
import dynamic from 'next/dynamic';
export const EmbeddedCheckoutForm = dynamic(
async () => {
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
return EmbeddedCheckout;
},
{
ssr: false,
},
);

View File

@@ -0,0 +1,106 @@
'use client';
import { useState, useTransition } from 'react';
import dynamic from 'next/dynamic';
import { useParams } from 'next/navigation';
import { PlanPicker } from '@kit/billing-gateway/components';
import { useAppEvents } from '@kit/shared/events';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import billingConfig from '~/config/billing.config';
import { createTeamAccountCheckoutSession } from '../_lib/server/server-actions';
const EmbeddedCheckout = dynamic(
async () => {
const { EmbeddedCheckout } = await import('@kit/billing-gateway/checkout');
return {
default: EmbeddedCheckout,
};
},
{
ssr: false,
},
);
export function TeamAccountCheckoutForm(params: {
accountId: string;
customerId: string | null | undefined;
}) {
const routeParams = useParams();
const [pending, startTransition] = useTransition();
const appEvents = useAppEvents();
const [checkoutToken, setCheckoutToken] = useState<string | undefined>(
undefined,
);
// If the checkout token is set, render the embedded checkout component
if (checkoutToken) {
return (
<EmbeddedCheckout
checkoutToken={checkoutToken}
provider={billingConfig.provider}
onClose={() => setCheckoutToken(undefined)}
/>
);
}
// only allow trial if the user is not already a customer
const canStartTrial = !params.customerId;
// Otherwise, render the plan picker component
return (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'billing:manageTeamPlan'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'billing:manageTeamPlanDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<PlanPicker
pending={pending}
config={billingConfig}
canStartTrial={canStartTrial}
onSubmit={({ planId, productId }) => {
startTransition(async () => {
const slug = routeParams.account as string;
appEvents.emit({
type: 'checkout.started',
payload: {
planId,
account: slug,
},
});
const { checkoutToken } = await createTeamAccountCheckoutSession({
planId,
productId,
slug,
accountId: params.accountId,
});
setCheckoutToken(checkoutToken);
});
}}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const TeamBillingPortalSchema = z.object({
accountId: z.string().uuid(),
slug: z.string().min(1),
});
export const TeamCheckoutSchema = z.object({
slug: z.string().min(1),
productId: z.string().min(1),
planId: z.string().min(1),
accountId: z.string().uuid(),
});

View File

@@ -0,0 +1,65 @@
'use server';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import featureFlagsConfig from '~/config/feature-flags.config';
// billing imports
import {
TeamBillingPortalSchema,
TeamCheckoutSchema,
} from '../schema/team-billing.schema';
import { createTeamBillingService } from './team-billing.service';
/**
* @name enabled
* @description This feature flag is used to enable or disable team account billing.
*/
const enabled = featureFlagsConfig.enableTeamAccountBilling;
/**
* @name createTeamAccountCheckoutSession
* @description Creates a checkout session for a team account.
*/
export const createTeamAccountCheckoutSession = enhanceAction(
async (data) => {
if (!enabled) {
throw new Error('Team account billing is not enabled');
}
const client = getSupabaseServerClient();
const service = createTeamBillingService(client);
return service.createCheckout(data);
},
{
schema: TeamCheckoutSchema,
},
);
/**
* @name createBillingPortalSession
* @description Creates a Billing Session Portal and redirects the user to the
* provider's hosted instance
*/
export const createBillingPortalSession = enhanceAction(
async (formData: FormData) => {
if (!enabled) {
throw new Error('Team account billing is not enabled');
}
const params = TeamBillingPortalSchema.parse(Object.fromEntries(formData));
const client = getSupabaseServerClient();
const service = createTeamBillingService(client);
// get url to billing portal
const url = await service.createBillingPortalSession(params);
return redirect(url);
},
{},
);

View File

@@ -0,0 +1,325 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { LineItemSchema } from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getLogger } from '@kit/shared/logger';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
import { Database } from '~/lib/database.types';
import { TeamCheckoutSchema } from '../schema/team-billing.schema';
export function createTeamBillingService(client: SupabaseClient<Database>) {
return new TeamBillingService(client);
}
/**
* @name TeamBillingService
* @description Service for managing billing for team accounts.
*/
class TeamBillingService {
private readonly namespace = 'billing.team-account';
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name createCheckout
* @description Creates a checkout session for a Team account
*/
async createCheckout(params: z.infer<typeof TeamCheckoutSchema>) {
// we require the user to be authenticated
const { data: user } = await requireUser(this.client);
if (!user) {
throw new Error('Authentication required');
}
const userId = user.id;
const accountId = params.accountId;
const logger = await getLogger();
const ctx = {
userId,
accountId,
name: this.namespace,
};
logger.info(ctx, `Requested checkout session. Processing...`);
const api = createTeamAccountsApi(this.client);
// verify permissions to manage billing
const hasPermission = await api.hasPermission({
userId,
accountId,
permission: 'billing.manage',
});
// if the user does not have permission to manage billing for the account
// then we should not proceed
if (!hasPermission) {
logger.warn(
ctx,
`User without permissions attempted to create checkout.`,
);
throw new Error('Permission denied');
}
// here we have confirmed that the user has permission to manage billing for the account
// so we go on and create a checkout session
const service = await getBillingGatewayProvider(this.client);
// retrieve the plan from the configuration
// so we can assign the correct checkout data
const { plan, product } = getPlanDetails(params.productId, params.planId);
// find the customer ID for the account if it exists
// (eg. if the account has been billed before)
const customerId = await api.getCustomerId(accountId);
const customerEmail = user.email;
// the return URL for the checkout session
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
// get variant quantities
// useful for setting an initial quantity value for certain line items
// such as per seat
const variantQuantities = await this.getVariantQuantities(
plan.lineItems,
accountId,
);
logger.info(
{
...ctx,
planId: plan.id,
},
`Creating checkout session...`,
);
try {
// call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({
accountId,
plan,
returnUrl,
customerEmail,
customerId,
variantQuantities,
enableDiscountField: product.enableDiscountField,
});
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
} catch (error) {
logger.error(
{
...ctx,
error,
},
`Error creating the checkout session`,
);
throw new Error(`Checkout not created`);
}
}
/**
* @name createBillingPortalSession
* @description Creates a new billing portal session for a team account
* @param accountId
* @param slug
*/
async createBillingPortalSession({
accountId,
slug,
}: {
accountId: string;
slug: string;
}) {
const client = getSupabaseServerClient();
const logger = await getLogger();
logger.info(
{
accountId,
name: this.namespace,
},
`Billing portal session requested. Processing...`,
);
const { data: user, error } = await requireUser(client);
if (error ?? !user) {
throw new Error('Authentication required');
}
const userId = user.id;
const api = createTeamAccountsApi(client);
// we require the user to have permissions to manage billing for the account
const hasPermission = await api.hasPermission({
userId,
accountId,
permission: 'billing.manage',
});
// if the user does not have permission to manage billing for the account
// then we should not proceed
if (!hasPermission) {
logger.warn(
{
userId,
accountId,
name: this.namespace,
},
`User without permissions attempted to create billing portal session.`,
);
throw new Error('Permission denied');
}
const customerId = await api.getCustomerId(accountId);
if (!customerId) {
throw new Error('Customer not found');
}
logger.info(
{
userId,
customerId,
accountId,
name: this.namespace,
},
`Creating billing portal session...`,
);
// get the billing gateway provider
const service = await getBillingGatewayProvider(client);
try {
const returnUrl = getBillingPortalReturnUrl(slug);
const { url } = await service.createBillingPortalSession({
customerId,
returnUrl,
});
// redirect the user to the billing portal
return url;
} catch (error) {
logger.error(
{
userId,
customerId,
accountId,
name: this.namespace,
error,
},
`Billing Portal session was not created`,
);
throw new Error(`Error creating Billing Portal`);
}
}
/**
* Retrieves variant quantities for line items.
*/
private async getVariantQuantities(
lineItems: z.infer<typeof LineItemSchema>[],
accountId: string,
) {
const variantQuantities: Array<{
quantity: number;
variantId: string;
}> = [];
for (const lineItem of lineItems) {
// check if the line item is a per seat type
const isPerSeat = lineItem.type === 'per_seat';
if (isPerSeat) {
// get the current number of members in the account
const quantity = await this.getCurrentMembersCount(accountId);
const item = {
quantity,
variantId: lineItem.id,
};
variantQuantities.push(item);
}
}
// set initial quantity for the line items
return variantQuantities;
}
private async getCurrentMembersCount(accountId: string) {
const api = createTeamAccountsApi(this.client);
const logger = await getLogger();
try {
const count = await api.getMembersCount(accountId);
return count ?? 1;
} catch (error) {
logger.error(
{
accountId,
error,
name: `billing.checkout`,
},
`Encountered an error while fetching the number of existing seats`,
);
return Promise.reject(error as Error);
}
}
}
function getCheckoutSessionReturnUrl(accountSlug: string) {
return getAccountUrl(pathsConfig.app.accountBillingReturn, accountSlug);
}
function getBillingPortalReturnUrl(accountSlug: string) {
return getAccountUrl(pathsConfig.app.accountBilling, accountSlug);
}
function getAccountUrl(path: string, slug: string) {
return new URL(path, appConfig.url).toString().replace('[account]', slug);
}
function getPlanDetails(productId: string, planId: string) {
const product = billingConfig.products.find(
(product) => product.id === productId,
);
if (!product) {
throw new Error('Product not found');
}
const plan = product?.plans.find((plan) => plan.id === planId);
if (!plan) {
throw new Error('Plan not found');
}
return { plan, product };
}

View File

@@ -0,0 +1,48 @@
'use client';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useCaptureException } from '@kit/monitoring/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
export default function BillingErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useCaptureException(error);
return (
<>
<PageHeader description={<AppBreadcrumbs />} />
<PageBody>
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'billing:planPickerAlertErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'billing:planPickerAlertErrorDescription'} />
</AlertDescription>
</Alert>
<div>
<Button variant={'outline'} onClick={reset}>
<Trans i18nKey={'common:retry'} />
</Button>
</div>
</div>
</PageBody>
</>
);
}

View File

@@ -0,0 +1,15 @@
import { notFound } from 'next/navigation';
import featureFlagsConfig from '~/config/feature-flags.config';
function TeamAccountBillingLayout(props: React.PropsWithChildren) {
const isEnabled = featureFlagsConfig.enableTeamAccountBilling;
if (!isEnabled) {
notFound();
}
return <>{props.children}</>;
}
export default TeamAccountBillingLayout;

View File

@@ -0,0 +1,135 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import {
BillingPortalCard,
CurrentLifetimeOrderCard,
CurrentSubscriptionCard,
} from '@kit/billing-gateway/components';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { If } from '@kit/ui/if';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import billingConfig from '~/config/billing.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
// local imports
import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header';
import { loadTeamAccountBillingPage } from '../_lib/server/team-account-billing-page.loader';
import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader';
import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form';
import { createBillingPortalSession } from './_lib/server/server-actions';
interface TeamAccountBillingPageProps {
params: Promise<{ account: string }>;
}
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('teams:billing.pageTitle');
return {
title,
};
};
async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) {
const account = (await params).account;
const workspace = await loadTeamWorkspace(account);
const accountId = workspace.account.id;
const [data, customerId] = await loadTeamAccountBillingPage(accountId);
const canManageBilling =
workspace.account.permissions.includes('billing.manage');
const Checkout = () => {
if (!canManageBilling) {
return <CannotManageBillingAlert />;
}
return (
<TeamAccountCheckoutForm customerId={customerId} accountId={accountId} />
);
};
const BillingPortal = () => {
if (!canManageBilling || !customerId) {
return null;
}
return (
<form action={createBillingPortalSession}>
<input type="hidden" name={'accountId'} value={accountId} />
<input type="hidden" name={'slug'} value={account} />
<BillingPortalCard />
</form>
);
};
return (
<>
<TeamAccountLayoutPageHeader
account={account}
title={<Trans i18nKey={'common:routes.billing'} />}
description={<AppBreadcrumbs />}
/>
<PageBody>
<div
className={cn(`flex w-full flex-col space-y-4`, {
'max-w-2xl': data,
})}
>
<If
condition={data}
fallback={
<div>
<Checkout />
</div>
}
>
{(data) => {
if ('active' in data) {
return (
<CurrentSubscriptionCard
subscription={data}
config={billingConfig}
/>
);
}
return (
<CurrentLifetimeOrderCard order={data} config={billingConfig} />
);
}}
</If>
<BillingPortal />
</div>
</PageBody>
</>
);
}
export default withI18n(TeamAccountBillingPage);
function CannotManageBillingAlert() {
return (
<Alert variant={'warning'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'billing:cannotManageBillingAlertTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'billing:cannotManageBillingAlertDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,87 @@
import { notFound, redirect } from 'next/navigation';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { BillingSessionStatus } from '@kit/billing-gateway/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import billingConfig from '~/config/billing.config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
import { EmbeddedCheckoutForm } from '../_components/embedded-checkout-form';
interface SessionPageProps {
searchParams: Promise<{
session_id: string;
}>;
}
async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
const sessionId = (await searchParams).session_id;
if (!sessionId) {
redirect('../');
}
const { customerEmail, checkoutToken } = await loadCheckoutSession(sessionId);
if (checkoutToken) {
return (
<EmbeddedCheckoutForm
checkoutToken={checkoutToken}
provider={billingConfig.provider}
/>
);
}
return (
<>
<div className={'fixed top-48 left-0 z-50 mx-auto w-full'}>
<BillingSessionStatus
redirectPath={'../billing'}
customerEmail={customerEmail ?? ''}
/>
</div>
<BlurryBackdrop />
</>
);
}
export default withI18n(ReturnCheckoutSessionPage);
function BlurryBackdrop() {
return (
<div
className={
'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' +
' !m-0 h-full'
}
/>
);
}
async function loadCheckoutSession(sessionId: string) {
await requireUserInServerComponent();
const client = getSupabaseServerClient();
const gateway = await getBillingGatewayProvider(client);
const session = await gateway.retrieveCheckoutSession({
sessionId,
});
if (!session) {
notFound();
}
const checkoutToken = session.isSessionOpen ? session.checkoutToken : null;
// otherwise - we show the user the return page
// and display the details of the session
return {
status: session.status,
customerEmail: session.customer.email,
checkoutToken,
};
}