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
Architecture
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 | No |
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[]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.
abstract class FeatureAccessPort {
abstract checkAccess(userId: string, featureKey: string): Promise<IFeatureAccessResult>;
abstract recordUsage(userId: string, featureKey: string): Promise<void>;
}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): Promise<IFeatureAccessResult> {
const subscription = await this._prisma.subscription.findFirst({
where: { userId, 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,
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): Promise<void> {
const access = await this.checkAccess(userId, featureKey);
if (!access.allowed) {
throw new Error(`Feature quota exceeded for ${featureKey}`);
}
await this._prisma.featureUsage.create({
data: { userId, 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(). Apply it globally or per-controller:
// Global registration
app.useGlobalGuards(app.get(FeatureGuard));
// Per-controller
@Controller('recipes')
@UseGuards(JwtAuthGuard, FeatureGuard)
export class RecipeController {
/* ... */
}The guard 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')
}
}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);
}
}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 |
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 |
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 |