| name | adding-payment-providers |
| description | Use when adding a new payment gateway (MercadoPago, PagSeguro, Asaas, etc.) or when needing to understand how provider-entity mappings work - covers adapter creation, factory registration, webhook normalization, and the polymorphic payment_provider_mappings table |
Adding Payment Providers
Overview
Payment providers follow a gateway-agnostic adapter pattern. Each provider implements IPaymentGateway, is registered in PaymentGatewayFactory, and uses the polymorphic payment_provider_mappings table to map external IDs (e.g., cus_xxx, sub_xxx) to domain entities.
Core principle: No provider-specific types leak outside adapters. All code above the adapter layer works with normalized types only.
When to Use
- Adding a new payment gateway (MercadoPago, PagSeguro, Asaas, Pagarme)
- Understanding how external provider IDs map to domain entities
- Implementing webhook normalization for a new provider
- Debugging provider ID resolution in billing flows
Architecture
BillingService
|
PaymentGatewayFactory.create(provider) → selects adapter from Map
|
[StripeAdapter | MercadoPagoAdapter | ...] → implements IPaymentGateway
|
Provider SDK (stripe, mercadopago, etc.)
Data layer: payment_provider_mappings replaces all stripe_* columns with a single polymorphic lookup table.
Quick Reference
| Step | What | Where |
|---|
| 1. Enum | Add provider to PaymentProvider | libs/domain/src/enums/PaymentProvider.ts |
| 2. Env vars | Add {PREFIX}_SECRET_KEY, {PREFIX}_WEBHOOK_SECRET | .env |
| 3. Adapter | Create {provider}.adapter.ts implementing IPaymentGateway | apps/server/src/api/modules/billing/adapters/ |
| 4. Factory | Inject adapter + register in Map | apps/server/src/api/modules/billing/payment-gateway.factory.ts |
| 5. Module | Register adapter + named token in BillingModule | apps/server/src/api/modules/billing/billing.module.ts |
| 6. Webhooks | Implement IWebhookNormalizer for provider | apps/server/src/api/modules/billing/adapters/ |
Implementation Guide
Step 1: Add to PaymentProvider Enum
export enum PaymentProvider {
STRIPE = 'stripe',
MERCADOPAGO = 'mercadopago',
}
Step 2: Environment Variables
Convention-based resolution via ConfigurationService.getGatewayConfig():
{PROVIDER_UPPERCASE}_SECRET_KEY # Required
{PROVIDER_UPPERCASE}_PUBLIC_KEY # Optional
{PROVIDER_UPPERCASE}_WEBHOOK_SECRET # Required for webhooks
Example: MERCADOPAGO_SECRET_KEY, MERCADOPAGO_WEBHOOK_SECRET
No code changes needed in ConfigurationService — it derives prefix from provider.toUpperCase().
Step 3: Create Adapter
import { Injectable, Inject } from '@nestjs/common';
import { IPaymentGateway } from '@fnd/contracts';
import { IConfigurationService } from '@fnd/contracts';
import { PaymentProvider } from '@fnd/domain';
import {
CustomerResult, CheckoutParams, CheckoutResult,
SubscriptionResult, GatewayProduct, GatewayPrice,
RawWebhookEvent, PortalResult, GatewayHealthResult, CustomerData,
} from '@fnd/contracts';
@Injectable()
export class MercadoPagoAdapter implements IPaymentGateway {
private client: MercadoPagoSDK;
constructor(
@Inject('IConfigurationService')
private readonly configService: IConfigurationService,
) {
const config = this.configService.getGatewayConfig(PaymentProvider.MERCADOPAGO);
this.client = new MercadoPagoSDK(config.secretKey);
}
async createCustomer(email: string, name: string, metadata?: Record<string, string>): Promise<CustomerResult> {
const customer = await this.client.customers.create({ email, name });
return { id: customer.id, email: customer.email };
}
}
Rules for adapters:
- Provider SDK is ONLY imported inside the adapter file
- All return types use
@fnd/contracts normalized types
- Status mapping: convert provider-specific statuses to
'active' | 'canceled' | 'pending'
- Webhook events MUST include
provider: PaymentProvider.X tag
Step 4: Register in Factory
@Injectable()
export class PaymentGatewayFactory implements IPaymentGatewayFactory {
constructor(
@Inject('StripeAdapter') private readonly stripeAdapter: StripeAdapter,
@Inject('MercadoPagoAdapter') private readonly mercadoPagoAdapter: MercadoPagoAdapter,
) {
this.adapters = new Map<PaymentProvider, IPaymentGateway>();
this.adapters.set(PaymentProvider.STRIPE, this.stripeAdapter);
this.adapters.set(PaymentProvider.MERCADOPAGO, this.mercadoPagoAdapter);
}
}
Step 5: Register in BillingModule
providers: [
MercadoPagoAdapter,
{ provide: 'MercadoPagoAdapter', useExisting: MercadoPagoAdapter },
],
exports: [
],
Step 6: Webhook Normalization
Each adapter's verifyWebhookSignature returns a RawWebhookEvent. The IWebhookNormalizer converts it to a NormalizedWebhookEvent:
interface NormalizedWebhookEvent {
eventType: WebhookEventType;
entityType: string;
entityId: string;
accountId: string;
provider: PaymentProvider;
idempotencyKey: string;
rawData: Record<string, unknown>;
}
Provider-Entity Mapping (payment_provider_mappings)
How It Works
The payment_provider_mappings table is a polymorphic lookup that replaces all stripe_* columns:
| Column | Purpose | Example |
|---|
entity_type | Domain entity kind | 'account', 'plan', 'subscription' |
entity_id | Domain entity UUID | '550e8400-...' |
provider | Gateway name | 'stripe', 'mercadopago' |
provider_id | External ID at provider | 'cus_xxx', 'sub_xxx' |
is_active | Soft-delete flag | true / false |
metadata | JSONB extra data | { "plan": "pro" } |
Unique constraint: (entity_type, entity_id, provider) — one mapping per entity per provider.
Allowed entity_types: account, workspace, plan, plan_price, subscription
Repository Methods
findActiveByEntityAndProvider(entityType, entityId, provider): PaymentProviderMapping | null
findByProviderAndProviderId(provider, providerId): PaymentProviderMapping | null
findByEntityTypeAndId(entityType, entityId): PaymentProviderMapping[]
create(data): PaymentProviderMapping
deactivateByEntity(entityType, entityId): void
Usage Pattern
const customer = await gateway.createCustomer(email, name);
await mappingRepo.create({
entityType: 'account',
entityId: account.id,
provider: PaymentProvider.STRIPE,
providerId: customer.id,
isActive: true,
});
const mapping = await mappingRepo.findByProviderAndProviderId('stripe', 'cus_xxx');
const mapping = await mappingRepo.findActiveByEntityAndProvider('account', accountId, provider);
const portalSession = await gateway.createPortalSession(mapping.providerId, returnUrl);
Common Mistakes
| Mistake | Fix |
|---|
| Importing provider SDK outside adapter | SDK imports ONLY inside adapters/{provider}.adapter.ts |
| Returning provider-specific types from adapter | Always return @fnd/contracts normalized types |
| Forgetting named token in module | Must register both class AND { provide: 'XAdapter', useExisting: X } |
| Hardcoding provider IDs on domain entities | Use payment_provider_mappings table instead |
Missing provider tag on RawWebhookEvent | Always set provider: PaymentProvider.X |
Not mapping statuses to active/canceled/pending | Each adapter must normalize provider-specific statuses |
Checklist for New Provider