Skip to content

Subscrições ​

@basaltkit/subscriptions modela a faturação na tua própria base de dados, com as gateways de pagamento como drivers. A tua app fala com o Basalt; só os drivers falam com o Stripe, Paddle, Lemon Squeezy — ou, para Angola, ProxyPay/Multicaixa. As verificações de funcionalidades e as quotas leem estado local, por isso são instantâneas e nunca chamam a gateway.

Modelo mental ​

A faturação são cinco peças, e saber qual delas é dona de uma pergunta responde a quase todo o resto:

PeçaÉ dona deFala com a gateway?
Planos (definePlans)O catálogo: preço, trial, funcionalidades por planoNão — um objeto simples, lido de forma síncrona
Subscriptions (SUBSCRIPTIONS)A que é que um billable tem direito agora: plano, período, estadoSó para movimentos de dinheiro (subscribe, checkout, portal, swap, cancel)
features(billableId)Imposição: can, limit, usage, remaining, consumeNunca — lê estado local, por isso é instantâneo em cada pedido
Invoices (INVOICES)O que lhes foi cobrado: linhas, desconto, imposto, totais, estadoNão — domínio puro; chamas markPaid quando um pagamento confirma
Driver de gatewayMover dinheiro e verificar webhooksEle é a gateway

A direção da verdade importa: a tua base de dados é o read model. Um webhook da gateway escreve nela (handleWebhook), e tudo o resto — guards, verificações de funcionalidades, imposição de quotas — lê dela. Nenhum caminho de pedido espera pela Stripe.

O billable é o id que passares. Por convenção é o id do tenant, e os route guards e as rotas HTTP resolvem-no a partir de ctx().tenant.id, razão pela qual a imposição de pertença ao tenant é estrutural para a faturação — vê o aviso na secção do Stripe mais abaixo.

Definir planos ​

Um plano é um preço mais um conjunto de funcionalidades. definePlans preserva os tipos exatos, para que subscriptions.features(...) e os route guards conheçam os nomes das tuas funcionalidades. meter(n) embrulha um número numa quota que se reinicia mensalmente.

ts
// src/billing/plans.ts
import { definePlans, meter } from '@basaltkit/subscriptions'

export const plans = definePlans({
  free: {
    price: 0, // 0 = gratuito, nunca toca na gateway
    features: { projects: 3, api: false },
  },
  pro: {
    price: { monthly: 29, yearly: 290 }, // preço por período
    trial: '14d',                        // trial de 14 dias → status 'trialing'
    features: {
      projects: 50,                 // saldo vitalício
      api: true,                    // flag on/off
      'api.requests': meter(100_000), // quota que se reinicia a cada mês de calendário
    },
  },
  scale: {
    price: 'custom', // "fala com o comercial" — sem checkout self-serve
    features: { projects: Number.POSITIVE_INFINITY, api: true },
  },
})

Os valores das funcionalidades falam por si:

ValorSignificado
booleanFlag on/off (can(feature))
numberSaldo consumível vitalício — nunca se reinicia
meter(n)Quota que se reinicia a cada mês de calendário (bucket YYYY-MM)
InfinityIlimitado

price é 0 (gratuito), um único number (igual em ambos os períodos), um objeto { monthly, yearly }, ou 'custom' (liderado pelo comercial — o checkout fica desativado).

Persistir o catálogo de planos ​

Os planos são consumidos de forma síncrona (pelo planPrice, features e guards), por isso mantém a fonte de verdade num PlanStore e carrega-o uma vez no arranque:

ts
import { loadPlans, subscriptionsPlugin } from '@basaltkit/subscriptions'

const plans = await loadPlans(planStore) // lê a tua BD, constrói o objeto Plans
subscriptionsPlugin({ plans, fallbackPlan: 'free', ...stores })

O MemoryPlanStore (semeia-o com um objeto definePlans) serve para testes; suporta um PlanStore real com a tua base de dados para gerir planos na BD — as edições aplicam-se no restart. O plansToStored(plans) transforma um objeto definePlans em linhas para o seed.

Ligar o plugin ​

subscriptionsPlugin regista o serviço sob o token SUBSCRIPTIONS e instala os route guards meta.subscribed / meta.feature. As suas opções são as mesmas de new Subscriptions(...) menos hooks (o plugin passa o HookBus da app automaticamente).

ts
// src/billing/subscriptions.ts
import { Subscriptions } from '@basaltkit/subscriptions'
import { plans } from './plans.js'

// Serviço standalone (sem HTTP, sem gateway) — tudo funciona localmente.
export const subscriptions = new Subscriptions({
  plans,
  fallbackPlan: 'free', // aplicado a quem não tem subscrição
})

O construtor falha logo se o fallbackPlan não existir no catálogo, por isso uma gralha é um erro de arranque e não um silencioso "ninguém tem funcionalidades". Todas as opções estão tabeladas em Referência de opções mais abaixo.

Subscrever e gerir ​

O faturável é o tenant por convenção — 'acme' abaixo é um id de tenant.

ts
await subscriptions.subscribe('acme', 'pro')                       // mensal (predefinição)
await subscriptions.subscribe('acme', 'pro', { period: 'yearly' }) // anual

await subscriptions.swap('acme', 'scale')                    // muda de plano, com proração
await subscriptions.swap('acme', 'scale', { prorate: false }) // muda apenas na próxima renovação

await subscriptions.cancel('acme')                         // no fim do período (fica ativo até lá)
await subscriptions.cancel('acme', { atPeriodEnd: false })  // imediato → status 'canceled'
await subscriptions.resume('acme')                          // desfaz um cancelamento agendado

await subscriptions.subscribed('acme')        // true se ativo ou num trial válido
await subscriptions.subscribed('acme', 'pro') // ...num plano específico
await subscriptions.onTrial('acme')           // boolean
await subscriptions.get('acme')               // SubscriptionRecord | null

O swap() nunca concede um plano pago de graça. Uma subscrição sem subscrição na gateway por trás (uma grátis ou concedida localmente) não pode passar para um plano pago ou 'custom' — lança PaymentRequiredError (402); encaminha o cliente pelo checkout() (ver Duas portas). Passa { allowUnpaid: true } apenas quando o pagamento é mesmo cobrado noutro lado (faturação manual, pagamentos por referência, um negócio fechado pelas vendas). Descer para um plano grátis não exige pagamento. Uma subscrição suportada pela gateway é trocada primeiro na gateway; uma gateway sem swapSubscription lança GatewayUnsupportedError em vez de mudar só o plano local enquanto a gateway continua a cobrar o preço antigo.

O resume() retira o cancelamento agendado também na gateway (resumeSubscription) — caso contrário a gateway terminaria a subscrição no fim do período na mesma, e o seu webhook subscription.canceled cancelaria localmente a subscrição "retomada". Uma gateway sem resumeSubscription lança GatewayUnsupportedError.

Um SubscriptionRecord é { billableId, plan, period, status, trialEndsAt?, cancelAtPeriodEnd?, canceledAt?, gatewayRef?, pendingPlan?, pendingPeriod? } onde status é um de active, trialing, past_due, canceled, incomplete.

Duas portas para uma subscrição ​

O subscribe() e o checkout() iniciam ambos uma subscrição e não se comportam da mesma maneira. Escolher o errado é a razão mais comum para uma subscrição "nunca funcionar, sem dizer porquê".

subscribe()checkout()
Estado que deixaactive, ou trialing se o plano tiver trialincomplete
Quem confirma o pagamentoninguém — decidiste tu, ou o plano é gratuitoa gateway, mais tarde, por webhook
subscribed() logo a seguirtruefalse
Usa quandoconcedes do lado do servidor, planos gratuitos, seeds, dev e testespagamentos self-service com cartão

subscribe() — ativo no instante em que retorna ​

ts
import { SUBSCRIPTIONS } from '@basaltkit/subscriptions'

const subscriptions = app.container.get(SUBSCRIPTIONS)

const record = await subscriptions.subscribe('acme', 'pro')
record.status                                 // 'trialing' — o plano pro declara trial: '14d'
await subscriptions.subscribed('acme')        // true — um trial válido conta como subscrito
await subscriptions.subscribed('acme', 'pro') // true

// Um plano sem trial fica logo em 'active'
await subscriptions.subscribe('acme', 'scale')  // status: 'active'

// Anual em vez do mensal por omissão
await subscriptions.subscribe('acme', 'pro', { period: 'yearly' })

// Um plano gratuito nunca toca na gateway
await subscriptions.subscribe('acme', 'free')   // status: 'active', sem chamada à gateway

Os planos pagos continuam a chamar o createSubscription da gateway para obter um gatewayRef — mas o registo local é escrito como ativo de qualquer forma, porque és tu a afirmar que a subscrição existe. As gateways checkout-first (Paddle, Lemon Squeezy) não conseguem de todo criar uma subscrição paga do lado do servidor: o seu createSubscription lança CheckoutRequiredError — usa o checkout().

checkout() — dois passos, e não termina quando retorna ​

ts
const { url } = await subscriptions.checkout('acme', 'pro', {
  successUrl: 'https://app.example.com/thank-you',
  cancelUrl: 'https://app.example.com/pricing',
})

await subscriptions.get('acme')          // { status: 'incomplete', … }
await subscriptions.subscribed('acme')   // false  ← ainda false, e bem

O fluxo só fecha quando a gateway responde:

checkout()  →  incomplete  →  [o cliente paga na gateway]
                                        ↓
                          POST /billing/webhook  (payment.succeeded)
                                        ↓
                                     active

Um URL não é uma subscrição

O checkout() devolver um URL significa que foi criada uma página de pagamento — e mais nada. Enquanto não chegar um webhook payment.succeeded, o registo fica em incomplete e todos os guards meta.subscribed continuam a responder 402 BILLING_SUBSCRIPTION_REQUIRED. Se estás a testar localmente e nunca acontece um pagamento real, esse webhook nunca chega sozinho — vê Desenvolvimento local abaixo.

Aquele registo incomplete é deliberado, tal como o facto de um segundo checkout não sobrepor uma subscrição viva. Um checkout abandonado estaciona a intenção em pendingPlan / pendingPeriod; só é promovida por um webhook de um gatewayRef novo cujo plano pago (WebhookEvent.plan) seja essa intenção. Os drivers incluídos gravam plan/period nos metadados assinados da gateway, ao lado do preço que cobram, por isso o webhook diz o que foi realmente pago. Sem estas regras, iniciar um checkout para um plano caro e depois pagar uma sessão de checkout anterior e mais barata — ou abandonar um checkout e esperar pela próxima renovação — escalaria a subscrição.

canceled é terminal para a subscrição da gateway que foi cancelada: um payment.succeeded/payment.failed tardio para o mesmo gatewayRef (ou sem ref) é ignorado, por isso uma fatura final entregue depois da eliminação nunca reativa o acesso. Só uma subscrição nova da gateway (um checkout novo) reativa o billable.

Drivers de gateway próprios

Se escreveres o teu próprio BillingGateway, define plan (e period) no WebhookEvent a partir dos metadados que anexaste ao criar o checkout — o helper exportado attestedPlan(metadata) lê-os. Sem isso, uma mudança de plano vinda de um checkout nunca é aplicada (fail closed); só um primeiro checkout sem ambiguidade ativa.

Que estados contam como subscrito ​

ts
await subscriptions.subscribed('acme')  // true para 'active' e para um 'trialing' válido
Estadosubscribed()Como se chega aqui
active✅subscribe(), ou um webhook payment.succeeded
trialing✅ (enquanto trialEndsAt for futuro)subscribe() num plano com trial
incomplete❌checkout(), à espera da gateway
past_due❌um webhook payment.failed
canceled❌cancel({ atPeriodEnd: false }), ou um webhook subscription.canceled

Desenvolvimento local sem gateway real ​

O FakeBillingGateway deixa toda a superfície funcionar sem conta na Stripe. Cria sessões de checkout e devolve URLs plausíveis — mas ninguém paga em https://fake.test/checkout/…, portanto nunca é enviado nenhum webhook e um checkout() nunca sai de incomplete por si.

Duas formas de obter uma subscrição ativa localmente.

A via curta — subscribe() ​

Ideal para testes, seeds e "só preciso deste tenant no plano pro":

ts
// src/seed.ts, ou uma rota só de dev
await app.container.get(SUBSCRIPTIONS).subscribe('demo', 'pro')
ts
// num teste
const app = await buildApp({ logLevel: 'silent' }).boot()
const subscriptions = app.container.get(SUBSCRIPTIONS)
await subscriptions.subscribe('demo', 'pro')
expect(await subscriptions.subscribed('demo', 'pro')).toBe(true)

A via fiel — envia tu o webhook ​

Exercita o mesmo caminho que a produção usa, que é o que queres quando o que estás mesmo a testar é o tratamento do webhook:

bash
# 1. inicia o checkout (precisa de auth + tenant — ver abaixo)
curl -X POST localhost:3000/billing/checkout \
  -H 'content-type: application/json' \
  -H 'x-tenant-id: demo' \
  -H "authorization: Bearer $TOKEN" \
  -d '{"plan":"pro"}'
# → { "url": "https://fake.test/checkout/fake_cs_1" }   ... o estado é agora 'incomplete'

# 2. faz de gateway: confirma o pagamento
curl -X POST localhost:3000/billing/webhook \
  -H 'content-type: application/json' \
  -H 'x-billing-signature: valid' \
  -d '{"id":"evt_1","type":"payment.succeeded","billableId":"demo","gatewayRef":"fake_sub_1","plan":"pro"}'
# → { "received": true, "duplicate": false }            ... o estado é agora 'active'

O FakeBillingGateway.verifyWebhook aceita qualquer corpo JSON cujo header de assinatura seja literalmente valid, e rejeita tudo o resto com BILLING_WEBHOOK_INVALID. Os ids de evento são deduplicados de forma durável, por isso repetir o evt_1 devolve { duplicate: true } e não muda nada — que é exatamente como as repetições de uma gateway real devem comportar-se.

Os outros tipos de evento funcionam da mesma maneira:

bash
# uma renovação falhada → past_due
-d '{"id":"evt_2","type":"payment.failed","billableId":"demo"}'

# o cliente cancelou na gateway → canceled
-d '{"id":"evt_3","type":"subscription.canceled","billableId":"demo"}'

O billable é o tenant, não o utilizador

O billingRoutes resolve o billable a partir de context.tenant.id. Um pedido sem tenant falha com 402 BILLING_SUBSCRIPTION_REQUIRED mesmo com o chamador perfeitamente autenticado — a mensagem é sobre o tenant não ter subscrição. Envia x-tenant-id, ou o que os resolvers do teu tenancyPlugin esperarem.

As rotas de escrita também têm auth: true por omissão, portanto um POST /billing/checkout sem autenticação é 401 antes de se chegar a isto.

Limites de funcionalidades e medição ​

features(billableId) devolve a API de aplicação. Lê apenas estado local, por isso é instantânea e segura de chamar em cada pedido.

ts
const features = subscriptions.features('acme')

await features.can('api')            // true no pro
await features.limit('projects')     // 50 (false→0, true→Infinity)
await features.usage('projects')     // consumido até agora neste período
await features.remaining('projects') // limite − uso

// Regista o consumo de forma atómica — seguro sob concorrência.
await features.consume('projects', 2)      // cria 2 projetos
await features.consume('api.requests', 1)  // medido; reinicia-se mensalmente

A quantidade tem de ser um inteiro positivo. Uma quantidade negativa devolveria quota e NaN desligaria a quota para sempre, por isso o consume() — e todos os UsageStore (memória, Redis, SQLite, Prisma) — rejeitam tudo o resto com InvalidUsageAmountError (BILLING_INVALID_USAGE_AMOUNT, 400).

consume lança QuotaExceededError (BILLING_QUOTA_EXCEEDED, 402) quando o limite se esgota, e FeatureUnavailableError (BILLING_FEATURE_UNAVAILABLE, 403) quando o plano não concede a funcionalidade de todo. Captura estes erros para mostrar um convite ao upgrade:

ts
import { QuotaExceededError, FeatureUnavailableError } from '@basaltkit/subscriptions'

try {
  await features.consume('api.requests', 1)
} catch (err) {
  if (err instanceof QuotaExceededError) return reply.code(402).send({ upgrade: true })
  if (err instanceof FeatureUnavailableError) return reply.code(403).send({ upgrade: true })
  throw err
}

Medidores vs. saldos

meter(n) reinicia-se a cada mês de calendário; um number simples é um saldo vitalício que nunca se reinicia. Escolhe o tipo de número deliberadamente — é a diferença entre "1000 chamadas de API por mês" e "1000 chamadas de API para sempre".

Uso medido e preços por escalões ​

Cobra o consumo por escalões — graduated (cada unidade ao preço do escalão em que cai) ou volume (todas as unidades ao escalão em que o total aterra) — e transforma o uso registado numa linha de fatura:

ts
import { meteredLine, tieredCost } from '@basaltkit/subscriptions'

const price = {
  mode: 'graduated' as const,
  tiers: [
    { upTo: 1000, unitAmount: 2 }, // primeiras 1.000 chamadas @ $0.02
    { upTo: null, unitAmount: 1 }, // acima @ $0.01
  ],
}

const line = meteredLine('api.calls', { units: 2_500, includedUnits: 1_000, price })
// → uma linha para as 1.500 unidades faturáveis; tieredCost(price, 1500) = 2500 (¢)

await invoices.draft({ billableId, currency: 'USD', lineItems: [line].filter(Boolean) })

O includedUnits (a franquia do plano) é subtraído primeiro; o meteredLine devolve null quando nada é faturável. Usa o tieredCost(price, units) diretamente para pré-visualizações ou rateio. Preços por escalões não têm uma taxa única por unidade, por isso a linha é um valor único com o detalhe no metadata.

Proteger rotas ​

Anexa requisitos como meta da rota. O guard resolve o faturável a partir do tenant do pedido e rejeita antes de o teu handler correr.

ts
import { route } from '@basaltkit/fastify'

route({ method: 'GET', url: '/reports', meta: { subscribed: 'pro' }, async handler() {
  return { ok: true }
}})

route({ method: 'GET', url: '/api/data', meta: { feature: 'api' }, async handler() {
  return { data: [] }
}})

meta: { subscribed: true } exige qualquer subscrição ativa; meta: { subscribed: 'pro' } exige esse plano específico. Requisitos não cumpridos devolvem 402 BILLING_SUBSCRIPTION_REQUIRED ou 403 BILLING_FEATURE_UNAVAILABLE. O meta.feature tem de ser uma string não vazia: qualquer outro valor (true, um array, '') não nomeia nenhuma funcionalidade e falha fechado com 403 em vez de ser ignorado.

Stripe: checkout, portal, webhook ​

O driver Stripe visa a API REST do Stripe diretamente (sem o SDK stripe) e verifica as assinaturas dos webhooks com o crypto do Node. Diz-lhe como mapear planos para Price IDs do Stripe e como resolver o Customer ID do Stripe de cada tenant.

ts
// src/billing/gateway.ts
import { StripeBillingGateway } from '@basaltkit/subscriptions'

const PRICE_IDS = {
  pro: { monthly: 'price_pro_m', yearly: 'price_pro_y' },
} as const

export const gateway = new StripeBillingGateway({
  secretKey: process.env.STRIPE_SECRET_KEY!,
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, // whsec_...
  priceId: (plan, period) => PRICE_IDS[plan as 'pro'][period],
  customerId: (tenantId) => getOrCreateStripeCustomer(tenantId),
})

Os drivers Stripe, Paddle e Lemon Squeezy falham fechados sem segredo de assinatura: com webhookSecret vazio, só com espaços ou undefined (o clássico process.env.X ?? ''), o verifyWebhook lança 500 BILLING_WEBHOOK_SECRET_MISSING em vez de "verificar" com uma chave HMAC vazia que qualquer pessoa consegue calcular.

Liga a gateway ao plugin e regista as rotas de faturação prontas a usar. billingRoutes dá-te o Checkout alojado e o Portal self-service; billingWebhookRoute dá-te o endpoint que o Stripe chama de volta.

O Checkout, o Portal e as rotas de faturas são autenticados por defeito (meta: { auth: true }, imposto pelo guard do @basaltkit/auth) — devolvem URLs de gestão de pagamento e o histórico do tenant atual e nunca podem ser anónimos. Se (e só se) a autenticação acontecer numa edge exterior, desativa deliberadamente com billingRoutes({ ..., auth: false }) / invoiceRoutes({ auth: false }). A rota de webhook é a exceção: é autenticada pela assinatura da gateway, nunca por sessão.

Autenticação não é autorização: por defeito qualquer membro do tenant pode iniciar um checkout ou abrir o Portal (mudar o cartão, o plano ou cancelar). Restringe a faturação a um papel com a opção meta — é fundida nas duas rotas (e não consegue desligar o auth):

ts
billingRoutes({ successUrl, cancelUrl, meta: { teamRole: 'owner' } })   // @basaltkit/teams
billingRoutes({ successUrl, cancelUrl, meta: { can: 'billing:manage' } }) // @basaltkit/permissions

O corpo pode sobrepor successUrl / cancelUrl / returnUrl por chamada, mas só com URLs nas origens dos URLs configurados (ou nas extra allowedRedirectOrigins: ['https://www.example.com'], só https). Tudo o resto é 400 BILLING_REDIRECT_NOT_ALLOWED — um link legítimo de checkout ou portal nunca pode ser transformado num open redirect para outro site.

meta.auth sozinho não isola tenants

billingRoutes() e invoiceRoutes() autenticam o utilizador mas resolvem o billable a partir do tenant (ctx().tenant.id) — e o tenant vem de um header ou de um Host, ambos controlados pelo cliente. A autenticação prova quem está a chamar, não a que tenant pertence. Regista o tenantMembershipPlugin() e um utilizador válido do tenant A que chame /billing/checkout, /billing/portal ou /billing/invoices com o identificador do tenant B é travado com 403 TEAM_NOT_A_MEMBER antes de correr qualquer código de faturação — nenhuma sessão de Checkout criada contra o plano de B, nenhum URL de Portal para o cartão de B, nenhuma leitura do histórico de pagamentos de B. Sem ele, meta.auth deixa qualquer utilizador autenticado operar sobre a faturação de qualquer tenant. O mesmo se aplica às tuas rotas com meta: { subscribed } / meta: { feature }. Vê Equipas e o guia de segurança.

O subscribed e o feature fazem parte da verificação de guarded-meta da framework. O subscriptionsPlugin reclama ambas as chaves, por isso uma rota anotada com meta: { subscribed: 'pro' } sem o plugin já não arranca a servir a funcionalidade paga a toda a gente — o adapter recusa arrancar com UnguardedRouteMetaError, nomeando as rotas em falta. Se o paywall vive mesmo numa edge exterior, opta por sair deliberadamente com a opção do adapter allowUnguardedMeta: ['subscribed', 'feature']. Cobre à mesma o paywall com um teste: o check de boot prova que existe um guard registado, não que a tua matriz de planos está correta.

Um Checkout abandonado nunca muda a subscrição ativa: o checkout() regista a intenção como pendingPlan, e o plano só muda quando a gateway confirma o pagamento de uma nova subscrição desse plano — uma renovação da subscrição atual (mesmo gatewayRef) não pode ativar um plano escalado, e pagar uma sessão de checkout anterior e mais barata também não.

ts
import { createApp } from '@basaltkit/core'
import { fastifyPlugin, route } from '@basaltkit/fastify'
import {
  billingRoutes,
  billingWebhookRoute,
  subscriptionsPlugin,
} from '@basaltkit/subscriptions'
import { plans } from './billing/plans.js'
import { gateway } from './billing/gateway.js'

const app = await createApp({
  plugins: [
    // ... o teu plugin de tenancy, que define context.tenant ...
    subscriptionsPlugin({ plans, fallbackPlan: 'free', gateway }),
    fastifyPlugin({
      routes: [
        ...billingRoutes({
          successUrl: 'https://app.example.com/thank-you',
          cancelUrl: 'https://app.example.com/pricing',
          // portalReturnUrl: 'https://app.example.com/account',
        }),
        billingWebhookRoute(gateway),
      ],
    }),
  ],
}).boot()

Rotas registadas:

RotaCorpoDevolve
POST /billing/checkout{ plan, period?, successUrl?, cancelUrl? } (sobreposições de URL: só origens permitidas){ url } — redireciona o cliente para aqui
POST /billing/portal{ returnUrl? } (opcional; só origens permitidas){ url }
POST /billing/webhookpayload bruto da gateway{ received, duplicate }

O ciclo de vida: o Checkout cria uma subscrição incomplete e devolve um URL; o cliente paga na página alojada do Stripe; o webhook payment.succeeded passa-a para active. O processamento de webhooks é idempotente por id de evento — uma entrega duplicada devolve { duplicate: true } e não altera nada.

Preferes conduzir tu mesmo em vez de usar as rotas? Os métodos do serviço são públicos:

ts
const { url } = await subscriptions.checkout('acme', 'pro', {
  successUrl: 'https://app.example.com/thank-you',
  cancelUrl: 'https://app.example.com/pricing',
})
const portal = await subscriptions.portal('acme', { returnUrl: 'https://app.example.com/account' })

O corpo bruto já está tratado

O Stripe verifica a assinatura contra os bytes intactos do pedido. O billingWebhookRoute() declara o corpo com rawBody(), por isso o Fastify, o Express e o Hono entregam-lhe os octetos exactos — sem content-type parser, sem hook verify, sem nada para configurar.

Isto era um bug a sério: a rota recorria a JSON.stringify(request.body) sempre que faltava o corpo bruto — o que acontecia em todos os adaptadores, por predefinição — e portanto uma entrega legítima do Stripe falhava a verificação sempre. Se a tua aplicação andava a responder 400 BILLING_WEBHOOK_INVALID a webhooks verdadeiros, era por isto. O fallback deixou de existir.

A única ressalva é no Express, e só quando trazes a tua própria aplicação com o express.json() já montado: o body-parser consome o stream primeiro, por isso acrescenta app.use(express.json({ verify: captureRawBody })) (de @basaltkit/express). Sem isso a rota responde 500 RAW_BODY_UNAVAILABLE — recusa em vez de verificar uma mensagem reconstruída.

Para desenvolvimento e testes há o FakeBillingGateway, que regista cada chamada em arrays (created, canceled, checkouts, portals, swaps) e aceita a assinatura de webhook 'valid'.

Faturas ​

Uma subscrição diz a que um tenant tem direito; uma fatura é o registo do que lhe foi cobrado num período — line items, desconto, imposto, totais e um estado de pagamento. O motor é domínio puro (sem HTTP, sem gateway), por isso comporta-se da mesma forma por trás de qualquer adaptador ou driver de pagamento.

O ciclo de vida é draft → open → paid, e uma fatura em draft ou open pode ser anulada (void). Todos os valores são inteiros em unidades menores (cêntimos), coerente com o resto do billing.

ts
import { Invoices, planLine, overageLine } from '@basaltkit/subscriptions'

const invoices = new Invoices({ taxRate: 0.14 }) // 14% de IVA por omissão

// Constrói a partir do plano + qualquer excedente medido no período
const draft = await invoices.draft({
  billableId: tenantId,
  currency: 'USD',
  lineItems: [
    planLine('pro', plans.pro, 'monthly'),                       // base de $29.00
    overageLine('api.calls', { used: 1500, included: 1000, unitAmount: 2 })!, // 500 × $0.02
  ],
  discount: 500,        // $5.00 de desconto, aplicado antes do imposto
})

const open = await invoices.finalize(draft.id) // atribui INV-2026-0001, estado → open
await invoices.markPaid(open.id, { paymentId: 'pay_123' }) // quando o gateway confirmar

overageLine() devolve null quando o uso está dentro da franquia (espalha e filtra, ou usa-o só quando há excedente). planLine() lança para um preço 'custom' (sales-led) — esses não têm valor self-serve.

Cupões e descontos ​

Define um cupão — percentOff (0–100) ou um amountOff fixo (unidades menores + moeda), com maxRedemptions e expiração redeemBy opcionais — e depois aplica-o ao criar uma fatura:

ts
import { Coupons } from '@basaltkit/subscriptions'

const coupons = new Coupons()
await coupons.define({ code: 'LAUNCH20', percentOff: 20, maxRedemptions: 100 })

// valida + calcula (lança se desconhecido, expirado, esgotado, ou moeda errada)
const { discount } = await coupons.quote('LAUNCH20', subtotalMinor, 'USD')

const invoice = await invoices.draft({
  billableId: tenantId,
  currency: 'USD',
  lineItems: [planLine('pro', plans.pro, 'monthly')],
  coupon: { code: 'LAUNCH20', percentOff: 20 }, // somado a qualquer desconto explícito
})
// → invoice.discount reflete o cupão; invoice.couponCode = 'LAUNCH20'

await coupons.redeem('LAUNCH20') // quando o pagamento tem sucesso, consome uma redenção

O quote() valida a redimibilidade sem consumir; o redeem() volta a verificar o redeemBy e só incrementa o contador enquanto este estiver abaixo de maxRedemptions — num único passo atómico no store, por isso dois resgates concorrentes do último lugar não podem ambos ter sucesso (o que perde recebe CouponNotRedeemableError). Um cupão de valor fixo só se aplica a faturas na sua própria moeda. Em produção, suporta o registo com um CouponStore durável (o padrão é em memória).

Liquidar a partir de um webhook de pagamento ​

As faturas não falam com os gateways. Quando o teu pagamento confirma (via handleWebhook ou o evento confirmed do PaymentLedger), chama markPaid:

ts
ledger.on('confirmed', async ({ record }) => {
  if (record?.reference) await invoices.markPaid(record.reference, { paymentId: record.id })
})

Expor faturas por HTTP ​

invoiceRoutes() adiciona endpoints só-de-leitura, com âmbito de tenant, construídos sobre o route() neutro — por isso servem de forma idêntica em Fastify, Express e Hono:

ts
import { subscriptionsPlugin, invoiceRoutes } from '@basaltkit/subscriptions'

createApp({
  plugins: [
    subscriptionsPlugin({ plans, fallbackPlan: 'free', gateway, invoices: { taxRate: 0.14 } }),
    fastifyPlugin({ routes: [...invoiceRoutes()] }), // ou expressPlugin / honoPlugin
  ],
})
RotaDevolve
GET /billing/invoices{ data: Invoice[] } do tenant atual, mais recentes primeiro
GET /billing/invoices/:iduma fatura em JSON (404 se não for do tenant)
GET /billing/invoices/:id/htmluma fatura em HTML imprimível

Resolve o token INVOICES (ou a tua instância Invoices) para emitir e finalizar faturas no servidor; as rotas são deliberadamente só-de-leitura. Em produção, suporta o motor com um InvoiceStore durável — o padrão é em memória.

Renderiza em qualquer lado com renderInvoiceText(invoice) (recibos, emails) ou renderInvoiceHtml(invoice) (autossuficiente, sem assets externos). Para PDF, adiciona o @basaltkit/subscriptions-pdf e chama renderInvoicePdf(invoice, { businessName }) → um Buffer (mantém o pdfkit fora do core sem dependências).

Stores duráveis ​

As stores predefinidas são em memória e por processo — bom para um único nó ou um teste, errado para produção, onde os contadores de uso têm de ser atómicos entre processos. Dois pacotes drop-in fornecem stores duráveis.

SQLite (@basaltkit/subscriptions-sqlite) ​

Suportado pelo node:sqlite incorporado do Node. sqliteSubscriptionsStores abre (ou cria) a base de dados, aplica o schema e devolve as três stores com nomes prontos a encaixar diretamente no plugin.

ts
import { sqliteSubscriptionsStores } from '@basaltkit/subscriptions-sqlite'
import { subscriptionsPlugin } from '@basaltkit/subscriptions'
import { plans } from './billing/plans.js'

const s = sqliteSubscriptionsStores('./data/billing.db')

subscriptionsPlugin({
  plans,
  fallbackPlan: 'free',
  store: s.store,
  usage: s.usage,
  webhooks: s.webhooks,
})

Prisma (@basaltkit/subscriptions-prisma) ​

Para PostgreSQL, MySQL e afins. Traz um PrismaClient cujo schema inclua os modelos Subscription, UsageCounter e WebhookEvent. Um prisma/schema.prisma acompanha o pacote — copia-o, em vez de o reescreveres:

prisma
model Subscription {
  billableId        String    @id
  plan              String
  period            String
  status            String
  trialEndsAt       DateTime?
  cancelAtPeriodEnd Boolean?
  canceledAt        DateTime?
  gatewayRef        String?
  // O plano que um checkout PRETENDIA, guardado até a gateway confirmar o
  // pagamento. Escrito em TODAS as gravações — ver o aviso abaixo.
  pendingPlan       String?
  pendingPeriod     String?

  @@map("subscriptions")
}

Uma cópia desatualizada deste modelo parte todas as gravações

O store escreve o registo completo em cada gravação, incluindo pendingPlan e pendingPeriod. Um schema copiado antes dessas colunas existirem — chegaram com o endurecimento da intenção de checkout — falha em todas as gravações de subscrição com:

Invalid `prisma.subscription.upsert()` invocation:
  pendingPlan: null,
  ~~~~~~~~~~~
Unknown argument `pendingPlan`.

Nada consegue ficar ativo, por isso o sintoma visível é o guard a queixar-se — um 402 BILLING_SUBSCRIPTION_REQUIRED permanente — enquanto a falha real aconteceu antes, no Prisma. Se as subscrições nunca ativam, verifica este modelo primeiro.

Acrescenta as duas colunas e corre npx prisma db push (ou gera uma migração).

ts
import { prismaSubscriptionsStores } from '@basaltkit/subscriptions-prisma'
import { subscriptionsPlugin } from '@basaltkit/subscriptions'
import { PrismaClient } from '@prisma/client'
import { plans } from './billing/plans.js'

const prisma = new PrismaClient()
const s = prismaSubscriptionsStores(prisma)

subscriptionsPlugin({ plans, fallbackPlan: 'free', ...s })

O consume() medido é atómico: um updateMany condicional incrementa apenas enquanto value <= limit - amount se mantém, e o lock da linha serializa os chamadores concorrentes — por isso uma quota nunca é ultrapassada.

Dica: alternativa com Redis

Se já corres Redis, RedisUsageStore e RedisWebhookStore (de @basaltkit/subscriptions) dão as mesmas garantias: check-and-increment numa única ida e volta EVAL (Lua), e deduplicação durável via SET NX.

Trials ​

Para um plano pago com trial, uma gateway configurada cria a subscrição com o período de trial à partida e cobra no fim do trial, enviando o webhook que passa trialing → active (ou past_due numa cobrança falhada).

Sem gateway, os trials são locais: corre expireTrials() a partir do scheduler para os liquidar. Um plano gratuito gradua-se para active; um pago fica em past_due (não há forma de cobrar). Os trials suportados por gateway são deliberadamente deixados ao webhook da gateway — expireTrials() ignora-os.

ts
// ex. de @basaltkit/scheduler
const settled = await subscriptions.expireTrials()

Pagamentos angolanos / por referência (PaymentGateway) ​

Dica: guia completo

Isto é um resumo. Para a história completa — o modelo de dinheiro, o ledger idempotente, os hooks de ciclo de vida, as stores duráveis Prisma/SQLite, o hot-path de deduplicação com Redis, e a faturação recorrente — vê Pagamentos por referência e mobile-money.

O BillingGateway do Stripe modela subscrições com cartão em ficheiro. Os fornecedores angolanos funcionam de forma diferente: não há cartão guardado nem portal self-service. O cliente paga uma Referência num ATM, no Multicaixa Express, ou numa app de banco, usando a Entidade fixa da tua conta — e o fornecedor confirma por webhook. O Basalt modela isto com um contrato separado, PaymentGateway, cujo driver para o ProxyPay é enviado como @basaltkit/subscriptions-proxypay.

bash
pnpm add @basaltkit/subscriptions @basaltkit/subscriptions-proxypay

Uma PaymentGateway tem dois métodos: createPayment(request) reserva um pagamento e devolve uma PaymentInstruction (o que mostrar ao cliente), e verifyWebhook(rawBody, signature) traduz o callback do fornecedor num PaymentEvent (payment.succeeded / payment.failed) ou null para eventos sobre os quais não atuas.

Construir a gateway ​

ts
// src/billing/payments.ts
import { ProxyPayGateway } from '@basaltkit/subscriptions-proxypay'

export const payments = new ProxyPayGateway({
  apiKey: process.env.PROXYPAY_API_KEY!,        // enviado como `Authorization: Token <key>`
  entity: process.env.PROXYPAY_ENTITY!,         // a tua Entidade Multicaixa
  sandbox: process.env.NODE_ENV !== 'production', // api.sandbox.proxypay.co.ao
  // Opcional — predefine para apiKey, que é com o que o ProxyPay assina o callback
  webhookSecret: process.env.PROXYPAY_WEBHOOK_SECRET,
})
OpçãoTipoPredefiniçãoFinalidade
apiKeystring— (obrigatório)Enviado como Authorization: Token <apiKey>
entitystring— (obrigatório)A tua Entidade Multicaixa, atribuída pelo ProxyPay/EMIS
sandboxbooleanfalseUsa o host de sandbox api.sandbox.proxypay.co.ao
baseUrlstringderivado de sandboxSubstitui o URL base por completo
webhookSecretstringa tua apiKeyHMAC-SHA256 (hex) sobre o corpo raw, no x-signature. A verificação está ligada de origem e não pode ser desligada: um segredo vazio ou só com espaços faz o verifyWebhook lançar WebhookSecretMissingError
callbackUrlstring—Devolvido no webhook como custom_fields.callback_url. O destino real de entrega do ProxyPay define-se na conta, no dashboard
expiryDaysnumber30Janela de validade de recurso quando PaymentRequest.expiresAt é omitido — o ProxyPay exige uma data de fim, por isso é sempre enviada
fetchFetchLikefetch globalFetch injetado

Criar um pagamento e mostrar a referência ​

createPayment reserva uma referência e devolve a entidade + referência para pôr à frente do cliente. Os montantes são em AOA na unidade maior (5000 = 5.000,00 Kz).

ts
import { payments } from './billing/payments.js'

const instruction = await payments.createPayment({
  billableId: 'acme',              // ecoado de volta no webhook para reconciliação
  amount: 5000,                    // 5.000,00 Kz
  reference: 'invoice_2026_08',    // o teu id de encomenda/fatura
  expiresAt: Date.now() + 3 * 24 * 60 * 60 * 1000, // pagável durante 3 dias
})

// instruction.reference = { entity: '00123', reference: '900000001', amount: 5000 }
// Mostra: "Entidade 00123 · Referência 900000001 · 5.000,00 Kz"

Uma PaymentInstruction é { id, status: 'pending' | 'paid' | 'failed', reference?, url?, push?, raw? }. O ProxyPay é baseado em referências, por isso preenche reference ({ entity, reference, amount }); fornecedores baseados em redirect ou push preencheriam url ou push em vez disso.

Receber o webhook ​

Aponta o webhook payment do ProxyPay para uma rota, passa o corpo bruto a verifyWebhook, e atua sobre payment.succeeded. Esta é uma rota tua, não o billingWebhookRoute (esse é para drivers BillingGateway de cartão) — declara-a com rawBody() e os bytes chegam intactos em qualquer adaptador.

ts
import { rawBody, route } from '@basaltkit/http'
import { payments } from './billing/payments.js'
import { subscriptions } from './billing/subscriptions.js'

route({
  method: 'POST',
  url: '/webhooks/proxypay',
  body: rawBody({ maxBytes: 64 * 1024 }),
  async handler({ body, request, reply }) {
    // body.text() — os bytes exatos, descodificados como UTF-8. A assinatura é
    // sobre estes; um objeto reserializado seria uma mensagem diferente.
    const event = payments.verifyWebhook(
      body.text(),
      request.headers['x-signature'] as string | undefined,
    )

    if (event?.type === 'payment.succeeded') {
      // event = { id, type, paymentId, amount, billableId?, reference?, raw? }
      await subscriptions.subscribe(event.billableId!, 'pro', { period: 'monthly' })
    }

    return reply.code(200).send()
  },
})

verifyWebhook lança WebhookInvalidError (HTTP 400) numa assinatura má e devolve null para eventos verificados que não são pagamentos. Deduplica em event.id se quiseres idempotência entre retentativas.

O corpo bruto já está tratado

Tal como no Stripe, a verificação da assinatura corre sobre os bytes intactos do pedido — que é o que o rawBody() entrega, no Fastify, no Express e no Hono, sem nada para configurar. Um fastify.post escrito à mão precisaria do seu próprio parser de corpo bruto e saltaria os enrichers, os guards e o rate limiting da Basalt.

Dica: recorrente = uma referência por período

O ProxyPay não tem cobrança recorrente com cartão em ficheiro. Modela a faturação recorrente emitindo uma referência por período: quando um período termina (ou em cada fatura), chama createPayment de novo para o período seguinte, e ativa esse período apenas quando o seu webhook payment.succeeded chegar. Não há fallback de polling com getPayment no driver do ProxyPay — confia no webhook.

Para desenvolvimento e testes, o FakePaymentGateway (de @basaltkit/subscriptions) implementa o mesmo contrato em processo: regista os pedidos em payments e o seu verifyWebhook devolve um payment.succeeded sintético.

Referência de opções ​

subscriptionsPlugin(options) ​

As mesmas opções de new Subscriptions(...) menos hooks (o plugin passa o HookBus da app), mais invoices.

OpçãoTipoPredefiniçãoFinalidade
plansPlans— (obrigatório)O catálogo vindo de definePlans ou de loadPlans(store). É consumido de forma síncrona, por isso tem de estar totalmente resolvido antes do arranque
fallbackPlanstring—Plano aplicado a billables sem subscrição (habitualmente 'free'). Validado na construção — um nome desconhecido lança UnknownPlanError de imediato
gatewayBillingGateway—Driver de subscrições com cartão (Stripe, Paddle, Lemon Squeezy). Sem ele tudo continua a funcionar localmente; só checkout/portal o exigem
storeSubscriptionStoreem memóriaOnde vivem as subscrições — troca por subscriptions-sqlite/-prisma ou desaparecem no restart
usageUsageStoreem memóriaContadores de medição. O predefinido é por processo, por isso uma quota pode ser ultrapassada entre réplicas; usa SQLite/Prisma/Redis para um consume atómico
webhooksWebhookStoreem memóriaDeduplicação de eventos da gateway por id. Por processo por predefinição, o que significa que um retry que caia noutra réplica é reprocessado
now() => numberDate.nowRelógio injetável (trials, períodos dos medidores, cancelamentos) — testes e simulações
invoicesInvoicesOptions{}Configuração do motor Invoices registado sob INVOICES (abaixo)

billingRoutes(options) ​

OpçãoTipoPredefiniçãoFinalidade
successUrlstring— (obrigatório)Para onde a gateway devolve o cliente depois do Checkout. O corpo do pedido pode sobrepor-se por chamada
cancelUrlstring— (obrigatório)Para onde volta um Checkout abandonado
portalReturnUrlstringsuccessUrlPara onde volta o Portal do Cliente
authbooleantrueExige meta: { auth: true } em ambas as rotas. Põe false só quando a autenticação acontece numa borda exterior — estas rotas criam URLs de gestão de pagamento reais

POST /billing/checkout recebe { plan, period?, successUrl?, cancelUrl? } e POST /billing/portal recebe um { returnUrl } opcional; ambos devolvem { url }.

invoiceRoutes(options) ​

OpçãoTipoPredefiniçãoFinalidade
authbooleantrueA mesma regra de billingRoutes — as faturas são o histórico de pagamentos do tenant

As três rotas são só de leitura e verificam a propriedade: uma fatura cujo billableId não seja o tenant atual lê-se como 404 INVOICE_NOT_FOUND, nunca como os dados de outra pessoa. Emitir e finalizar ficam do lado do servidor, através de INVOICES.

billingWebhookRoute(gateway, options?) ​

Recebe a instância da gateway e { maxBytes, signatureHeader } opcionais. Deliberadamente não é protegida por meta.auth: a assinatura da gateway é a autenticação. A assinatura é lida do header que o driver declara em signatureHeader — stripe-signature (Stripe), paddle-signature (Paddle), x-signature (Lemon Squeezy); um driver próprio que não declare nenhum recorre a stripe-signature / x-billing-signature, e options.signatureHeader sobrepõe-se a ambos. Devolve 200 { received: true, ignored: true } para um evento que o driver não mapeia, e 200 { received: true, duplicate: true } para um já processado.

Invoices — InvoicesOptions ​

OpçãoTipoPredefiniçãoFinalidade
storeInvoiceStoreMemoryInvoiceStoreFaturas duráveis; é também dono de nextNumber(), que tem de alocar de forma atómica
numberPrefixstring'INV'Prefixo do número humano da fatura — INV-2026-0001
taxRatenumber0Aplicado a (subtotal − desconto) quando um rascunho omite tax. 0.14 = 14%
now() => numberDate.nowRelógio injetável (testes, retroatividade)
idFactory() => stringrandomUUIDGerador de ids injetável

Coupons — CouponsOptions ​

OpçãoTipoPredefiniçãoFinalidade
storeCouponStoreMemoryCouponStoreRegisto durável de cupões; incrementRedemptions(code, limit) só pode incrementar enquanto a contagem estiver abaixo de limit, de forma atómica, e devolver null no limite — ou maxRedemptions escapa
now() => numberDate.nowRelógio injetável, para o redeemBy

StripeBillingGateway(options) ​

OpçãoTipoPredefiniçãoFinalidade
secretKeystring— (obrigatório)Chave secreta da API do Stripe
webhookSecretstring— (obrigatório)Segredo de assinatura do endpoint (whsec_…). Sem ele a verificação falha fechada
priceId(plan, period) => string— (obrigatório)Mapeia um plano + período para um Price ID do Stripe
customerId(billableId) => string | Promise<string>— (obrigatório)Resolve (ou cria) o Customer do Stripe para um billable
resolveBillableId(event) => string | undefinedlê data.object.metadata.billableIdSobrepõe quando os teus eventos levam o id noutro sítio
tolerancenumber (segundos)300Tolerância do timestamp do webhook — janela de replay
fetchtypeof fetchfetch globalCliente HTTP injetado (testes)
now() => numberDate.nowRelógio injetável em ms
apiBasestringhttps://api.stripe.comBase da API, para mocks

PaddleBillingGateway(options) ​

OpçãoTipoPredefiniçãoFinalidade
apiKeystring— (obrigatório)Chave da API do Paddle (Bearer)
webhookSecretstring— (obrigatório)Segredo de assinatura das notificações; verifica o esquema Paddle-Signature (ts=…;h1=…)
priceId(plan, period) => string— (obrigatório)Mapeia um plano + período para um Price ID do Paddle (pri_…)
customerId(billableId) => string | Promise<string>— (obrigatório)Customer ID do Paddle (ctm_…)
resolveBillableId(event) => string | undefinedlê data.custom_data.billableIdSobrepõe para eventos que levem o id noutro sítio
tolerancenumber (segundos)300Tolerância do timestamp do webhook
fetch / now / apiBase—fetch global / Date.now / https://api.paddle.comPontos de injeção para testes

O Paddle é checkout-first: o createCheckoutSession cria uma transação, e o id durável da subscrição chega mais tarde nos webhooks como gatewayRef. Não existe "criar uma subscrição paga" do lado do servidor, por isso o createSubscription lança CheckoutRequiredError — usa o checkout(). O resumeSubscription remove o cancelamento agendado. Durante uma rotação de segredo o Paddle envia um h1 por segredo ativo; todos são experimentados (tal como o v1 do Stripe).

LemonSqueezyBillingGateway(options) ​

OpçãoTipoPredefiniçãoFinalidade
apiKeystring— (obrigatório)Chave da API do Lemon Squeezy (Bearer)
webhookSecretstring— (obrigatório)Verifica o header X-Signature (HMAC-SHA256 hex sobre o corpo raw)
storeIdstring— (obrigatório)O id da tua loja — necessário para criar checkouts
variantId(plan, period) => string— (obrigatório)Mapeia um plano + período para um Variant ID
customerId(billableId) => string | Promise<string>—Só obrigatório para o portal; sem ele o portal() não tem nada para abrir
resolveBillableId(event) => string | undefinedlê meta.custom_data.billableIdSobrepõe para eventos que levem o id noutro sítio
maxEventAgeSecondsnumberdesligadoJanela de replay opcional: rejeita um evento cujo updated_at (ou created_at) seja mais antigo, ou esteja ausente. Deixa desligado se reenvias eventos antigos a partir do dashboard
fetch / now / apiBase—fetch global / Date.now / https://api.lemonsqueezy.com/v1Pontos de injeção para testes

O Lemon Squeezy é merchant of record e checkout-first, com a mesma forma de "o id da subscrição chega por webhook" do Paddle (o createSubscription lança CheckoutRequiredError). O esquema X-Signature não leva timestamp, por isso uma entrega capturada continua válida e só o store de deduplicação de webhooks trava um replay — o maxEventAgeSeconds acrescenta uma janela. Os seus eventos também não têm id: o driver baseia a idempotência no nome do evento, no id do objeto (a fatura nos eventos de pagamento, por isso cada renovação é distinta) e no seu updated_at.

ProxyPayGateway(options) / AppyPayGateway(options) ​

Estes implementam o contrato PaymentGateway por referência, não o BillingGateway. As opções do ProxyPay estão tabeladas em Construir a gateway, acima; o AppyPay (@basaltkit/subscriptions-appypay, pré-lançamento) acrescenta OAuth2 (clientId, clientSecret, tokenUrl, scope?) e defaultMethod. A história completa está em Pagamentos por referência e mobile money.

Fábricas de stores duráveis ​

FábricaPacoteDevolve
sqliteSubscriptionsStores(dbOrLocation = ':memory:')@basaltkit/subscriptions-sqlite{ db, store, usage, webhooks } — abre e migra
prismaSubscriptionsStores(client)@basaltkit/subscriptions-prisma{ store, usage, webhooks } — lança de imediato se o cliente não tiver o modelo subscription
sqlitePaymentStores(...) / prismaPaymentStores(client)os mesmos pacotesO PaymentStore + RecurringStore para pagamentos por referência
renderInvoicePdf(invoice, { locale?, businessName? })@basaltkit/subscriptions-pdfPromise<Buffer> — mantém o pdfkit fora do core

Modos de falha e resolução de problemas ​

ErroCódigoHTTPQuando
NotSubscribedErrorBILLING_SUBSCRIPTION_REQUIRED402meta.subscribed não satisfeito; ou sem tenant no contexto numa rota de faturação/faturas; ou swap/cancel/resume sem subscrição ativa
FeatureUnavailableErrorBILLING_FEATURE_UNAVAILABLE403meta.feature não concedido; ou consume() numa funcionalidade cujo limite é 0 (ou sem plano e sem fallbackPlan)
QuotaExceededErrorBILLING_QUOTA_EXCEEDED402O consume() levaria o uso para além do limite — a verificação atómica do store recusou
InvalidUsageAmountErrorBILLING_INVALID_USAGE_AMOUNT400consume()/um UsageStore recebeu uma quantidade que não é um inteiro positivo (negativa, zero, fracionária, NaN)
PaymentRequiredErrorBILLING_PAYMENT_REQUIRED402swap() para um plano pago ou 'custom' a partir de uma subscrição sem subscrição na gateway — usa o checkout(), ou { allowUnpaid: true } quando o pagamento é cobrado noutro lado
GatewayUnsupportedErrorBILLING_GATEWAY_UNSUPPORTED501checkout() ou portal() sem gateway, ou swap()/resume() de uma subscrição suportada por uma gateway sem swapSubscription/resumeSubscription
CheckoutRequiredErrorBILLING_CHECKOUT_REQUIRED501subscribe() a um plano pago numa gateway checkout-first (Paddle, Lemon Squeezy) — usa o checkout()
UnknownPlanErrorBILLING_UNKNOWN_PLAN—Um nome de plano ausente do catálogo — incluindo um fallbackPlan mal escrito, que lança na construção
WebhookInvalidErrorBILLING_WEBHOOK_INVALID400A verificação da assinatura falhou — quase sempre um corpo já processado (reserializado)
WebhookSecretMissingErrorBILLING_WEBHOOK_SECRET_MISSING500verifyWebhook sem segredo de assinatura configurado. Falha fechada: um callback sem assinatura nunca é confiável
PaymentAmountMismatchErrorBILLING_PAYMENT_AMOUNT_MISMATCH400O montante de um pagamento confirmado ≠ o montante pedido para esse id — pagamento a menos ou callback forjado
StripeRequestError · PaddleRequestError · LemonSqueezyRequestErrorBILLING_GATEWAY_ERROR—A API REST da gateway devolveu um não-2xx; o estado original está em err.httpStatus
InvoiceNotFoundErrorINVOICE_NOT_FOUND404Id de fatura desconhecido — ou um que pertence a outro tenant, via invoiceRoutes
InvoiceStateErrorINVOICE_INVALID_STATE409finalize de algo que não é rascunho, markPaid de algo que não está aberto, void de uma paga, addLine numa finalizada — ou planLine() sobre um preço 'custom'
InvoiceInputErrorINVOICE_INVALID_INPUT400Uma quantity de linha que não é um inteiro positivo, tax/discount/taxa de imposto negativos ou NaN, ou uma currency que não é um código ISO 4217 de 3 letras
CouponInvalidErrorCOUPON_INVALID422Forma inválida: os dois/nenhum de percentOff/amountOff, percentagem fora de 0–100 (ou NaN), amountOff sem moeda, maxRedemptions que não é um inteiro ≥ 1
CouponNotRedeemableErrorCOUPON_NOT_REDEEMABLE422Expirado (redeemBy), limite de resgates atingido, ou a moeda da fatura difere da de um cupão de montante fixo
CouponNotFoundErrorCOUPON_NOT_FOUND404Não existe cupão com esse código
UnguardedRouteMetaErrorHTTP_UNGUARDED_ROUTE_METAarranquebillingRoutes()/invoiceRoutes() registados com o auth: true predefinido mas sem authPlugin
  • Uma subscrição que nunca fica ativa — percorre esta lista, é quase sempre uma de três coisas. (1) Chamaste checkout() e nenhum webhook o confirmou, por isso o registo ainda está incomplete — vê Duas portas. (2) O teu modelo Subscription do Prisma não tem pendingPlan / pendingPeriod, por isso todas as gravações rebentam e nada é guardado. (3) O pedido não traz tenant, e o guard está a perguntar por um billable que não existe. Descobre qual lendo o registo diretamente: await subscriptions.get(tenantId) — null significa que nunca se escreveu nada, incomplete significa que falta o webhook.
  • 400 BILLING_WEBHOOK_INVALID que não desaparece — a assinatura é calculada sobre os bytes intocados do pedido, e o billingWebhookRoute() passou a recebê-los em todos os adaptadores, por isso um corpo reserializado já não é causa possível. Verifica o webhookSecret e o desvio de relógio (tolerância de 5 minutos). No Express com um express.json() trazido pela aplicação, acrescenta verify: captureRawBody; sem isso a rota responde 500 RAW_BODY_UNAVAILABLE em vez de adivinhar.
  • Todos os pedidos recebem 402 BILLING_SUBSCRIPTION_REQUIRED, mesmo no plano gratuito — o guard resolve o billable a partir de ctx().tenant.id. Sem plugin de tenancy, ou sem identificador de tenant no pedido, não há billable. Verifica primeiro a tenancy; depois verifica se fallbackPlan está definido.
  • Uma quota foi ultrapassada sob carga — o UsageStore em memória é por processo. Só os stores SQLite, Prisma e Redis fazem um check-and-increment realmente atómico (updateMany condicional / um único EVAL em Lua).
  • Um webhook foi aplicado duas vezes depois de um retry da gateway — o WebhookStore de deduplicação é em memória por predefinição, por isso um retry que caia noutra réplica parece novo. Usa o RedisWebhookStore ou o de SQLite/Prisma. (Dentro de um processo é exato: uma escrita de estado falhada liberta a reserva para o retry poder reprocessar.)
  • Um utilizador do tenant A abriu o Checkout do tenant B — meta.auth prova identidade, não pertença. Regista o tenantMembershipPlugin().
  • 501 BILLING_GATEWAY_UNSUPPORTED a partir de /billing/portal — o driver não implementa createPortalSession para a tua configuração; o Lemon Squeezy, por exemplo, precisa de customerId para abrir um.
  • Um Checkout abandonado não mudou nada, como esperado — o checkout() só regista pendingPlan/pendingPeriod. O plano só é promovido quando chega um payment.succeeded com um gatewayRef novo, por isso uma renovação da subscrição existente nunca consegue escalar o plano.
  • Os trials nunca expiram — os trials suportados pela gateway são liquidados pelo webhook da gateway e são deliberadamente ignorados pelo expireTrials(). Os trials locais (sem gateway) precisam de expireTrials() a correr a partir do scheduler.

Hooks de domínio ​

O plugin emite hooks no HookBus da app — billing:subscribed, billing:checkout_started, billing:swapped, billing:canceled, billing:trial_expired, billing:webhook. Subscreve para enviar emails ou notificações:

ts
app.hooks.on('billing:trial_expired', ({ subscription }) => {
  // mailer.send(...) / notifier.notify(...)
})

Publicado sob a licença MIT.