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.
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
buildPaymentProvideris 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
IPaymentProviderandBillingEvent— never a Stripe or MP DTO. - Currencies are modeled, not stringly-typed.
ARS,BRL, andMXNare in theCurrencyunion next toUSD/EURfrom 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.