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