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,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 };
}