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,47 @@
import 'server-only';
import { cache } from 'react';
import { z } from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
/**
* The variable BILLING_MODE represents the billing mode for a service. It can
* have either the value 'subscription' or 'one-time'. If not provided, the default
* value is 'subscription'. The value can be overridden by the environment variable
* BILLING_MODE.
*
* If the value is 'subscription', we fetch the subscription data for the user.
* If the value is 'one-time', we fetch the orders data for the user.
* if none of these suits your needs, please override the below function.
*/
const BILLING_MODE = z
.enum(['subscription', 'one-time'])
.default('subscription')
.parse(process.env.BILLING_MODE);
/**
* Load the personal account billing page data for the given user.
* @param userId
* @returns The subscription data or the orders data and the billing customer ID.
* This function is cached per-request.
*/
export const loadPersonalAccountBillingPageData = cache(
personalAccountBillingPageDataLoader,
);
function personalAccountBillingPageDataLoader(userId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
const data =
BILLING_MODE === 'subscription'
? api.getSubscription(userId)
: api.getOrder(userId);
const customerId = api.getCustomerId(userId);
return Promise.all([data, customerId]);
}

View File

@@ -0,0 +1,58 @@
'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';
import { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
import { createUserBillingService } from './user-billing.service';
/**
* @name enabled
* @description This feature flag is used to enable or disable personal account billing.
*/
const enabled = featureFlagsConfig.enablePersonalAccountBilling;
/**
* @name createPersonalAccountCheckoutSession
* @description Creates a checkout session for a personal account.
*/
export const createPersonalAccountCheckoutSession = enhanceAction(
async function (data) {
if (!enabled) {
throw new Error('Personal account billing is not enabled');
}
const client = getSupabaseServerClient();
const service = createUserBillingService(client);
return await service.createCheckoutSession(data);
},
{
schema: PersonalAccountCheckoutSchema,
},
);
/**
* @name createPersonalAccountBillingPortalSession
* @description Creates a billing Portal session for a personal account
*/
export const createPersonalAccountBillingPortalSession = enhanceAction(
async () => {
if (!enabled) {
throw new Error('Personal account billing is not enabled');
}
const client = getSupabaseServerClient();
const service = createUserBillingService(client);
// get url to billing portal
const url = await service.createBillingPortalSession();
return redirect(url);
},
{},
);

View File

@@ -0,0 +1,202 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getProductPlanPair } from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getLogger } from '@kit/shared/logger';
import { requireUser } from '@kit/supabase/require-user';
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 { PersonalAccountCheckoutSchema } from '../schema/personal-account-checkout.schema';
export function createUserBillingService(client: SupabaseClient<Database>) {
return new UserBillingService(client);
}
/**
* @name UserBillingService
* @description Service for managing billing for personal accounts.
*/
class UserBillingService {
private readonly namespace = 'billing.personal-account';
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name createCheckoutSession
* @description Create a checkout session for the user
* @param planId
* @param productId
*/
async createCheckoutSession({
planId,
productId,
}: z.infer<typeof PersonalAccountCheckoutSchema>) {
// get the authenticated user
const { data: user, error } = await requireUser(this.client);
if (error ?? !user) {
throw new Error('Authentication required');
}
const service = await getBillingGatewayProvider(this.client);
// in the case of personal accounts
// the account ID is the same as the user ID
const accountId = user.id;
// the return URL for the checkout session
const returnUrl = getCheckoutSessionReturnUrl();
// find the customer ID for the account if it exists
// (eg. if the account has been billed before)
const api = createAccountsApi(this.client);
const customerId = await api.getCustomerId(accountId);
const product = billingConfig.products.find(
(item) => item.id === productId,
);
if (!product) {
throw new Error('Product not found');
}
const { plan } = getProductPlanPair(billingConfig, planId);
const logger = await getLogger();
logger.info(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
},
`User requested a personal account checkout session. Contacting provider...`,
);
try {
// call the payment gateway to create the checkout session
const { checkoutToken } = await service.createCheckoutSession({
returnUrl,
accountId,
customerEmail: user.email,
customerId,
plan,
variantQuantities: [],
enableDiscountField: product.enableDiscountField,
});
logger.info(
{
userId: user.id,
},
`Checkout session created. Returning checkout token to client...`,
);
// return the checkout token to the client
// so we can call the payment gateway to complete the checkout
return {
checkoutToken,
};
} catch (error) {
logger.error(
{
name: `billing.personal-account`,
planId,
customerId,
accountId,
error,
},
`Checkout session not created due to an error`,
);
throw new Error(`Failed to create a checkout session`);
}
}
/**
* @name createBillingPortalSession
* @description Create a billing portal session for the user
* @returns The URL to redirect the user to the billing portal
*/
async createBillingPortalSession() {
const { data, error } = await requireUser(this.client);
if (error ?? !data) {
throw new Error('Authentication required');
}
const service = await getBillingGatewayProvider(this.client);
const logger = await getLogger();
const accountId = data.id;
const api = createAccountsApi(this.client);
const customerId = await api.getCustomerId(accountId);
const returnUrl = getBillingPortalReturnUrl();
if (!customerId) {
throw new Error('Customer not found');
}
const ctx = {
name: this.namespace,
customerId,
accountId,
};
logger.info(
ctx,
`User requested a Billing Portal session. Contacting provider...`,
);
let url: string;
try {
const session = await service.createBillingPortalSession({
customerId,
returnUrl,
});
url = session.url;
} catch (error) {
logger.error(
{
error,
...ctx,
},
`Failed to create a Billing Portal session`,
);
throw new Error(
`Encountered an error creating the Billing Portal session`,
);
}
logger.info(ctx, `Session successfully created.`);
// redirect user to billing portal
return url;
}
}
function getCheckoutSessionReturnUrl() {
return new URL(
pathsConfig.app.personalAccountBillingReturn,
appConfig.url,
).toString();
}
function getBillingPortalReturnUrl() {
return new URL(
pathsConfig.app.personalAccountBilling,
appConfig.url,
).toString();
}