platform-payments
Provider-agnostic payment infrastructure for NestJS. Exposes a unified PaymentClientPort abstraction with four ready-made implementations (Stripe, Paddle, LemonSqueezy, Mollie), a feature-gating guard, and a usage-tracking interceptor. Feature access checks are fully decoupled via the FeatureAccessPort.
Package: @breadstone/archipel-platform-payments
npm install @breadstone/archipel-platform-paymentsArchitecture
graph TD
Core["@breadstone/archipel-platform-payments<br/><i>Port, models, guard, interceptor, module</i>"]
Stripe["@breadstone/archipel-platform-payments/stripe<br/><i>StripeClient</i>"]
Paddle["@breadstone/archipel-platform-payments/paddle<br/><i>PaddleClient</i>"]
Lemon["@breadstone/archipel-platform-payments/lemonsqueezy<br/><i>LemonSqueezyClient</i>"]
Mollie["@breadstone/archipel-platform-payments/mollie<br/><i>MollieClient</i>"]
Stripe -->|implements| Core
Paddle -->|implements| Core
Lemon -->|implements| Core
Mollie -->|implements| Core
Stripe -.-|requires| S["stripe"]
Paddle -.-|requires| P["@paddle/paddle-node-sdk"]
Lemon -.-|requires| L["@lemonsqueezy/lemonsqueezy.js"]
Mollie -.-|requires| M["@mollie/api-client"]Each provider SDK is an optional peer dependency. Install only the one you need.
Quick Start
1. Install the provider SDK
# Pick one:
yarn add stripe
yarn add @paddle/paddle-node-sdk
yarn add @lemonsqueezy/lemonsqueezy.js
yarn add @mollie/api-client2. Register the module
import { Module } from '@nestjs/common';
import { PaymentModule } from '@breadstone/archipel-platform-payments';
import { StripeClient, STRIPE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/stripe';
@Module({
imports: [
PaymentModule.register({
paymentClient: StripeClient,
configEntries: STRIPE_CONFIG_ENTRIES,
featureAccess: PrismaFeatureAccessAdapter, // optional
isGlobal: false,
}),
],
})
export class AppModule {}Switching providers
Replace the imports — no other code change needed:
import { PaddleClient, PADDLE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/paddle';
PaymentModule.register({
paymentClient: PaddleClient,
configEntries: PADDLE_CONFIG_ENTRIES,
});Module Configuration
IPaymentModuleOptions
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
paymentClient | Type<PaymentClientPort> | Yes | — | Provider implementation (e.g. StripeClient) |
configEntries | ReadonlyArray<Omit<IConfigRegistryEntry, 'module'>> | No | [] | Provider-specific config entries |
featureAccess | Type<FeatureAccessPort> | No | — | Feature quota checking and usage recording |
isGlobal | boolean | No | false | Register as a global module |
Payment Providers
Stripe
Subpath: @breadstone/archipel-platform-payments/stripeSDK: stripe ≥ 22.0.0
| Variable | Description | Required |
|---|---|---|
STRIPE_API_KEY | Stripe secret API key | Yes |
STRIPE_WEBHOOK_SECRET | Stripe webhook signing secret | Yes |
import { StripeClient, STRIPE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/stripe';Paddle
Subpath: @breadstone/archipel-platform-payments/paddleSDK: @paddle/paddle-node-sdk ≥ 3.0.0
| Variable | Description | Required | Default |
|---|---|---|---|
PADDLE_API_KEY | Paddle API key | Yes | — |
PADDLE_WEBHOOK_SECRET | Paddle webhook secret key | Yes | — |
PADDLE_ENVIRONMENT | production or sandbox | No | production |
import { PaddleClient, PADDLE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/paddle';LemonSqueezy
Subpath: @breadstone/archipel-platform-payments/lemonsqueezySDK: @lemonsqueezy/lemonsqueezy.js ≥ 4.0.0
| Variable | Description | Required |
|---|---|---|
LEMONSQUEEZY_API_KEY | LemonSqueezy API key | Yes |
LEMONSQUEEZY_WEBHOOK_SECRET | LemonSqueezy webhook signing secret | Yes |
LEMONSQUEEZY_STORE_ID | LemonSqueezy store ID | Yes |
import { LemonSqueezyClient, LEMONSQUEEZY_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/lemonsqueezy';Mollie
Subpath: @breadstone/archipel-platform-payments/mollieSDK: @mollie/api-client ≥ 4.0.0
| Variable | Description | Required |
|---|---|---|
MOLLIE_API_KEY | Mollie API key | Yes |
MOLLIE_WEBHOOK_SECRET | Mollie webhook secret for verification | Yes |
import { MollieClient, MOLLIE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/mollie';Mollie Limitations
Mollie does not have a native price catalog API. fetchPrices() returns an empty array — maintain prices in your local database. fetchSubscription() expects the format "customerId:subscriptionId".
PaymentClientPort
All providers implement this abstract contract. Inject PaymentClientPort in services to stay provider-agnostic:
abstract class PaymentClientPort {
abstract fetchPrices(productId: string): Promise<INormalizedPrice[]>;
abstract createCheckoutSession(
priceId: string,
successUrl: string,
cancelUrl: string,
userId?: string,
): Promise<INormalizedCheckoutSession>;
abstract fetchSubscription(subscriptionId: string): Promise<INormalizedSubscription | null>;
abstract constructWebhookEvent(
payload: string | Buffer,
signature: string,
): Promise<INormalizedWebhookEvent> | INormalizedWebhookEvent;
}Usage in services
import { PaymentClientPort } from '@breadstone/archipel-platform-payments';
@Injectable()
export class CheckoutService {
constructor(private readonly _paymentClient: PaymentClientPort) {}
public async createCheckout(priceId: string, userId: string): Promise<string | null> {
const session = await this._paymentClient.createCheckoutSession(
priceId,
'https://app.example.com/success',
'https://app.example.com/cancel',
userId,
);
return session.url;
}
}Prices
const prices = await this._paymentClient.fetchPrices('prod_ABC123');
// → INormalizedPrice[]Error handling:
fetchPricesthrows on API or network errors instead of returning an empty array. Errors are logged with structured metadata before being re-thrown. Wrap calls intry/catchif you need fallback behavior.
Subscriptions
const subscription = await this._paymentClient.fetchSubscription('sub_ABC123');
// → INormalizedSubscription | nullWebhook Events
const event = await this._paymentClient.constructWebhookEvent(rawBody, signature);
// → INormalizedWebhookEventNormalized Types
All providers map their native API responses into these shared interfaces.
INormalizedPrice
| Field | Type | Description |
|---|---|---|
id | string | Price identifier |
productId | string | Associated product identifier |
amount | number | Price amount (in smallest unit) |
currency | string | ISO 4217 currency code |
interval | 'week' | 'month' | 'quarter' | 'year' | null | Billing interval |
intervalCount | number | Number of intervals per cycle |
active | boolean | Whether the price is active |
INormalizedCheckoutSession
| Field | Type | Description |
|---|---|---|
id | string | Session identifier |
url | string | null | Checkout URL to redirect |
subscriptionId | string | null | Created subscription ID |
customerId | string | null | Customer identifier |
clientReferenceId | string | null | Client-supplied ref |
paymentStatus | 'paid' | 'unpaid' | 'no_payment_required' | Payment status |
INormalizedSubscription
| Field | Type | Description |
|---|---|---|
id | string | Subscription identifier |
customerId | string | Customer identifier |
status | SubscriptionStatus | Current status |
priceId | string | Associated price identifier |
currentPeriodStart | Date | Start of current billing period |
currentPeriodEnd | Date | End of current billing period |
cancelAtPeriodEnd | boolean | Whether cancellation is scheduled |
trialStart | Date | null | Trial start date |
trialEnd | Date | null | Trial end date |
SubscriptionStatus
type SubscriptionStatus =
| 'active'
| 'canceled'
| 'incomplete'
| 'incompleteExpired'
| 'pastDue'
| 'trialing'
| 'unpaid'
| 'paused';INormalizedWebhookEvent
| Field | Type | Description |
|---|---|---|
type | string (see values below) | Normalized event type |
data | object | Event payload (see shape below) |
Event types: checkout.session.completed, subscription.updated, subscription.deleted, invoice.payment_failed, invoice.payment_succeeded, unknown
Data shape:
{
checkoutSession?: INormalizedCheckoutSession;
subscription?: INormalizedSubscription;
invoice?: {
id: string;
subscriptionId: string | null;
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void';
};
}FeatureAccessPort
Abstract port for checking feature quotas and recording usage. This enables feature-gating with any billing system and optional request-scoped tenancy context.
abstract class FeatureAccessPort {
abstract checkAccess(
userId: string,
featureKey: string,
context?: IFeatureAccessContext,
): Promise<IFeatureAccessResult>;
abstract recordUsage(userId: string, featureKey: string, context?: IFeatureAccessContext): Promise<void>;
}FeatureGuard and FeatureUsageInterceptor create IFeatureAccessContext from the authenticated request subject. The guard resolves request.user.id, converts numeric ids to strings, forwards request.user.tenantId when present, and includes the original subject for adapters that need additional claims.
IFeatureAccessContext
| Field | Type | Description |
|---|---|---|
userId | string | Authenticated user identifier resolved from request.user |
tenantId | string | number | Optional tenant identifier from the authenticated subject |
subject | unknown | Original authenticated subject from the request |
IFeatureAccessResult
| Field | Type | Description |
|---|---|---|
allowed | boolean | Whether the user can use the feature |
used | number | Current usage count in the active period |
limit | number | Maximum allowed (-1 = unlimited) |
remaining | number | Remaining quota (-1 = unlimited) |
resetAt | Date | When the usage period resets |
Real-World Adapter: Prisma + Subscription
@Injectable()
export class PrismaFeatureAccessAdapter extends FeatureAccessPort {
private readonly _prisma: PrismaService;
constructor(prisma: PrismaService) {
super();
this._prisma = prisma;
}
public async checkAccess(
userId: string,
featureKey: string,
context?: IFeatureAccessContext,
): Promise<IFeatureAccessResult> {
const subscription = await this._prisma.subscription.findFirst({
where: { userId, tenantId: context?.tenantId, status: 'active' },
include: { plan: { include: { features: true } } },
});
const feature = subscription?.plan.features.find((f) => f.key === featureKey);
if (!feature) {
return { allowed: false, used: 0, limit: 0, remaining: 0, resetAt: new Date() };
}
const periodStart = this.getCurrentPeriodStart(subscription.currentPeriodStart);
const usage = await this._prisma.featureUsage.count({
where: {
userId,
tenantId: context?.tenantId,
featureKey,
createdAt: { gte: periodStart },
},
});
const limit = feature.limit;
const remaining = limit === -1 ? -1 : Math.max(0, limit - usage);
return {
allowed: limit === -1 || usage < limit,
used: usage,
limit,
remaining,
resetAt: subscription.currentPeriodEnd,
};
}
public async recordUsage(userId: string, featureKey: string, context?: IFeatureAccessContext): Promise<void> {
const access = await this.checkAccess(userId, featureKey, context);
if (!access.allowed) {
throw new Error(`Feature quota exceeded for ${featureKey}`);
}
await this._prisma.featureUsage.create({
data: { userId, tenantId: context?.tenantId, featureKey },
});
}
private getCurrentPeriodStart(periodStart: Date): Date {
return periodStart;
}
}Feature Gating
@RequiresFeature Decorator
Mark controller endpoints with a feature key:
import { RequiresFeature } from '@breadstone/archipel-platform-payments';
@Controller('recipes')
export class RecipeController {
@Post()
@RequiresFeature('RECIPE_CREATION')
public async createRecipe(@Body() body: CreateRecipeDto): Promise<RecipeResponse> {
// Only executed if user has quota for RECIPE_CREATION
}
}FeatureGuard
Checks the @RequiresFeature metadata against FeatureAccessPort.checkAccess(). Metadata is resolved from route handlers first and controller classes second. Apply the guard globally or per-controller:
// Global registration
app.useGlobalGuards(app.get(FeatureGuard));
// Per-controller
@Controller('recipes')
@UseGuards(JwtAuthGuard, FeatureGuard)
export class RecipeController {
/* ... */
}The guard passes IFeatureAccessContext into the access port and attaches the IFeatureAccessResult to the request as request.featureAccess, enabling downstream access to quota information.
FeatureUsageInterceptor
Records feature usage after successful request completion:
@Controller('recipes')
@UseGuards(JwtAuthGuard, FeatureGuard)
@UseInterceptors(FeatureUsageInterceptor)
export class RecipeController {
@Post()
@RequiresFeature('RECIPE_CREATION')
public async createRecipe(@Body() body: CreateRecipeDto): Promise<RecipeResponse> {
// After successful response, FeatureUsageInterceptor calls
// FeatureAccessPort.recordUsage(userId, 'RECIPE_CREATION', context)
}
}End-to-End Example: Feature-Gated API with Stripe
// 1. Define feature keys
export const FEATURES = {
RECIPE_CREATION: 'RECIPE_CREATION',
AI_SUGGESTIONS: 'AI_SUGGESTIONS',
EXPORT_PDF: 'EXPORT_PDF',
} as const;
// 2. Implement the adapter
@Injectable()
export class AppFeatureAccessAdapter extends FeatureAccessPort {
// ... (see Real-World Adapter above)
}
// 3. Register with Stripe
import { StripeClient, STRIPE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/stripe';
@Module({
imports: [
PaymentModule.register({
paymentClient: StripeClient,
configEntries: STRIPE_CONFIG_ENTRIES,
featureAccess: AppFeatureAccessAdapter,
}),
],
})
export class AppModule {}
// 4. Use in controllers
@Controller('recipes')
@UseGuards(JwtAuthGuard, FeatureGuard)
@UseInterceptors(FeatureUsageInterceptor)
export class RecipeController {
constructor(private readonly _paymentClient: PaymentClientPort) {}
@Post()
@RequiresFeature(FEATURES.RECIPE_CREATION)
public async create(@Body() body: CreateRecipeDto): Promise<RecipeResponse> {
return this._recipeService.create(body);
}
@Post(':id/ai-suggestions')
@RequiresFeature(FEATURES.AI_SUGGESTIONS)
public async suggest(@Param('id') id: string): Promise<SuggestionResponse> {
return this._aiService.suggest(id);
}
}Error Handling
All payment clients throw a PaymentError when provider operations fail (checkout session creation, webhook signature verification, price fetching, etc.). This domain error wraps the underlying SDK error and exposes structured metadata:
import { PaymentError } from '@breadstone/archipel-platform-payments';
try {
await this._paymentClient.createCheckoutSession(priceId, successUrl, cancelUrl, userId);
} catch (error) {
if (error instanceof PaymentError) {
logger.error('Payment operation failed', {
provider: error.provider, // 'stripe', 'paddle', 'mollie', 'lemonsqueezy'
code: error.code, // 'PAYMENT'
cause: error.cause, // Original provider SDK error
});
}
}| Property | Type | Description |
|---|---|---|
code | string | Always 'PAYMENT' |
provider | string | Provider that failed (stripe, paddle, etc.) |
cause | unknown | Original error from the provider SDK |
Health Check
The PaymentHealthIndicator verifies that a PaymentClientPort is injected. If no payment client is registered, it reports disabled: true. Import it from the /health subpath:
import { PaymentHealthIndicator } from '@breadstone/archipel-platform-payments/health';
import { HealthModule } from '@breadstone/archipel-platform-health';
@Module({
imports: [
PaymentModule.register({ /* ... */ }),
HealthModule.withIndicators([PaymentHealthIndicator]),
],
})
export class AppModule {}| Key | Check | Dependencies |
|---|---|---|
payment | up if PaymentClientPort injected, else disabled | @Optional() PaymentClientPort |
Exports Summary
Core (@breadstone/archipel-platform-payments)
| Export | Type | Description |
|---|---|---|
PaymentModule | NestJS Module | Main module with register() |
PaymentClientPort | Abstract Port | Provider-agnostic payment contract |
FeatureAccessPort | Abstract Port | Feature quota checking |
IFeatureAccessContext | Interface | Request-scoped access context |
IFeatureAccessResult | Interface | Quota check result |
FeatureGuard | Guard | Enforces @RequiresFeature |
FeatureUsageInterceptor | Interceptor | Records feature usage |
RequiresFeature | Decorator | Marks feature-gated endpoints |
FEATURE_KEY_METADATA | Constant | Metadata key for feature decorators |
INormalizedPrice | Interface | Normalized price |
INormalizedCheckoutSession | Interface | Normalized checkout session |
INormalizedSubscription | Interface | Normalized subscription |
INormalizedWebhookEvent | Interface | Normalized webhook event |
SubscriptionStatus | Type Alias | Subscription status union |
PaymentError | Error class | Domain error for payment failures |
PaymentHealthIndicator | Health | Payment readiness check (/health subpath) |
Provider subpaths
| Subpath | Exports |
|---|---|
/stripe | StripeClient, STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_CONFIG_ENTRIES |
/paddle | PaddleClient, PADDLE_API_KEY, PADDLE_WEBHOOK_SECRET, PADDLE_ENVIRONMENT, PADDLE_CONFIG_ENTRIES |
/lemonsqueezy | LemonSqueezyClient, LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_WEBHOOK_SECRET, LEMONSQUEEZY_STORE_ID, LEMONSQUEEZY_CONFIG_ENTRIES |
/mollie | MollieClient, MOLLIE_API_KEY, MOLLIE_WEBHOOK_SECRET, MOLLIE_CONFIG_ENTRIES |