Billing para SaaS B2B com Planos Custom: Padrões que Funcionam em Produção
TL;DR
- Não existe um padrão único: planos custom em B2B exigem decisão consciente entre facturação por uso, modelos híbridos, ou acerto mensal. Escolha muda custo operacional 3x.
- Stripe Billing + Webhooks é a base, mas precisas sempre de uma camada de abstração própria. APIs de terceiros deixam-te preso; uma interface normalizada resolve.
- Planos custom sem segregação clara (per-tenant usage tracking) viram um pesadelo: auditoria impossível, disputas sobre consumo, código spaghetti. Multi-tenancy correcta é prerequisito.
- Reconciliação mensal manual é normal em early stage, mas automatiza desde dia 1: SQL + alerts salvam horas e evitam churn por billing surpresa.
- Metadata estruturada nas invoices (unit breakdowns, rates aplicadas) paga-se sozinha quando clientes grandes questionam facturas ou precisas de relatórios de uso.
A Realidade de Billing em B2B Complexo
Quando começámos a estruturar billing para SaaS B2B com planos custom, a primeira coisa que percebemos é que não é um problema técnico: é um problema de negócio. A maioria das frameworks de billing (Stripe, Paddle, Supabase Auth) resolvem o caso standard: price tiers fixos, renovação mensal, churn. Isto não é esse caso.
Em B2B custom, o que acontece é isto: cliente A paga €50/mês por 10.000 API calls, com SLA de 99.9% e suporte por email. Cliente B paga €200/mês pelos mesmos 10.000 calls, mas com suporte 24/7, integração custom e relatórios trimesttrais dedicados. Cliente C não quer calls: quer por-utilizador, com limite de storage.
Isto não é preço, é comercial. E a engenharia tem que conseguir executar rapidez, auditoria, e sem erros.
1. Padrões de Billing Core: Qual Escolher?
Existem três padrões principais que vimos funcionar. Nenhum é melhor: dependem do teu custo marginal, retenção de clientes, e capacidade de suporte.
Modelo A: Facturação Fixa por Período
Cada cliente tem um plano negociado que se renova mensalmente (ou trimestral, anual). Sem surpresas, previsível. Ideal para clientes que querem orçamentar.
Prós: fácil reconciliar, cliente sabe exactamente quanto paga, previsão de MRR exacta, churn por billing surpresa = zero.
Contras: não captas upside de crescimento dentro do período, suporte operacional é alto (mudanças de plano geram invoices ajustadas), difícil justificar aumento de preço se uso cresce 10x.
Modelo B: Facturação por Uso Puro
Mede tudo (API calls, storage, users, compute time), cobra unit price fixo. Scalable infinitamente.
Prós: cliente paga exactamente pelo que usa, tu captures upside, alinha incentivos (eficiência do cliente = lucro dele também).
Contras: impredizível para o cliente (budget blowout é real), churn maior quando faturas surpreendem, reconciliação é complexa, precisas de event tracking 100% fiável.
Modelo C: Híbrido (Recomendado para B2B custom)
Base fixa (exemplo: €100/mês) cobre um quota (exemplo: 50.000 API calls). Acima disso, cobra-se overages a unit price (€0.001 por call). Alguns recursos podem ser flatline (exemplo: suporte), outros metered.
Prós: cliente previsível na base, tu captures upside, psicologicamente mais fácil de vender (soa a "plano com possibilidade de crescimento"), permite tiers.
Contras: reconciliação mais complexa, precisa auditoria de usage real vs reported, mais linhas em invoice = mais questões de clientes.
A nossa experiência: 70% das negociações B2B custom terminam em híbrido. É o meio-termo que reduz churn e suporte.
2. Arquitectura: A Camada de Abstração que Precisas
Stripe Billing é robusto, mas não é agnóstico. Paddle, Supabase Auth extensions, ou custom solutions têm constraints diferentes. Se amarras a arquitectura directamente a Stripe, quando mudares (e vais mudar), redesenhas tudo.
A solução: uma camada de normalização própria.
// Exemplo de abstração de billing provider
// Isto permite trocar Stripe por Paddle sem reescrever controllers
interface BillingProvider {
createSubscription(params: {
customerId: string;
planId: string;
metadata: Record<string, string>;
trialDays?: number;
}): Promise<{
subscriptionId: string;
nextBillingDate: Date;
status: 'active' | 'trialing' | 'past_due';
}>;
recordUsage(params: {
subscriptionId: string;
meterId: string;
quantity: number;
timestamp: Date;
}): Promise<{ meterEventId: string }>;
getInvoice(invoiceId: string): Promise<{
id: string;
customerId: string;
total: number;
lineItems: {
description: string;
quantity: number;
unitPrice: number;
metadata: Record<string, unknown>;
}[];
paidAt?: Date;
status: 'draft' | 'sent' | 'paid' | 'failed';
}>;
updateSubscription(params: {
subscriptionId: string;
planId?: string;
metadata?: Record<string, string>;
}): Promise<{ nextBillingDate: Date }>;
}
// Implementação Stripe
class StripeProvider implements BillingProvider {
private stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-04-10',
});
async createSubscription(params) {
const subscription = await this.stripe.subscriptions.create({
customer: params.customerId,
items: [{ price: params.planId }],
metadata: params.metadata,
trial_period_days: params.trialDays,
payment_settings: {
payment_method_types: ['card'],
save_default_payment_method: 'on_subscription',
},
});
return {
subscriptionId: subscription.id,
nextBillingDate: new Date(subscription.current_period_end * 1000),
status: subscription.status as any,
};
}
async recordUsage(params) {
const event = await this.stripe.billing.meterEvents.create({
meter_event: {
meter: params.meterId,
timestamp: Math.floor(params.timestamp.getTime() / 1000),
value: params.quantity.toString(),
identifier: params.subscriptionId,
},
});
return { meterEventId: event.id };
}
async getInvoice(invoiceId) {
const invoice = await this.stripe.invoices.retrieve(invoiceId, {
expand: ['lines'],
});
return {
id: invoice.id,
customerId: invoice.customer as string,
total: invoice.total / 100,
lineItems: (invoice.lines.data || []).map((line) => ({
description: line.description || '',
quantity: line.quantity || 1,
unitPrice: (line.unit_amount || 0) / 100,
metadata: (line.metadata as Record<string, unknown>) || {},
})),
paidAt: invoice.paid ? new Date(invoice.paid_at! * 1000) : undefined,
status: invoice.status as any,
};
}
async updateSubscription(params) {
const subscription = await this.stripe.subscriptions.update(
params.subscriptionId,
{
items: params.planId ? [{ id: params.subscriptionId, price: params.planId }] : undefined,
metadata: params.metadata,
}
);
return {
nextBillingDate: new Date(subscription.current_period_end * 1000),
};
}
}
Por que isto importa: quando Stripe muda a API (aconteceu várias vezes em 2024), muda uma implementação, não a whole codebase. Quando cliente grande negocia plano super custom que Stripe Billing não suporta nativamente, consegues preencher.
3. Multi-Tenancy: Sem Isto, Não Há Auditoria
Isto é o ponto onde vemos código realmente frágil: usage tracking sem tenancy clear.
Imagina: dois clientes (Tenant A, Tenant B) dividem infraestrutura. Um API call acontece. Como sabes a qual tenant atribuir esse usage? Se é ambíguo, a reconciliação é impossível.
A estrutura que funciona:
-- PostgreSQL 16, estrutura core
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_name TEXT NOT NULL,
billing_email TEXT NOT NULL,
plan_type TEXT NOT NULL, -- 'fixed', 'usage', 'hybrid'
monthly_base_amount DECIMAL(10, 2),
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE subscription_periods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
status TEXT NOT NULL, -- 'active', 'invoiced', 'paid'
base_amount DECIMAL(10, 2),
overage_total DECIMAL(10, 2) DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (customer_id, period_start),
CHECK (period_end > period_start)
);
CREATE TABLE usage_events (
id BIGSERIAL PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
subscription_period_id UUID NOT NULL REFERENCES subscription_periods(id) ON DELETE CASCADE,
meter_type TEXT NOT NULL, -- 'api_calls', 'storage_gb', 'users', etc
quantity DECIMAL(10, 4) NOT NULL,
unit_price DECIMAL(10, 4) NOT NULL,
recorded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
external_event_id TEXT, -- idempotency: Stripe event ID, webhook ID
metadata JSONB,
INDEX idx_customer_meter (customer_id, meter_type, recorded_at)
);
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
subscription_period_id UUID NOT NULL REFERENCES subscription_periods(id) ON DELETE CASCADE,
invoice_number TEXT NOT NULL UNIQUE,
total_amount DECIMAL(10, 2) NOT NULL,
paid_at TIMESTAMP WITH TIME ZONE,
stripe_invoice_id TEXT UNIQUE,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE invoice_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity DECIMAL(10, 4),
unit_price DECIMAL(10, 4),
total_amount DECIMAL(10, 2) NOT NULL,
breakdown JSONB, -- detalhe: { meter_type, unit_count, rate }
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- View para reconciliação diária
CREATE VIEW daily_usage_summary AS
SELECT
customer_id,
subscription_periods.id as period_id,
meter_type,
SUM(quantity) as total_qty,
MIN(unit_price) as current_rate,
SUM(quantity * unit_price) as total_value,
COUNT(*) as event_count,
DATE(usage_events.recorded_at) as event_date
FROM usage_events
JOIN subscription_periods ON subscription_periods.id = usage_events.subscription_period_id
GROUP BY customer_id, subscription_periods.id, meter_type, DATE(usage_events.recorded_at)
ORDER BY event_date DESC, customer_id;
Por que isto funciona: cada usage event está ancorado a customer_id E subscription_period_id. Não há ambiguidade. Quando vem um webhook de Stripe (ou Paddle), a idempotência é garantida por external_event_id. Reconciliação é uma query:
-- Detectar discrepâncias: usage que não facturamos
SELECT
de.customer_id,
de.meter_type,
SUM(de.quantity) as total_usage,
COALESCE(SUM(ili.quantity), 0) as invoiced_qty,
SUM(de.quantity), COALESCE(SUM(ili.quantity), 0) as delta
FROM usage_events de
LEFT JOIN invoice_line_items ili
ON ili.invoice_id IN (
SELECT id FROM invoices
WHERE customer_id = de.customer_id
AND subscription_period_id = de.subscription_period_id
)
AND de.meter_type = ili.description
WHERE de.subscription_period_id = $1
GROUP BY de.customer_id, de.meter_type
HAVING SUM(de.quantity) > COALESCE(SUM(ili.quantity), 0);
Isto detecta se tens usage gravado que não entrou em invoice. Rodas isto todo o dia às 5am, alertas se delta > 0.
4. Webhook Handling e Idempotência: Onde Erros Custam Dinheiro
Stripe envia webhooks para eventos de billing: subscription criada, invoice gerada, pagamento recebido. Se não tratares com cuidado, podes processar o mesmo webhook 2x, duplicar charges, ou perder dados.
Regra: sempre guardar external_event_id e fazer upsert, não insert.
// Handler Stripe webhook em Next.js API Route
import { Webhook } from 'svix'; // ou raw crypto verification
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error('Webhook signature verification failed');
return new Response('Webhook error', { status: 400 });
}
// Idempotência: store event ID, check se já processámos
const eventId = event.id;
const existingRecord = await db.query(
'SELECT id FROM webhook_events WHERE external_id = $1',
[eventId]
);
if (existingRecord.rows.length > 0) {
console.log(`Event ${eventId} already processed, skipping`);
return new Response('OK', { status: 200 });
}
try {
switch (event.type) {
case 'invoice.payment_succeeded': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Upsert subscription_period como paid
await db.query(
`
UPDATE subscription_periods
SET status = $1, updated_at = NOW()
WHERE customer_id = (
SELECT id FROM customers WHERE stripe_customer_id = $2
)
AND period_end = $3
`,
['paid', customerId, new Date(invoice.period_end * 1000)]
);
// Log event
await db.query(
`
INSERT INTO webhook_events (external_id, event_type, processed_at)
VALUES ($1, $2, NOW())
`,
[eventId, event.type]
);
break;
}
case 'billing_portal.session.created': {
// Handle portal session
break;
}
// ... outros eventos
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
} catch (error) {
console.error('Webhook processing error:', error);
// Não retorna 200: deixa Stripe tentar retry
return new Response('Internal error', { status: 500 });
}
}
Gotcha real: Stripe retenta webhooks por 3 dias se receber 5xx. Se teu endpoint está slow (> 30s) ou fora, vai processar atrasado. Logs não aparecem imediatamente. Solução: guarda o evento raw em fila (Redis, Bull, Trigger.dev), processa async, retorna 200 imediatamente.
5. Metadata Estruturada: Invoice que Explica Tudo
Cliente grande questiona uma fatura de €450. "Por quê? No mês passado era €300."
Se a invoice só tem uma linha "Pro Plan", não consegues responder. Se tem breakdown estruturado, é 30 segundos:
{
"lineItem": {
"description": "API Calls (Metered)",
"quantity": 1250000,
"unitPrice": 0.0001,
"metadata": {
"meter_type": "api_calls",
"period": "2025-01-01 to 2025-01-31",
"threshold": "50000 calls included in base plan",
"overage_count": 1200000,
"overage_rate": 0.0001,
"calculation": "1200000 * 0.0001 = €120"
}
}
}
Quando crias invoice em Stripe, passa isto em line_item.metadata:
const lineItem = {
description: 'API Calls (Overage)',
quantity: overageCount,
unit_amount_decimal: Math.round(unitPrice * 100 * 100).toString(), // cents, subunits
metadata: {
meter_type: 'api_calls',
included_in_plan: '50000',
overage_calculation: `${overageCount} calls × €${unitPrice} = €${total}`,
period_start: periodStart.toISOString(),
period_end: periodEnd.toISOString(),
},
};
Isto aparece em Stripe Dashboard e em PDFs de invoice. Cliente consegue debugar sozinho, suporte reduz 40%.
6. Reconciliação Automatizada: O Que Pode Dar Errado
Duas bases de verdade: a tua DB e a do Stripe. Se não reconciliarem diariamente, descobres o problema quando cliente cancela por billing surpresa (€2k overage que não esperava).
Script SQL que roda todo o dia às 6am:
// Cron job: npm run billing:reconcile
import cron from 'node-cron';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
export async function reconcileDailyBilling() {
console.log('[Billing] Starting daily reconciliation...');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate(), 1); // Yesterday's data
// Step 1: Fetch all subscriptions due this period
const duePeriods = await db.query(
`
SELECT sp.id, sp.customer_id, c.stripe_customer_id, sp.period_end, sp.base_amount
FROM subscription_periods sp
JOIN customers c ON c.id = sp.customer_id
WHERE sp.period_end::DATE <= $1
AND sp.status != 'invoiced'
AND sp.status != 'paid'
`,
[cutoffDate]
);
for (const period of duePeriods.rows) {
// Step 2: Sum usage for this period
const usageResult = await db.query(
`
SELECT
meter_type,
SUM(quantity) as total,
unit_price
FROM usage_events
WHERE subscription_period_id = $1
GROUP BY meter_type, unit_price
`,
[period.id]
);
let overageTotal = 0;
const lineItems = [];
// Base amount
if (period.base_amount > 0) {
lineItems.push({
description: `Base Plan (${period.period_start} to ${period.period_end})`,
amount: Math.round(period.base_amount * 100),
});
}
// Overages
for (const usage of usageResult.rows) {
const totalCharge = usage.total * usage.unit_price;
overageTotal += totalCharge;
lineItems.push({
description: `${usage.meter_type} (${usage.total} units)`,
amount: Math.round(totalCharge * 100),
});
}
// Step 3: Check if invoice already exists
const existingInvoice = await db.query(
`SELECT stripe_invoice_id FROM invoices WHERE subscription_period_id = $1`,
[period.id]
);
if (existingInvoice.rows.length > 0 && existingInvoice.rows[0].stripe_invoice_id) {
console.log(`Invoice already created for period ${period.id}, skipping`);
continue;
}
// Step 4: Create invoice in Stripe
try {
const stripeInvoice = await stripe.invoices.create({
customer: period.stripe_customer_id,
lines: lineItems.map((item) => ({
description: item.description,
amount: item.amount,
})),
collection_method: 'send_invoice',
days_until_due: 14,
metadata: {
subscription_period_id: period.id,
customer_id: period.customer_id,
},
});
// Step 5: Update local DB
await db.query(
`
UPDATE subscription_periods
SET status = 'invoiced', updated_at = NOW()
WHERE id = $1
`,
[period.id]
);
await db.query(
`
INSERT INTO invoices (
customer_id, subscription_period_id, invoice_number,
total_amount, stripe_invoice_id
)
VALUES ($1, $2, $3, $4, $5)
`,
[
period.customer_id,
period.id,
stripeInvoice.number,
stripeInvoice.total / 100,
stripeInvoice.id,
]
);
console.log(`Created invoice ${stripeInvoice.id} for period ${period.id}`);
} catch (error) {
console.error(`Failed to create invoice for period ${period.id}:`, error);
// Alert ops team
await sendAlert({
channel: 'ops',
message: `Billing reconciliation failed for period ${period.id}: ${error.message}`,
severity: 'high',
});
}
}
console.log('[Billing] Reconciliation complete');
}
// Schedule: 06:00 UTC
cron.schedule('0 6 * * *', reconcileDailyBilling);
Este job detecta períodos não facturados, agrega usage, cria invoices, atualiza estado. Falhas são alertadas. Sem isto, clientes "esquecidos" recebem invoice 3 meses depois (toxicidade máxima).
7. Planos Custom: Quando o Padrão Não Chega
Às vezes, um cliente negocia algo tão custom que nenhum provider standard o suporta. Exemplo: "€3k base, mas se revenue deles cresce > 20% face ao ano anterior, desconto de 10% no overage". Ou: "factura em moeda deles (COP), com câmbio dia 5 do mês anterior".
Solução: override manual com auditoria.
// Custom override para plano específico
interface CustomBillingOverride {
subscriptionId: string;
customerId: string;
description: string; // ex: "Q1 2025 discount, large volume negotiation"
adjustmentType: 'fixed_discount' | 'percentage_discount' | 'custom_calculation';
value: number;
applicablePeriods: {
start: Date;
end: Date;
};
approvedBy: string; // email de quem negociou
notes: string;
}
// Guardar em DB
CREATE TABLE custom_billing_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
override_type TEXT NOT NULL,
description TEXT NOT NULL,
adjustment_value DECIMAL(10, 2),
adjustment_percentage DECIMAL(5, 2),
period_start DATE,
period_end DATE,
approved_by TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
notes TEXT
);
// Quando calculas invoice, checa overrides
async function calculateInvoiceTotal(customerId: string, period: DateRange) {
let baseAmount = await getBaseAmount(customerId);
let overages = await getOverageAmount(customerId, period);
let total = baseAmount + overages;
const overrides = await db.query(
`
SELECT * FROM custom_billing_overrides
WHERE customer_id = $1
AND period_start <= $2
AND period_end >= $3
`,
[customerId, period.end, period.start]
);
for (const override of overrides.rows) {
if (override.adjustment_type === 'fixed_discount') {
total -= override.adjustment_value;
} else if (override.adjustment_type === 'percentage_discount') {
total *= 1, override.adjustment_percentage / 100;
}
}
return total;
}
Key: sempre auditável. Quem negociou, quando, por quanto, por quê. Sem isto, contabilidade questiona.
Conclusão
Billing em B2B custom é 20% técnica, 80% operação disciplinada. O código é simples: create subscription, track usage, aggregate, invoice. Mas a estrutura tem que ser à prova de erro porque cada bug custa churn directo.
Os padrões que recomendo: (1) começa em hybrid fixo + usage overage, (2) normaliza com provider abstraction layer, (3) multi-tenancy impecável desde dia 1, (4) reconciliação automática diária, (5) metadata estruturada em todas as invoices, (6) overrides com auditoria clara.
Se aplicas isto, reduz suporte de billing 60%, aumenta previsibilidade, e scales de forma limpa quando clientes crescem.
Se estás a enfrentar um problema parecido, marca uma conversa em https://impact-origin.com/agendamento.
