B2B-88: add starter kit structure and elements
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user