One billing port, three providers: Stripe, MercadoPago and Polar

How UseDeploy puts Stripe, MercadoPago and Polar behind a single typed IPaymentProvider interface — so charging in pesos or dollars is a one-file swap, and the rest of the app never sees a provider SDK.

MC
Martin Coll

Most SaaS boilerplates are Stripe-first, which is fine until you need to charge in Argentine pesos or Brazilian reais. Stripe doesn't onboard Argentine entities directly; the pragmatic — sometimes only — local rail is MercadoPago. If your billing code is import Stripe from 'stripe' sprinkled across use cases, adding a second provider is a rewrite. If it's behind a port, it's a new file.

UseDeploy takes the port route. There's one interface, IPaymentProvider, and the rest of the application — checkout, the customer portal, webhook handling, the pricing page — talks only to that interface. Providers are adapters selected from an env var. This post is the anatomy of that seam, and an honest accounting of which adapters are actually finished. The billing feature page and the billing doc cover the product surface; this is the layer underneath.

The port is the commitment

The port lives in the application layer and names every operation billing needs, in provider-agnostic terms:

export interface IPaymentProvider {
  readonly name: string;

  createCheckoutSession(input: CreateCheckoutSessionInput): Promise<Result<CheckoutSession, DomainError>>;
  createCustomerPortalLink(input: CustomerPortalInput): Promise<Result<CustomerPortalLink, DomainError>>;

  /** Verify the signature AND parse the payload into a normalized BillingEvent.
   *  ok(null) means "valid, but we don't model that event type yet". */
  parseWebhook(req: WebhookRequest): Promise<Result<BillingEvent | null, DomainError>>;

  /** Active product/price catalog — feeds the public pricing page. */
  listPlans(): Promise<Result<ReadonlyArray<Plan>, DomainError>>;

  // ... admin ops: getSubscriptionSummary, changePlan, extendTrial,
  //     cancelSubscription, listInvoices
}

Two design choices carry most of the weight here.

First, everything returns Result<T, DomainError>, not a thrown exception. When a use case reads Result<CheckoutSession, DomainError>, the failure mode is in the type — an agent or a human reading the code knows a checkout can fail without running it. Throwing is reserved for genuinely unexpected conditions.

Second, provider events are normalized on the way in. Adapters translate provider-specific webhook payloads into a single BillingEvent union, and the rest of the app never sees a raw Stripe or MercadoPago DTO:

export type BillingEvent =
  | { type: 'subscription.created'; provider: string; customerInternalId: string; subscriptionId: string; priceId: string; currentPeriodEnd: Date; metadata: Readonly<Record<string, string>> }
  | { type: 'subscription.updated'; provider: string; customerInternalId: string; subscriptionId: string; status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing'; currentPeriodEnd: Date; priceId: string }
  | { type: 'subscription.canceled'; provider: string; customerInternalId: string; subscriptionId: string }
  | { type: 'invoice.paid'; provider: string; customerInternalId: string; amount: Money; invoiceId: string; priceId: string; productId: string; productName: string; metadata?: Readonly<Record<string, string>> }
  | { type: 'invoice.payment_failed'; provider: string; customerInternalId: string; invoiceId: string; reason?: string };

The Money type is currency-aware from the start, with the LATAM currencies as first-class citizens rather than an afterthought:

export type Currency = 'USD' | 'EUR' | 'ARS' | 'BRL' | 'MXN';

export interface Money {
  readonly amountMinor: number; // smallest unit: cents, centavos
  readonly currency: Currency;
}

Selecting the adapter

There's one place that turns config into a provider. Adding a provider means extending the union, adding its config keys, and adding a case — nothing else in the app changes:

export const PAYMENT_PROVIDERS = ['none', 'stripe', 'mercado-pago', 'polar', 'fake'] as const;

export const buildPaymentProvider = (env: ProviderEnvSlice, deps?: ProviderDeps): IPaymentProvider => {
  const kind = env.PAYMENT_PROVIDER ?? 'none';
  switch (kind) {
    case 'none':
      return new NullPaymentProvider();
    case 'stripe':
      if (!env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET)
        throw new ValidationError('PAYMENT_PROVIDER=stripe requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET');
      return new StripePaymentProvider({ secretKey: env.STRIPE_SECRET_KEY, webhookSecret: env.STRIPE_WEBHOOK_SECRET });
    case 'mercado-pago':
      if (!env.MERCADO_PAGO_ACCESS_TOKEN || !env.MERCADO_PAGO_WEBHOOK_SECRET)
        throw new ValidationError('PAYMENT_PROVIDER=mercado-pago requires MERCADO_PAGO_ACCESS_TOKEN and MERCADO_PAGO_WEBHOOK_SECRET');
      return new MercadoPagoPaymentProvider({ /* ... */ });
    case 'polar':
      // ...
    case 'fake':
      // fully-implemented in-memory adapter so E2E + the admin UI work end-to-end
  }
};

Note none and fake. The boilerplate must boot with zero billing config — NullPaymentProvider is the no-op default — and the fake adapter is a complete in-memory implementation so the end-to-end tests and the admin UI exercise the whole billing flow without touching a real provider. That's the same optional-adapter discipline the rest of the codebase follows: unset config means a no-op, never a crash.

How the webhook path consumes the port

The most instructive consumer is the webhook handler, because it shows the "normalize then re-publish" pattern the whole design is built around. The provider verifies the signature and parses the raw body; the use case re-emits the result as a domain event so audit, notifications, and the billing read model can react without ever knowing which provider sent it:

async execute(req: WebhookRequest): Promise<Result<HandleWebhookOutput, DomainError>> {
  const parsed = await this.provider.parseWebhook(req);
  if (!parsed.ok) return parsed;           // bad signature → surfaces as an error
  if (!parsed.value) return ok({ accepted: true }); // valid, unmodeled → skip

  const event = parsed.value;
  const aggregateId = 'subscriptionId' in event ? event.subscriptionId : event.invoiceId;
  await this.events.publish([{
    name: `billing.${event.type}`,
    aggregateId,
    occurredAt: new Date(),
    payload: event as unknown as Record<string, unknown>,
  }]);
  return ok({ accepted: true, eventType: event.type });
}

Because the bus in production is the durable outbox, an insert failure here rejects publish, the route returns a 5xx, and the provider redelivers — the fail-loud property you want on the money path. The route mounts the webhook with express.raw({ type: '*/*' }) so the adapter gets the exact bytes it needs to verify a signature; a JSON-parsed body would invalidate the HMAC.

The MercadoPago adapter is the one that's actually built out

Here's the honest part, because a boilerplate that oversells its adapters wastes your afternoon. Of the real-provider adapters, MercadoPago is the most complete — which is deliberate, since native MercadoPago is the whole reason the port exists rather than a direct Stripe dependency.

Its parseWebhook does the full dance MercadoPago's asynchronous model requires. MP's webhook doesn't carry the payment; it carries a payment id you then fetch. So the adapter verifies the signature, then calls the API to get the real status:

async parseWebhook(req: WebhookRequest): Promise<Result<BillingEvent | null, DomainError>> {
  let body: MpWebhookBody;
  try { body = JSON.parse(req.rawBody) as MpWebhookBody; }
  catch { return err(new ValidationError('MercadoPago webhook body is not valid JSON')); }

  if (body.type !== 'payment' || !body.data?.id) return ok(null);

  const xSignature = req.headers['x-signature'];
  const xRequestId = req.headers['x-request-id'];
  if (!xSignature || !xRequestId)
    return err(new ValidationError('Missing x-signature or x-request-id header'));

  if (!this.verifyWebhookSignature(xSignature, xRequestId, body.data.id))
    return err(new ValidationError('MercadoPago webhook signature invalid'));

  const paymentResult = await this.getPaymentStatus(body.data.id); // GET /v1/payments/:id
  if (!paymentResult.ok) return paymentResult;
  return ok(this.mapPaymentToEvent(paymentResult.value));
}

The signature check is MercadoPago's exact manifest scheme — id:<data.id>;request-id:<x-request-id>;ts:<ts>; — HMAC-SHA256'd with the webhook secret and compared in constant time:

const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
const expected = crypto.createHmac('sha256', this.cfg.webhookSecret).update(manifest).digest('hex');
// ... crypto.timingSafeEqual(hashBuf, expectedBuf)

That "the webhook tells you an id, you fetch the truth" shape is exactly where a DIY integration trips: it's the point where idempotency matters most, because MP may deliver the same notification more than once. Publishing the normalized event through the at-least-once outbox — with idempotent downstream handlers — is how that's absorbed here instead of turning into a double-charge.

Checkout is implemented against MP's Preferences API (Checkout Pro redirect) for mode: 'payment', and the adapter is explicit that recurring subscriptions aren't done yet rather than pretending:

if (input.mode === 'subscription') {
  return err(new InfrastructureError(
    'MercadoPago subscriptions (preapproval) are not yet supported. Use mode "payment" for one-time checkout.',
  ));
}

listPlans reads from the local BillingPlan table (MP has no hosted product catalog the way Stripe does), and the admin operations (changePlan, listInvoices, …) return a clear adminUnsupported(...) error until you wire them to MP's API. Nothing silently no-ops.

The Stripe adapter is a typed skeleton — on purpose

By contrast, the Stripe adapter is intentionally a skeleton. The Stripe SDK isn't a baseline dependency, so most methods return a clear "not yet implemented" error:

async createCheckoutSession(_input: CreateCheckoutSessionInput): Promise<Result<CheckoutSession, DomainError>> {
  return err(new InfrastructureError('StripePaymentProvider.createCheckoutSession not yet implemented'));
}

async parseWebhook(_req: WebhookRequest): Promise<Result<BillingEvent | null, DomainError>> {
  // When wired: stripe.webhooks.constructEvent(req.rawBody, sig, webhookSecret)
  return ok(null);
}

The one method that is wired shows the pattern for going live: a lazy dynamic import, so an install using Polar or MercadoPago never pays the Stripe SDK cost, with a precise error if the package isn't there:

async createCustomerPortalLink(input: CustomerPortalInput): Promise<Result<CustomerPortalLink, DomainError>> {
  let StripeCtor;
  try {
    const mod = await import('stripe');
    StripeCtor = mod.default;
  } catch {
    return err(new InfrastructureError('Stripe SDK is not installed. Add `stripe` to apps/server/package.json.'));
  }
  const stripe = new StripeCtor(this.cfg.secretKey);
  const session = await stripe.billingPortal.sessions.create({
    customer: input.customer.internalId,
    return_url: input.returnUrl,
  });
  return ok({ url: session.url, provider: this.name });
}

This is the honest tradeoff of the port pattern: the interface is the thing that's fully committed, and the adapters sit at different completeness levels. The methods' docstrings tell you the exact SDK call to drop in (stripe.subscriptions.update for changePlan, stripe.invoices.list for listInvoices, and so on), so finishing Stripe is filling in named blanks against a contract, not designing an integration from scratch. Polar sits between the two — it's the provider the project's own checkout is wired to.

Why this is worth the indirection

For a single-provider app, a port is over-engineering — and if you only ever sell in USD through Stripe, you don't need this. The value shows up the moment "we also need to charge in pesos" lands, which for a LATAM product is week one, not year two. With the port:

  • Swapping providers is a one-file change plus env config. The switch in buildPaymentProvider is the only place that knows a provider exists.
  • The rest of the app is provider-blind. Checkout, the portal, the pricing page, and every webhook consumer speak IPaymentProvider and BillingEvent — never a Stripe or MP DTO.
  • Currencies are modeled, not stringly-typed. ARS, BRL, and MXN are in the Currency union next to USD/EUR from the first commit.

If you're evaluating this for a product that needs local payment rails, the MercadoPago adapter is the finished reference and the honest example of what a real integration looks like — signature verification, the async fetch-the-truth flow, idempotency through the outbox. Start at the billing feature page or the billing doc, and read the adapter next to the port to see exactly where the seam is.