Billing for B2B SaaS with Custom Plans: Patterns That Work in Production
TL;DR
- There is no single pattern: custom plans in B2B require conscious decision between usage-based billing, hybrid models, or monthly settlement. Your choice changes operational cost 3x.
- Stripe Billing + Webhooks is the foundation, but you always need your own abstraction layer. Third-party APIs lock you in; a normalised interface solves this.
- Custom plans without clear segregation (per-tenant usage tracking) become a nightmare: impossible audit, disputes over consumption, spaghetti code. Correct multi-tenancy is a prerequisite.
- Manual monthly reconciliation is normal in early stage, but automate from day 1: SQL + alerts save hours and prevent churn from billing surprises.
- Structured metadata on invoices (unit breakdowns, applied rates) pays for itself when large customers question bills or you need usage reports.
The Reality of Billing in Complex B2B
When we started structuring billing for B2B SaaS with custom plans, the first thing we realised is that it's not a technical problem: it's a business problem. Most billing frameworks (Stripe, Paddle, Supabase Auth) solve the standard case: fixed price tiers, monthly renewal, churn. This is not that case.
In B2B custom, what happens is this: customer A pays €50/month for 10,000 API calls, with 99.9% SLA and email support. Customer B pays €200/month for the same 10,000 calls, but with 24/7 support, custom integration and dedicated quarterly reports. Customer C doesn't want calls: they want per-user, with storage limits.
This is not pricing, it's commercial. And engineering has to be able to execute quickly, with audit trail, and without errors.
1. Core Billing Patterns: Which One to Choose?
There are three main patterns we've seen work. None is better: they depend on your marginal cost, customer retention, and support capacity.
Model A: Fixed Billing per Period
Each customer has a negotiated plan that renews monthly (or quarterly, annually). No surprises, predictable. Ideal for customers who want to budget.
Pros: easy to reconcile, customer knows exactly what they pay, accurate MRR forecasting, zero churn from billing surprises.
Cons: you don't capture upside from growth within the period, operational support is high (plan changes generate adjusted invoices), hard to justify price increases if usage grows 10x.
Model B: Pure Usage-Based Billing
Measure everything (API calls, storage, users, compute time), charge fixed unit price. Scales infinitely.
Pros: customer pays exactly for what they use, you capture upside, aligns incentives (customer efficiency = their profit too).
Cons: unpredictable for the customer (budget blowout is real), higher churn when invoices surprise, reconciliation is complex, you need 100% reliable event tracking.
Model C: Hybrid (Recommended for B2B custom)
Fixed base (example: €100/month) covers a quota (example: 50,000 API calls). Above that, you charge overages at unit price (€0.001 per call). Some resources can be flatline (example: support), others metered.
Pros: customer predictable on base, you capture upside, psychologically easier to sell (sounds like "plan with growth potential"), allows tiers.
Cons: reconciliation more complex, requires audit of actual vs reported usage, more invoice lines = more customer questions.
Our experience: 70% of B2B custom negotiations end in hybrid. It's the middle ground that reduces churn and support.
2. Architecture: The Abstraction Layer You Need
Stripe Billing is robust, but not agnostic. Paddle, Supabase Auth extensions, or custom solutions have different constraints. If you tie architecture directly to Stripe, when you switch (and you will), you redesign everything.
The solution: your own normalisation layer.
// Example of billing provider abstraction
// This allows you to swap Stripe for Paddle without rewriting 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 }>;
}
// Stripe implementation
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),
};
}
}
Why this matters: when Stripe changes their API (it happened several times in 2024), you change one implementation, not the whole codebase. When a large customer negotiates a super custom plan that Stripe Billing doesn't natively support, you can fill the gap.
3. Multi-Tenancy: Without This, There Is No Audit
This is where we see really fragile code: usage tracking without clear tenancy.
Imagine: two customers (Tenant A, Tenant B) share infrastructure. An API call happens. How do you know which tenant to attribute this usage to? If it's ambiguous, reconciliation is impossible.
The structure that works:
-- PostgreSQL 16, core structure
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, -- detail: { meter_type, unit_count, rate }
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- View for daily reconciliation
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;
Why this works: each usage event is anchored to customer_id AND subscription_period_id. No ambiguity. When a Stripe webhook comes in (or Paddle), idempotency is guaranteed by external_event_id. Reconciliation is a query:
-- Detect discrepancies: usage we haven't invoiced
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);
This detects if you have recorded usage that didn't make it into an invoice. You run this every day at 5am, alerts if delta > 0.
4. Webhook Handling and Idempotency: Where Errors Cost Money
Stripe sends webhooks for billing events: subscription created, invoice generated, payment received. If you don't handle it carefully, you can process the same webhook twice, duplicate charges, or lose data.
Rule: always store external_event_id and do upsert, not insert.
// Stripe webhook handler in Next.js API Route
import { Webhook } from 'svix'; // or 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 });
}
// Idempotency: store event ID, check if we've already processed it
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 as 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;
}
// ... other events
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
} catch (error) {
console.error('Webhook processing error:', error);
// Don't return 200: let Stripe retry
return new Response('Internal error', { status: 500 });
}
}
Real gotcha: Stripe retries webhooks for 3 days if it receives 5xx. If your endpoint is slow (> 30s) or down, it will process late. Logs won't appear immediately. Solution: store the raw event in a queue (Redis, Bull, Trigger.dev), process async, return 200 immediately.
5. Structured Metadata: Invoice That Explains Everything
Large customer questions a €450 invoice. "Why? Last month it was €300."
If the invoice just has one line "Pro Plan", you can't answer. If it has structured breakdown, it's 30 seconds:
{
"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"
}
}
}
When you create an invoice in Stripe, pass this in 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(),
},
};
This appears in Stripe Dashboard and on invoice PDFs. Customers can debug themselves, support reduces 40%.
6. Automated Reconciliation: What Can Go Wrong
Two sources of truth: your DB and Stripe's. If they don't reconcile daily, you discover the problem when a customer cancels from billing surprise (€2k overage they didn't expect).
SQL script that runs every day at 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);
This job detects non-invoiced periods, aggregates usage, creates invoices, updates state. Failures are alerted. Without this, "forgotten" customers get invoiced 3 months later (maximum toxicity).
7. Custom Plans: When the Standard Pattern Isn't Enough
Sometimes a customer negotiates something so custom that no standard provider supports it. Example: "€3k base, but if their revenue grows > 20% year-on-year, 10% discount on overages". Or: "invoice in their currency (COP), with exchange rate on the 5th of the previous month".
Solution: manual override with audit trail.
// Custom override for specific plan
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 of who negotiated
notes: string;
}
// Store in 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
);
// When you calculate invoice, check 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: always auditable. Who negotiated, when, for how much, why. Without this, accounting will question you.
Conclusion
Billing in B2B custom is 20% technical, 80% disciplined operations. The code is simple: create subscription, track usage, aggregate, invoice. But the structure has to be bulletproof because every bug costs direct churn.
The patterns I recommend: (1) start with hybrid fixed + usage overage, (2) normalise with provider abstraction layer, (3) flawless multi-tenancy from day 1, (4) daily automatic reconciliation, (5) structured metadata on all invoices, (6) overrides with clear audit trail.
If you apply this, you reduce billing support 60%, increase predictability, and scale cleanly as customers grow.
If you're facing a similar problem, book a conversation at https://impact-origin.com/agendamento.
