Billing
Stripe, Mercado Pago ou Polar — agnóstico ao provider.
Última atualização 19 de jul. de 2026
Estratégia
PAYMENT_PROVIDER no env decide. A interface PaymentProvider abstrai checkout, webhooks, subscriptions. Trocar de provider = um commit.
interface PaymentProvider {
createCheckout(input): Promise<Result<CheckoutSession, BillingError>>;
handleWebhook(req): Promise<Result<DomainEvent, BillingError>>;
cancelSubscription(id): Promise<Result<void, BillingError>>;
}Providers suportados
Stripe
PAYMENT_PROVIDER=stripe. Vars: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Webhook: POST /webhooks/stripe.
Mercado Pago
PAYMENT_PROVIDER=mercado-pago. Vars: MERCADO_PAGO_ACCESS_TOKEN, MERCADO_PAGO_WEBHOOK_SECRET. Pensado para LATAM — suporta Pix, wallet do Mercado Pago, parcelamento.
Polar
PAYMENT_PROVIDER=polar. Vars: POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_SERVER (sandbox | production; default sandbox, mas obrigatória com NODE_ENV=production). Ideal para developer tools — integra com GitHub.
Modelo
Subscription ─→ Plan ─→ Feature
│
└──→ OrganizationSubscription.status é a SSOT (active, past_due, canceled, trialing). Os webhooks atualizam; o frontend nunca assume.
Fake (somente E2E / testing)
PAYMENT_PROVIDER=fake. Var: FAKE_WEBHOOK_SECRET (qualquer string).
Retorna planos estáticos, URLs falsas de checkout/portal e aceita webhooks assinados com HMAC-SHA256
(x-fake-signature: sha256=<hex>). Usado pela suite E2E — nunca fazer deploy em produção.
Teste de webhooks local
stripe listen --forward-to localhost:3005/api/v1/billing/webhookPara Mercado Pago / Polar use ngrok. Para testes E2E: PAYMENT_PROVIDER=fake FAKE_WEBHOOK_SECRET=<secret>.
Operações de admin de plataforma
O Plano 05 adiciona cinco métodos cross-tenant de assinatura ao IPaymentProvider. Eles dão suporte à página /admin/billing/[orgId] e aos endpoints /api/v1/platform/organizations/:id/subscription/*.
| Método | Descrição |
|---|---|
getSubscriptionSummary(customerInternalId) | Retorna a assinatura atual (null se não houver). |
changePlan(customerInternalId, newPriceId) | Troca a assinatura para outro preço. |
extendTrial(customerInternalId, days) | Estende trialEndsAt em [1, 90] dias. |
cancelSubscription(customerInternalId, { immediate }) | Cancela agora ou ao fim do período. |
listInvoices(customerInternalId, { page, pageSize }) | Histórico paginado de invoices. |
[!NOTE] Apenas o adapter Fake os implementa de saída (estado em memória, usado pelos testes E2E e smoke do admin). Stripe / Polar / MercadoPago retornam
InfrastructureError("X is not implemented by the Y adapter; ...")via o helper compartilhadoadminUnsupportedemapps/server/src/modules/billing/infrastructure/providers/admin-unsupported.ts. Para produção, substitua cada stub pela chamada ao SDK correspondente (ex.:stripe.subscriptions.update,stripe.invoices.list).
Resolução org → customer
Subscription.customerInternalId é um userId (billing pessoal e assinaturas de org legadas chaveadas no owner) ou org:<organizationId> (checkouts org-scoped). Toda leitura org-scoped resolve o alvo via application/resolve-org-customer.ts: primeiro o customer org:<id> e, se não tiver assinatura viva, o fallback legado org → userId do owner-member. Tanto as operações cross-tenant de admin quanto o port de assinatura ativa do módulo usage passam por ali. Os customers do provider não podem ser migrados, então as assinaturas existentes chaveadas no owner continuam funcionando pelo fallback.
[!NOTE]
subscriptions.customerInternalId/purchases.customerInternalIdnão têm mais FK parausers(uma keyorg:<id>não é um usuário). Um trigger de DB emuserspreserva o cascade do purge GDPR.
Eventos de auditoria
Cada mutação emite uma entrada de audit com o userId real do ator (consciente de impersonation):
platform.org.plan_changed—{ previousPriceId, newPriceId }platform.org.trial_extended—{ days, newTrialEndsAt }platform.org.subscription_cancelled—{ mode: 'immediate' | 'at_period_end' }
Cancelamento, ordem dos eventos e chaves de cliente
- Polar, cancelar no fim do período.
subscription.canceledé mapeado para umsubscription.updatedque definecanceledAt(cancelamento pendente) e mantémstatus = active; apenassubscription.revokedencerra o acesso.subscription.uncanceledlimpacanceledAt.cancelSubscription({ immediate: false })do admin usacancel_at_period_endda Polar;immediate: truerevoga. - Eventos fora de ordem.
Subscription.providerUpdatedAtguarda a data de modificação do provider; eventos mais antigos que chegam atrasados são ignorados. Status desconhecido viraincompleteesubscription.updatednunca apagametadata. - Renovações da Polar (
order.paidcombilling_reasondiferente depurchase) não são registradas comoPurchase. - Chave de cliente. O checkout a partir de um contexto de organização (
x-organization-id) exigebilling:updatee é atribuído ao customer próprio da orgorg:<organizationId>; sem contexto é pessoal (userId). O portal da org (POST /billing/portal) abre o customer em que está a assinatura viva da org: para uma assinaturaorg:<id>qualquer membro combilling:updatepode abri-lo; para uma assinatura legada chaveada no owner continua restrito ao billing owner (403 caso contrário). - Mercado Pago. Preço, moeda e produto do checkout vêm do catálogo
BillingPlan(priceId= id do plano), nunca de metadata do cliente. Assinaturas de webhook com mais de 5 minutos são rejeitadas.