Back to Blog

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.

Viprasol Tech Team
September 12, 2026
14 min read

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

ModelStructureBest ForStripe Feature
Flat-rateFixed price per periodSimple, predictableSubscription
Seat-basedPer user/seatB2B team toolsSubscription with quantity
Usage-basedPer unit consumedAPIs, AI tokens, storageMetered billing
HybridBase + usage overageMost B2B SaaSSubscription + meters
Volume tiersPrice breaks at thresholdsInfrastructure, dataTiered 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)

ComponentToolCost
Payment processingStripe2.9% + $0.30/transaction
Metered billingStripe Billing0.5โ€“0.8% of MRR
Invoice PDF generationpdfkit (self-hosted)~$0
Dunning automationIn-house (this post)Engineering cost only
Dunning automation (SaaS)Chargify / Chargebee$300โ€“$1,500/month
Revenue recognitionMaxio$500โ€“$2,000/month
Tax calculationStripe Tax0.5% of transactions

See Also


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.

SaaS engineering services โ†’ | Talk to our engineers โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.