B2B-88: add starter kit structure and elements
This commit is contained in:
65
app/home/[account]/billing/_lib/server/server-actions.ts
Normal file
65
app/home/[account]/billing/_lib/server/server-actions.ts
Normal 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);
|
||||
},
|
||||
{},
|
||||
);
|
||||
325
app/home/[account]/billing/_lib/server/team-billing.service.ts
Normal file
325
app/home/[account]/billing/_lib/server/team-billing.service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user