SaaS Billing Engineering: Metered Usage, Invoice Generation, and Dunning
Build production SaaS billing infrastructure: metered usage tracking, seat-based and usage-based pricing, invoice generation, payment retry logic (dunning), and billing webhook handling.
SaaS billing is one of those problems that looks simple until you're in the middle of it. A flat monthly subscription is straightforward. Add usage-based pricing, volume discounts, annual prepayment, mid-cycle upgrades, proration, trial periods, and team seat management โ and you have a complex system where bugs directly cause revenue loss or customer trust damage.
This post covers the engineering required to build billing infrastructure that handles these cases reliably.
Billing Models and When to Use Them
| Model | Structure | Best For | Stripe Feature |
|---|---|---|---|
| Flat-rate | Fixed price per period | Simple, predictable | Subscription |
| Seat-based | Per user/seat | B2B team tools | Subscription with quantity |
| Usage-based | Per unit consumed | APIs, AI tokens, storage | Metered billing |
| Hybrid | Base + usage overage | Most B2B SaaS | Subscription + meters |
| Volume tiers | Price breaks at thresholds | Infrastructure, data | Tiered pricing |
The hybrid model โ a base platform fee plus metered usage โ is where most maturing B2B SaaS products end up.
Usage Metering Infrastructure
Usage events must be fast to record (never block user requests), accurate (double-counting = overbilling), and idempotent (retries don't create duplicate events).
// src/services/billing/usage-meter.ts
import { Redis } from "ioredis";
import { db } from "@/db";
const redis = new Redis(process.env.REDIS_URL!);
interface UsageEvent {
idempotencyKey: string; // Prevent double-counting on retries
tenantId: string;
meterId: string; // "api_calls" | "ai_tokens" | "storage_gb" | "seats"
quantity: number;
timestamp: Date;
metadata?: Record<string, string>;
}
/**
* Record a usage event.
* Fast: writes to Redis for real-time display and aggregation.
* Durable: also writes to Postgres for billing and audit.
* Idempotent: duplicate idempotencyKey is a no-op.
*/
export async function recordUsage(event: UsageEvent): Promise<void> {
// Check idempotency key (24h TTL)
const idempKey = `usage:idem:${event.idempotencyKey}`;
const alreadyRecorded = await redis.set(idempKey, "1", "EX", 86400, "NX");
if (alreadyRecorded === null) {
// Already recorded โ skip
return;
}
// Write to Postgres (durable, queryable for invoicing)
await db.query(
`INSERT INTO usage_events
(idempotency_key, tenant_id, meter_id, quantity, occurred_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (idempotency_key) DO NOTHING`,
[
event.idempotencyKey,
event.tenantId,
event.meterId,
event.quantity,
event.timestamp,
JSON.stringify(event.metadata ?? {}),
]
);
// Increment Redis counter for real-time dashboard
const monthKey = `usage:${event.tenantId}:${event.meterId}:${getMonthKey()}`;
await redis.incrby(monthKey, event.quantity);
await redis.expire(monthKey, 60 * 60 * 24 * 35); // Keep 35 days
// Check if usage exceeds plan limit โ trigger alert if needed
const usage = await redis.get(monthKey);
if (usage) {
await checkUsageLimits(event.tenantId, event.meterId, parseInt(usage));
}
}
function getMonthKey(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
// Aggregate usage for a billing period
export async function getUsageForPeriod(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<Record<string, number>> {
const { rows } = await db.query<{ meter_id: string; total: string }>(
`SELECT meter_id, SUM(quantity)::text AS total
FROM usage_events
WHERE tenant_id = $1
AND occurred_at >= $2
AND occurred_at < $3
GROUP BY meter_id`,
[tenantId, startDate, endDate]
);
return Object.fromEntries(rows.map((r) => [r.meter_id, parseInt(r.total)]));
}
Batch Usage Reporting to Stripe
// src/jobs/billing/report-usage.job.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
/**
* Runs at end of billing period (or hourly for real-time metering).
* Reports usage to Stripe's metered billing API.
*/
export async function reportUsageToStripe(): Promise<void> {
const period = getCurrentBillingPeriod();
// Get all active subscriptions with metered items
const { rows: subscriptions } = await db.query<{
tenant_id: string;
stripe_subscription_id: string;
stripe_meter_id: string;
meter_id: string;
}>(
`SELECT t.id as tenant_id, s.stripe_subscription_id,
smi.stripe_meter_id, smi.meter_id
FROM tenants t
JOIN subscriptions s ON s.tenant_id = t.id
JOIN subscription_meter_items smi ON smi.subscription_id = s.id
WHERE s.status = 'active'
AND s.current_period_end > NOW()`
);
for (const sub of subscriptions) {
const usage = await getUsageForPeriod(
sub.tenant_id,
period.start,
period.end
);
const quantity = usage[sub.meter_id] ?? 0;
if (quantity === 0) continue;
// Report to Stripe Billing Meter
await stripe.billing.meterEvents.create({
event_name: sub.stripe_meter_id,
payload: {
stripe_customer_id: await getStripeCustomerId(sub.tenant_id),
value: String(quantity),
},
timestamp: Math.floor(period.end.getTime() / 1000),
identifier: `${sub.tenant_id}:${sub.meter_id}:${period.start.toISOString()}`,
});
console.log(
`Reported ${quantity} ${sub.meter_id} for tenant ${sub.tenant_id}`
);
}
}
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Subscription Lifecycle
// src/services/billing/subscription.service.ts
interface CreateSubscriptionInput {
tenantId: string;
stripeCustomerId: string;
planId: "starter" | "growth" | "enterprise";
seats?: number;
trialDays?: number;
couponCode?: string;
}
const STRIPE_PRICE_IDS: Record<string, string> = {
"starter:monthly": process.env.STRIPE_PRICE_STARTER_MONTHLY!,
"growth:monthly": process.env.STRIPE_PRICE_GROWTH_MONTHLY!,
"enterprise:monthly": process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!,
"growth:seats": process.env.STRIPE_PRICE_GROWTH_SEATS!,
"growth:api_calls": process.env.STRIPE_PRICE_GROWTH_API_CALLS!,
};
export async function createSubscription(
input: CreateSubscriptionInput
): Promise<Stripe.Subscription> {
const items: Stripe.SubscriptionCreateParams.Item[] = [
// Base platform fee
{
price: STRIPE_PRICE_IDS[`${input.planId}:monthly`],
},
];
// Add seat-based pricing for team plans
if (input.seats && input.planId !== "starter") {
items.push({
price: STRIPE_PRICE_IDS[`${input.planId}:seats`],
quantity: input.seats,
});
}
// Add metered usage items (quantity not set for metered)
if (input.planId === "growth" || input.planId === "enterprise") {
items.push({
price: STRIPE_PRICE_IDS[`${input.planId}:api_calls`],
});
}
const subscription = await stripe.subscriptions.create({
customer: input.stripeCustomerId,
items,
trial_period_days: input.trialDays,
coupon: input.couponCode,
payment_behavior: "default_incomplete", // Require payment method confirmation
payment_settings: {
save_default_payment_method: "on_subscription",
payment_method_types: ["card"],
},
expand: ["latest_invoice.payment_intent"],
metadata: { tenant_id: input.tenantId, plan: input.planId },
});
// Persist subscription to database
await db.query(
`INSERT INTO subscriptions
(tenant_id, stripe_subscription_id, plan, status, current_period_start, current_period_end)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
input.tenantId,
subscription.id,
input.planId,
subscription.status,
new Date(subscription.current_period_start * 1000),
new Date(subscription.current_period_end * 1000),
]
);
return subscription;
}
// Proration preview before upgrade
export async function previewUpgrade(
subscriptionId: string,
newPlanId: string
): Promise<{ immediateCharge: number; newMonthlyPrice: number }> {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItemId = subscription.items.data[0].id;
const proration = await stripe.invoices.createPreview({
subscription: subscriptionId,
subscription_details: {
items: [
{
id: currentItemId,
price: STRIPE_PRICE_IDS[`${newPlanId}:monthly`],
},
],
proration_behavior: "create_prorations",
proration_date: Math.floor(Date.now() / 1000),
},
});
const immediateCharge = proration.lines.data
.filter((line) => line.amount > 0)
.reduce((sum, line) => sum + line.amount, 0);
return {
immediateCharge,
newMonthlyPrice: proration.lines.data
.filter((line) => line.amount > 0 && !line.proration)
.reduce((sum, line) => sum + line.amount, 0),
};
}
Invoice Generation
// src/services/billing/invoice.service.ts
import PDFDocument from "pdfkit";
interface InvoiceData {
invoiceNumber: string;
tenantName: string;
tenantAddress: string;
period: { start: Date; end: Date };
lineItems: Array<{
description: string;
quantity: number;
unitPrice: number;
total: number;
}>;
subtotal: number;
tax: number;
total: number;
currency: string;
}
export async function generateInvoicePDF(data: InvoiceData): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: "A4", margin: 50 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
// Header
doc
.fontSize(20)
.font("Helvetica-Bold")
.text("Viprasol Tech", 50, 50)
.fontSize(10)
.font("Helvetica")
.text("CIN: U62090HR2025PTC135188", 50, 75)
.text("support@viprasol.com", 50, 90);
doc
.fontSize(24)
.font("Helvetica-Bold")
.text("INVOICE", 400, 50, { align: "right" })
.fontSize(10)
.font("Helvetica")
.text(`Invoice #: ${data.invoiceNumber}`, 400, 80, { align: "right" })
.text(`Period: ${formatDate(data.period.start)} โ ${formatDate(data.period.end)}`, 400, 95, { align: "right" });
// Bill to
doc
.fontSize(11)
.font("Helvetica-Bold")
.text("Bill To:", 50, 150)
.font("Helvetica")
.text(data.tenantName, 50, 165)
.text(data.tenantAddress, 50, 180);
// Line items table
let y = 250;
doc.font("Helvetica-Bold").fontSize(10);
["Description", "Qty", "Unit Price", "Total"].forEach((header, i) => {
const x = [50, 300, 380, 460][i];
doc.text(header, x, y);
});
y += 20;
doc.moveTo(50, y).lineTo(550, y).stroke();
y += 10;
doc.font("Helvetica").fontSize(10);
for (const item of data.lineItems) {
doc
.text(item.description, 50, y, { width: 240 })
.text(String(item.quantity), 300, y)
.text(formatCurrency(item.unitPrice, data.currency), 380, y)
.text(formatCurrency(item.total, data.currency), 460, y);
y += 25;
}
// Totals
y += 20;
doc
.text("Subtotal:", 380, y)
.text(formatCurrency(data.subtotal, data.currency), 460, y);
y += 20;
doc
.text("Tax:", 380, y)
.text(formatCurrency(data.tax, data.currency), 460, y);
y += 20;
doc.font("Helvetica-Bold")
.text("Total:", 380, y)
.text(formatCurrency(data.total, data.currency), 460, y);
doc.end();
});
}
function formatCurrency(cents: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(cents / 100);
}
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Dunning: Payment Retry Logic
Dunning is the process of recovering failed payments. A smart dunning sequence recovers 20โ40% of failed payments:
// src/services/billing/dunning.service.ts
interface DunningConfig {
retrySchedule: Array<{ delayDays: number; action: "charge" | "notify" | "cancel" }>;
}
const DUNNING_SEQUENCE: DunningConfig = {
retrySchedule: [
{ delayDays: 0, action: "charge" }, // Day 0: First attempt failed (Stripe auto-retries)
{ delayDays: 3, action: "notify" }, // Day 3: Email customer
{ delayDays: 7, action: "charge" }, // Day 7: Retry payment
{ delayDays: 10, action: "notify" }, // Day 10: Urgent email + degrade service
{ delayDays: 14, action: "charge" }, // Day 14: Final retry
{ delayDays: 21, action: "cancel" }, // Day 21: Cancel subscription
],
};
export async function handleFailedPayment(
subscriptionId: string,
invoiceId: string
): Promise<void> {
const subscription = await db.query(
"SELECT * FROM subscriptions WHERE stripe_subscription_id = $1",
[subscriptionId]
);
if (subscription.rows.length === 0) return;
const sub = subscription.rows[0];
// Record the failed payment
const failedAt = new Date();
await db.query(
`INSERT INTO failed_payments
(subscription_id, invoice_id, failed_at, retry_count, next_retry_at)
VALUES ($1, $2, $3, 0, $4)
ON CONFLICT (invoice_id) DO UPDATE
SET retry_count = failed_payments.retry_count + 1,
last_failed_at = $3`,
[sub.id, invoiceId, failedAt, addDays(failedAt, 3)]
);
// Send initial payment failure notification
await sendPaymentFailedEmail({
tenantId: sub.tenant_id,
invoiceId,
invoiceUrl: `https://app.viprasol.com/billing/invoices/${invoiceId}`,
updatePaymentUrl: `https://app.viprasol.com/billing/payment-method`,
daysUntilCancellation: 21,
});
// Degrade service (but don't cut off immediately)
await db.query(
"UPDATE tenants SET billing_status = 'past_due' WHERE id = $1",
[sub.tenant_id]
);
// Schedule retry job
await scheduleRetryJob({ subscriptionId, invoiceId, retryAt: addDays(failedAt, 7) });
}
export async function retryFailedPayment(invoiceId: string): Promise<void> {
const { rows } = await db.query(
"SELECT * FROM failed_payments WHERE invoice_id = $1",
[invoiceId]
);
if (rows.length === 0) return;
const failedPayment = rows[0];
try {
// Attempt to pay the invoice
const invoice = await stripe.invoices.pay(invoiceId, {
forgive: false, // Don't write off โ keep retrying
});
if (invoice.status === "paid") {
// Payment recovered!
await db.query(
"UPDATE failed_payments SET recovered_at = NOW() WHERE invoice_id = $1",
[invoiceId]
);
await db.query(
"UPDATE tenants SET billing_status = 'active' WHERE id = $1",
[failedPayment.tenant_id]
);
await sendPaymentRecoveredEmail({ tenantId: failedPayment.tenant_id });
}
} catch (error) {
// Still failed โ schedule next retry or cancel
const retryCount = failedPayment.retry_count + 1;
const nextStep = DUNNING_SEQUENCE.retrySchedule[retryCount];
if (!nextStep || nextStep.action === "cancel") {
await cancelForNonPayment(failedPayment.subscription_id);
} else {
await scheduleRetryJob({
subscriptionId: failedPayment.subscription_id,
invoiceId,
retryAt: addDays(new Date(), nextStep.delayDays),
});
}
}
}
async function cancelForNonPayment(subscriptionId: string): Promise<void> {
await stripe.subscriptions.cancel(subscriptionId, {
cancellation_details: { comment: "Non-payment after dunning sequence" },
});
await db.query(
"UPDATE subscriptions SET status = 'canceled', canceled_at = NOW(), cancellation_reason = 'non_payment' WHERE stripe_subscription_id = $1",
[subscriptionId]
);
// Downgrade to free tier (don't delete data)
await db.query(
"UPDATE tenants SET plan = 'free', billing_status = 'canceled' WHERE stripe_subscription_id = $1",
[subscriptionId]
);
}
Billing Webhook Handler
// src/app/api/webhooks/stripe/route.ts (billing-specific events)
switch (event.type) {
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case "invoice.payment_failed":
await handleFailedPayment(
(event.data.object as Stripe.Invoice).subscription as string,
(event.data.object as Stripe.Invoice).id
);
break;
case "customer.subscription.updated":
const sub = event.data.object as Stripe.Subscription;
await db.query(
`UPDATE subscriptions
SET status = $1, plan = $2,
current_period_start = $3, current_period_end = $4
WHERE stripe_subscription_id = $5`,
[
sub.status,
sub.metadata.plan,
new Date(sub.current_period_start * 1000),
new Date(sub.current_period_end * 1000),
sub.id,
]
);
break;
case "customer.subscription.deleted":
const canceled = event.data.object as Stripe.Subscription;
await db.query(
"UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = $1",
[canceled.id]
);
break;
}
Billing Cost Reference (2026)
| Component | Tool | Cost |
|---|---|---|
| Payment processing | Stripe | 2.9% + $0.30/transaction |
| Metered billing | Stripe Billing | 0.5โ0.8% of MRR |
| Invoice PDF generation | pdfkit (self-hosted) | ~$0 |
| Dunning automation | In-house (this post) | Engineering cost only |
| Dunning automation (SaaS) | Chargify / Chargebee | $300โ$1,500/month |
| Revenue recognition | Maxio | $500โ$2,000/month |
| Tax calculation | Stripe Tax | 0.5% of transactions |
See Also
- Stripe Billing and Subscription Management โ deeper Stripe billing patterns
- Stripe Connect for Marketplaces โ marketplace payments
- SaaS Pricing Strategy Engineering โ pricing model design
- SaaS Metrics and Benchmarks โ MRR, churn, LTV tracking
Working With Viprasol
Billing is revenue. Bugs in billing logic directly reduce MRR through failed payments, incorrect invoices, or proration errors. Our engineers have built metered billing systems handling millions of events per day, dunning sequences that recover 30%+ of failed payments, and invoice pipelines that comply with GST, VAT, and US sales tax requirements.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.