12 min de leitura
saas
billing
arquitectura
b2b
pricing

Billing para SaaS B2B com Planos Custom: Padrões que Funcionam em Produção

Explora os padrões de billing mais eficazes para SaaS B2B com planos personalizados. Código real, armadilhas comuns e decisões arquitectónicas que escalaram em produção.

Billing para SaaS B2B com Planos Custom: Padrões que Funcionam em Produção

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.

Impact OriginGostaste deste artigo?Na Impact Origin ajudamos fundadores e equipas a construir e escalar software à medida, do MVP da startup ao próximo passo. Se tens um projeto em mente, vamos falar.