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,48 @@
import 'server-only';
import { z } from 'zod';
import {
type BillingProviderSchema,
BillingWebhookHandlerService,
type PlanTypeMap,
} from '@kit/billing';
import { createRegistry } from '@kit/shared/registry';
/**
* @description Creates a registry for billing webhook handlers
* @param planTypesMap - A map of plan types as setup by the user in the billing config
* @returns The billing webhook handler registry
*/
export function createBillingEventHandlerFactoryService(
planTypesMap: PlanTypeMap,
) {
// Create a registry for billing webhook handlers
const billingWebhookHandlerRegistry = createRegistry<
BillingWebhookHandlerService,
z.infer<typeof BillingProviderSchema>
>();
// Register the Stripe webhook handler
billingWebhookHandlerRegistry.register('stripe', async () => {
const { StripeWebhookHandlerService } = await import('@kit/stripe');
return new StripeWebhookHandlerService(planTypesMap);
});
// Register the Lemon Squeezy webhook handler
billingWebhookHandlerRegistry.register('lemon-squeezy', async () => {
const { LemonSqueezyWebhookHandlerService } = await import(
'@kit/lemon-squeezy'
);
return new LemonSqueezyWebhookHandlerService(planTypesMap);
});
// Register Paddle webhook handler (not implemented yet)
billingWebhookHandlerRegistry.register('paddle', () => {
throw new Error('Paddle is not supported yet');
});
return billingWebhookHandlerRegistry;
}

View File

@@ -0,0 +1,32 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { PlanTypeMap } from '@kit/billing';
import { Database, Enums } from '@kit/supabase/database';
import { createBillingEventHandlerFactoryService } from './billing-event-handler-factory.service';
import { createBillingEventHandlerService } from './billing-event-handler.service';
// a function that returns a Supabase client
type ClientProvider = () => SupabaseClient<Database>;
// the billing provider from the database
type BillingProvider = Enums<'billing_provider'>;
/**
* @name getBillingEventHandlerService
* @description This function retrieves the billing provider from the database and returns a
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
* defined in the host application.
*/
export async function getBillingEventHandlerService(
clientProvider: ClientProvider,
provider: BillingProvider,
planTypesMap: PlanTypeMap,
) {
const strategy =
await createBillingEventHandlerFactoryService(planTypesMap).get(provider);
return createBillingEventHandlerService(clientProvider, strategy);
}

View File

@@ -0,0 +1,285 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { BillingWebhookHandlerService } from '@kit/billing';
import {
UpsertOrderParams,
UpsertSubscriptionParams,
} from '@kit/billing/types';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
/**
* @name CustomHandlersParams
* @description Allow consumers to provide custom handlers for the billing events
* that are triggered by the webhook events.
*/
interface CustomHandlersParams {
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
onSubscriptionUpdated: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>;
onCheckoutSessionCompleted: (
subscription: UpsertSubscriptionParams | UpsertOrderParams,
customerId: string,
) => Promise<unknown>;
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
onPaymentFailed: (sessionId: string) => Promise<unknown>;
onInvoicePaid: (subscription: UpsertSubscriptionParams) => Promise<unknown>;
onEvent(event: unknown): Promise<unknown>;
}
/**
* @name createBillingEventHandlerService
* @description Create a new instance of the `BillingEventHandlerService` class
* @param clientProvider
* @param strategy
*/
export function createBillingEventHandlerService(
clientProvider: () => SupabaseClient<Database>,
strategy: BillingWebhookHandlerService,
) {
return new BillingEventHandlerService(clientProvider, strategy);
}
/**
* @name BillingEventHandlerService
* @description This class is used to handle the webhook events from the billing provider
*/
class BillingEventHandlerService {
private readonly namespace = 'billing';
constructor(
private readonly clientProvider: () => SupabaseClient<Database>,
private readonly strategy: BillingWebhookHandlerService,
) {}
/**
* @name handleWebhookEvent
* @description Handle the webhook event from the billing provider
* @param request
* @param params
*/
async handleWebhookEvent(
request: Request,
params: Partial<CustomHandlersParams> = {},
) {
const event = await this.strategy.verifyWebhookSignature(request);
if (!event) {
throw new Error('Invalid signature');
}
return this.strategy.handleWebhookEvent(event, {
onSubscriptionDeleted: async (subscriptionId: string) => {
const client = this.clientProvider();
const logger = await getLogger();
const ctx = {
namespace: this.namespace,
subscriptionId,
};
// Handle the subscription deleted event
// here we delete the subscription from the database
logger.info(ctx, 'Processing subscription deleted event...');
const { error } = await client
.from('subscriptions')
.delete()
.match({ id: subscriptionId });
if (error) {
logger.error(
{
error,
...ctx,
},
`Failed to delete subscription`,
);
throw new Error('Failed to delete subscription');
}
if (params.onSubscriptionDeleted) {
await params.onSubscriptionDeleted(subscriptionId);
}
logger.info(ctx, 'Successfully deleted subscription');
},
onSubscriptionUpdated: async (subscription) => {
const client = this.clientProvider();
const logger = await getLogger();
const ctx = {
namespace: this.namespace,
subscriptionId: subscription.target_subscription_id,
provider: subscription.billing_provider,
accountId: subscription.target_account_id,
customerId: subscription.target_customer_id,
};
logger.info(ctx, 'Processing subscription updated event ...');
// Handle the subscription updated event
// here we update the subscription in the database
const { error } = await client.rpc('upsert_subscription', subscription);
if (error) {
logger.error(
{
error,
...ctx,
},
'Failed to update subscription',
);
throw new Error('Failed to update subscription');
}
if (params.onSubscriptionUpdated) {
await params.onSubscriptionUpdated(subscription);
}
logger.info(ctx, 'Successfully updated subscription');
},
onCheckoutSessionCompleted: async (payload) => {
// Handle the checkout session completed event
// here we add the subscription to the database
const client = this.clientProvider();
const logger = await getLogger();
// Check if the payload contains an order_id
// if it does, we add an order, otherwise we add a subscription
if ('target_order_id' in payload) {
const ctx = {
namespace: this.namespace,
orderId: payload.target_order_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId: payload.target_customer_id,
};
logger.info(ctx, 'Processing order completed event...');
const { error } = await client.rpc('upsert_order', payload);
if (error) {
logger.error({ ...ctx, error }, 'Failed to add order');
throw new Error('Failed to add order');
}
if (params.onCheckoutSessionCompleted) {
await params.onCheckoutSessionCompleted(
payload,
payload.target_customer_id,
);
}
logger.info(ctx, 'Successfully added order');
} else {
const ctx = {
namespace: this.namespace,
subscriptionId: payload.target_subscription_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId: payload.target_customer_id,
};
logger.info(ctx, 'Processing checkout session completed event...');
const { error } = await client.rpc('upsert_subscription', payload);
// handle the error
if (error) {
logger.error({ ...ctx, error }, 'Failed to add subscription');
throw new Error('Failed to add subscription');
}
// allow consumers to provide custom handlers for the event
if (params.onCheckoutSessionCompleted) {
await params.onCheckoutSessionCompleted(
payload,
payload.target_customer_id,
);
}
// all good
logger.info(ctx, 'Successfully added subscription');
}
},
onPaymentSucceeded: async (sessionId: string) => {
const client = this.clientProvider();
const logger = await getLogger();
const ctx = {
namespace: this.namespace,
sessionId,
};
// Handle the payment succeeded event
// here we update the payment status in the database
logger.info(ctx, 'Processing payment succeeded event...');
const { error } = await client
.from('orders')
.update({ status: 'succeeded' })
.match({ id: sessionId });
// handle the error
if (error) {
logger.error({ error, ...ctx }, 'Failed to update payment status');
throw new Error('Failed to update payment status');
}
// allow consumers to provide custom handlers for the event
if (params.onPaymentSucceeded) {
await params.onPaymentSucceeded(sessionId);
}
logger.info(ctx, 'Successfully updated payment status');
},
onPaymentFailed: async (sessionId: string) => {
const client = this.clientProvider();
const logger = await getLogger();
const ctx = {
namespace: this.namespace,
sessionId,
};
// Handle the payment failed event
// here we update the payment status in the database
logger.info(ctx, 'Processing payment failed event');
const { error } = await client
.from('orders')
.update({ status: 'failed' })
.match({ id: sessionId });
if (error) {
logger.error({ error, ...ctx }, 'Failed to update payment status');
throw new Error('Failed to update payment status');
}
// allow consumers to provide custom handlers for the event
if (params.onPaymentFailed) {
await params.onPaymentFailed(sessionId);
}
logger.info(ctx, 'Successfully updated payment status');
},
onInvoicePaid: async (payload) => {
if (params.onInvoicePaid) {
return params.onInvoicePaid(payload);
}
},
onEvent: params.onEvent,
});
}
}

View File

@@ -0,0 +1,33 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { createBillingGatewayService } from './billing-gateway.service';
/**
* @description This function retrieves the billing provider from the database and returns a
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
* defined in the host application.
*/
export async function getBillingGatewayProvider(
client: SupabaseClient<Database>,
) {
const provider = await getBillingProvider(client);
return createBillingGatewayService(provider);
}
async function getBillingProvider(client: SupabaseClient<Database>) {
const { data, error } = await client
.from('config')
.select('billing_provider')
.single();
if (error ?? !data.billing_provider) {
throw error;
}
return data.billing_provider;
}

View File

@@ -0,0 +1,34 @@
import 'server-only';
import { z } from 'zod';
import {
type BillingProviderSchema,
BillingStrategyProviderService,
} from '@kit/billing';
import { createRegistry } from '@kit/shared/registry';
// Create a registry for billing strategy providers
export const billingStrategyRegistry = createRegistry<
BillingStrategyProviderService,
z.infer<typeof BillingProviderSchema>
>();
// Register the Stripe billing strategy
billingStrategyRegistry.register('stripe', async () => {
const { StripeBillingStrategyService } = await import('@kit/stripe');
return new StripeBillingStrategyService();
});
// Register the Lemon Squeezy billing strategy
billingStrategyRegistry.register('lemon-squeezy', async () => {
const { LemonSqueezyBillingStrategyService } = await import(
'@kit/lemon-squeezy'
);
return new LemonSqueezyBillingStrategyService();
});
// Register Paddle billing strategy (not implemented yet)
billingStrategyRegistry.register('paddle', () => {
throw new Error('Paddle is not supported yet');
});

View File

@@ -0,0 +1,143 @@
import { z } from 'zod';
import type { BillingProviderSchema } from '@kit/billing';
import {
CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema,
CreateBillingPortalSessionSchema,
QueryBillingUsageSchema,
ReportBillingUsageSchema,
RetrieveCheckoutSessionSchema,
UpdateSubscriptionParamsSchema,
} from '@kit/billing/schema';
import { billingStrategyRegistry } from './billing-gateway-registry';
export function createBillingGatewayService(
provider: z.infer<typeof BillingProviderSchema>,
) {
return new BillingGatewayService(provider);
}
/**
* @description The billing gateway service to interact with the billing provider of choice (e.g. Stripe)
* @class BillingGatewayService
* @param {BillingProvider} provider - The billing provider to use
* @example
*
* const provider = 'stripe';
* const billingGatewayService = new BillingGatewayService(provider);
*/
class BillingGatewayService {
constructor(
private readonly provider: z.infer<typeof BillingProviderSchema>,
) {}
/**
* Creates a checkout session for billing.
*
* @param {CreateBillingCheckoutSchema} params - The parameters for creating the checkout session.
*
*/
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const strategy = await this.getStrategy();
const payload = CreateBillingCheckoutSchema.parse(params);
return strategy.createCheckoutSession(payload);
}
/**
* Retrieves the checkout session from the specified provider.
*
* @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session.
*/
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
) {
const strategy = await this.getStrategy();
const payload = RetrieveCheckoutSessionSchema.parse(params);
return strategy.retrieveCheckoutSession(payload);
}
/**
* Creates a billing portal session for the specified parameters.
*
* @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session.
*/
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
const strategy = await this.getStrategy();
const payload = CreateBillingPortalSessionSchema.parse(params);
return strategy.createBillingPortalSession(payload);
}
/**
* Cancels a subscription.
*
* @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription.
*/
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
const strategy = await this.getStrategy();
const payload = CancelSubscriptionParamsSchema.parse(params);
return strategy.cancelSubscription(payload);
}
/**
* Reports the usage of the billing.
* @description This is used to report the usage of the billing to the provider.
* @param params
*/
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
const strategy = await this.getStrategy();
const payload = ReportBillingUsageSchema.parse(params);
return strategy.reportUsage(payload);
}
/**
* @name queryUsage
* @description Queries the usage of the metered billing.
* @param params
*/
async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) {
const strategy = await this.getStrategy();
const payload = QueryBillingUsageSchema.parse(params);
return strategy.queryUsage(payload);
}
/**
* Updates a subscription with the specified parameters.
* @param params
*/
async updateSubscriptionItem(
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
) {
const strategy = await this.getStrategy();
const payload = UpdateSubscriptionParamsSchema.parse(params);
return strategy.updateSubscriptionItem(payload);
}
/**
* Retrieves a subscription from the provider.
* @param subscriptionId
*/
async getSubscription(subscriptionId: string) {
const strategy = await this.getStrategy();
return strategy.getSubscription(subscriptionId);
}
private getStrategy() {
return billingStrategyRegistry.get(this.provider);
}
}

View File

@@ -0,0 +1,37 @@
import 'server-only';
import { Tables } from '@kit/supabase/database';
import { createBillingGatewayService } from '../billing-gateway/billing-gateway.service';
type Subscription = Tables<'subscriptions'>;
export function createBillingWebhooksService() {
return new BillingWebhooksService();
}
/**
* @name BillingWebhooksService
* @description Service for handling billing webhooks.
*/
class BillingWebhooksService {
/**
* @name handleSubscriptionDeletedWebhook
* @description Handles the webhook for when a subscription is deleted.
* @param subscription
*/
async handleSubscriptionDeletedWebhook(subscription: Subscription) {
const gateway = createBillingGatewayService(subscription.billing_provider);
const subscriptionData = await gateway.getSubscription(subscription.id);
const isCanceled = subscriptionData.status === 'canceled';
if (isCanceled) {
return;
}
return gateway.cancelSubscription({
subscriptionId: subscription.id,
});
}
}