Back to Blog

SaaS Team Billing: Per-Seat Pricing, Prorated Seat Changes, and Team Invoice Portal

Build per-seat SaaS billing with Stripe. Covers seat quantity updates with proration, mid-cycle additions, seat limit enforcement, team billing portal, and invoice management for team plans.

Viprasol Tech Team
March 8, 2027
13 min read

Per-seat billing is the standard pricing model for B2B SaaS. Customers pay per user per month, seats increase as they hire, and your revenue grows with their team. Simple to explain in a sales call โ€” but the implementation has real complexity: prorating mid-cycle additions, enforcing seat limits before invite, handling downgrades at period end, and building the billing portal that keeps finance teams happy.

This guide builds the complete per-seat billing system on top of Stripe Subscriptions.

Data Model

model Workspace {
  id            String   @id @default(cuid())
  name          String
  stripeCustomerId    String?  @unique
  stripeSubscriptionId String? @unique
  plan          WorkspacePlan @default(FREE)
  seatLimit     Int      @default(1)
  billingEmail  String?
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  members       WorkspaceMember[]
  subscription  WorkspaceSubscription?
  invoices      WorkspaceInvoice[]
}

model WorkspaceSubscription {
  id                    String   @id @default(cuid())
  workspaceId           String   @unique
  stripeSubscriptionId  String   @unique
  stripePriceId         String
  status                SubscriptionStatus
  quantity              Int      // current seat count (matches Stripe)
  currentPeriodStart    DateTime
  currentPeriodEnd      DateTime
  cancelAtPeriodEnd     Boolean  @default(false)
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt

  workspace Workspace @relation(fields: [workspaceId], references: [id])
}

model WorkspaceMember {
  id          String   @id @default(cuid())
  workspaceId String
  userId      String
  role        MemberRole @default(MEMBER)
  invitedBy   String?
  joinedAt    DateTime @default(now())

  workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([workspaceId, userId])
  @@index([workspaceId])
}

model WorkspaceInvoice {
  id              String   @id @default(cuid())
  workspaceId     String
  stripeInvoiceId String   @unique
  amountDue       Int      // cents
  amountPaid      Int
  currency        String
  status          InvoiceStatus
  periodStart     DateTime
  periodEnd       DateTime
  invoiceUrl      String?
  invoicePdf      String?
  createdAt       DateTime @default(now())

  workspace Workspace @relation(fields: [workspaceId], references: [id])

  @@index([workspaceId, createdAt(sort: Desc)])
}

enum WorkspacePlan { FREE STARTER PROFESSIONAL ENTERPRISE }
enum SubscriptionStatus { TRIALING ACTIVE PAST_DUE CANCELED UNPAID }
enum InvoiceStatus { DRAFT OPEN PAID UNCOLLECTIBLE VOID }
enum MemberRole { OWNER ADMIN MEMBER VIEWER }

Stripe Setup: Per-Seat Pricing

// lib/stripe/products.ts
// Run once to create products and prices in Stripe

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createSeatBasedProducts() {
  const product = await stripe.products.create({
    name: "TeamApp Professional",
    description: "Per-seat team collaboration software",
    metadata: { type: "seat_based" },
  });

  // Monthly per-seat price
  const monthlyPrice = await stripe.prices.create({
    product: product.id,
    currency: "usd",
    unit_amount: 1500, // $15.00 per seat per month
    recurring: {
      interval: "month",
      usage_type: "licensed", // fixed quantity (not metered)
    },
    nickname: "Professional Monthly (per seat)",
    metadata: { plan: "professional", interval: "month" },
  });

  // Annual per-seat price (2 months free = 10 months price / 12)
  const annualPrice = await stripe.prices.create({
    product: product.id,
    currency: "usd",
    unit_amount: 12500, // $125.00 per seat per year
    recurring: {
      interval: "year",
      usage_type: "licensed",
    },
    nickname: "Professional Annual (per seat)",
    metadata: { plan: "professional", interval: "year" },
  });

  return { product, monthlyPrice, annualPrice };
}

export const PLANS = {
  starter: {
    monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
    annual: process.env.STRIPE_STARTER_ANNUAL_PRICE_ID!,
    seatPrice: 800,  // $8/seat/month
    maxSeats: 5,
  },
  professional: {
    monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
    annual: process.env.STRIPE_PRO_ANNUAL_PRICE_ID!,
    seatPrice: 1500, // $15/seat/month
    maxSeats: 100,
  },
  enterprise: {
    monthly: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID!,
    annual: process.env.STRIPE_ENTERPRISE_ANNUAL_PRICE_ID!,
    seatPrice: 2500, // $25/seat/month
    maxSeats: Infinity,
  },
} as const;

๐Ÿš€ 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

Creating a Subscription

// lib/billing/create-subscription.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { PLANS } from "./products";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

interface CreateSubscriptionParams {
  workspaceId: string;
  plan: keyof typeof PLANS;
  interval: "monthly" | "annual";
  seats: number;
  paymentMethodId: string;
  billingEmail: string;
}

export async function createSubscription({
  workspaceId,
  plan,
  interval,
  seats,
  paymentMethodId,
  billingEmail,
}: CreateSubscriptionParams) {
  const workspace = await prisma.workspace.findUniqueOrThrow({
    where: { id: workspaceId },
    select: { name: true, stripeCustomerId: true },
  });

  // Create or retrieve Stripe customer
  let customerId = workspace.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: billingEmail,
      name: workspace.name,
      payment_method: paymentMethodId,
      invoice_settings: { default_payment_method: paymentMethodId },
      metadata: { workspaceId },
    });
    customerId = customer.id;

    await prisma.workspace.update({
      where: { id: workspaceId },
      data: { stripeCustomerId: customerId, billingEmail },
    });
  }

  const planConfig = PLANS[plan];
  const priceId =
    interval === "annual" ? planConfig.annual : planConfig.monthly;

  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId, quantity: seats }],
    payment_behavior: "error_if_incomplete",
    payment_settings: { save_default_payment_method: "on_subscription" },
    expand: ["latest_invoice.payment_intent"],
    metadata: {
      workspaceId,
      plan,
      interval,
    },
  });

  // Save to database
  await prisma.$transaction([
    prisma.workspace.update({
      where: { id: workspaceId },
      data: {
        plan: plan.toUpperCase() as any,
        seatLimit: seats,
        stripeSubscriptionId: subscription.id,
      },
    }),
    prisma.workspaceSubscription.create({
      data: {
        workspaceId,
        stripeSubscriptionId: subscription.id,
        stripePriceId: priceId,
        status: "ACTIVE",
        quantity: seats,
        currentPeriodStart: new Date(subscription.current_period_start * 1000),
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      },
    }),
  ]);

  return subscription;
}

Adding Seats: Prorated Mid-Cycle

When a workspace adds seats mid-billing-cycle, Stripe calculates proration automatically:

// lib/billing/update-seats.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

interface UpdateSeatsParams {
  workspaceId: string;
  newSeatCount: number;
  reason?: string;
}

export async function updateSeats({
  workspaceId,
  newSeatCount,
  reason,
}: UpdateSeatsParams) {
  const workspace = await prisma.workspace.findUniqueOrThrow({
    where: { id: workspaceId },
    include: { subscription: true },
  });

  if (!workspace.stripeSubscriptionId || !workspace.subscription) {
    throw new Error("No active subscription");
  }

  if (newSeatCount < 1) {
    throw new Error("Must have at least 1 seat");
  }

  const currentMemberCount = await prisma.workspaceMember.count({
    where: { workspaceId },
  });

  if (newSeatCount < currentMemberCount) {
    throw new Error(
      `Cannot reduce below active member count (${currentMemberCount} members)`
    );
  }

  const sub = await stripe.subscriptions.retrieve(
    workspace.stripeSubscriptionId
  );

  const item = sub.items.data[0];
  const currentSeats = item.quantity ?? 1;
  const isIncrease = newSeatCount > currentSeats;

  let updatedSub: Stripe.Subscription;

  if (isIncrease) {
    // Immediate with proration โ€” charge for remaining days of cycle
    updatedSub = await stripe.subscriptions.update(
      workspace.stripeSubscriptionId,
      {
        items: [{ id: item.id, quantity: newSeatCount }],
        proration_behavior: "create_prorations",
        payment_behavior: "error_if_incomplete",
      }
    );
  } else {
    // Decrease: apply at period end to avoid refund complexity
    updatedSub = await stripe.subscriptions.update(
      workspace.stripeSubscriptionId,
      {
        items: [{ id: item.id, quantity: newSeatCount }],
        proration_behavior: "none",
        billing_cycle_anchor: "unchanged",
      }
    );
  }

  // Update database
  await prisma.$transaction([
    prisma.workspace.update({
      where: { id: workspaceId },
      data: { seatLimit: newSeatCount },
    }),
    prisma.workspaceSubscription.update({
      where: { workspaceId },
      data: { quantity: newSeatCount },
    }),
    // Log the change
    prisma.billingEvent.create({
      data: {
        workspaceId,
        eventType: isIncrease ? "SEATS_ADDED" : "SEATS_REMOVED",
        previousQuantity: currentSeats,
        newQuantity: newSeatCount,
        reason: reason ?? (isIncrease ? "Manual increase" : "Manual decrease"),
      },
    }),
  ]);

  return {
    previousSeats: currentSeats,
    newSeats: newSeatCount,
    isIncrease,
    subscription: updatedSub,
  };
}

// Preview proration before committing
export async function previewSeatChange(
  workspaceId: string,
  newSeatCount: number
) {
  const workspace = await prisma.workspace.findUniqueOrThrow({
    where: { id: workspaceId },
    include: { subscription: true },
  });

  if (!workspace.stripeSubscriptionId) {
    throw new Error("No active subscription");
  }

  const sub = await stripe.subscriptions.retrieve(
    workspace.stripeSubscriptionId
  );

  const prorationDate = Math.floor(Date.now() / 1000);

  const invoice = await stripe.invoices.retrieveUpcoming({
    customer: workspace.stripeCustomerId!,
    subscription: workspace.stripeSubscriptionId,
    subscription_items: [
      { id: sub.items.data[0].id, quantity: newSeatCount },
    ],
    subscription_proration_date: prorationDate,
  });

  const proratedLines = invoice.lines.data.filter((l) => l.proration);
  const immediateCharge = proratedLines
    .filter((l) => l.amount > 0)
    .reduce((sum, l) => sum + l.amount, 0);
  const credits = proratedLines
    .filter((l) => l.amount < 0)
    .reduce((sum, l) => sum + Math.abs(l.amount), 0);

  return {
    immediateCharge,
    credits,
    netCharge: immediateCharge - credits,
    nextInvoiceTotal: invoice.total,
    currency: invoice.currency,
    lines: proratedLines.map((l) => ({
      description: l.description,
      amount: l.amount,
      proration: l.proration,
    })),
  };
}

๐Ÿ’ก 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

Seat Enforcement Before Invite

// lib/billing/seat-check.ts
import { prisma } from "@/lib/prisma";

export class SeatLimitError extends Error {
  constructor(
    public readonly used: number,
    public readonly limit: number
  ) {
    super(`Seat limit reached (${used}/${limit})`);
    this.name = "SeatLimitError";
  }
}

export async function assertSeatAvailable(workspaceId: string): Promise<void> {
  const workspace = await prisma.workspace.findUniqueOrThrow({
    where: { id: workspaceId },
    select: { seatLimit: true, _count: { select: { members: true } } },
  });

  const used = workspace._count.members;
  const limit = workspace.seatLimit;

  if (used >= limit) {
    throw new SeatLimitError(used, limit);
  }
}

// Usage in invitation flow:
export async function inviteMember(
  workspaceId: string,
  email: string,
  role: string
) {
  // Check seat limit before creating invitation
  await assertSeatAvailable(workspaceId);

  // Create invitation...
  return prisma.invitation.create({
    data: { workspaceId, email, role },
  });
}

API Route: Seat Management

// app/api/billing/seats/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { updateSeats, previewSeatChange } from "@/lib/billing/update-seats";
import { z } from "zod";

const UpdateSeatsSchema = z.object({
  seats: z.number().int().min(1).max(10000),
  preview: z.boolean().optional().default(false),
});

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Only workspace owner/admin can change seats
  const member = await prisma.workspaceMember.findFirst({
    where: {
      workspaceId: session.user.organizationId,
      userId: session.user.id,
      role: { in: ["OWNER", "ADMIN"] },
    },
  });

  if (!member) {
    return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
  }

  const body = await req.json();
  const parsed = UpdateSeatsSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    );
  }

  const { seats, preview } = parsed.data;

  if (preview) {
    const proration = await previewSeatChange(
      session.user.organizationId,
      seats
    );
    return NextResponse.json({ preview: proration });
  }

  const result = await updateSeats({
    workspaceId: session.user.organizationId,
    newSeatCount: seats,
    reason: "Admin manual change",
  });

  return NextResponse.json({ result });
}

Team Billing Portal

// app/billing/page.tsx (Server Component)
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { formatCurrency } from "@/lib/utils";
import { SeatManager } from "./seat-manager";
import { InvoiceList } from "./invoice-list";

async function getBillingData(workspaceId: string) {
  const workspace = await prisma.workspace.findUniqueOrThrow({
    where: { id: workspaceId },
    include: {
      subscription: true,
      _count: { select: { members: true } },
    },
  });

  const invoices = workspace.stripeCustomerId
    ? await stripe.invoices.list({
        customer: workspace.stripeCustomerId,
        limit: 12,
        expand: ["data.payment_intent"],
      })
    : { data: [] };

  return { workspace, invoices: invoices.data };
}

export default async function BillingPage() {
  const session = await auth();
  if (!session?.user) redirect("/auth/signin");

  const { workspace, invoices } = await getBillingData(
    session.user.organizationId
  );

  const sub = workspace.subscription;
  const seatsUsed = workspace._count.members;
  const seatsTotal = workspace.seatLimit;
  const seatsAvailable = seatsTotal - seatsUsed;

  return (
    <div className="max-w-3xl mx-auto py-8 px-4 space-y-8">
      <h1 className="text-2xl font-semibold">Billing</h1>

      {/* Current Plan */}
      <div className="bg-white rounded-xl border border-gray-200 p-6">
        <div className="flex justify-between items-start mb-4">
          <div>
            <h2 className="font-semibold text-gray-900">{workspace.plan} Plan</h2>
            <p className="text-sm text-gray-500 mt-1">
              {sub?.cancelAtPeriodEnd
                ? `Cancels ${sub.currentPeriodEnd.toLocaleDateString()}`
                : `Renews ${sub?.currentPeriodEnd?.toLocaleDateString() ?? "โ€”"}`}
            </p>
          </div>
          <span
            className={`px-2.5 py-1 text-xs font-medium rounded-full ${
              sub?.status === "ACTIVE"
                ? "bg-green-100 text-green-700"
                : "bg-yellow-100 text-yellow-700"
            }`}
          >
            {sub?.status ?? "No subscription"}
          </span>
        </div>

        {/* Seat usage */}
        <div className="mb-4">
          <div className="flex justify-between text-sm mb-2">
            <span className="text-gray-600">Seats</span>
            <span className="font-medium">
              {seatsUsed} / {seatsTotal}
            </span>
          </div>
          <div className="w-full bg-gray-100 rounded-full h-2">
            <div
              className={`h-2 rounded-full transition-all ${
                seatsAvailable === 0 ? "bg-red-500" : "bg-blue-600"
              }`}
              style={{
                width: `${Math.min(100, (seatsUsed / seatsTotal) * 100)}%`,
              }}
            />
          </div>
          {seatsAvailable === 0 && (
            <p className="text-xs text-red-600 mt-1">
              All seats used โ€” add seats to invite more members
            </p>
          )}
        </div>
      </div>

      {/* Seat Manager */}
      <SeatManager
        currentSeats={seatsTotal}
        usedSeats={seatsUsed}
        workspaceId={workspace.id}
      />

      {/* Invoice History */}
      <InvoiceList invoices={invoices} />
    </div>
  );
}
// app/billing/seat-manager.tsx
"use client";

import { useState } from "react";
import { formatCurrency } from "@/lib/utils";

interface SeatManagerProps {
  currentSeats: number;
  usedSeats: number;
  workspaceId: string;
}

export function SeatManager({ currentSeats, usedSeats }: SeatManagerProps) {
  const [targetSeats, setTargetSeats] = useState(currentSeats);
  const [preview, setPreview] = useState<any | null>(null);
  const [loading, setLoading] = useState(false);
  const [saving, setSaving] = useState(false);

  const previewChange = async () => {
    if (targetSeats === currentSeats) return;
    setLoading(true);
    try {
      const res = await fetch("/api/billing/seats", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ seats: targetSeats, preview: true }),
      });
      const data = await res.json();
      setPreview(data.preview);
    } finally {
      setLoading(false);
    }
  };

  const confirmChange = async () => {
    setSaving(true);
    try {
      const res = await fetch("/api/billing/seats", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ seats: targetSeats }),
      });
      if (res.ok) {
        window.location.reload();
      }
    } finally {
      setSaving(false);
    }
  };

  return (
    <div className="bg-white rounded-xl border border-gray-200 p-6">
      <h2 className="font-semibold mb-4">Manage Seats</h2>

      <div className="flex items-center gap-4 mb-4">
        <div className="flex items-center border rounded-lg overflow-hidden">
          <button
            onClick={() => setTargetSeats((n) => Math.max(usedSeats, n - 1))}
            className="px-3 py-2 text-gray-600 hover:bg-gray-50 text-lg leading-none"
          >
            โˆ’
          </button>
          <span className="px-4 py-2 font-medium min-w-[3rem] text-center">
            {targetSeats}
          </span>
          <button
            onClick={() => setTargetSeats((n) => n + 1)}
            className="px-3 py-2 text-gray-600 hover:bg-gray-50 text-lg leading-none"
          >
            +
          </button>
        </div>
        <span className="text-sm text-gray-500">seats</span>

        {targetSeats !== currentSeats && (
          <button
            onClick={previewChange}
            disabled={loading}
            className="ml-auto text-sm text-blue-600 hover:text-blue-700 font-medium"
          >
            {loading ? "Calculating..." : "Preview change"}
          </button>
        )}
      </div>

      {preview && targetSeats !== currentSeats && (
        <div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2 mb-4">
          <div className="font-medium text-gray-900">Billing preview</div>
          {preview.netCharge > 0 ? (
            <p className="text-gray-600">
              You'll be charged{" "}
              <strong>{formatCurrency(preview.netCharge, preview.currency)}</strong>{" "}
              now for the remaining days in your billing period.
            </p>
          ) : (
            <p className="text-gray-600">
              The reduction takes effect at your next renewal.
            </p>
          )}
          <button
            onClick={confirmChange}
            disabled={saving}
            className="w-full mt-2 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-60"
          >
            {saving ? "Saving..." : `Confirm โ€” ${targetSeats} seats`}
          </button>
        </div>
      )}

      <p className="text-xs text-gray-500">
        Minimum {usedSeats} seats (current team size). Seat reductions apply at next
        renewal.
      </p>
    </div>
  );
}

Webhook: Sync Subscription State

// app/api/webhooks/stripe/route.ts (billing-relevant events)
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "customer.subscription.updated": {
      const sub = event.data.object as Stripe.Subscription;
      const workspaceId = sub.metadata.workspaceId;
      if (!workspaceId) break;

      await prisma.workspaceSubscription.update({
        where: { stripeSubscriptionId: sub.id },
        data: {
          status: sub.status.toUpperCase() as any,
          quantity: sub.items.data[0]?.quantity ?? 1,
          currentPeriodStart: new Date(sub.current_period_start * 1000),
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
          cancelAtPeriodEnd: sub.cancel_at_period_end,
        },
      });

      // Sync seat limit from Stripe (source of truth)
      await prisma.workspace.update({
        where: { id: workspaceId },
        data: { seatLimit: sub.items.data[0]?.quantity ?? 1 },
      });
      break;
    }

    case "invoice.paid": {
      const invoice = event.data.object as Stripe.Invoice;
      if (!invoice.subscription) break;

      const sub = await prisma.workspaceSubscription.findFirst({
        where: { stripeSubscriptionId: invoice.subscription as string },
        select: { workspaceId: true },
      });

      if (!sub) break;

      await prisma.workspaceInvoice.upsert({
        where: { stripeInvoiceId: invoice.id },
        create: {
          workspaceId: sub.workspaceId,
          stripeInvoiceId: invoice.id,
          amountDue: invoice.amount_due,
          amountPaid: invoice.amount_paid,
          currency: invoice.currency,
          status: "PAID",
          periodStart: new Date(invoice.period_start * 1000),
          periodEnd: new Date(invoice.period_end * 1000),
          invoiceUrl: invoice.hosted_invoice_url ?? null,
          invoicePdf: invoice.invoice_pdf ?? null,
        },
        update: {
          amountPaid: invoice.amount_paid,
          status: "PAID",
          invoiceUrl: invoice.hosted_invoice_url ?? null,
          invoicePdf: invoice.invoice_pdf ?? null,
        },
      });
      break;
    }
  }

  return NextResponse.json({ received: true });
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic per-seat subscription creation1 dev2โ€“3 days$600โ€“1,200
Seat update with proration + preview1 dev3โ€“5 days$1,000โ€“2,000
Full billing portal (usage, invoices, seat manager)1โ€“2 devs1โ€“2 weeks$3,000โ€“6,000
Enterprise billing (volume discounts, annual contracts, custom invoicing)2โ€“3 devs3โ€“5 weeks$8,000โ€“20,000

See Also


Working With Viprasol

Per-seat billing sounds simple until you're handling proration previews, enforcing limits during concurrent invite requests, and reconciling Stripe state with your database after a webhook replay. Our team has built Stripe billing integrations for B2B SaaS products at every stage โ€” from first paying customer to enterprise contracts.

What we deliver:

  • Per-seat Stripe subscription setup with proration handling
  • Billing portal with seat management, invoice history, and plan changes
  • Webhook processing with idempotency and full state sync
  • Seat enforcement integrated with your invitation flow
  • Annual billing with mid-cycle upgrade/downgrade logic

Talk to our team about your billing implementation โ†’

Or explore our SaaS development services.

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.