Files
myeasycms-v2/docs/billing/custom-integration.mdoc
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
2026-03-24 13:40:38 +08:00

638 lines
18 KiB
Plaintext

---
status: "published"
label: "Custom Integration"
title: "How to create a custom billing integration in Makerkit"
order: 11
description: "Learn how to create a custom billing integration in Makerkit"
---
This guide explains how to create billing integration plugins for the Makerkit SaaS platform to allow you to use a custom billing provider.
{% sequence title="How to create a custom billing integration in Makerkit" description="Learn how to create a custom billing integration in Makerkit" %}
[Architecture Overview](#architecture-overview)
[Package Structure](#package-structure)
[Core Interface Implementation](#core-interface-implementation)
[Environment Configuration](#environment-configuration)
[Billing Strategy Service](#billing-strategy-service)
[Webhook Handler Service](#webhook-handler-service)
[Client-Side Components](#client-side-components)
[Registration and Integration](#registration-and-integration)
[Testing Strategy](#testing-strategy)
[Security Best Practices](#security-best-practices)
[Example Implementation](#example-implementation)
{% /sequence %}
## Architecture Overview
The Makerkit billing system uses a plugin-based architecture that allows multiple billing providers to coexist. The system consists of:
### Core Components
1. **Billing Strategy Provider Service** - Abstract interface for billing operations
2. **Billing Webhook Handler Service** - Abstract interface for webhook processing
3. **Registry System** - Dynamic loading and management of providers
4. **Schema Validation** - Type-safe configuration and data validation
### Provider Structure
Each billing provider is implemented as a separate package under `packages/{provider-name}/` with:
- **Server-side services** - Billing operations and webhook handling
- **Client-side components** - Checkout flows and UI integration
- **Configuration schemas** - Environment variable validation
- **SDK abstractions** - Provider-specific API integrations
### Data Flow
```
Client Request → Registry → Provider Service → External API → Webhook → Handler → Database
```
## Creating a package
You can create a new package for your billing provider by running the following command:
```bash
pnpm turbo gen package
```
This will create a new package in the packages directory, ready to use. You can move this anywhere in the `packages` directory, but we recommend keeping it in the `packages/billing` directory.
## Package Structure
Once we finalize the package structure, your structure should look like this:
```
packages/{provider-name}/
├── package.json
├── tsconfig.json
├── index.ts
└── src/
├── index.ts
├── components/
│ ├── index.ts
│ └── {provider}-checkout.tsx
├── constants/
│ └── {provider}-events.ts
├── schema/
│ ├── {provider}-client-env.schema.ts
│ └── {provider}-server-env.schema.ts
└── services/
├── {provider}-billing-strategy.service.ts
├── {provider}-webhook-handler.service.ts
├── {provider}-sdk.ts
└── create-{provider}-billing-portal-session.ts
```
### package.json Template
```json
{
"name": "@kit/{provider-name}",
"private": true,
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"typesVersions": {
"*": {
"*": ["src/*"]
}
},
"dependencies": {
"{provider-sdk}": "^x.x.x"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.13",
"next": "16.0.0",
"react": "19.1.1",
"zod": "^3.25.74"
}
}
```
## Core Interface Implementation
### BillingStrategyProviderService
This abstract class defines the contract for all billing operations:
```typescript {% title="packages/{provider}/src/services/{provider}-billing-strategy.service.ts" %}
import { BillingStrategyProviderService } from '@kit/billing';
export class YourProviderBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.{provider}';
async createCheckoutSession(params) {
// Implementation
}
async createBillingPortalSession(params) {
// Implementation
}
async cancelSubscription(params) {
// Implementation
}
async retrieveCheckoutSession(params) {
// Implementation
}
async reportUsage(params) {
// Implementation (if supported)
}
async queryUsage(params) {
// Implementation (if supported)
}
async updateSubscriptionItem(params) {
// Implementation
}
async getPlanById(planId: string) {
// Implementation
}
async getSubscription(subscriptionId: string) {
// Implementation
}
}
```
### BillingWebhookHandlerService
This abstract class handles webhook events from the billing provider:
```typescript {% title="packages/{provider}/src/services/{provider}-webhook-handler.service.ts" %}
import { BillingWebhookHandlerService } from '@kit/billing';
export class YourProviderWebhookHandlerService
implements BillingWebhookHandlerService
{
private readonly provider = '{provider}' as const;
private readonly namespace = 'billing.{provider}';
async verifyWebhookSignature(request: Request) {
// Verify signature using provider's SDK
// Throw error if invalid
}
async handleWebhookEvent(event: unknown, params) {
// Route events to appropriate handlers
switch (event.type) {
case 'subscription.created':
return this.handleSubscriptionCreated(event, params);
case 'subscription.updated':
return this.handleSubscriptionUpdated(event, params);
// ... other events
}
}
}
```
## Environment Configuration
### Server Environment Schema
Create schemas for server-side configuration:
```typescript {% title="packages/{provider}/src/schema/{provider}-server-env.schema.ts" %}
// src/schema/{provider}-server-env.schema.ts
import * as z from 'zod';
export const YourProviderServerEnvSchema = z.object({
apiKey: z.string({
description: '{Provider} API key for server-side operations',
required_error: '{PROVIDER}_API_KEY is required',
}),
webhooksSecret: z.string({
description: '{Provider} webhook secret for verifying signatures',
required_error: '{PROVIDER}_WEBHOOK_SECRET is required',
}),
});
export type YourProviderServerEnv = z.infer<typeof YourProviderServerEnvSchema>;
```
### Client Environment Schema
Create schemas for client-side configuration:
```typescript {% title="packages/{provider}/src/schema/{provider}-client-env.schema.ts" %}
// src/schema/{provider}-client-env.schema.ts
import * as z from 'zod';
export const YourProviderClientEnvSchema = z.object({
publicKey: z.string({
description: '{Provider} public key for client-side operations',
required_error: 'NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY is required',
}),
});
export type YourProviderClientEnv = z.infer<typeof YourProviderClientEnvSchema>;
```
## Billing Strategy Service
### Implementation Example
{% alert type="warning" title="This is an abstract example" %}
The "client" class in the example below is not a real class, it's just an example of how to implement the BillingStrategyProviderService interface. You should refer to the SDK of your billing provider to implement the actual methods.
{% /alert %}
Here's a detailed implementation pattern based on the Paddle service:
```typescript
import 'server-only';
import * as z from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { createYourProviderClient } from './your-provider-sdk';
export class YourProviderBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.{provider}';
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const logger = await getLogger();
const client = await createYourProviderClient();
const ctx = {
name: this.namespace,
customerId: params.customerId,
accountId: params.accountId,
};
logger.info(ctx, 'Creating checkout session...');
try {
const response = await client.checkout.create({
customer: {
id: params.customerId,
email: params.customerEmail,
},
lineItems: params.plan.lineItems.map((item) => ({
priceId: item.id,
quantity: 1,
})),
successUrl: params.returnUrl,
metadata: {
accountId: params.accountId,
},
});
logger.info(ctx, 'Checkout session created successfully');
return {
checkoutToken: response.id,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to create checkout session');
throw new Error('Failed to create checkout session');
}
}
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
const logger = await getLogger();
const client = await createYourProviderClient();
const ctx = {
name: this.namespace,
subscriptionId: params.subscriptionId,
};
logger.info(ctx, 'Cancelling subscription...');
try {
await client.subscriptions.cancel(params.subscriptionId, {
immediate: params.invoiceNow ?? true,
});
logger.info(ctx, 'Subscription cancelled successfully');
return { success: true };
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to cancel subscription');
throw new Error('Failed to cancel subscription');
}
}
// Implement other required methods...
}
```
### SDK Client Wrapper
Create a reusable SDK client:
```typescript
// src/services/{provider}-sdk.ts
import 'server-only';
import { YourProviderServerEnvSchema } from '../schema/{provider}-server-env.schema';
export async function createYourProviderClient() {
// parse the environment variables
const config = YourProviderServerEnvSchema.parse({
apiKey: process.env.{PROVIDER}_API_KEY,
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
});
return new YourProviderSDK({
apiKey: config.apiKey,
});
}
```
## Webhook Handler Service
### Implementation Pattern
```typescript
import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { createYourProviderClient } from './your-provider-sdk';
export class YourProviderWebhookHandlerService
implements BillingWebhookHandlerService
{
constructor(private readonly planTypesMap: PlanTypeMap) {}
private readonly provider = '{provider}' as const;
private readonly namespace = 'billing.{provider}';
async verifyWebhookSignature(request: Request) {
const body = await request.clone().text();
const signature = request.headers.get('{provider}-signature');
if (!signature) {
throw new Error('Missing {provider} signature');
}
const { webhooksSecret } = YourProviderServerEnvSchema.parse({
apiKey: process.env.{PROVIDER}_API_KEY,
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
environment: process.env.{PROVIDER}_ENVIRONMENT || 'sandbox',
});
const client = await createYourProviderClient();
try {
const eventData = await client.webhooks.verify(body, signature, webhooksSecret);
if (!eventData) {
throw new Error('Invalid signature');
}
return eventData;
} catch (error) {
throw new Error(`Webhook signature verification failed: ${error}`);
}
}
async handleWebhookEvent(event: unknown, params) {
const logger = await getLogger();
switch (event.type) {
case 'checkout.session.completed': {
return this.handleCheckoutCompleted(event, params.onCheckoutSessionCompleted);
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
return this.handleSubscriptionUpdated(event, params.onSubscriptionUpdated);
}
case 'customer.subscription.deleted': {
return this.handleSubscriptionDeleted(event, params.onSubscriptionDeleted);
}
default: {
logger.info(
{
name: this.namespace,
eventType: event.type,
},
'Unhandled webhook event type',
);
if (params.onEvent) {
await params.onEvent(event);
}
}
}
}
private async handleCheckoutCompleted(event, onCheckoutSessionCompleted) {
// Extract subscription/order data from event
// Transform to standard format
// Call onCheckoutSessionCompleted with normalized data
}
// Implement other event handlers...
}
```
## Client-Side Components
### Checkout Component
Create a React component for the checkout flow:
```typescript
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { YourProviderClientEnvSchema } from '../schema/{provider}-client-env.schema';
interface YourProviderCheckoutProps {
onClose?: () => void;
checkoutToken: string;
}
const config = YourProviderClientEnvSchema.parse({
publicKey: process.env.NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY,
environment: process.env.NEXT_PUBLIC_{PROVIDER}_ENVIRONMENT || 'sandbox',
});
export function YourProviderCheckout({
onClose,
checkoutToken,
}: YourProviderCheckoutProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function initializeCheckout() {
try {
// Initialize provider's JavaScript SDK
const { YourProviderSDK } = await import('{provider}-js-sdk');
const sdk = new YourProviderSDK({
publicKey: config.publicKey,
environment: config.environment,
});
// Open checkout
await sdk.redirectToCheckout({
sessionId: checkoutToken,
successUrl: window.location.href,
cancelUrl: window.location.href,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Checkout failed';
setError(errorMessage);
onClose?.();
}
}
void initializeCheckout();
}, [checkoutToken, onClose]);
if (error) {
throw new Error(error);
}
return null; // Provider handles the UI
}
```
## Registration and Integration
### Register Billing Strategy
Add your provider to the billing strategy registry:
```typescript
// packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts
// Register {Provider} billing strategy
billingStrategyRegistry.register('{provider}', async () => {
const { YourProviderBillingStrategyService } = await import('@kit/{provider}');
return new YourProviderBillingStrategyService();
});
```
### Register Webhook Handler
Add your provider to the webhook handler factory:
```typescript
// packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts
// Register {Provider} webhook handler
billingWebhookHandlerRegistry.register('{provider}', async () => {
const { YourProviderWebhookHandlerService } = await import('@kit/{provider}');
return new YourProviderWebhookHandlerService(planTypesMap);
});
```
### Update Package Exports
Export your services from the main index file:
```typescript
// packages/{provider}/src/index.ts
export { YourProviderBillingStrategyService } from './services/{provider}-billing-strategy.service';
export { YourProviderWebhookHandlerService } from './services/{provider}-webhook-handler.service';
export * from './components';
export * from './constants/{provider}-events';
export {
YourProviderClientEnvSchema,
type YourProviderClientEnv,
} from './schema/{provider}-client-env.schema';
export {
YourProviderServerEnvSchema,
type YourProviderServerEnv,
} from './schema/{provider}-server-env.schema';
```
## Security Best Practices
### Environment Variables
1. **Never expose secrets in client-side code**
2. **Use different credentials for sandbox and production**
3. **Validate all environment variables with Zod schemas**
4. **Store secrets securely (e.g., in environment variables or secret managers)**
### Webhook Security
1. **Always verify webhook signatures**
2. **Use HTTPS endpoints for webhooks**
3. **Log security events for monitoring**
### Data Handling
1. **Validate all incoming data with Zod schemas**
2. **Sanitize user inputs**
3. **Never log sensitive information (API keys, customer data)**
4. **Use structured logging with appropriate log levels**
### Error Handling
1. **Don't expose internal errors to users**
2. **Log errors with sufficient context for debugging**
3. **Implement proper error boundaries in React components**
4. **Handle rate limiting and API errors gracefully**
## Example Implementation
For a complete reference implementation, see the Stripe integration at `packages/billing/stripe/`. Key files to study:
- `src/services/stripe-billing-strategy.service.ts` - Complete billing strategy implementation
- `src/services/stripe-webhook-handler.service.ts` - Webhook handling patterns
- `src/components/stripe-embedded-checkout.tsx` - Client-side checkout component
- `src/schema/` - Environment configuration schemas
Also take a look at the Lemon Squeezy integration at `packages/billing/lemon-squeezy/` or the Paddle integration at `packages/plugins/paddle/` (in the plugins repository)
## Conclusion
**Important:** Different providers have different APIs, so the implementation will be different for each provider.
Following this guide, you should be able to create a robust billing integration that:
- Implements all required interfaces correctly
- Handles errors gracefully and securely
- Provides a good user experience
- Follows established patterns and best practices
- Integrates seamlessly with the existing billing system
Remember to:
1. Test thoroughly with the provider's sandbox environment
2. Follow security best practices throughout development
3. Document any provider-specific requirements or limitations
4. Consider edge cases and error scenarios
5. Validate your implementation against the existing test suite