Crossdeck Docs
Dashboard

Revenue intelligence

Revenue 9 min read · One derivation, every rail, every surface

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

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:

StateCounts toward MRRWhy
ACTIVEYesA paid, current subscription.
BILLING_RETRYYesPayment failed; the rail is retrying. Still on the book — see at-risk.
GRACE_PERIODYesRetries continue inside a configured grace window. Still on the book.
PAUSEDYesCollection is paused, not ended; the stream resumes rather than restarts.
TRIALNoNo money has moved. A trial becomes MRR the moment it converts to ACTIVE.
EXPIREDNoThe subscription lapsed or was canceled and the period ended.
REFUNDEDNoThe 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:

IntervalMonthly factorExample
month1 / count$10/month → $10.00/mo
year1 / (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.

Why one function matters.

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:

  1. 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 ≤ T and (canceledAt is unset or canceledAt > T). The dashboard shows your rail's trailing history from the moment you connect — not "we started tracking today."
  2. 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.

What historical reconstruction does not model (v1).

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:

StateBankedMeaning
PAIDYesThe charge succeeded and stands.
DISPUTEDYesA 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).
REFUNDEDNoThe 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:

For subscriptions the rails genuinely differ, and Crossdeck mirrors each rail rather than inventing a common fiction:

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.

Ordering guard: a stale event can never resurrect banked revenue.

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:

RailBILLING_RETRYGRACE_PERIOD
Stripepast_due — payment failed, Stripe is retrying.unpaid — retries exhausted but the subscription is not yet canceled.
AppleDID_FAIL_TO_RENEW (no subtype) — Apple's billing-retry window.DID_FAIL_TO_RENEW with subtype GRACE_PERIOD — the configured grace window is open.
GoogleON_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:

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.