SaaS Starter

Billing

Stripe, Mercado Pago ou Polar — agnóstico ao provider.

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. Ideal para developer tools — integra com GitHub.

Modelo

Subscription ─→ Plan ─→ Feature

     └──→ Organization

Subscription.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/webhook

Para 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étodoDescriçã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 compartilhado adminUnsupported em apps/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

O modelo de billing do boilerplate é per-user: Subscription.customerInternalId === userId. As operações cross-tenant resolvem o alvo via org → userId do owner-member (ver application/use-cases/admin/resolve-org-customer.ts). Quando migrar para billing per-org, esse arquivo é o único que precisa mudar.

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

Boost (catalog)

O Diretório de servidores acrescenta uma assinatura Boost por servidor sobre o adaptador Polar. Três tiers (Bronze / Silver / Gold) mapeiam para três produtos Polar configurados via env:

BOOST_POLAR_PRODUCT_BRONZE=...
BOOST_POLAR_PRICE_BRONZE=...
BOOST_POLAR_PRODUCT_SILVER=...
BOOST_POLAR_PRICE_SILVER=...
BOOST_POLAR_PRODUCT_GOLD=...
BOOST_POLAR_PRICE_GOLD=...

Cada produto precisa de metadata.tier = "BRONZE" | "SILVER" | "GOLD" para que o dashboard identifique sem analisar o nome (PATCH via REST do Polar após criar o produto).

O dono verificado inicia o checkout em POST /api/v1/catalog/servers/:id/boost/checkout { tier }. O webhook do Polar carrega metadata.{ serverId, tier }, que o listener em modules/catalog/application/listeners/on-subscription-activated.ts converte em um upsert de Boost (idempotente em polarSubscriptionId).

Guardrail

Se o servidor com boost tiver média menor que 5 jogadores em 7 dias, o multiplicador não é aplicado no ranking. A linha Boost permanece (o dono continua pagando) mas RankingFormula.boostedScore retorna rawScore. O dashboard mostra um banner âmbar.

Cancelamento

POST /api/v1/catalog/servers/:id/boost/cancel marca como cancelAtPeriodEnd = true. A linha é mantida até activeUntil; o cron diário catalog.boost-expirer (03:00 UTC) remove as linhas vencidas.

Nesta página