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

Architecture

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 verificationNo
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[]

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.

typescript
abstract class FeatureAccessPort {
  abstract checkAccess(userId: string, featureKey: string): Promise<IFeatureAccessResult>;
  abstract recordUsage(userId: string, featureKey: string): Promise<void>;
}

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

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(). Apply it globally or per-controller:

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

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')
  }
}

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);
  }
}

Exports Summary

Core (@breadstone/archipel-platform-payments)

ExportTypeDescription
PaymentModuleNestJS ModuleMain module with register()
PaymentClientPortAbstract PortProvider-agnostic payment contract
FeatureAccessPortAbstract PortFeature quota checking
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

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.