How to Build a Customer Portal for Your SaaS

How to Build a Customer Portal for Your SaaS

Profile-Image
Bright SEO Tools in saas Published: Apr 04, 2026 | Updated: Apr 04, 2026 · 2 months ago
0:00

How to Build a Customer Portal for Your SaaS

Customer support tickets that could have been self-served cost SaaS companies 20-40% of their support budget. Users asking "what plan am I on?", "how do I update my payment method?", and "where's my invoice?" are signals that your customer portal doesn't exist or doesn't work. Every support ticket that starts with account management questions is a failure of self-service infrastructure.

This article covers how to build a customer portal that handles account management, billing, team administration, and usage visibility without requiring support intervention. You'll see the database schema that supports multi-tenant account hierarchies, the API patterns that keep Stripe data synchronized with your application, and the specific UI components that reduce support volume measurably.

We'll start with the foundational account management features, progress through billing and subscription controls, then cover team management and usage dashboards that enterprise customers expect.

Why Customer Portals Matter More Than You Think

A customer portal is not a "nice to have" feature you build after product-market fit. It's infrastructure that determines whether your support team scales linearly with customers (unsustainable) or stays flat as you grow (required for unit economics to work). Support costs that grow linearly with customer count cap your growth potential because margin decreases as you scale.

The counterintuitive finding from analyzing support tickets across SaaS companies: 40-60% of tickets are requests for information that should be visible in a portal. Users aren't asking complex product questions—they're asking "show me my invoices" or "add a team member." These tickets take 2-5 minutes to resolve but interrupt support workflows and slow response times on complex issues.

Beyond support cost reduction, customer portals reduce churn. Users who can't figure out how to upgrade their plan don't submit tickets—they leave. Users who can't download invoices for expense reports get frustrated. Users who can't invite team members find tools that make collaboration easier. Self-service isn't just about cost; it's about removing friction from actions that drive revenue (upgrades) and retention (team adoption).

Key Insight: Build the customer portal before you hire a support team, not after. If your first 100 customers can self-serve account management, your first support hire can focus on product education and power user needs instead of answering billing questions. This sets a foundation where support quality improves as you scale rather than degrading.

What Belongs in a Customer Portal

Not everything belongs in a customer portal. The product application handles feature usage. The customer portal handles everything related to the customer relationship: billing, account settings, team management, and usage visibility. The boundary: if a user needs it to use your product, it's in the application. If they need it to manage their account, it's in the portal.

Essential portal features by priority: (1) Subscription and billing management - view plan, upgrade/downgrade, update payment method, view invoices. (2) Team and user management - invite users, assign roles, remove members. (3) Usage and billing visibility - current usage, upcoming charges, historical consumption. (4) Account settings - company information, billing contacts, notification preferences.

Features that seem important but aren't: detailed product analytics (belongs in the application), feature requests (use a dedicated tool like Canny), comprehensive documentation (belongs in a knowledge base). The portal should answer: "What am I paying?" and "Who has access?" Everything else is scope creep that delays shipping the functionality that actually reduces support load.

Database Schema for Portal Features

Customer portals require a database schema that represents organizational hierarchy and separates billing entities from users. Many developers start with a users table and retrofit organization structure later, which creates migration headaches. Design for multi-tenancy and organizational hierarchy from the start.

Core Schema Design

-- Organizations are the billing entity
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(255) UNIQUE NOT NULL,

  -- Billing information
  stripe_customer_id VARCHAR(255) UNIQUE,
  billing_email VARCHAR(255),
  tax_id VARCHAR(255),

  -- Subscription status
  plan_id VARCHAR(100),
  subscription_status VARCHAR(50), -- active, trialing, past_due, canceled
  trial_ends_at TIMESTAMP,
  subscription_ends_at TIMESTAMP,

  -- Settings
  settings JSONB DEFAULT '{}',

  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Users belong to organizations with specific roles
CREATE TABLE organization_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,

  role VARCHAR(50) NOT NULL, -- owner, admin, member, billing

  -- Permissions can be customized per member
  permissions JSONB DEFAULT '[]',

  invited_by UUID REFERENCES users(id),
  invited_at TIMESTAMP,
  joined_at TIMESTAMP,

  created_at TIMESTAMP DEFAULT NOW(),

  UNIQUE(organization_id, user_id)
);

CREATE INDEX idx_org_members_org ON organization_members(organization_id);
CREATE INDEX idx_org_members_user ON organization_members(user_id);

-- Track subscription changes for billing history
CREATE TABLE subscription_changes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID REFERENCES organizations(id),

  previous_plan_id VARCHAR(100),
  new_plan_id VARCHAR(100),

  change_type VARCHAR(50), -- upgrade, downgrade, cancellation, reactivation

  -- Financial impact
  mrr_change DECIMAL(10, 2),
  effective_date TIMESTAMP,

  changed_by UUID REFERENCES users(id),
  created_at TIMESTAMP DEFAULT NOW()
);

-- Usage tracking for metered billing
CREATE TABLE usage_records (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID REFERENCES organizations(id),

  metric_name VARCHAR(100) NOT NULL, -- api_calls, storage_gb, seats
  quantity INTEGER NOT NULL,

  period_start TIMESTAMP NOT NULL,
  period_end TIMESTAMP NOT NULL,

  synced_to_stripe BOOLEAN DEFAULT FALSE,
  stripe_usage_record_id VARCHAR(255),

  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_usage_org_period ON usage_records(organization_id, period_start, period_end);

The critical design decision: organizations are the billing entity, users are members of organizations. This supports the natural model where one company has multiple users, and users can belong to multiple organizations (agency users managing client accounts, consultants working with multiple companies). Don't conflate user identity with billing entity.

Handling Subscription Data

You have two options for storing subscription data: (1) Stripe as source of truth, query their API on portal load, or (2) Your database as source of truth, sync from Stripe webhooks. Option 1 is simpler initially but creates latency on every portal page load. Option 2 requires webhook infrastructure but provides instant portal loads and lets you query subscription data in analytics.

The production approach: store subscription metadata in your database, sync via webhooks, but treat Stripe as authoritative for billing operations. Your database has plan_id, subscription_status, and trial_ends_at for portal display. When users want to update their subscription, you redirect to Stripe Billing Portal or use Stripe API directly, then webhooks update your database.

// Webhook handler to sync Stripe subscription changes
app.post('/webhooks/stripe', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  switch (event.type) {
    case 'customer.subscription.updated':
    case 'customer.subscription.created':
      const subscription = event.data.object;
      await syncSubscription(subscription);
      break;

    case 'customer.subscription.deleted':
      const deletedSub = event.data.object;
      await handleSubscriptionCancellation(deletedSub);
      break;

    case 'invoice.paid':
      const invoice = event.data.object;
      await recordPayment(invoice);
      break;

    case 'invoice.payment_failed':
      const failedInvoice = event.data.object;
      await handlePaymentFailure(failedInvoice);
      break;
  }

  res.json({ received: true });
});

async function syncSubscription(stripeSubscription) {
  const organization = await db.organizations.findOne({
    stripe_customer_id: stripeSubscription.customer
  });

  if (!organization) {
    console.error('Organization not found for customer:', stripeSubscription.customer);
    return;
  }

  await db.organizations.updateOne(
    { id: organization.id },
    {
      $set: {
        plan_id: stripeSubscription.items.data[0].price.id,
        subscription_status: stripeSubscription.status,
        trial_ends_at: stripeSubscription.trial_end
          ? new Date(stripeSubscription.trial_end * 1000)
          : null,
        subscription_ends_at: new Date(stripeSubscription.current_period_end * 1000),
        updated_at: new Date()
      }
    }
  );

  // Record the subscription change for history
  await db.subscription_changes.create({
    organization_id: organization.id,
    new_plan_id: stripeSubscription.items.data[0].price.id,
    change_type: determineChangeType(organization.plan_id, stripeSubscription.items.data[0].price.id),
    effective_date: new Date()
  });
}
Warning: Never store credit card details in your database. Let Stripe handle all payment method storage. Your database should only store Stripe customer IDs and subscription metadata. Storing payment information yourself requires PCI compliance, which adds months of security work and ongoing audit costs that are unnecessary when Stripe handles it.

Building the Billing Management Interface

The billing section of your customer portal handles subscription viewing, plan changes, payment method updates, and invoice access. This is the highest-value portal feature because it directly impacts revenue (upgrades) and reduces churn (failed payment recovery).

Current Plan Display

Show users exactly what they're paying and when they'll be charged next. The minimal viable display includes: current plan name and price, billing period (monthly/annual), next billing date, and payment method (last 4 digits). This answers 90% of billing-related support questions.

// API endpoint for current subscription details
app.get('/api/portal/subscription', authenticateUser, async (req, res) => {
  const organization = await db.organizations.findOne({
    id: req.user.currentOrganizationId
  });

  if (!organization.stripe_customer_id) {
    return res.json({
      status: 'no_subscription',
      trial_ends_at: organization.trial_ends_at
    });
  }

  // Fetch current subscription from Stripe for real-time accuracy
  const subscriptions = await stripe.subscriptions.list({
    customer: organization.stripe_customer_id,
    status: 'active',
    limit: 1
  });

  if (subscriptions.data.length === 0) {
    return res.json({ status: 'no_active_subscription' });
  }

  const subscription = subscriptions.data[0];
  const priceId = subscription.items.data[0].price.id;
  const plan = await db.plans.findOne({ stripe_price_id: priceId });

  // Get payment method
  const paymentMethods = await stripe.paymentMethods.list({
    customer: organization.stripe_customer_id,
    type: 'card'
  });

  const defaultPaymentMethod = paymentMethods.data.find(
    pm => pm.id === subscription.default_payment_method
  );

  res.json({
    status: subscription.status,
    plan: {
      name: plan.name,
      price: subscription.items.data[0].price.unit_amount / 100,
      interval: subscription.items.data[0].price.recurring.interval,
      currency: subscription.items.data[0].price.currency
    },
    current_period_end: new Date(subscription.current_period_end * 1000),
    cancel_at_period_end: subscription.cancel_at_period_end,
    payment_method: defaultPaymentMethod ? {
      brand: defaultPaymentMethod.card.brand,
      last4: defaultPaymentMethod.card.last4,
      exp_month: defaultPaymentMethod.card.exp_month,
      exp_year: defaultPaymentMethod.card.exp_year
    } : null
  });
});

The UI should make it immediately clear whether the subscription is active, past due, or will cancel at period end. Use visual indicators (colors, icons) not just text. A subscription that will cancel in 3 days needs prominent display with a reactivation call-to-action.

Plan Upgrade and Downgrade Flows

Let users change plans without contacting support. The implementation challenge: handling prorations correctly. When a user upgrades mid-month from $50/mo to $100/mo, Stripe credits the unused portion of the $50 plan and charges a prorated amount for the $100 plan. You need to show users what they'll be charged today versus next month.

// Preview plan change with proration calculation
app.post('/api/portal/subscription/preview-change', authenticateUser, async (req, res) => {
  const { new_price_id } = req.body;
  const organization = await db.organizations.findOne({
    id: req.user.currentOrganizationId
  });

  const subscription = await stripe.subscriptions.retrieve(
    organization.stripe_subscription_id
  );

  // Calculate proration with Stripe API
  const invoice = await stripe.invoices.retrieveUpcoming({
    customer: organization.stripe_customer_id,
    subscription: subscription.id,
    subscription_items: [{
      id: subscription.items.data[0].id,
      price: new_price_id
    }],
    subscription_proration_behavior: 'create_prorations',
    subscription_proration_date: Math.floor(Date.now() / 1000)
  });

  res.json({
    immediate_charge: invoice.total / 100,
    proration_credit: invoice.lines.data
      .filter(line => line.proration)
      .reduce((sum, line) => sum + (line.amount / 100), 0),
    new_monthly_amount: invoice.lines.data
      .find(line => line.price.id === new_price_id)
      .amount / 100,
    next_billing_date: new Date(invoice.period_end * 1000)
  });
});

// Execute plan change
app.post('/api/portal/subscription/change-plan', authenticateUser, requireRole('admin'), async (req, res) => {
  const { new_price_id } = req.body;
  const organization = await db.organizations.findOne({
    id: req.user.currentOrganizationId
  });

  const subscription = await stripe.subscriptions.retrieve(
    organization.stripe_subscription_id
  );

  // Update subscription in Stripe
  const updated = await stripe.subscriptions.update(subscription.id, {
    items: [{
      id: subscription.items.data[0].id,
      price: new_price_id
    }],
    proration_behavior: 'create_prorations'
  });

  // Record the change
  await db.subscription_changes.create({
    organization_id: organization.id,
    previous_plan_id: subscription.items.data[0].price.id,
    new_plan_id: new_price_id,
    change_type: determineChangeType(subscription.items.data[0].price.id, new_price_id),
    changed_by: req.user.id,
    effective_date: new Date()
  });

  res.json({ success: true, subscription: updated });
});

Critical UX consideration: show the proration preview before confirming the change. Users need to understand: (1) What they'll be charged today, (2) What their new monthly payment will be, (3) When the next charge occurs. Without this preview, users are surprised by immediate charges and submit chargebacks or support tickets.

Scenario Proration Behavior User Impact
Upgrade mid-cycle Credit unused time, charge prorated new plan Immediate charge (partial month), higher monthly rate
Downgrade mid-cycle Credit applied to account, no refund No immediate charge, lower rate at next renewal
Cancel mid-cycle Access continues until period end No refund, service continues until paid period ends
Monthly to annual switch Credit unused monthly time toward annual Large immediate charge (full annual minus credit)
Pro Tip: For downgrades, apply the change at the end of the current billing period instead of immediately. This prevents users from losing access to features they've already paid for and reduces the "I downgraded but was still charged the full amount" support tickets. Stripe supports scheduled subscription changes via the schedule parameter.

Payment Method Management

Users need to update credit cards when they expire or change banks. Failed payments are a major source of involuntary churn—subscriptions cancelled because the payment method on file expired, not because the user wanted to leave. Making payment updates frictionless reduces this churn.

Using Stripe Billing Portal vs Custom UI

You have two options: build custom payment method UI or use Stripe's hosted Billing Portal. Custom UI gives you complete control over design and flow but requires handling PCI compliance considerations and building card input forms. Stripe Billing Portal is fully hosted, PCI-compliant, and handles all payment method management with one redirect.

The pragmatic choice for most SaaS: use Stripe Billing Portal for payment method updates, build custom UI for everything else. Payment method handling is the most security-sensitive part of billing, and Stripe's solution is production-ready, tested at scale, and automatically maintains PCI compliance.

// Generate Stripe Billing Portal session
app.post('/api/portal/create-billing-portal-session', authenticateUser, async (req, res) => {
  const organization = await db.organizations.findOne({
    id: req.user.currentOrganizationId
  });

  if (!organization.stripe_customer_id) {
    return res.status(400).json({ error: 'No payment account exists' });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: organization.stripe_customer_id,
    return_url: `${process.env.APP_URL}/portal/billing`
  });

  res.json({ url: session.url });
});

// Client-side: redirect to Billing Portal
async function updatePaymentMethod() {
  const response = await fetch('/api/portal/create-billing-portal-session', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` }
  });

  const { url } = await response.json();
  window.location.href = url; // Redirect to Stripe's hosted portal
}

The Stripe Billing Portal handles: payment method updates, viewing invoices, updating billing information, and cancelling subscriptions. You can configure which features are enabled. For many SaaS products, this eliminates 80% of customer portal billing features you'd otherwise need to build.

Handling Payment Failures Proactively

When a payment fails, don't wait for the user to notice. Send immediate notification and provide a clear path to update their payment method. Stripe automatically retries failed payments using Smart Retries, but you should also prompt users to fix the issue.

// Webhook handler for failed payment
async function handlePaymentFailure(invoice) {
  const organization = await db.organizations.findOne({
    stripe_customer_id: invoice.customer
  });

  // Update subscription status
  await db.organizations.updateOne(
    { id: organization.id },
    { $set: { subscription_status: 'past_due' } }
  );

  // Get all admins who can fix billing
  const admins = await db.organization_members.find({
    organization_id: organization.id,
    role: { $in: ['owner', 'admin', 'billing'] }
  });

  // Send notification to each admin
  for (const admin of admins) {
    const user = await db.users.findById(admin.user_id);

    await sendEmail({
      to: user.email,
      subject: 'Action Required: Payment Failed',
      html: `
        

Hi ${user.first_name},

We weren't able to process your payment for ${organization.name}.

Amount due: $${invoice.amount_due / 100}

Please update your payment method to avoid service interruption:

Update Payment Method

If your payment method is up to date, you can retry the payment immediately.

` }); } // Also create in-app notification await db.notifications.create({ organization_id: organization.id, type: 'payment_failed', severity: 'critical', message: 'Payment failed. Update your payment method to avoid service interruption.', action_url: '/portal/billing', created_at: new Date() }); }

Give users grace period before restricting access. The standard approach: allow 7-14 days past due before downgrading to free tier or restricting features. Most payment failures are legitimate issues (expired card, insufficient funds) that users fix quickly when notified. Immediate service cutoff creates poor experience and increases churn.

Invoice Management and Billing History

Enterprise customers need invoices for accounting and expense reporting. Self-serve customers want to see their payment history. An invoices section that lets users view, download, and search past invoices eliminates a common support request category.

Displaying Invoice History

// Fetch invoice history from Stripe
app.get('/api/portal/invoices', authenticateUser, async (req, res) => {
  const organization = await db.organizations.findOne({
    id: req.user.currentOrganizationId
  });

  if (!organization.stripe_customer_id) {
    return res.json({ invoices: [] });
  }

  const invoices = await stripe.invoices.list({
    customer: organization.stripe_customer_id,
    limit: 100
  });

  const formattedInvoices = invoices.data.map(inv => ({
    id: inv.id,
    number: inv.number,
    amount: inv.total / 100,
    currency: inv.currency,
    status: inv.status,
    due_date: inv.due_date ? new Date(inv.due_date * 1000) : null,
    paid_at: inv.status_transitions.paid_at
      ? new Date(inv.status_transitions.paid_at * 1000)
      : null,
    invoice_pdf: inv.invoice_pdf,
    hosted_invoice_url: inv.hosted_invoice_url
  }));

  res.json({ invoices: formattedInvoices });
});

// Download invoice PDF
app.get('/api/portal/invoices/:invoice_id/pdf', authenticateUser, async (req, res) => {
  const organization = await db.organizations.findOne({
    id: req.user.currentOrganizationId
  });

  const invoice = await stripe.invoices.retrieve(req.params.invoice_id);

  // Verify invoice belongs to this organization
  if (invoice.customer !== organization.stripe_customer_id) {
    return res.status(403).json({ error: 'Unauthorized' });
  }

  // Redirect to Stripe-hosted PDF
  res.redirect(invoice.invoice_pdf);
});

Display invoices in reverse chronological order with clear status indicators. Users primarily care about recent invoices (last 3-6 months) but need access to all historical invoices for tax and compliance purposes. Implement pagination or infinite scroll if invoice history exceeds 20-30 items.

Handling Invoice Customization

Enterprise customers often need customized invoices: company tax ID, purchase order numbers, specific billing addresses. Stripe supports custom invoice fields, but you need to let users configure these in your portal.

// Update billing details for invoices
app.put('/api/portal/billing-details', authenticateUser, requireRole('admin'), async (req, res) => {
  const {
    company_name,
    tax_id,
    billing_email,
    address_line1,
    address_line2,
    city,
    state,
    postal_code,
    country
  } = req.body;

  const organization = await db.organizations.findOne({
    id: req.user.currentOrganizationId
  });

  // Update Stripe customer
  await stripe.customers.update(organization.stripe_customer_id, {
    name: company_name,
    email: billing_email,
    address: {
      line1: address_line1,
      line2: address_line2,
      city,
      state,
      postal_code,
      country
    },
    tax_id: tax_id ? {
      type: determineTaxIdType(country, tax_id),
      value: tax_id
    } : undefined
  });

  // Update local database
  await db.organizations.updateOne(
    { id: organization.id },
    {
      $set: {
        billing_email,
        tax_id,
        'settings.billing_address': {
          company_name,
          line1: address_line1,
          line2: address_line2,
          city,
          state,
          postal_code,
          country
        }
      }
    }
  );

  res.json({ success: true });
});

Team and User Management

Team management lets account owners invite users, assign roles, and remove team members. This is critical for adoption—if the person who signed up can't easily add their team, the product remains single-user and has lower perceived value.

Invitation Flow

When an admin invites a user, create a pending invitation that the invitee accepts by clicking an email link. Don't automatically create accounts—require acceptance to avoid adding users who didn't consent. The invitation should expire after 7 days to prevent security issues from stale invite links.

// Invite user to organization
app.post('/api/portal/team/invite', authenticateUser, requireRole('admin'), async (req, res) => {
  const { email, role } = req.body;
  const organization = await db.organizations.findById(req.user.currentOrganizationId);

  // Check if user already exists
  const existingMember = await db.organization_members.findOne({
    organization_id: organization.id,
    user_id: await getUserIdByEmail(email)
  });

  if (existingMember) {
    return res.status(400).json({ error: 'User already a member' });
  }

  // Create invitation
  const invitation = await db.invitations.create({
    organization_id: organization.id,
    email,
    role,
    token: crypto.randomBytes(32).toString('hex'),
    invited_by: req.user.id,
    expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    created_at: new Date()
  });

  // Send invitation email
  await sendEmail({
    to: email,
    subject: `You've been invited to join ${organization.name}`,
    html: `
      

${req.user.name} has invited you to join ${organization.name}.

Role: ${role}

Accept Invitation

This invitation expires in 7 days.

` }); res.json({ success: true, invitation_id: invitation.id }); }); // Accept invitation app.post('/api/accept-invitation', async (req, res) => { const { token } = req.body; const invitation = await db.invitations.findOne({ token, expires_at: { $gt: new Date() }, accepted_at: null }); if (!invitation) { return res.status(400).json({ error: 'Invalid or expired invitation' }); } // Get or create user account let user = await db.users.findOne({ email: invitation.email }); if (!user) { user = await db.users.create({ email: invitation.email, // User will complete profile during onboarding created_at: new Date() }); } // Add user to organization await db.organization_members.create({ organization_id: invitation.organization_id, user_id: user.id, role: invitation.role, invited_by: invitation.invited_by, invited_at: invitation.created_at, joined_at: new Date() }); // Mark invitation as accepted await db.invitations.updateOne( { id: invitation.id }, { $set: { accepted_at: new Date() } } ); res.json({ success: true, user_id: user.id }); });

Role-Based Access Control

Implement clear role hierarchy with distinct permissions. Typical SaaS roles: Owner (full control, can delete organization), Admin (can manage team and billing), Member (can use product), Billing (can manage payment methods and view invoices but not product access).

Role Permissions Use Case
Owner All permissions, delete org, transfer ownership Person who created account, typically one per org
Admin Manage team, billing, settings, product access Trusted team members who manage the account
Member Product access only, no admin functions Regular users who need to use the product
Billing View/update payment methods, view invoices Finance team members who handle payments
Read-only View data, no modifications Stakeholders, auditors, reporting purposes
Critical Security: Always verify role permissions server-side. Never trust client-side role checks. An admin can modify client code to show owner-level features, but your API must reject those requests. Implement middleware that checks permissions against the database on every protected endpoint.

Usage Dashboard and Billing Visibility

For usage-based or metered billing, users need visibility into current usage, projected costs, and usage trends. Surprise bills create churn. Transparent usage dashboards build trust and let users optimize their consumption.

Current Usage Display

// Get current billing period usage
app.get('/api/portal/usage/current', authenticateUser, async (req, res) => {
  const organization = await db.organizations.findById(req.user.currentOrganizationId);

  // Get subscription to determine billing period
  const subscription = await stripe.subscriptions.retrieve(
    organization.stripe_subscription_id
  );

  const periodStart = new Date(subscription.current_period_start * 1000);
  const periodEnd = new Date(subscription.current_period_end * 1000);

  // Aggregate usage for current period
  const usage = await db.usage_records.aggregate([
    {
      $match: {
        organization_id: organization.id,
        period_start: { $gte: periodStart },
        period_end: { $lte: periodEnd }
      }
    },
    {
      $group: {
        _id: '$metric_name',
        total_quantity: { $sum: '$quantity' }
      }
    }
  ]);

  // Get plan limits
  const plan = await db.plans.findOne({
    stripe_price_id: subscription.items.data[0].price.id
  });

  const usageWithLimits = usage.map(metric => ({
    metric: metric._id,
    current: metric.total_quantity,
    limit: plan.limits[metric._id],
    percentage: (metric.total_quantity / plan.limits[metric._id]) * 100,
    overage: Math.max(0, metric.total_quantity - plan.limits[metric._id])
  }));

  res.json({
    period_start: periodStart,
    period_end: periodEnd,
    days_remaining: Math.ceil((periodEnd - Date.now()) / (1000 * 60 * 60 * 24)),
    usage: usageWithLimits
  });
});

Show usage as percentage of limit with visual indicators. When users approach 80% of their limit, show a warning. At 100%, show an upgrade prompt. Don't wait until they hit the limit—proactive visibility prevents service interruptions and drives upgrades.

Usage Trends and Predictions

Advanced usage dashboards show historical trends and projected end-of-month usage. This helps users understand if current usage is normal or anomalous and whether they need to upgrade or optimize.

// Historical usage trends
app.get('/api/portal/usage/trends', authenticateUser, async (req, res) => {
  const organization = await db.organizations.findById(req.user.currentOrganizationId);

  const trendsData = await db.usage_records.aggregate([
    {
      $match: {
        organization_id: organization.id,
        period_start: { $gte: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) }
      }
    },
    {
      $group: {
        _id: {
          metric: '$metric_name',
          month: { $dateToString: { format: '%Y-%m', date: '$period_start' } }
        },
        total: { $sum: '$quantity' }
      }
    },
    {
      $sort: { '_id.month': 1 }
    }
  ]);

  // Calculate projected usage for current month
  const currentMonth = new Date().toISOString().slice(0, 7);
  const daysInMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate();
  const dayOfMonth = new Date().getDate();

  const projections = trendsData
    .filter(d => d._id.month === currentMonth)
    .map(d => ({
      metric: d._id.metric,
      current: d.total,
      projected: Math.round((d.total / dayOfMonth) * daysInMonth)
    }));

  res.json({
    trends: trendsData,
    projections
  });
});

The projection algorithm is simple: (current usage / days elapsed) * days in month. This gives users a rough estimate of month-end usage based on current consumption rate. Add a disclaimer that this is an estimate and actual usage may vary.

Frequently Asked Questions

Should I build a custom portal or use a pre-built solution?

Build custom for core differentiators (unique billing models, complex team structures), use pre-built for commodity features (payment methods, basic invoices). Services like Stripe Billing Portal, Chargebee Portal, or CustomerPortal.dev handle standard billing UI. The decision point: if your billing model or organizational structure is standard (monthly/annual subscriptions, simple team roles), pre-built solutions save 40-60 hours of development. If you have usage-based pricing with multiple dimensions or complex hierarchy (teams within organizations within enterprises), custom is necessary.

How do I handle users who belong to multiple organizations?

Store organization membership in a junction table (organization_members) that maps users to organizations with their role in each. When a user logs in, either default to their most recently active organization or show an organization picker. Store current_organization_id in the session or JWT token. Every API request must include organization context—either from the token or an X-Organization-ID header. Validate that the user has access to the requested organization before processing any request. This pattern supports agency users managing multiple clients or consultants working with multiple companies.

What's the best way to handle seat-based billing in the portal?

Automatically update subscription quantity when team members are added or removed. When an admin invites a user and they accept, increment the subscription quantity in Stripe. When a user is removed, decrement it. Stripe handles prorated charges automatically. Show current seat count and cost per seat in the portal. For enterprise plans with fixed seat counts, track seat usage separately and show "X of Y seats used" without automatic billing updates—require admin action to purchase additional seats.

How do I prevent unauthorized access to billing information?

Implement role-based access control with a dedicated billing role. Only users with Owner, Admin, or Billing roles can view payment methods and invoices. Regular Members should not see billing information—this prevents junior team members from accessing company financial data. On every billing endpoint, check req.user.role against allowed roles before returning data. For sensitive operations (changing plans, updating payment methods), require re-authentication even for authorized users—implement step-up authentication that prompts for password or MFA.

Should I show usage data in real-time or with delay?

Show usage with 5-15 minute delay rather than true real-time. Real-time usage requires complex infrastructure (websockets, streaming aggregations) for marginal user benefit. Users don't need to see API call count update every second—they need accurate daily trends and month-to-date totals. Aggregate usage every 5-15 minutes into a summary table and query that. This reduces database load by 95% compared to querying raw event tables and provides sufficiently fresh data for user decision-making.

How do I handle failed payment recovery in the portal?

Show a prominent banner when subscription is past_due with a direct link to update payment method. Stripe automatically retries failed payments, but users should be able to manually retry after fixing the issue. Implement a "Retry Payment" button that calls stripe.invoices.pay() to attempt immediate payment with the updated method. Send email notifications at day 1, 3, and 7 of past_due status. After 14 days, downgrade to free tier or restrict access rather than deleting data—this gives users a grace period to resolve payment issues without losing their work.

What invoicing features do enterprise customers actually need?

Enterprise customers need: (1) Customizable invoice fields (tax ID, PO number, billing address), (2) Multi-currency support if selling internationally, (3) Invoice PDF downloads in a format acceptable to their accounting systems, (4) The ability to add billing contacts who receive invoices but don't have product access, (5) Net-30 or Net-60 payment terms instead of automatic charging. For enterprises on annual contracts, generate a single invoice at contract start rather than monthly invoices. Support custom billing schedules (quarterly, semi-annual) if required by contract.

How do I handle subscription cancellations without increasing churn?

Make cancellation easy but include retention hooks. When users click "Cancel Subscription", show a cancellation flow that: (1) Asks for cancellation reason (collect feedback), (2) Offers to pause subscription instead (1-3 month pause), (3) Offers a discount or downgrade if price is the issue, (4) Confirms that data will be retained for 30 days if they want to reactivate. Don't force users through a support call to cancel—this creates frustration and damages brand reputation. Track cancellation reasons to identify product issues. High cancellation due to "too expensive" signals pricing misalignment. High cancellation due to "not using it" signals activation or engagement problems.

Should customer portal be part of the main app or a separate subdomain?

Keep it in the main application as a /portal or /account route unless you have security reasons to separate it. A separate subdomain (portal.yourapp.com) adds complexity: separate authentication cookies, CORS configuration, and harder session management. The benefit is security isolation—if your main app has a vulnerability, the portal remains protected. For most SaaS products, the added complexity outweighs the security benefit. Use the same application with route-based separation. For extremely high-security contexts (financial services, healthcare), consider subdomain isolation.

How do I show costs for usage-based pricing before charges occur?

Calculate estimated cost based on current usage and display it prominently in the portal. Show: "Based on current usage (15,234 API calls), your estimated charge this month is $76.17". Update this daily. Use Stripe's upcoming invoice API to get the exact amount that will be charged: stripe.invoices.retrieveUpcoming({ customer: customer_id }). This preview includes metered usage, subscriptions, and any credits or discounts. Show this in a "Projected Bill" section with a breakdown by charge type. For users approaching budget limits, send email alerts at 50%, 80%, and 100% of expected monthly spend.

Conclusion

A functional customer portal reduces support load, increases self-service upgrades, and improves retention by giving users control over their account. The features that matter most—billing visibility, team management, and usage tracking—are not complex to build but require careful attention to user experience and security.

Start with billing features first. Users will tolerate manual team invites via email but won't tolerate opaque billing or inability to update payment methods. Build subscription display, plan upgrades, and payment method management before team features. Then add team invitations and role management. Finally, layer in usage dashboards if your pricing model requires them.

The portal is never "done"—it evolves with your product and business model. As you add enterprise features, the portal needs SSO management and department-level usage visibility. As you add usage-based pricing, the portal needs real-time usage tracking and cost alerts. Build the MVP that handles today's support tickets, then incrementally add features that prevent tomorrow's.


Share on Social Media: