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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user