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.
It helps to name what the portal is not. It is not the system of record for money — that is the ledger. It is not the authority on subscription lifecycle — that is the FSM behind the Command API. It is not where retries, proration math, or tax calculation live. The portal is a thin, heavily-cached read surface plus a command inbox, and almost every serious bug in a self-service billing UI comes from an engineer who forgot that boundary and let the browser mutate authoritative state directly. When a customer clicks “Cancel,” the portal’s job is to record the intent durably and hand it to the parts of the system that already know how to unwind a subscription safely; it is emphatically not to run an UPDATE subscriptions SET status='canceled' from the request handler. Holding that line is what makes the difference between a portal that survives a Black Friday traffic spike and one that double-charges a customer because two tabs and a webhook all wrote the same row.
Prerequisites
The portal is a projection over an eventually consistent ledger, so it needs a vault, an idempotency store, scoped auth, an FSM, and read models. The stack lists them before the checklist.
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.
The dual-write problem and the outbox
The tempting shortcut is to have the Command API write the billing_events row and publish to the message broker in the same handler. That is a dual write across two systems with no shared transaction: the row commits, the process dies before the publish, and the event exists in Postgres but never reaches a consumer. The portal shows 202 Accepted, the customer waits, and nothing ever reconciles. The fix is the transactional outbox. Write the event only to the database inside the request transaction, and let a separate relay poll the billing_events table for rows with published_at IS NULL, publish them to the broker, and stamp published_at once the broker acknowledges. Because the relay is at-least-once, a crash between publish and stamp replays the event — which is exactly why every consumer downstream must key on idempotency_key or the gateway event_id. The outbox turns an unsolvable distributed-transaction problem into an ordinary single-database transaction plus an idempotent reader.
Ordering, sequence numbers, and the buffering window
Partitioning by customer_id gives you per-customer linear execution on the consumer, but it does not give you ordering across the network. A gateway can emit invoice.payment_succeeded and invoice.payment_failed for the same invoice_id within the same second, and HTTP retries can deliver them in either order. Do not trust wall-clock timestamps to sort them — clock skew between the gateway’s edge nodes and your ingest is routinely 200ms to several seconds. Instead, carry a monotonic per-subscription sequence number in the event envelope and track the last applied sequence per subscription_id in a small table. When sequence 3 arrives before sequence 2, park event 3 in a pending buffer keyed on (subscription_id, sequence) and apply it only once 2 commits. Keep the buffer window bounded — 60 to 120 seconds is generous for a webhook that is merely late — and alert if an event sits unbuffered past that, because a permanently missing predecessor usually means a dropped delivery that needs a manual replay from the gateway dashboard rather than an infinite wait.
Why the read model lags, and how much lag is acceptable
The gold reconciliation path in the diagram is asynchronous by construction, so the read model the UI queries is always slightly behind the ledger. The engineering question is not whether to accept staleness but how to bound it and how to communicate it. A settlement webhook that lands in 300ms and a materialized-view refresh that runs in another 200ms means the portal shows a paid invoice roughly half a second after the money moves — fine. The failure mode is unbounded lag: a backed-up consumer partition can push the read model minutes behind, so a customer who just paid still sees past_due and re-enters a card. Expose a per-customer reconciliation watermark (the timestamp of the last applied event) and, in the UI, show optimistic pending state for actions whose events have not yet been reconciled rather than pretending the change already landed.
Implementation Walkthrough
The five steps establish the CQRS shape: scope reads to the customer, capture cards via hosted fields, apply actions as domain events (not direct ledger writes), reconcile from idempotent consumers, and serve history from a projection. The command/query split diagram shows why a portal action returns 202, not a ledger write.
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.
The rule that prevents almost every leak is simple to state and easy to violate: the customer_id used in a query must come from the server-side session, never from the request body or a URL parameter. The moment a route reads req.params.customerId and trusts it, an attacker increments the value and walks the entire customer table. Bind the identity once, in middleware, and thread it through every downstream call as an argument the handler cannot override. Then enforce it a second time at the data layer: every query against a customer-owned table must include a WHERE customer_id = $1 predicate, and the safest way to guarantee that is Postgres row-level security with a session variable, so a forgotten predicate fails closed instead of returning someone else’s invoice_id. Defense in depth matters here because the blast radius of a single missing clause is the whole book of business.
One subtlety often missed: a customer is not always a single natural person. Team plans, agencies managing sub-accounts, and parent/child billing hierarchies mean a session may legitimately span several customer_id values, or a customer_id may be readable by multiple authenticated users with different roles. Model that explicitly as a membership table joining users to the customer records they may act on, and resolve the allowed set at session establishment. Do not paper over it by widening the scope check, because “this user can read this customer” and “this user can change this customer’s plan” are different authorizations — a seat-level member browsing invoices should not be able to cancel the subscription that funds their colleagues.
// 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.
Setting a new card as default is not the whole job — you also have to decide what happens to the old one and to any payments already scheduled against it. Deleting the previous payment_method outright is a mistake: a dunning retry or a proration charge queued minutes ago may still reference it, and yanking the token out from under an in-flight charge produces a confusing “payment method not found” failure that looks like a decline to the customer. Mark the old method inactive rather than deleting the row, keep the vault token until every open authorization referencing it has settled, and only then let a background reaper purge it. Similarly, when the customer adds a card specifically to clear a past_due invoice, do not silently retry in the same request. Save the method, return 202, and let the reconciliation path trigger the retry so the attempt is idempotent and observable rather than buried in a synchronous handler that might time out mid-charge.
There is also a verification decision to make at capture time. A hosted field can tokenize a card that is expired, over its limit, or subject to issuer fraud rules — tokenization is not authorization. If you want to know the card actually works before you show the customer a reassuring green checkmark, run a zero-amount or small account-verification authorization (and immediately void it) through the gateway, and store the result alongside the token. That extra round trip costs a few hundred milliseconds but prevents the common support ticket where a customer “updated their card” in the portal, saw success, and then failed their next renewal because the number was fat-fingered.
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.
The idempotency_key in that insert is doing more work than it looks. It must be generated on the client and held stable across retries of the same user intent — a common pattern is to mint a UUID when the confirm dialog opens and reuse it for every submit attempt until the server acknowledges, so a double-click, a flaky network retry, and a browser refresh all collapse to one plan_change_requested event. If instead you generate the key server-side on each request, you have defeated the entire mechanism: two rapid submits become two distinct keys and two plan changes. The ON CONFLICT (idempotency_key) DO NOTHING clause then makes the write safely repeatable, but note what it does not do — it returns success without telling the caller whether this was the first insert or a duplicate. If the client needs to distinguish “your request was accepted” from “we already had this,” add a RETURNING clause and check whether a row came back, or the UI cannot tell an idempotent replay from a genuine no-op.
A second consideration is the shape of the payload. Store the target as a stable price_id or plan_id, never a computed amount, because the customer’s confirmation happened against a proration preview that could be seconds stale by the time the consumer applies it. The consumer recomputes the authoritative proration at apply time from the current cycle boundaries; if the recomputed figure diverges from what the customer was shown beyond a small tolerance, the correct behavior is to reject the event and surface a “pricing changed, please review” prompt rather than silently charging a different number than the one the customer clicked to approve.
4. Reconcile from idempotent webhook consumers
Settlement and subscription webhooks converge the ledger. Deduplicate on event id, validate sequence, commit atomically.
The dedup check and the ledger write must live inside the same transaction, and the order matters. In the snippet, store.mark(event_id) runs after ledger.apply(event) but before the transaction commits, so either both persist or neither does. If you mark the event processed in a separate transaction that commits first and the ledger write then fails, you have poisoned the pipeline: the retry sees the event as already handled and skips it, and the ledger silently misses the entry forever. The ttl_seconds=604800 seven-day window is sized to the gateway’s own retry schedule — most providers retry a failed webhook with backoff for several days, so the dedup key must outlive the longest possible legitimate redelivery. Set the TTL too short and a webhook redelivered on day four sails past an expired key and applies twice; a duplicate refund or a double credit is the result.
Signature verification belongs at the very top of this function, before the dedup lookup and before any parsing that trusts the body. Verify the HMAC over the raw request bytes using the gateway’s signing secret, and reject with 400 on mismatch — never 200, which tells the sender to stop retrying a message you refused. A frequent mistake is to verify the signature against a re-serialized JSON object; frameworks reorder keys and normalize whitespace, so the recomputed signature never matches. Capture the raw body before any middleware parses it. Only once the signature is proven do you trust the event_id, the sequence number, and the payload enough to touch the ledger.
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.
A materialized view is the simplest projection, but REFRESH MATERIALIZED VIEW on the whole view is an O(all-invoices) operation that grows with your total book and will eventually take longer than the interval between refreshes. Two paths scale better. The first is REFRESH MATERIALIZED VIEW CONCURRENTLY, which requires a unique index on the view and lets reads continue during the refresh at the cost of more work — acceptable up to a point. The second, and the one large deployments converge on, is to abandon the database-managed materialized view for a plain projection table that the reconciliation consumer writes to incrementally: when an invoice reconciles, upsert exactly that one invoice_id row into portal_invoice_history. That turns refresh cost from O(all invoices) into O(one row per event) and keeps the read model within a few hundred milliseconds of the ledger regardless of how large the history grows. The materialized-view approach shown here is the right starting point; migrate to the incremental table once a single refresh crosses roughly a second or the view exceeds a few million rows.
Denormalizing brand and last4 onto each history row deserves a note, because payment-method attributes change. If a customer replaces their card, historical invoices should still show the card that actually paid them, not the current default — an invoice paid in March by a Visa ending 4242 must keep saying so even after the customer switches to a Mastercard in June. That is an argument for snapshotting the display metadata onto the invoice row at settlement time rather than joining live to payment_methods, since the live join would rewrite history every time the card changes. Decide this deliberately: a live join keeps one source of truth but misrepresents the past, while a snapshot preserves an accurate audit trail at the cost of storing the brand and last4 twice.
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
The portal edge cases split into ledger sync (out-of-order webhooks, double submits), cross-engine collisions (cancel vs dunning), and scope/PCI leaks. The map groups them so the defense — sequencing, pause-on-cancel, or hosted-field capture — is obvious.
| 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 |
The stale-browser row hides a subtle race worth spelling out. A customer opens the portal in two tabs, changes their plan in the first, and the second tab still holds the pre-change state in memory. If that second tab now submits its own action — say, canceling — it does so against a subscription version it believes is current but is not. Guard writes with an optimistic-concurrency token: return a version or updated_at with every subscription read, require the client to echo it on the next command, and reject with 409 Conflict when the token no longer matches the server’s current version. The UI then refetches and asks the customer to re-confirm against fresh state. Without this, the second tab’s command silently clobbers the first tab’s change, and the customer is left on a plan neither of them intended.
The tax-rate row also deserves nuance. Freezing the rate at the invoice timestamp is correct for a closed invoice, but a plan change mid-cycle generates a proration line that must use the rate in effect on the proration date, not the original subscription date. Jurisdictions change VAT and sales-tax rates on statutory effective dates, and a customer who upgrades the day after a rate change should be prorated at the new rate for the new portion. Resolve the rate per line item against its own service-period start, not once per invoice, or a multi-line prorated invoice will apply a single wrong rate to charges that legally straddle a rate boundary.
Performance & Scale
The portal’s read/write asymmetry is the whole story: a lean command path returns 202 in under 100ms, while history reads come from an indexed, cached projection refreshed on settlement webhooks. The diagram shows the split.
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
The tests cover consumer idempotency, cancel timing, signature rejection, out-of-order buffering, and the authorization boundary (404, never 403). The panel lists them before the detail.
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.
How should the portal poll for the result of an async action?
After a 202, the client should poll a status endpoint keyed on the same idempotency_key it submitted, with exponential backoff starting around 500ms and capped near 4s, giving up after 20–30s into a “still processing, we’ll email you” state rather than spinning forever. The status endpoint reads the projection, so it reflects the reconciled outcome — applied, rejected, or still pending — without touching the write path. Avoid tight fixed-interval polling from thousands of open tabs; it turns a quiet portal into a self-inflicted thundering herd against the read model. Where your stack supports it, a server-sent event or a single WebSocket message that fires when the customer’s reconciliation watermark advances past the action’s event is strictly better than polling, because it collapses the latency to the moment the consumer commits.
Can a customer delete their payment history or invoices from the portal?
No, and the portal should not offer it. Invoices are financial records with statutory retention requirements — commonly seven to ten years depending on jurisdiction — so “delete” in the UI must mean hide-from-view at most, never a hard delete of the underlying invoice_id. Model visibility as a separate flag on the read projection and keep the ledger immutable. The same applies to a closed account: retain the billing records under the original customer_id, redact or tokenize personal data to satisfy erasure requests where the two obligations conflict, but never destroy the financial trail a future audit or chargeback dispute will need.
Where should refunds and credits be initiated — the portal or an internal tool?
Keep customer-initiated refunds out of the self-service portal in almost every case. A refund is a privileged, often irreversible money movement that wants a human approval step, a reason code, and fraud checks that do not belong in an unauthenticated-adjacent surface. The portal can let a customer request a refund, which records a refund_requested event routed to a support queue, but the actual refund_issued transition should originate from an internal tool with its own authorization and its own idempotency key. This keeps the portal’s command surface small and auditable, and it prevents the obvious abuse where a customer scripts repeated refund requests against a single charge.