Customer Portal & Self-Service

A self-service billing portal lets customers update cards, change plans, and download invoices without a support ticket — but it sits directly on top of the financial ledger, so a careless implementation turns every button into a race condition. This guide is part of Frontend Checkout UX & Dunning Recovery Flows and treats the portal as a read-optimized projection over an eventually consistent billing system: user actions emit domain events, and authoritative state is reconciled asynchronously rather than mutated inline.

The portal must offload PCI scope to the vault, present accurate proration before a customer confirms a change, and never let a portal action collide with the dunning engine running in the background. Engineers building this surface should assume out-of-order webhooks, double-clicks, and stale browser state as the default operating condition.

Prerequisites

Architecture & Data Flow

The portal frontend captures input and renders state; the backend converts each action into a domain event, applies it inside a transaction, and lets idempotent consumers reconcile the ledger against gateway settlements. Reads are served from a projection optimized for the portal’s access patterns, isolating heavy history queries from the write path.

Portal action and reconciliation flow Portal actions become domain events, are reconciled by idempotent consumers against gateway settlements, and projected into read models for the portal UI. Portal UI (browser) Command API emit event Idempotent consumer Ledger Gateway settlements Read model (invoices)
Portal commands flow right toward the ledger; reconciled state flows back along the gold path into a read model the UI queries.

Partition the consumer by customer_id so a single customer’s events execute linearly — this guarantees a portal-initiated plan change and a background renewal never interleave incorrectly on the same subscription.

Implementation Walkthrough

1. Scope every read to the authenticated customer

Authorization is the first correctness boundary. A portal that leaks another customer’s invoices is both a privacy breach and a financial one.

// Express middleware: bind the session to a single customer_id
function requireCustomer(req: Request, res: Response, next: NextFunction) {
  const customerId = req.session?.customerId;
  if (!customerId) return res.status(401).json({ error: 'unauthenticated' });
  req.customerId = customerId; // ✅ all downstream queries scope to this id
  next();
}

2. Capture payment methods through hosted fields

The new card never touches your server. The hosted element returns a token; you store only the reference.

app.post('/portal/payment-method', requireCustomer, async (req, res) => {
  const { gatewayToken, last4, brand, expMonth, expYear } = req.body;
  await db.query(
    `INSERT INTO payment_methods
       (customer_id, gateway_token, last4, brand, exp_month, exp_year, is_default)
     VALUES ($1, $2, $3, $4, $5, $6, true)`,
    [req.customerId, gatewayToken, last4, brand, expMonth, expYear]
  ); // ⚠️ gatewayToken is a vault reference, never a PAN
  res.json({ status: 'saved' });
});

3. Apply portal actions as domain events

A plan change does not write the ledger directly — it records an intent that consumers reconcile.

app.post('/portal/plan-change', requireCustomer, async (req, res) => {
  const { targetPriceId, idempotencyKey } = req.body;
  await db.query(
    `INSERT INTO billing_events (customer_id, type, payload, idempotency_key)
     VALUES ($1, 'plan_change_requested', $2, $3)
     ON CONFLICT (idempotency_key) DO NOTHING`,
    [req.customerId, JSON.stringify({ targetPriceId }), idempotencyKey]
  );
  res.status(202).json({ status: 'accepted' }); // ✅ async; UI polls for confirmation
});

For the full upgrade/downgrade/cancel surface including proration preview, see Building a Self-Service Plan Change Flow.

4. Reconcile from idempotent webhook consumers

Settlement and subscription webhooks converge the ledger. Deduplicate on event id, validate sequence, commit atomically.

def consume(event: dict, ledger, store) -> None:
    event_id = event["id"]
    if store.exists(event_id):
        return  # ✅ duplicate delivery — no-op
    with db.transaction():
        ledger.apply(event)
        store.mark(event_id, ttl_seconds=604800)  # 7-day retry window

5. Project read-optimized history views

Serve invoice and subscription history from a denormalized projection so portal browsing never contends with billing writes.

CREATE MATERIALIZED VIEW portal_invoice_history AS
SELECT i.invoice_id, i.customer_id, i.amount_minor, i.currency,
       i.status, i.issued_at, pm.brand, pm.last4
FROM invoices i
LEFT JOIN payment_methods pm ON pm.payment_method_id = i.payment_method_id;

CREATE INDEX idx_portal_history_customer
  ON portal_invoice_history (customer_id, issued_at DESC);

Edge Cases & Failure Modes

Scenario Mitigation
Out-of-order webhook delivery desyncs ledger Sequence validation; buffer events until predecessor commits
Customer double-submits a plan change Idempotency key on the command insert collapses the duplicate
Portal cancel collides with active dunning retry Pause dunning on cancel intent; revoke access at period end
Tax rate changes mid-cycle Freeze rate at invoice timestamp; apply new rate next cycle
Custom UI component pulls PCI scope in Capture only via hosted fields; audit DOM access to inputs
Stale browser shows old plan after change Poll command status; reconcile optimistic UI against server

Performance & Scale

Index the read model on (customer_id, issued_at DESC) so history pagination is a single index range scan. Refresh materialized views incrementally or on settlement webhooks rather than on a fixed cron, to keep portal data fresh without full rebuilds. Cache the rendered invoice list per customer with a short TTL (30–60s) and invalidate on any reconciled billing event for that customer. Keep the command path lean — a portal action should return 202 Accepted in under 100ms and let consumers do the heavy reconciliation off the request thread.

Testing Strategy

Test the consumer with a replay harness that delivers the same webhook twice and asserts a single ledger entry. Use a mock clock to verify that a cancellation requested mid-cycle revokes access exactly at period end, not immediately. Forge a webhook with an invalid signature and assert it is rejected before any state mutation. Deliver events out of order (sequence 3 before sequence 2) and assert the buffer holds event 3 until 2 commits. Finally, fuzz the authorization boundary: request another customer’s invoice id and assert a 404, never a 403 that confirms existence.

Frequently Asked Questions

How do I keep the portal from expanding PCI scope? Capture every card through hosted tokenization fields that communicate directly with the vault, and store only the returned token plus display metadata (brand, last4, expiry). Raw PAN never reaches your servers or logs, keeping most deployments at SAQ A — see Secure Card Vaulting & Tokenization.

What happens if a customer cancels while a dunning retry is in flight? Treat cancellation as a high-priority intent that pauses the dunning campaign for that subscription immediately, then schedules access revocation at period end. This prevents the recovery engine and the portal from issuing conflicting state transitions — the coordination logic lives in Grace Period & Retry Logic.

Should portal actions write to the ledger synchronously? No. Emit a domain event and return 202 Accepted, then let idempotent consumers reconcile against gateway settlements. Synchronous ledger writes from a browser action create race conditions with background billing jobs and couple UI latency to financial consistency.

How do I show accurate plan-change pricing before the customer confirms? Compute a proration preview server-side from the current cycle boundaries and the target price, and render the exact prorated amount plus tax before confirmation. The full preview-and-confirm pattern is in Building a Self-Service Plan Change Flow.