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.
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.