Skip to content

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-payments

Architecture

mermaid
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

bash
# Pick one:
yarn add stripe
yarn add @paddle/paddle-node-sdk
yarn add @lemonsqueezy/lemonsqueezy.js
yarn add @mollie/api-client

2. Register the module

typescript
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:

typescript
import { PaddleClient, PADDLE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/paddle';

PaymentModule.register({
  paymentClient: PaddleClient,
  configEntries: PADDLE_CONFIG_ENTRIES,
});

Module Configuration

IPaymentModuleOptions

PropertyTypeRequiredDefaultDescription
paymentClientType<PaymentClientPort>YesProvider implementation (e.g. StripeClient)
configEntriesReadonlyArray<Omit<IConfigRegistryEntry, 'module'>>No[]Provider-specific config entries
featureAccessType<FeatureAccessPort>NoFeature quota checking and usage recording
isGlobalbooleanNofalseRegister as a global module

Payment Providers

Stripe

Subpath: @breadstone/archipel-platform-payments/stripeSDK: stripe ≥ 22.0.0

VariableDescriptionRequired
STRIPE_API_KEYStripe secret API keyYes
STRIPE_WEBHOOK_SECRETStripe webhook signing secretYes
typescript
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

VariableDescriptionRequiredDefault
PADDLE_API_KEYPaddle API keyYes
PADDLE_WEBHOOK_SECRETPaddle webhook secret keyYes
PADDLE_ENVIRONMENTproduction or sandboxNoproduction
typescript
import { PaddleClient, PADDLE_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/paddle';

LemonSqueezy

Subpath: @breadstone/archipel-platform-payments/lemonsqueezySDK: @lemonsqueezy/lemonsqueezy.js ≥ 4.0.0

VariableDescriptionRequired
LEMONSQUEEZY_API_KEYLemonSqueezy API keyYes
LEMONSQUEEZY_WEBHOOK_SECRETLemonSqueezy webhook signing secretYes
LEMONSQUEEZY_STORE_IDLemonSqueezy store IDYes
typescript
import { LemonSqueezyClient, LEMONSQUEEZY_CONFIG_ENTRIES } from '@breadstone/archipel-platform-payments/lemonsqueezy';

Mollie

Subpath: @breadstone/archipel-platform-payments/mollieSDK: @mollie/api-client ≥ 4.0.0

VariableDescriptionRequired
MOLLIE_API_KEYMollie API keyYes
MOLLIE_WEBHOOK_SECRETMollie webhook secret for verificationYes
typescript
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:

typescript
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

typescript
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

typescript
const prices = await this._paymentClient.fetchPrices('prod_ABC123');
// → INormalizedPrice[]

Error handling: fetchPrices throws on API or network errors instead of returning an empty array. Errors are logged with structured metadata before being re-thrown. Wrap calls in try/catch if you need fallback behavior.

Subscriptions

typescript
const subscription = await this._paymentClient.fetchSubscription('sub_ABC123');
// → INormalizedSubscription | null

Webhook Events

typescript
const event = await this._paymentClient.constructWebhookEvent(rawBody, signature);
// → INormalizedWebhookEvent

Normalized Types

All providers map their native API responses into these shared interfaces.

INormalizedPrice

FieldTypeDescription
idstringPrice identifier
productIdstringAssociated product identifier
amountnumberPrice amount (in smallest unit)
currencystringISO 4217 currency code
interval'week' | 'month' | 'quarter' | 'year' | nullBilling interval
intervalCountnumberNumber of intervals per cycle
activebooleanWhether the price is active

INormalizedCheckoutSession

FieldTypeDescription
idstringSession identifier
urlstring | nullCheckout URL to redirect
subscriptionIdstring | nullCreated subscription ID
customerIdstring | nullCustomer identifier
clientReferenceIdstring | nullClient-supplied ref
paymentStatus'paid' | 'unpaid' | 'no_payment_required'Payment status

INormalizedSubscription

FieldTypeDescription
idstringSubscription identifier
customerIdstringCustomer identifier
statusSubscriptionStatusCurrent status
priceIdstringAssociated price identifier
currentPeriodStartDateStart of current billing period
currentPeriodEndDateEnd of current billing period
cancelAtPeriodEndbooleanWhether cancellation is scheduled
trialStartDate | nullTrial start date
trialEndDate | nullTrial end date

SubscriptionStatus

typescript
type SubscriptionStatus =
  | 'active'
  | 'canceled'
  | 'incomplete'
  | 'incompleteExpired'
  | 'pastDue'
  | 'trialing'
  | 'unpaid'
  | 'paused';

INormalizedWebhookEvent

FieldTypeDescription
typestring (see values below)Normalized event type
dataobjectEvent payload (see shape below)

Event types: checkout.session.completed, subscription.updated, subscription.deleted, invoice.payment_failed, invoice.payment_succeeded, unknown

Data shape:

typescript
{
  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.

typescript
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

FieldTypeDescription
userIdstringAuthenticated user identifier resolved from request.user
tenantIdstring | numberOptional tenant identifier from the authenticated subject
subjectunknownOriginal authenticated subject from the request

IFeatureAccessResult

FieldTypeDescription
allowedbooleanWhether the user can use the feature
usednumberCurrent usage count in the active period
limitnumberMaximum allowed (-1 = unlimited)
remainingnumberRemaining quota (-1 = unlimited)
resetAtDateWhen the usage period resets

Real-World Adapter: Prisma + Subscription

typescript
@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:

typescript
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:

typescript
// 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:

typescript
@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

typescript
// 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:

typescript
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
    });
  }
}
PropertyTypeDescription
codestringAlways 'PAYMENT'
providerstringProvider that failed (stripe, paddle, etc.)
causeunknownOriginal 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:

typescript
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 {}
KeyCheckDependencies
paymentup if PaymentClientPort injected, else disabled@Optional() PaymentClientPort

Exports Summary

Core (@breadstone/archipel-platform-payments)

ExportTypeDescription
PaymentModuleNestJS ModuleMain module with register()
PaymentClientPortAbstract PortProvider-agnostic payment contract
FeatureAccessPortAbstract PortFeature quota checking
IFeatureAccessContextInterfaceRequest-scoped access context
IFeatureAccessResultInterfaceQuota check result
FeatureGuardGuardEnforces @RequiresFeature
FeatureUsageInterceptorInterceptorRecords feature usage
RequiresFeatureDecoratorMarks feature-gated endpoints
FEATURE_KEY_METADATAConstantMetadata key for feature decorators
INormalizedPriceInterfaceNormalized price
INormalizedCheckoutSessionInterfaceNormalized checkout session
INormalizedSubscriptionInterfaceNormalized subscription
INormalizedWebhookEventInterfaceNormalized webhook event
SubscriptionStatusType AliasSubscription status union
PaymentErrorError classDomain error for payment failures
PaymentHealthIndicatorHealthPayment readiness check (/health subpath)

Provider subpaths

SubpathExports
/stripeStripeClient, STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_CONFIG_ENTRIES
/paddlePaddleClient, PADDLE_API_KEY, PADDLE_WEBHOOK_SECRET, PADDLE_ENVIRONMENT, PADDLE_CONFIG_ENTRIES
/lemonsqueezyLemonSqueezyClient, LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_WEBHOOK_SECRET, LEMONSQUEEZY_STORE_ID, LEMONSQUEEZY_CONFIG_ENTRIES
/mollieMollieClient, MOLLIE_API_KEY, MOLLIE_WEBHOOK_SECRET, MOLLIE_CONFIG_ENTRIES

Released under the MIT License.