MRR Dashboard: incremental per-customer Stripe refresh¶
Context¶
Services/MrrDashboardService.cs powers the CRM Customers page (/Customer/MrrTransactions). Today it rebuilds the entire payload in one shot every 10 minutes via Raklet.WebJobs.Secondary/CrmDashboard/MrrDashboardWarmFunctions.cs. Three follow-ups in docs/payments/mrr-dashboard-prewarm.md were explicitly deferred:
- Per-customer Stripe data caching with longer TTL (incremental refresh).
- Pre-warming for
days≠ 30. - Webhook-driven cache invalidation.
This plan covers (1) — the incremental refresh — because the all-or-nothing rebuild has shown two problems in production after the warm job shipped:
- One bad Stripe customer or revoked Connect account causes the warm cycle to re-query Stripe and re-throw on every 10-minute tick. The previous PR (this branch's predecessor,
fix/crm-mrr-stale-stripe-cleanup) added a 24h denylist that hides the noise, but the denylist is a band-aid: the data for those orgs is still missing from the payload, and a customer whose Stripe state legitimately changes within 24h won't be picked up until the denylist expires. - The cap
CrmMrrDashboard-MaxLifetimeCustomers=1000is enforced every cycle, so any customer past the cap re-falls-back to localTotalProfitrepeatedly even though their Stripe LTV has never changed. A per-customer cache with a much longer TTL than the dashboard payload makes the cap irrelevant for steady-state — only newly-seen customers need a Stripe round-trip.
Goal: every customer row on the dashboard reads from a per-customer cache entry that is refreshed by a background queue, one customer at a time, on a schedule that decouples from the user-facing dashboard rebuild.
Approach¶
Three pieces, each fits in the existing patterns.
1. Per-customer Redis entries¶
One key per Stripe customer (and one per Connect account, same shape):
crm-mrr-customer:{stripeCustomerId} → MrrCustomerCacheEntry { LifetimeRevenue, LastFetchedAtUtc, LastError, LastErrorAtUtc, CapHit }
crm-mrr-connect:{stripeAccountId} → MrrConnectCacheEntry { ApplicationFeeAmount, TotalCollectedAmount, TransactionCount, LastFetchedAtUtc, LastError, ... }
TTL: 7 days. Entries are written whenever the background worker fetches them; the warm cycle reads but never writes. LastError/LastErrorAtUtc replace the current denylist — a customer that 404s gets LastError="resource_missing" and is skipped by the worker for a backoff window (e.g. 24h), but the entry still exists so the warm cycle has something to show.
MrrCustomerCacheEntry and MrrConnectCacheEntry are new POCOs alongside MrrTxCachePayload, serialised via Newtonsoft like the existing payload.
2. New queue-driven worker MrrCustomerRefreshFunctions¶
Sits next to MrrDashboardWarmFunctions in Raklet.WebJobs.Secondary/CrmDashboard/. Two queue triggers:
trigger-mrr-customer-refresh— message body is aMrrCustomerRefreshMsg { StripeCustomerId, Reason }. Handler fetches Stripe invoices for that one customer, writescrm-mrr-customer:{id}. No locks needed since each message is for one customer; concurrent runs across customers are safe.trigger-mrr-connect-refresh— same shape for Connect accounts.
Each handler uses the existing WebJobUtil.IsDeploymentTime guard pattern, the same StripeException classification helpers introduced on the cleanup branch, and on any Stripe-side "this is permanently broken" error writes the failure into the cache entry instead of re-throwing.
3. Scheduler that enqueues refresh work¶
Two ways to feed the queue:
- Periodic sweep (Scheduled5mins): a single dispatcher entry
trigger-mrr-customer-refresh-sweepruns every 5 minutes. It loads allOrganisationrows with a non-nullStripeCustomerId, ranks them by(IsLikelyPaying desc, LastFetchedAtUtc asc nulls first, LifetimeRevenue desc), and enqueues the top N (configCrmMrrDashboard-RefreshBatchSize, default 100) as individualtrigger-mrr-customer-refreshmessages. At 100/sweep × 6 sweeps/hour = 600 customers refreshed/hour; a 6000-customer base completes a full refresh roughly every 10 hours, paying ones every cycle. - On-demand (later): a Stripe webhook handler (
POST /CustomerWebhook/InvalidateMrrCache) can enqueue a singletrigger-mrr-customer-refreshfor the affected customer when an invoice / subscription event fires. Out of scope for this PR — webhook delivery infrastructure decision pending.
The dispatcher itself is cheap: a single DB read + N queue inserts, no Stripe calls.
4. MrrDashboardService.BuildPayload reads from per-customer cache¶
GetCustomerLifetimeRevenueMap becomes a Redis read instead of a Stripe loop. For each StripeCustomerId in the org list:
- Read
crm-mrr-customer:{id}. - If present and
LastErroris null → useLifetimeRevenue. - If present and
LastErroris set (recent failure) → use0or fall back to localTotalProfitand surface_lifetimeRevenueStatus = "stripeError"on the row. - If absent → fall back to local
TotalProfit, enqueue a one-offtrigger-mrr-customer-refreshfor that customer so the next dashboard load is warm.
Net effect: the warm cycle does zero Stripe calls in steady state. The same change applies to GetConnectAccountMetricsMap.
Files to modify¶
New files¶
Services/MrrCustomerCacheEntry.cs+Services/MrrConnectCacheEntry.cs— POCOs.Raklet.WebJobs.Secondary/CrmDashboard/MrrCustomerRefreshFunctions.cs— three handlers (TriggerMrrCustomerRefresh,TriggerMrrConnectRefresh,TriggerMrrCustomerRefreshSweep).Models/Queues/MrrCustomerRefreshMsg.cs(andMrrConnectRefreshMsg.cs) — queue payloads.
Edited files¶
Services/MrrDashboardService.cs— replace inline Stripe loops with cache reads; remove the stale-denylist code from the cleanup branch (the new cache entries supersede it).Raklet.WebJobs.Scheduled5mins/Functions.cs— add{"trigger-mrr-customer-refresh-sweep", new[] { 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55 }}.Services/Services.csproj+Raklet.WebJobs.Secondary/<csproj>+Models/<csproj>— register new.csfiles (per CLAUDE.md non-SDK rule).
Cache key + TTL design¶
| Key | TTL | Writer | Reader |
|---|---|---|---|
crm-mrr-customer:{id} |
7 days | refresh worker | warm cycle + on-demand controller fallback |
crm-mrr-connect:{id} |
7 days | refresh worker | warm cycle |
crm-mrr-summary:days:30 |
12 minutes | warm cycle (unchanged) | controller |
~~crm-mrr-stale-customer-ids~~ |
(removed) | — | superseded by LastError on per-customer entry |
~~crm-mrr-stale-connect-account-ids~~ |
(removed) | — | same |
A 7-day TTL handles the case where a customer is deleted and we want to eventually forget them; once the worker stops succeeding, the entry expires naturally.
Verification¶
After implementing:
- Local Services build:
scripts/dev/build-fast.ps1 -Projects Services. - Controller cold path: with Redis empty,
https://crm.raklet.local/Customer/MrrTransactionSummary?days=30&bypassCache=truereturns within request timeout, every row'sLifetimeRevenuefalls back to localTotalProfit, and the queue contains ~Ntrigger-mrr-customer-refreshmessages (one per org with aStripeCustomerId). - Refresh worker: start Secondary WebJob console; watch entries get written to
crm-mrr-customer:*; confirm a deliberatecus_doesnotexistenqueue recordsLastError="resource_missing"instead of throwing. - Steady state: bypass cache twice in a row 1 minute apart; second call should perform zero Stripe API calls (verify via Stripe dashboard request log).
- Failure modes: stop Redis → existing fallback in
CacheService.Getreturns null → controller falls back to localTotalProfit, dashboard still renders (slower, no Stripe LTV). Stop the refresh WebJob → existing entries serve until they expire (7 days).
Out of scope (defer further)¶
- The Stripe webhook →
/CustomerWebhook/InvalidateMrrCacheendpoint for event-driven freshness. Add when product needs sub-hour LTV updates. - Pre-warming for
days≠ 30. Add only if dashboard analytics show meaningful usage of other ranges. - Cosmos / SQL persistence of per-customer LTV (would survive Redis flushes and let us query historical LTV). Premature; 7-day Redis TTL + sweep refresh is enough.
Risks¶
- Sweep DB read becomes expensive at scale. Mitigation: project only
Id, StripeCustomerId, StripePlanId, TotalProfit, LastFetchedAt(the latter requires a new Redis index or a smallMrrCustomerRefreshHinttable — decide at implementation time). Current org count is well under what a simpleSELECT … WHERE StripeCustomerId IS NOT NULLcan handle in a 5-minute window. - Queue depth runaway if a sweep enqueues faster than handlers drain. Mitigation: dispatcher checks queue depth before enqueueing and skips if above
CrmMrrDashboard-RefreshQueueMaxDepth(default 500). Same pattern asEmailFunctions.TriggerEmailStats. - Cache and DB drift if a customer's Stripe state changes but the entry is still warm. Acceptable at 7-day TTL with sweep-driven refresh prioritising oldest first. Webhook invalidation fixes this fully, but the dashboard is internal-only so multi-hour staleness on a deleted customer is fine.