Revenue intelligence
This page documents exactly how Crossdeck computes the money numbers you see: which subscription states count toward MRR, how billing intervals normalize to a monthly figure, what a refund or dispute does to a record, and what "at-risk" means. Every rule here traces to a single backend implementation — the same code path produces the number whether the subscription came from Stripe, Apple, or Google, and whether you're reading the web dashboard or the iOS companion. When two surfaces show the same project at the same moment, they cannot disagree, because they read the same document through the same derivation.
TL;DR
- Four states count toward MRR:
ACTIVE,BILLING_RETRY,GRACE_PERIOD, andPAUSED.TRIAL,EXPIRED, andREFUNDEDcontribute zero. - Intervals normalize on Gregorian math. A month is 365.25 / 12 days; a year contributes 1/12 per month; a week contributes 365.25 / 7 / 12 ≈ 4.348 weeks per month. One exported function does this for every renderer.
- One-off purchases bank in two states:
PAIDandDISPUTED. AREFUNDEDpurchase drops out of totals and appears signed-negative in the activity ledger. - A refund on a Stripe subscription is a money event, not a state change. The subscription stays in its rail-derived state; on Apple and Google a refund/revoke flips the subscription to
REFUNDED. - At-risk = subscriptions in
BILLING_RETRYorGRACE_PERIOD. Both states still count as revenue-bearing — the rail is still trying to collect — until they resolve toACTIVEorEXPIRED. - All amounts are FX-normalized to USD cents before any aggregation, using a daily rates table.
How MRR is computed
Which subscriptions count
Every subscription Crossdeck mirrors from a rail carries one canonical state. There are exactly seven, and the revenue rule over them is a fixed set — a subscription contributes to MRR if and only if its state is revenue-bearing:
| State | Counts toward MRR | Why |
|---|---|---|
ACTIVE | Yes | A paid, current subscription. |
BILLING_RETRY | Yes | Payment failed; the rail is retrying. Still on the book — see at-risk. |
GRACE_PERIOD | Yes | Retries continue inside a configured grace window. Still on the book. |
PAUSED | Yes | Collection is paused, not ended; the stream resumes rather than restarts. |
TRIAL | No | No money has moved. A trial becomes MRR the moment it converts to ACTIVE. |
EXPIRED | No | The subscription lapsed or was canceled and the period ended. |
REFUNDED | No | The rail reversed the money (Apple refund, Google voided/revoked purchase). |
The revenue-bearing set — ACTIVE, BILLING_RETRY, GRACE_PERIOD, PAUSED — is a single exported constant in the backend, and every read that decides "is this customer paying?" goes through the same isRevenueBearing check: the MRR snapshot writer, the unified revenue read, the active-subscription count, the top-products roll-up, and the revenue-by-user ranking. "Paying" means the same thing everywhere it is decided.
Normalizing intervals to monthly
MRR is a monthly-equivalent figure, so every billing interval is converted by one function, monthlyFactor(interval, intervalCount). The factors are Gregorian — a month is 365.25 / 12 days, not 30:
| Interval | Monthly factor | Example |
|---|---|---|
month | 1 / count | $10/month → $10.00/mo |
year | 1 / (12 × count) | $120/year → $10.00/mo |
week | (365.25 / 7 / 12) / count ≈ 4.3482 / count | $5/week → ~$21.74/mo |
day | (365.25 / 12) / count = 30.4375 / count | $1/day → ~$30.44/mo |
A missing or unknown interval falls back to month; a missing or non-positive intervalCount is treated as 1. A subscription's monthly contribution is its per-cycle charge — already FX-normalized to USD cents — multiplied by this factor.
The dashboard used to carry its own copy of this conversion, and the two drifted: the backend said 30 days per month and 4.345 weeks per month, the dashboard said Gregorian 365.25/12 and 365.25/7/12. Mirrors are how money numbers fork. The math was consolidated on the Gregorian factors (the more accurate of the two — month and year subscriptions are identical either way), and there is now exactly one copy, exported from the backend and reused by every renderer.
One derivation, all rails
Stripe, Apple, and Google name their lifecycle states differently — Stripe says past_due, Apple sends DID_FAIL_TO_RENEW, Google says ON_HOLD. Each rail's webhook translates its native events into the canonical states above through a pure, unit-tested state machine, and writes a subscription record with the same shape: state, per-cycle price, currency, interval, created and canceled timestamps. From that point on, the rails are indistinguishable to the revenue math. There is no Stripe MRR formula and a different Apple one; there is one formula and three translators.
Currency is handled the same way: each rail's native amount (Stripe minor units, Apple milliunits, Google micros) is converted to the currency's minor units at the webhook boundary, then FX-normalized to USD cents using a daily rates table before any aggregation. A ¥1,500 subscription and a $10 subscription land in the same unit before they are ever added together.
Where the number comes from, and where it surfaces
Crossdeck materializes MRR as daily snapshots — one document per calendar day per project, with the total in USD cents, the deduplicated paying-customer count, and a per-rail breakdown (stripe / apple / google). The headline MRR you see is the latest snapshot's per-rail values summed; paying customers are counted once per crossdeckCustomerId, so one customer holding a Stripe sub and an Apple sub is one paying customer, not two.
Snapshots are written on two paths:
- Backfill on rail connect. When a project completes its first backfill from a rail, the worker reconstructs a snapshot for every day in the trailing 730 days (24 months — the window the rails themselves make available). The reconstruction rule per subscription is the one any accounting engine uses: it was live on date T if
createdAt ≤ Tand (canceledAtis unset orcanceledAt > T). The dashboard shows your rail's trailing history from the moment you connect — not "we started tracking today." - Daily refresh. A scheduled job rewrites today's snapshot for every connected project, picking up state transitions that arrived by webhook since the last run. Yesterday's snapshot is recomputed too, so a webhook landing just after midnight UTC still settles into the correct day.
Every renderer reads the same snapshot through the same server-side read (getRevenueSummary): the dashboard Revenue page, the all-projects rollup, the iOS companion, and the daily summary push. The reads are guaranteed to match by construction — they are the same query against the same document — not by two implementations happening to agree.
Reconstructed history assumes a subscription's current per-cycle price held for its whole life — a mid-life plan change ($10 → $20) is reflected at today's price across the back-history. Pause/unpause transitions are collapsed to the active window, and refunds reduce the revenue-bearing window only when they end the subscription (a canceledAt / terminal state), not as amount adjustments. A reconcile worker checks the live numbers against the rails for drift.
One note on rounding, since cents are involved: each day's per-rail MRR total is rounded once, after summing the unrounded per-subscription contributions. The top-products table rounds per subscription before grouping, and the revenue-by-user ranking accumulates unrounded and rounds at display time — each surface keeps its math stable rather than re-rounding another surface's output.
Refunds & disputes
One-off purchases: PAID, REFUNDED, DISPUTED
A one-off (non-recurring) purchase is a point-in-time money record with exactly three states: PAID, REFUNDED, or DISPUTED. Two of them count as banked revenue:
| State | Banked | Meaning |
|---|---|---|
PAID | Yes | The charge succeeded and stands. |
DISPUTED | Yes | A chargeback is open. The money is contested, not yet reversed — the purchase stands in totals until the dispute resolves against you (at which point the rail refunds it). |
REFUNDED | No | The money was returned. Excluded from every total; shown signed-negative in the activity ledger so the reversal is visible rather than silently vanishing. |
These states drive the one-off windows on the Revenue page — last 30 days, last 90 days, and all-time gross, each with purchase counts and a deduplicated paying-customer count — plus the refunded-purchase counts shown alongside the 30- and 90-day windows, the top one-off products table, and the one-off component of revenue-by-user. The live payment-activity feed (last-24h / last-7d counts and 7-day gross) is stricter still: it lists a purchase only while it has neither a refundedAt nor a disputedAt timestamp, so a disputed charge leaves the recent-payments feed immediately even though it still stands in the banked windows.
What a refund does
A refund never deletes anything. The purchase record keeps its identity (the rail's own transaction ID is the document key), its state flips to REFUNDED, and a refundedAt timestamp is stamped. From that read onward the purchase is out of every banked total and listed signed-negative in the ledger. Per rail:
- Stripe. A
charge.refundedevent whose charge has no invoice is a one-off refund. Crossdeck re-fetches the charge from the Stripe API (webhooks are the nudge; the API is the truth) and projectsrefunded: true→ stateREFUNDED. Partial refunds stayPAID— Stripe'srefundedflag means fully refunded, and a partial refund does not flip the purchase's state. - Apple. Refunds and revocations arrive as the same notification types Apple uses for subscriptions (
REFUND,REVOKE,REFUND_REVERSED); the transaction's product type routes one-offs to the purchase record. A transaction with arevocationDateprojects asREFUNDED, withrefundedAtset from Apple's revocation timestamp. - Google. A one-off whose
purchaseStateis 1 (canceled/refunded) projects asREFUNDED; voided-purchase notifications carry the refund signal directly.
For subscriptions the rails genuinely differ, and Crossdeck mirrors each rail rather than inventing a common fiction:
- Stripe: a refund is a money event, never a state change. Refunding a charge does not cancel the subscription in Stripe — the rail keeps it active and keeps billing. Crossdeck therefore never flips a Stripe subscription to
REFUNDED; its state continues to mirror the live Stripe status. (Treating refunds as cancellations would desync the record from the rail and hide that the customer is still billable. To end the revenue, the subscription itself must be canceled.) - Apple: a
REFUNDnotification flips the subscription toREFUNDED— a terminal state. The moment it goes terminal, acanceledAttimestamp is stamped from Apple's server-sidesignedDate, which removes the subscription from MRR from that instant forward (including in reconstructed history).REFUND_REVERSED— Apple un-refunding — returns the subscription toACTIVE. - Google: a voided purchase or
REVOKEDnotification flips the subscription toREFUNDED. Google's own API later reports refunded subscriptions as merely expired, but Crossdeck preserves theREFUNDEDintent — natural lapse and reversed revenue are different facts for accounting, and an earlier truthful terminal timestamp is never overwritten by a later event.
What a dispute does
A dispute is a Stripe concept in this pipeline: charge.dispute.created on an invoice-less charge marks the one-off purchase DISPUTED and stamps disputedAt. The amount stays in banked totals — the money has not moved yet — while the dispute is flagged on the record and surfaces as a purchase_disputed signal. If the dispute is lost, the rail's subsequent refund flips the record to REFUNDED and it exits totals as above. Apple and Google do not expose a customer-chargeback event distinct from a refund; on those rails reversals arrive as refund/revoke notifications and follow the refund path directly.
Rails redeliver webhooks, and redeliveries can arrive out of order. Every money record carries the timestamp of the last event applied to it, and an incoming event that is strictly older is refused. A delayed redelivery of the original PAID event cannot overwrite a REFUNDED record and quietly put the money back in your totals.
At-risk revenue
The Revenue page's at-risk count is exact: the number of subscriptions currently in BILLING_RETRY or GRACE_PERIOD. Both are "payment failed, the rail is still trying" states, and each rail maps into them deterministically:
| Rail | → BILLING_RETRY | → GRACE_PERIOD |
|---|---|---|
| Stripe | past_due — payment failed, Stripe is retrying. | unpaid — retries exhausted but the subscription is not yet canceled. |
| Apple | DID_FAIL_TO_RENEW (no subtype) — Apple's billing-retry window. | DID_FAIL_TO_RENEW with subtype GRACE_PERIOD — the configured grace window is open. |
ON_HOLD — account hold; Google retries for up to 30 days. | IN_GRACE_PERIOD — payment failed inside the developer-configured grace window. |
Why at-risk still counts as revenue
Both states are members of the revenue-bearing set, so an at-risk subscription still contributes its full monthly-equivalent amount to MRR and still counts in active subscriptions and paying customers. This is deliberate, and it is the accounting answer, not the pessimistic one: the subscription has not ended. The rail is actively retrying the card, the customer has not churned, and on every rail the platform's default access policy keeps the customer's entitlements granted through both windows. The revenue stream is impaired, not gone — so it stays on the book, and the at-risk count tells you how much of the book is impaired. Removing it from MRR at the first failed charge would make recovered payments look like new revenue and double-count the churn when the subscription actually expires.
How it resolves
At-risk is always a transient state, and it resolves in exactly one of two directions:
- Recovery. The charge succeeds: Stripe's status returns to
active, Apple sendsDID_RECOVER, Google sendsRECOVERED. The subscription transitions back toACTIVE, the at-risk count drops, and MRR is unchanged — it never left. The transition is surfaced as abilling_retry_recoveredsignal. - Lapse. Retries exhaust: Stripe cancels the subscription (
canceled), Apple sendsGRACE_PERIOD_EXPIREDorEXPIRED, Google sendsEXPIRED. The subscription transitions toEXPIRED— a terminal, non-revenue-bearing state — acanceledAttimestamp is stamped, and MRR drops by the subscription's monthly-equivalent amount from that day's snapshot forward.
Either way, the dashboard reflects the resolution on the next snapshot write — and the live state, the MRR contribution, and the at-risk count all move together because they are all reads of the same record.
What this page does not include
Three metrics people often expect next to MRR do not exist in Crossdeck today, and this page won't pretend otherwise.
- Net-new MRR decomposition. There is no breakdown of MRR movement into new / expansion / contraction / churned components; the dashboard shows the MRR level and its history, not a waterfall.
- Trials reporting.
TRIALis a tracked subscription state (and trial-to-paid conversion fires a signal), but there is no trials metric — no trial counts, cohorts, or conversion-rate report. - Churn rate. Subscriptions that end transition to
EXPIREDand leave MRR, but no churn-rate percentage is computed or displayed.
Related
- Rail webhooks — how Stripe, Apple, and Google events reach Crossdeck and become the subscription records this page's math runs on.
- Apple one-off purchases — how consumables, non-consumables, and non-renewing subscriptions project into purchase records.
- Sandbox vs production — every number on this page is computed per environment; sandbox money never mixes into production MRR.
- Entitlements & gating — the access side of the same states, including the default keep-access policy during
BILLING_RETRYandGRACE_PERIOD.