From 65f7560278a9f545d57a14689e3d714a22d075f1 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Tue, 5 May 2026 23:21:48 +0900
Subject: [PATCH 01/14] feat(kit): analytics dashboard with revenue / MRR /
churn metrics (closes #129)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an Analytics tab that visualizes revenue, active subs, new subs,
renewals, cancellations, refunds, and churn — split by platform
(iOS/Android), product, and currency, with daily/weekly/monthly
aggregation toggles over a 7/30/90-day range.
Backend
- New `revenueMetricsDaily` populator (`recomputeAllRevenueMetrics`
picker + per-project mutation) wired to a daily cron. Reads the
`webhookEvents` log over a trailing 3-day window so late ASN v2 /
RTDN notifications fold into their correct day's bucket. Schema:
added `platform` to `revenueMetricsDaily` + matching index so
iOS/Android revenue can be charted separately.
- New `getRevenueMetrics` query reads pre-computed rollups; all
filters (range, platform, product, currency) are derived in JS to
avoid Convex refetch flicker on filter clicks.
Frontend
- Analytics tab + page with 5 charts (Revenue / Active subs / New +
Renewed / Cancellations + Refunds / Churn rate), platform cards
(clickable filters, purchases-style gradient), product / currency
dropdowns, range + period chiclet groups with primary-bordered
active state.
- Purchases cards: surface the active filter selection (was hover-only).
Docs
- New /docs/analytics section explaining the webhook prerequisite,
setup checklist, rollup mechanics, currency/FX, churn definition,
limitations.
- In-page amber callout on the Analytics tab linking to Webhooks tab
+ setup guide.
Tests
- 48 new unit + integration tests covering pure helpers, every event
type branch, multi-dimensional bucketing, window boundaries,
idempotency, project isolation, and stale-row cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
bun.lock | 71 +-
packages/kit/convex/_generated/api.d.ts | 2 +
packages/kit/convex/crons.ts | 16 +
packages/kit/convex/schema.ts | 10 +-
packages/kit/convex/subscriptions/query.ts | 111 +++
.../subscriptions/revenueMetrics.test.ts | 931 ++++++++++++++++++
.../convex/subscriptions/revenueMetrics.ts | 386 ++++++++
packages/kit/package.json | 1 +
packages/kit/src/pages/auth/index.tsx | 9 +
.../auth/organization/project/analytics.tsx | 870 ++++++++++++++++
.../pages/auth/organization/project/index.tsx | 8 +
.../auth/organization/project/purchases.tsx | 110 ++-
packages/kit/src/pages/docs/nav.ts | 5 +
packages/kit/src/pages/docs/routes.tsx | 2 +
.../kit/src/pages/docs/sections/analytics.tsx | 166 ++++
15 files changed, 2651 insertions(+), 47 deletions(-)
create mode 100644 packages/kit/convex/subscriptions/revenueMetrics.test.ts
create mode 100644 packages/kit/convex/subscriptions/revenueMetrics.ts
create mode 100644 packages/kit/src/pages/auth/organization/project/analytics.tsx
create mode 100644 packages/kit/src/pages/docs/sections/analytics.tsx
diff --git a/bun.lock b/bun.lock
index f782a7cd..5cb4a57c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -104,6 +104,7 @@
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.7.1",
+ "recharts": "^2.13.3",
"remark-gfm": "^4.0.1",
"resend": "^4.8.0",
"sonner": "^2.0.3",
@@ -945,6 +946,24 @@
"@types/css-font-loading-module": ["@types/css-font-loading-module@0.0.7", "", {}, "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q=="],
+ "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
+
+ "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
+
+ "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
+
+ "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
+
+ "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
+
+ "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
+
+ "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
+
+ "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
+
+ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
+
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -1221,6 +1240,28 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+ "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
+
+ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
+
+ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
+
+ "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
+
+ "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
+
+ "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
+
+ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
+
+ "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
+
+ "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
+
+ "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
+
+ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
+
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
@@ -1241,6 +1282,8 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+ "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
+
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -1271,6 +1314,8 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+ "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
+
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -1353,7 +1398,7 @@
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
- "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
@@ -1369,6 +1414,8 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+ "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
+
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -1535,6 +1582,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
+ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
+
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
@@ -1723,6 +1772,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
+
"lodash._baseflatten": ["lodash._baseflatten@3.1.4", "", { "dependencies": { "lodash.isarguments": "^3.0.0", "lodash.isarray": "^3.0.0" } }, "sha512-fESngZd+X4k+GbTxdMutf8ohQa0s3sJEHIcwtu4/LsIQ2JTDzdRxDCMQjW+ezzwRitLmHnacVVmosCbxifefbw=="],
"lodash._basefor": ["lodash._basefor@3.0.3", "", {}, "sha512-6bc3b8grkpMgDcVJv9JYZAk/mHgcqMljzm7OsbmcE2FGUMmmLQTPHlh/dFqR8LA0GQ7z4K67JSotVKu5058v1A=="],
@@ -2065,6 +2116,8 @@
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
+ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -2105,10 +2158,18 @@
"react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
+ "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
+
"react-toastify": ["react-toastify@11.1.0", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg=="],
+ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
+
"read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="],
+ "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
+
+ "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
+
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
@@ -2307,6 +2368,8 @@
"timeout-signal": ["timeout-signal@2.0.0", "", {}, "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA=="],
+ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
@@ -2411,6 +2474,8 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
+
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
@@ -2603,6 +2668,8 @@
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "listr2/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+
"load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
@@ -2627,6 +2694,8 @@
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts
index d879f8c1..d59d379f 100644
--- a/packages/kit/convex/_generated/api.d.ts
+++ b/packages/kit/convex/_generated/api.d.ts
@@ -59,6 +59,7 @@ import type * as subscriptions_internal from "../subscriptions/internal.js";
import type * as subscriptions_monthlyMicros from "../subscriptions/monthlyMicros.js";
import type * as subscriptions_mutation from "../subscriptions/mutation.js";
import type * as subscriptions_query from "../subscriptions/query.js";
+import type * as subscriptions_revenueMetrics from "../subscriptions/revenueMetrics.js";
import type * as subscriptions_selectLatest from "../subscriptions/selectLatest.js";
import type * as subscriptions_stateMachine from "../subscriptions/stateMachine.js";
import type * as subscriptions_stats from "../subscriptions/stats.js";
@@ -137,6 +138,7 @@ declare const fullApi: ApiFromModules<{
"subscriptions/monthlyMicros": typeof subscriptions_monthlyMicros;
"subscriptions/mutation": typeof subscriptions_mutation;
"subscriptions/query": typeof subscriptions_query;
+ "subscriptions/revenueMetrics": typeof subscriptions_revenueMetrics;
"subscriptions/selectLatest": typeof subscriptions_selectLatest;
"subscriptions/stateMachine": typeof subscriptions_stateMachine;
"subscriptions/stats": typeof subscriptions_stats;
diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts
index a926cf39..34bf8815 100644
--- a/packages/kit/convex/crons.ts
+++ b/packages/kit/convex/crons.ts
@@ -81,6 +81,22 @@ crons.interval(
{ batchSize: 50 },
);
+// Daily revenue rollup. Walks `webhookEvents` over the trailing
+// 3-day window and refreshes the `revenueMetricsDaily` rows that
+// power the Analytics dashboard. Trailing window covers Apple ASN v2
+// and Google RTDN late-arrival retries (real-world p99 < 48h); each
+// tick overwrites the trailing window so a webhook arriving up to 3
+// days late still lands in its correct day's bucket. 50 projects
+// per tick share the same self-paginating-via-staleness picker the
+// subscription stats cron uses (one mutation per project, each gets
+// its own 40k document-read budget).
+crons.interval(
+ "recompute revenue metrics daily",
+ { hours: 24 },
+ internal.subscriptions.revenueMetrics.recomputeAllRevenueMetrics,
+ { batchSize: 50 },
+);
+
// Mark stuck product-sync jobs as failed. Convex caps actions at
// ~10min; the worker sets `expectedDeadline = startedAt + 9min`,
// and this reaper flips anything still `running` past
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index 16ed83c0..91e2c2cc 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -722,6 +722,13 @@ const schema = defineSchema({
day: v.string(), // ISO date (YYYY-MM-DD), UTC
productId: v.string(),
currency: v.string(),
+ // Platform split is part of the key — same SKU sold on iOS and
+ // Android on the same day produces two distinct rollup rows so
+ // the dashboard can chart per-store revenue. Nullable platform
+ // (the empty `""` sentinel) absorbs events that arrived before
+ // the rollout — kept defensively so a partially-backfilled
+ // window doesn't crash the read path.
+ platform: v.union(v.literal("IOS"), v.literal("Android")),
activeSubs: v.number(),
newSubs: v.number(),
renewals: v.number(),
@@ -736,7 +743,8 @@ const schema = defineSchema({
"productId",
"day",
"currency",
- ]),
+ ])
+ .index("by_project_and_day_and_platform", ["projectId", "day", "platform"]),
// Unified product catalog. Mirrors what onesub holds in @onesub/providers
// — the subset of App Store Connect / Play Console that kit can read /
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index 82337e85..3b97b86c 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -435,6 +435,117 @@ export const metricsSummary = query({
},
});
+// Daily revenue + lifecycle metrics for the Analytics dashboard. Reads
+// pre-computed rollups from `revenueMetricsDaily` (populated by the
+// `recomputeAllRevenueMetrics` cron) so the dashboard never scans the
+// raw webhookEvents log on render.
+//
+// `fromDay` and `toDay` are inclusive ISO date strings (YYYY-MM-DD,
+// UTC) — same format `revenueMetricsDaily.day` is stored under, so
+// the index range is a direct string comparison.
+//
+// Currency split: the underlying table is keyed by currency because
+// MRR / revenue can't be summed across currencies without an FX
+// conversion (matches the `subscriptionStats` reasoning). The query
+// returns one entry per (day, currency) so the UI can either filter
+// to a single currency or stack the breakdown.
+const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
+
+export const getRevenueMetrics = query({
+ args: {
+ apiKey: v.string(),
+ fromDay: v.string(),
+ toDay: v.string(),
+ productId: v.optional(v.string()),
+ currency: v.optional(v.string()),
+ platform: v.optional(platformValidator),
+ },
+ returns: v.object({
+ days: v.array(
+ v.object({
+ day: v.string(),
+ currency: v.string(),
+ productId: v.string(),
+ platform: platformValidator,
+ activeSubs: v.number(),
+ newSubs: v.number(),
+ renewals: v.number(),
+ cancellations: v.number(),
+ refunds: v.number(),
+ revenueMicros: v.number(),
+ }),
+ ),
+ // Available filter values surfaced to the dashboard so the UI
+ // can render dropdowns / chiclets for everything the project
+ // actually has data for, without a second round-trip.
+ currencies: v.array(v.string()),
+ productIds: v.array(v.string()),
+ platforms: v.array(platformValidator),
+ }),
+ handler: async (ctx, args) => {
+ const project = await projectByApiKey(ctx, args.apiKey);
+ if (!project) {
+ return {
+ days: [],
+ currencies: [],
+ productIds: [],
+ platforms: [],
+ };
+ }
+
+ // Range scan via `by_project_and_day_and_currency`. We don't
+ // anchor on currency in the index range because the dashboard
+ // needs the multi-currency breakdown; filtering is applied in
+ // the loop below.
+ let rows = await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q
+ .eq("projectId", project._id)
+ .gte("day", args.fromDay)
+ .lte("day", args.toDay),
+ )
+ .collect();
+
+ if (args.productId) {
+ rows = rows.filter((row) => row.productId === args.productId);
+ }
+ if (args.currency) {
+ rows = rows.filter((row) => row.currency === args.currency);
+ }
+ if (args.platform) {
+ rows = rows.filter((row) => row.platform === args.platform);
+ }
+
+ const currencies = new Set();
+ const productIds = new Set();
+ const platforms = new Set<"IOS" | "Android">();
+ for (const row of rows) {
+ if (row.currency) currencies.add(row.currency);
+ productIds.add(row.productId);
+ platforms.add(row.platform);
+ }
+
+ return {
+ days: rows.map((row) => ({
+ day: row.day,
+ currency: row.currency,
+ productId: row.productId,
+ platform: row.platform,
+ activeSubs: row.activeSubs,
+ newSubs: row.newSubs,
+ renewals: row.renewals,
+ cancellations: row.cancellations,
+ refunds: row.refunds,
+ revenueMicros: row.revenueMicros,
+ })),
+ currencies: Array.from(currencies).sort(),
+ productIds: Array.from(productIds).sort(),
+ platforms: Array.from(platforms).sort(),
+ };
+ },
+});
+
async function loadPeriodByProductId(
ctx: QueryCtx,
projectId: Id<"projects">,
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
new file mode 100644
index 00000000..1f0e66a5
--- /dev/null
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -0,0 +1,931 @@
+import { beforeEach, describe, expect, it } from "vitest";
+
+import type { Doc } from "../_generated/dataModel";
+import {
+ applyEventToBucket,
+ bucketKey,
+ isActiveAt,
+ runRecompute,
+ startOfUtcDay,
+ utcDayKey,
+ type RollupBucket,
+} from "./revenueMetrics";
+
+// ──────────────────────────────────────────────────────────────────────
+// Pure helper tests — no DB needed.
+// ──────────────────────────────────────────────────────────────────────
+
+describe("utcDayKey", () => {
+ it("converts an epoch ms to ISO date in UTC", () => {
+ // 2026-03-15T07:30:00Z → "2026-03-15" regardless of host timezone.
+ const ts = Date.UTC(2026, 2, 15, 7, 30, 0);
+ expect(utcDayKey(ts)).toBe("2026-03-15");
+ });
+
+ it("does not roll into next day at 23:59:59 UTC", () => {
+ const ts = Date.UTC(2026, 2, 15, 23, 59, 59);
+ expect(utcDayKey(ts)).toBe("2026-03-15");
+ });
+
+ it("rolls into next day at 00:00:00 UTC", () => {
+ const ts = Date.UTC(2026, 2, 16, 0, 0, 0);
+ expect(utcDayKey(ts)).toBe("2026-03-16");
+ });
+});
+
+describe("startOfUtcDay", () => {
+ it("returns midnight UTC for the given timestamp", () => {
+ const ts = Date.UTC(2026, 2, 15, 7, 30, 0);
+ expect(startOfUtcDay(ts)).toBe(Date.UTC(2026, 2, 15));
+ });
+
+ it("is idempotent — startOfUtcDay(startOfUtcDay(ts)) === startOfUtcDay(ts)", () => {
+ const ts = Date.UTC(2026, 2, 15, 7, 30, 0);
+ const once = startOfUtcDay(ts);
+ expect(startOfUtcDay(once)).toBe(once);
+ });
+});
+
+describe("bucketKey", () => {
+ it("composes day + productId + currency + platform uniquely", () => {
+ expect(bucketKey("2026-03-15", "sub.monthly", "USD", "IOS")).toBe(
+ "2026-03-15|sub.monthly|USD|IOS",
+ );
+ });
+
+ it("differs when ANY component differs (including platform)", () => {
+ const a = bucketKey("2026-03-15", "sub.monthly", "USD", "IOS");
+ const b = bucketKey("2026-03-16", "sub.monthly", "USD", "IOS");
+ const c = bucketKey("2026-03-15", "sub.yearly", "USD", "IOS");
+ const d = bucketKey("2026-03-15", "sub.monthly", "EUR", "IOS");
+ const e = bucketKey("2026-03-15", "sub.monthly", "USD", "Android");
+ expect(new Set([a, b, c, d, e]).size).toBe(5);
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────────
+// applyEventToBucket — every event-type branch.
+// ──────────────────────────────────────────────────────────────────────
+
+function emptyBucket(
+ day = "2026-03-15",
+ productId = "sub.monthly",
+ currency = "USD",
+ platform: "IOS" | "Android" = "IOS",
+): RollupBucket {
+ return {
+ day,
+ productId,
+ currency,
+ platform,
+ activeSubs: 0,
+ newSubs: 0,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 0,
+ };
+}
+
+function makeEvent(
+ partial: Partial> & Pick, "type">,
+): Doc<"webhookEvents"> {
+ // Cast — only the fields the helper reads matter. The rest of the
+ // shape is satisfied with sensible defaults so the test stays
+ // compact.
+ return {
+ _id: "we_1" as never,
+ _creationTime: 0,
+ projectId: "p_1" as never,
+ source: "AppleAppStoreServerNotificationsV2",
+ platform: "IOS",
+ environment: "Production",
+ sourceNotificationId: "notif_1",
+ occurredAt: 0,
+ receivedAt: 0,
+ ...partial,
+ };
+}
+
+describe("applyEventToBucket", () => {
+ it("SubscriptionStarted → newSubs++ and revenueMicros += price", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(
+ bucket,
+ makeEvent({ type: "SubscriptionStarted", priceAmountMicros: 9_990_000 }),
+ );
+ expect(bucket.newSubs).toBe(1);
+ expect(bucket.revenueMicros).toBe(9_990_000);
+ // No other counter moved.
+ expect(bucket.renewals).toBe(0);
+ expect(bucket.cancellations).toBe(0);
+ expect(bucket.refunds).toBe(0);
+ });
+
+ it("SubscriptionRenewed → renewals++ and revenueMicros += price", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(
+ bucket,
+ makeEvent({ type: "SubscriptionRenewed", priceAmountMicros: 9_990_000 }),
+ );
+ expect(bucket.renewals).toBe(1);
+ expect(bucket.revenueMicros).toBe(9_990_000);
+ expect(bucket.newSubs).toBe(0);
+ });
+
+ it("SubscriptionStarted with no priceAmountMicros → newSubs++ but revenue stays 0", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionStarted" }));
+ expect(bucket.newSubs).toBe(1);
+ expect(bucket.revenueMicros).toBe(0);
+ });
+
+ it("SubscriptionCanceled → cancellations++", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionCanceled" }));
+ expect(bucket.cancellations).toBe(1);
+ });
+
+ it("SubscriptionUncanceled → cancellations--", () => {
+ const bucket = { ...emptyBucket(), cancellations: 2 };
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionUncanceled" }));
+ expect(bucket.cancellations).toBe(1);
+ });
+
+ it("Cancel followed by Uncancel within same window nets to 0", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionCanceled" }));
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionUncanceled" }));
+ expect(bucket.cancellations).toBe(0);
+ });
+
+ it("Uncancel without prior cancel clamps at 0 (no negative cancellations)", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionUncanceled" }));
+ expect(bucket.cancellations).toBe(0);
+ });
+
+ it("PurchaseRefunded → refunds++", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "PurchaseRefunded" }));
+ expect(bucket.refunds).toBe(1);
+ });
+
+ it("SubscriptionRevoked → refunds++ (store-issued reversal)", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionRevoked" }));
+ expect(bucket.refunds).toBe(1);
+ });
+
+ it("SubscriptionExpired does NOT bump any financial counter", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionExpired" }));
+ expect(bucket).toEqual(emptyBucket());
+ });
+
+ it("SubscriptionInGracePeriod / SubscriptionPaused / SubscriptionPriceChange ignored", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(
+ bucket,
+ makeEvent({ type: "SubscriptionInGracePeriod" }),
+ );
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionPaused" }));
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionPriceChange" }));
+ expect(bucket).toEqual(emptyBucket());
+ });
+
+ it("TestNotification ignored", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "TestNotification" }));
+ expect(bucket).toEqual(emptyBucket());
+ });
+
+ it("multiple renewals accumulate priceAmountMicros", () => {
+ const bucket = emptyBucket();
+ applyEventToBucket(
+ bucket,
+ makeEvent({ type: "SubscriptionRenewed", priceAmountMicros: 5_000_000 }),
+ );
+ applyEventToBucket(
+ bucket,
+ makeEvent({ type: "SubscriptionRenewed", priceAmountMicros: 9_990_000 }),
+ );
+ expect(bucket.renewals).toBe(2);
+ expect(bucket.revenueMicros).toBe(14_990_000);
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────────
+// isActiveAt — end-of-day snapshot logic.
+// ──────────────────────────────────────────────────────────────────────
+
+function makeSub(
+ partial: Partial> & Pick, "state">,
+): Doc<"subscriptions"> {
+ return {
+ _id: "s_1" as never,
+ _creationTime: 0,
+ projectId: "p_1" as never,
+ purchaseToken: "tok_1",
+ productId: "sub.monthly",
+ platform: "IOS",
+ startedAt: Date.UTC(2026, 2, 1),
+ updatedAt: Date.UTC(2026, 2, 1),
+ ...partial,
+ };
+}
+
+describe("isActiveAt", () => {
+ const dayEnd = Date.parse("2026-03-15T23:59:59.999Z");
+
+ it("active sub started before dayEnd and not yet expired → true", () => {
+ const sub = makeSub({
+ state: "Active",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(true);
+ });
+
+ it("sub started AFTER dayEnd → false (no time-travel)", () => {
+ const sub = makeSub({
+ state: "Active",
+ startedAt: Date.UTC(2026, 2, 16),
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(false);
+ });
+
+ it("sub expired BEFORE dayEnd → false", () => {
+ const sub = makeSub({
+ state: "Active",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 2, 10),
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(false);
+ });
+
+ it("InGracePeriod sub with expiresAt > dayEnd → true", () => {
+ const sub = makeSub({
+ state: "InGracePeriod",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(true);
+ });
+
+ it("InBillingRetry counts as active for snapshot purposes", () => {
+ const sub = makeSub({
+ state: "InBillingRetry",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(true);
+ });
+
+ it("Expired / Revoked / Refunded / Paused → false even if still in window", () => {
+ for (const state of [
+ "Expired",
+ "Revoked",
+ "Refunded",
+ "Paused",
+ "Unknown",
+ ] as const) {
+ const sub = makeSub({
+ state,
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+ expect(isActiveAt(sub, dayEnd), `state=${state}`).toBe(false);
+ }
+ });
+
+ it("undefined expiresAt + counted state → still active (matches steady-state semantics)", () => {
+ const sub = makeSub({
+ state: "Active",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: undefined,
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(true);
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────────
+// Round-trip integration via in-memory DB. Same MemDb pattern as
+// `purchases/stats-integration.test.ts`, extended for index range
+// predicates (gte/lte) since revenueMetrics.ts uses them.
+// ──────────────────────────────────────────────────────────────────────
+
+type Row = Record & { _id: string; _creationTime: number };
+
+class IndexBuilder {
+ predicates: Array<(row: Row) => boolean> = [];
+ eq(field: string, value: unknown): IndexBuilder {
+ this.predicates.push((row) => row[field] === value);
+ return this;
+ }
+ gte(field: string, value: unknown): IndexBuilder {
+ this.predicates.push((row) => (row[field] as number) >= (value as number));
+ return this;
+ }
+ lte(field: string, value: unknown): IndexBuilder {
+ this.predicates.push((row) => (row[field] as number) <= (value as number));
+ return this;
+ }
+ gt(field: string, value: unknown): IndexBuilder {
+ this.predicates.push((row) => (row[field] as number) > (value as number));
+ return this;
+ }
+ lt(field: string, value: unknown): IndexBuilder {
+ this.predicates.push((row) => (row[field] as number) < (value as number));
+ return this;
+ }
+}
+
+class MemQuery {
+ constructor(private rows: Row[]) {}
+
+ withIndex(_name: string, cb?: (q: IndexBuilder) => IndexBuilder): MemQuery {
+ if (!cb) return this;
+ const builder = new IndexBuilder();
+ cb(builder);
+ return new MemQuery(
+ this.rows.filter((row) => builder.predicates.every((p) => p(row))),
+ );
+ }
+
+ order(direction: "asc" | "desc"): MemQuery {
+ // Approximate Convex's index-order behaviour for our scans:
+ // sort by `_creationTime` ascending or descending. The
+ // production code never depends on a more specific tiebreak, so
+ // this is sufficient for round-trip correctness.
+ const sorted = [...this.rows].sort((a, b) =>
+ direction === "asc"
+ ? a._creationTime - b._creationTime
+ : b._creationTime - a._creationTime,
+ );
+ return new MemQuery(sorted);
+ }
+
+ filter(_cb: unknown): MemQuery {
+ void _cb;
+ return this;
+ }
+
+ async first(): Promise {
+ return this.rows[0] ?? null;
+ }
+
+ async take(n: number): Promise {
+ return this.rows.slice(0, n);
+ }
+
+ async collect(): Promise {
+ return [...this.rows];
+ }
+
+ async unique(): Promise {
+ if (this.rows.length > 1) {
+ throw new Error(`unique: expected ≤1 row, got ${this.rows.length}`);
+ }
+ return this.rows[0] ?? null;
+ }
+}
+
+class MemDb {
+ tables = new Map>();
+ private counter = 0;
+
+ private table(name: string): Map {
+ let t = this.tables.get(name);
+ if (!t) {
+ t = new Map();
+ this.tables.set(name, t);
+ }
+ return t;
+ }
+
+ query(tableName: string): MemQuery {
+ return new MemQuery([...this.table(tableName).values()]);
+ }
+
+ async insert(
+ tableName: string,
+ doc: Record,
+ ): Promise {
+ const id = `${tableName}_${++this.counter}`;
+ const row: Row = {
+ ...doc,
+ _id: id,
+ _creationTime: Date.now() + this.counter, // ensure deterministic ordering
+ };
+ this.table(tableName).set(id, row);
+ return id;
+ }
+
+ async get(id: string): Promise {
+ for (const table of this.tables.values()) {
+ const row = table.get(id);
+ if (row) return row;
+ }
+ return null;
+ }
+
+ async patch(id: string, patch: Record): Promise {
+ for (const table of this.tables.values()) {
+ const row = table.get(id);
+ if (row) {
+ Object.assign(row, patch);
+ return;
+ }
+ }
+ throw new Error(`patch: no doc with id ${id}`);
+ }
+
+ async delete(id: string): Promise {
+ for (const table of this.tables.values()) {
+ if (table.delete(id)) return;
+ }
+ throw new Error(`delete: no doc with id ${id}`);
+ }
+}
+
+// Shared fixture: anchor "now" at a known UTC instant so day-key
+// math is deterministic across test runs / hosts. Picking 12:00 UTC
+// avoids any near-midnight edge case from leaking into assertions.
+const NOW = Date.UTC(2026, 2, 15, 12, 0, 0); // 2026-03-15T12:00:00Z
+
+// Trailing window the populator covers: [today-2, today-1, today].
+const TODAY = "2026-03-15";
+const D1 = "2026-03-14";
+const D2 = "2026-03-13";
+const OUT_OF_WINDOW = "2026-03-12";
+
+const PROJECT_ID = "p_test" as never;
+
+function makeCtx(db: MemDb) {
+ return { db } as unknown as Parameters[0];
+}
+
+async function seedEvent(
+ db: MemDb,
+ partial: Partial> & Pick, "type">,
+): Promise {
+ await db.insert("webhookEvents", {
+ projectId: PROJECT_ID,
+ source: "AppleAppStoreServerNotificationsV2",
+ platform: "IOS",
+ environment: "Production",
+ sourceNotificationId: `notif_${Math.random()}`,
+ productId: "sub.monthly",
+ currency: "USD",
+ occurredAt: NOW,
+ receivedAt: NOW,
+ ...partial,
+ });
+}
+
+async function seedSub(
+ db: MemDb,
+ partial: Partial> & Pick, "state">,
+): Promise {
+ await db.insert("subscriptions", {
+ projectId: PROJECT_ID,
+ purchaseToken: `tok_${Math.random()}`,
+ productId: "sub.monthly",
+ platform: "IOS",
+ currency: "USD",
+ priceAmountMicros: 9_990_000,
+ startedAt: Date.UTC(2026, 1, 1),
+ updatedAt: NOW,
+ ...partial,
+ });
+}
+
+async function rollupRows(db: MemDb): Promise {
+ return await db.query("revenueMetricsDaily").collect();
+}
+
+describe("runRecompute — round-trip integration", () => {
+ let db: MemDb;
+ let ctx: ReturnType;
+
+ beforeEach(() => {
+ db = new MemDb();
+ ctx = makeCtx(db);
+ });
+
+ it("empty project → no rollup rows written", async () => {
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("single SubscriptionStarted on today → newSubs=1, revenue=price", async () => {
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ day: TODAY,
+ productId: "sub.monthly",
+ currency: "USD",
+ newSubs: 1,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 9_990_000,
+ activeSubs: 0,
+ });
+ });
+
+ it("renewals are counted (the v2-deferred-then-fixed regression test)", async () => {
+ // The whole reason renewals matter: a sub started months ago,
+ // renewed today. The `subscriptions` table only knows the
+ // current state — webhookEvents is the canonical source.
+ await seedEvent(db, {
+ type: "SubscriptionRenewed",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ renewals: 1,
+ revenueMicros: 9_990_000,
+ newSubs: 0,
+ });
+ });
+
+ it("Refunded + Revoked both flow into refunds counter", async () => {
+ await seedEvent(db, {
+ type: "PurchaseRefunded",
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+ await seedEvent(db, {
+ type: "SubscriptionRevoked",
+ receivedAt: Date.parse(`${TODAY}T11:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ refunds: 2 });
+ });
+
+ it("Cancel + Uncancel within same day net to zero (skipped as empty)", async () => {
+ await seedEvent(db, {
+ type: "SubscriptionCanceled",
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+ await seedEvent(db, {
+ type: "SubscriptionUncanceled",
+ receivedAt: Date.parse(`${TODAY}T11:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ // All-zero buckets are intentionally skipped.
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("multi-platform events on same day produce separate rows (per-store split)", async () => {
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ platform: "IOS",
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 5_000_000,
+ platform: "Android",
+ receivedAt: Date.parse(`${TODAY}T11:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(2);
+ const ios = rows.find((r) => r.platform === "IOS");
+ const android = rows.find((r) => r.platform === "Android");
+ expect(ios?.revenueMicros).toBe(9_990_000);
+ expect(android?.revenueMicros).toBe(5_000_000);
+ });
+
+ it("activeSubs is split by platform — same productId on iOS + Android counts twice", async () => {
+ await seedSub(db, {
+ state: "Active",
+ platform: "IOS",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+ await seedSub(db, {
+ state: "Active",
+ platform: "Android",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+
+ const rows = await rollupRows(db);
+ // 3 days × 2 platforms = 6 rows.
+ expect(rows).toHaveLength(6);
+ const todayRows = rows.filter((r) => r.day === TODAY);
+ expect(todayRows).toHaveLength(2);
+ expect(todayRows.find((r) => r.platform === "IOS")?.activeSubs).toBe(1);
+ expect(todayRows.find((r) => r.platform === "Android")?.activeSubs).toBe(1);
+ });
+
+ it("multi-currency events on same day produce separate rows (no cross-FX summing)", async () => {
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ currency: "USD",
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 8_500_000,
+ currency: "EUR",
+ receivedAt: Date.parse(`${TODAY}T11:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(2);
+ const usd = rows.find((r) => r.currency === "USD");
+ const eur = rows.find((r) => r.currency === "EUR");
+ expect(usd?.revenueMicros).toBe(9_990_000);
+ expect(eur?.revenueMicros).toBe(8_500_000);
+ });
+
+ it("multi-product events on same day produce separate rows", async () => {
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ productId: "sub.monthly",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ productId: "sub.yearly",
+ priceAmountMicros: 99_990_000,
+ receivedAt: Date.parse(`${TODAY}T11:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(2);
+ expect(rows.find((r) => r.productId === "sub.yearly")?.revenueMicros).toBe(
+ 99_990_000,
+ );
+ });
+
+ it("event without productId is silently skipped (TestNotification path)", async () => {
+ await seedEvent(db, {
+ type: "TestNotification",
+ productId: undefined,
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("events outside trailing window are NOT included", async () => {
+ // 4 days ago — outside the 3-day window (today + 2 prior).
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${OUT_OF_WINDOW}T10:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("events spanning all 3 days produce per-day rows", async () => {
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${D2}T10:00:00Z`),
+ });
+ await seedEvent(db, {
+ type: "SubscriptionRenewed",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${D1}T10:00:00Z`),
+ });
+ await seedEvent(db, {
+ type: "PurchaseRefunded",
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ const byDay = new Map(rows.map((r) => [r.day, r]));
+ expect(byDay.get(D2)).toMatchObject({ newSubs: 1 });
+ expect(byDay.get(D1)).toMatchObject({ renewals: 1 });
+ expect(byDay.get(TODAY)).toMatchObject({ refunds: 1 });
+ });
+
+ it("activeSubs end-of-day snapshot from subscriptions table", async () => {
+ // Sub active across the whole window — should show up on every
+ // day's activeSubs.
+ await seedSub(db, {
+ state: "Active",
+ startedAt: Date.UTC(2026, 1, 1), // way before window
+ expiresAt: Date.UTC(2026, 3, 1), // after window
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(3);
+ for (const row of rows) {
+ expect(row.activeSubs).toBe(1);
+ }
+ });
+
+ it("activeSubs respects expiry mid-window", async () => {
+ // Sub expires at midnight UTC on TODAY — active on D2 and D1 (their
+ // dayEnd is 23:59:59.999 of those days, which is before the sub's
+ // expiresAt), inactive on TODAY (dayEnd 23:59:59.999 > expiresAt).
+ await seedSub(db, {
+ state: "Active",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 2, 15), // 2026-03-15T00:00:00Z
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const byDay = new Map(
+ (await rollupRows(db)).map((r) => [r.day, r.activeSubs]),
+ );
+ expect(byDay.get(D2)).toBe(1);
+ expect(byDay.get(D1)).toBe(1);
+ expect(byDay.get(TODAY)).toBeUndefined(); // empty bucket → skipped
+ });
+
+ it("activeSubs respects start mid-window", async () => {
+ // Sub started D1 noon — only active on D1 + TODAY, NOT on D2.
+ await seedSub(db, {
+ state: "Active",
+ startedAt: Date.parse(`${D1}T12:00:00Z`),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const byDay = new Map(
+ (await rollupRows(db)).map((r) => [r.day, r.activeSubs]),
+ );
+ expect(byDay.get(D2)).toBeUndefined();
+ expect(byDay.get(D1)).toBe(1);
+ expect(byDay.get(TODAY)).toBe(1);
+ });
+
+ it("expired sub does not contribute to activeSubs even if window-overlapping", async () => {
+ await seedSub(db, {
+ state: "Expired",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("idempotent: running twice produces the same set of rows", async () => {
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+ await seedSub(db, {
+ state: "Active",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const first = (await rollupRows(db)).map((r) => ({
+ day: r.day,
+ productId: r.productId,
+ currency: r.currency,
+ newSubs: r.newSubs,
+ renewals: r.renewals,
+ revenueMicros: r.revenueMicros,
+ activeSubs: r.activeSubs,
+ }));
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const second = (await rollupRows(db)).map((r) => ({
+ day: r.day,
+ productId: r.productId,
+ currency: r.currency,
+ newSubs: r.newSubs,
+ renewals: r.renewals,
+ revenueMicros: r.revenueMicros,
+ activeSubs: r.activeSubs,
+ }));
+
+ expect(second).toEqual(first);
+ });
+
+ it("late-arriving event in trailing window updates the existing day's bucket", async () => {
+ // First tick: 1 new sub.
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${D1}T10:00:00Z`),
+ });
+ await runRecompute(ctx, PROJECT_ID, NOW);
+
+ let row = (await rollupRows(db)).find((r) => r.day === D1);
+ expect(row?.newSubs).toBe(1);
+
+ // Late RENEWED event for D1 arrives between ticks (within
+ // trailing window).
+ await seedEvent(db, {
+ type: "SubscriptionRenewed",
+ priceAmountMicros: 9_990_000,
+ receivedAt: Date.parse(`${D1}T15:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ row = (await rollupRows(db)).find((r) => r.day === D1);
+ expect(row?.newSubs).toBe(1);
+ expect(row?.renewals).toBe(1);
+ expect(row?.revenueMicros).toBe(2 * 9_990_000);
+ });
+
+ it("project isolation — events in another project don't leak into rollup", async () => {
+ // Same productId but different projectId.
+ await db.insert("webhookEvents", {
+ projectId: "p_other",
+ source: "AppleAppStoreServerNotificationsV2",
+ platform: "IOS",
+ environment: "Production",
+ sourceNotificationId: "notif_other",
+ type: "SubscriptionStarted",
+ productId: "sub.monthly",
+ currency: "USD",
+ priceAmountMicros: 9_990_000,
+ occurredAt: NOW,
+ receivedAt: NOW,
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("delete-before-insert: stale rows in window are wiped on recompute", async () => {
+ // Pre-seed a stale rollup row in the window for a productId that
+ // no longer has any events. After recompute it must be GONE.
+ await db.insert("revenueMetricsDaily", {
+ projectId: PROJECT_ID,
+ day: TODAY,
+ productId: "sub.deprecated",
+ currency: "USD",
+ activeSubs: 0,
+ newSubs: 5,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 50_000_000,
+ updatedAt: NOW - 86400000,
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("rollup rows OUTSIDE window are preserved (not blanket-deleted)", async () => {
+ // Old row from 30 days ago — must survive a daily recompute.
+ await db.insert("revenueMetricsDaily", {
+ projectId: PROJECT_ID,
+ day: "2026-02-15",
+ productId: "sub.monthly",
+ currency: "USD",
+ activeSubs: 0,
+ newSubs: 3,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 30_000_000,
+ updatedAt: Date.UTC(2026, 1, 16),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ const old = rows.find((r) => r.day === "2026-02-15");
+ expect(old?.newSubs).toBe(3);
+ });
+});
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
new file mode 100644
index 00000000..6ddaf822
--- /dev/null
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -0,0 +1,386 @@
+// Daily revenue rollup populator. Reads `webhookEvents` (the canonical
+// store-side event log — Apple ASN v2 / Google RTDN / Meta Horizon
+// reconciler all converge here) over a trailing window and writes
+// per-(project, day, productId, currency) rollups to
+// `revenueMetricsDaily`.
+//
+// Using the event log instead of walking `subscriptions` is what lets
+// us count renewals correctly: the `subscriptions` table holds the
+// CURRENT state of each entitlement, so the fact that a sub renewed
+// three times since signup is invisible there. The webhook event
+// stream records every transition individually, including
+// `SubscriptionRenewed` with its `priceAmountMicros` extracted at
+// receive time.
+//
+// `activeSubs` is a different shape — it's an end-of-day snapshot,
+// not a count of events. We compute it from the `subscriptions` table
+// in the same per-project mutation, in one pass, for every day in the
+// window.
+//
+// Trailing window: 3 days. Apple ASN v2 retries up to 5 days and
+// Google RTDN's Pub/Sub default is 7 days, but in practice 99% of
+// late-arriving notifications land within 24-48h. RevenueCat picked
+// the same 3-day reprocess window for the same reason. Each cron
+// tick overwrites the trailing 3 days, so a webhook arriving up to
+// 3 days late still gets folded into its correct day's bucket.
+
+import { internalMutation } from "../_generated/server";
+import type { MutationCtx } from "../_generated/server";
+import { internal } from "../_generated/api";
+import { v } from "convex/values";
+import type { Doc, Id } from "../_generated/dataModel";
+
+// Trailing recompute window in days. Bumping this past ~7 days means
+// every cron tick walks more events for diminishing accuracy gains
+// (late events past 7d are vanishingly rare — Apple/Google both
+// quarantine those into manual reconciliation paths instead).
+const TRAILING_DAYS = 3;
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+// UTC day key (YYYY-MM-DD) for an epoch-millis timestamp. Keying in
+// UTC matches `revenueMetricsDaily.day`'s stored format and avoids
+// the off-by-one a project's local timezone would introduce when
+// the same day's events get split across two rollup rows after a
+// dashboard timezone change.
+export function utcDayKey(ts: number): string {
+ return new Date(ts).toISOString().slice(0, 10);
+}
+
+export function startOfUtcDay(ts: number): number {
+ const date = new Date(ts);
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
+}
+
+export type Platform = "IOS" | "Android";
+
+// Composite key for daily buckets: (day, productId, currency, platform).
+// Same SKU sold in multiple storefront currencies / platforms on the
+// same day produces distinct buckets — same reasoning as
+// `revenueMetricsDaily`'s composite index.
+type BucketKey = string;
+export function bucketKey(
+ day: string,
+ productId: string,
+ currency: string,
+ platform: Platform,
+): string {
+ return `${day}|${productId}|${currency}|${platform}`;
+}
+
+export type RollupBucket = {
+ day: string;
+ productId: string;
+ currency: string;
+ platform: Platform;
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+};
+
+const COUNTED_STATES = new Set([
+ "Active",
+ "InGracePeriod",
+ "InBillingRetry",
+] as const);
+
+// Daily entry point: schedule per-project recomputes. Mirrors the
+// `recomputeAllSubscriptionStats` pattern so each project gets its
+// own 40k document-read budget rather than sharing the picker's.
+export const recomputeAllRevenueMetrics = internalMutation({
+ args: {
+ batchSize: v.optional(v.number()),
+ },
+ returns: v.object({ scheduled: v.number() }),
+ handler: async (ctx, args) => {
+ const limit = args.batchSize ?? 50;
+ // Walk the same `subscriptionStats.by_updated_at` index the
+ // existing drift cron uses, so the two crons stay in sync on
+ // which projects need attention. A project without
+ // `subscriptionStats` rows has never had a counted-state sub
+ // either, so it has nothing to roll up.
+ const SCAN_CAP = Math.max(limit * 3, 300);
+ const stale = await ctx.db
+ .query("subscriptionStats")
+ .withIndex("by_updated_at")
+ .order("asc")
+ .take(SCAN_CAP);
+ const seen = new Set();
+ const projects: Id<"projects">[] = [];
+ for (const row of stale) {
+ if (seen.has(row.projectId)) continue;
+ seen.add(row.projectId);
+ projects.push(row.projectId);
+ if (projects.length >= limit) break;
+ }
+ let scheduled = 0;
+ for (const projectId of projects) {
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics.recomputeRevenueMetricsForProject,
+ { projectId },
+ );
+ scheduled += 1;
+ }
+ return { scheduled };
+ },
+});
+
+// Per-project recompute. Scans the trailing-window slice of
+// `webhookEvents` once and the project's `subscriptions` table once,
+// then writes one rollup row per (day, productId, currency) bucket.
+//
+// Bounded reads:
+// - webhookEvents: trailing TRAILING_DAYS × per-project event rate.
+// Capped at WEBHOOK_SCAN_CAP so a runaway-loop project can't
+// exceed Convex's 40k document-read mutation budget.
+// - subscriptions: walks the project's full sub list once via
+// `by_project_and_updated`. Capped at SUBS_SCAN_CAP — projects
+// past the cap should switch to incremental maintenance in v2.
+export const recomputeRevenueMetricsForProject = internalMutation({
+ args: { projectId: v.id("projects") },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await runRecompute(ctx, args.projectId, Date.now());
+ return null;
+ },
+});
+
+const WEBHOOK_SCAN_CAP = 20_000;
+const SUBS_SCAN_CAP = 20_000;
+
+export async function runRecompute(
+ ctx: MutationCtx,
+ projectId: Id<"projects">,
+ now: number,
+): Promise {
+ const todayStart = startOfUtcDay(now);
+ const windowStart = todayStart - (TRAILING_DAYS - 1) * DAY_MS;
+ // Inclusive end-of-window — covers events received up to "now"
+ // on the most recent day in the window. Events received after the
+ // cron ran will be folded in by the next tick.
+ const windowEnd = now;
+
+ // Pre-build the day list for activeSubs snapshots and to ensure
+ // every day in the window gets a row even when it had zero events
+ // (so a "no churn yesterday" day still surfaces as activeSubs=N
+ // instead of disappearing from the chart).
+ const days: string[] = [];
+ for (let i = 0; i < TRAILING_DAYS; i++) {
+ days.push(utcDayKey(windowStart + i * DAY_MS));
+ }
+
+ const buckets = new Map();
+
+ // ---- Pass 1: webhookEvents → newSubs / renewals / cancellations
+ // / refunds / revenueMicros buckets.
+ const events = await ctx.db
+ .query("webhookEvents")
+ .withIndex("by_project_and_received", (q) =>
+ q
+ .eq("projectId", projectId)
+ .gte("receivedAt", windowStart)
+ .lte("receivedAt", windowEnd),
+ )
+ .take(WEBHOOK_SCAN_CAP);
+
+ for (const event of events) {
+ if (!event.productId) continue;
+ const day = utcDayKey(event.receivedAt);
+ const currency = event.currency ?? "";
+ // The webhookEvents schema only allows `IOS` / `Android` for
+ // `platform`; the Meta Horizon reconciler synthesizes events
+ // under `platform: "Android"` because Quest devices map to the
+ // Play store's commerce model. No third value to handle here.
+ const platform = event.platform;
+ const key = bucketKey(day, event.productId, currency, platform);
+ const bucket = getOrCreateBucket(
+ buckets,
+ key,
+ day,
+ event.productId,
+ currency,
+ platform,
+ );
+ applyEventToBucket(bucket, event);
+ }
+
+ // ---- Pass 2: subscriptions → activeSubs end-of-day snapshots.
+ // We need every project sub (not just the trailing window)
+ // because a sub started months ago can still be active "today".
+ // Walk the by_project_and_updated index ascending for index-order
+ // determinism; the activeSubs computation doesn't care about
+ // order, but this avoids surprising tiebreak behaviour if a
+ // future caller pages off the same handle.
+ const subs = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_updated", (q) => q.eq("projectId", projectId))
+ .take(SUBS_SCAN_CAP);
+
+ // Per-day end-of-day boundary timestamps. activeSubs snapshot is
+ // taken at `dayEnd` (start of next UTC day - 1ms) so a sub that
+ // expires at exactly midnight UTC counts toward the day it was
+ // active during, not the day it expired into.
+ const dayEnds = days.map((day) => Date.parse(`${day}T23:59:59.999Z`));
+
+ for (const sub of subs) {
+ for (let i = 0; i < days.length; i++) {
+ if (!isActiveAt(sub, dayEnds[i])) continue;
+ // activeSubs key uses the sub's productId + currency + platform
+ // so it composes with the event-driven counters that share the
+ // same bucket key.
+ const currency = sub.currency ?? "";
+ const platform = sub.platform;
+ const key = bucketKey(days[i], sub.productId, currency, platform);
+ const bucket = getOrCreateBucket(
+ buckets,
+ key,
+ days[i],
+ sub.productId,
+ currency,
+ platform,
+ );
+ bucket.activeSubs += 1;
+ }
+ }
+
+ // ---- Commit: upsert each bucket, delete any pre-existing row in
+ // the window that's no longer in the recomputed set (otherwise a
+ // sub that switched products would leave a stale bucket behind).
+ await commitBuckets(ctx, projectId, days, buckets, now);
+}
+
+function getOrCreateBucket(
+ buckets: Map,
+ key: BucketKey,
+ day: string,
+ productId: string,
+ currency: string,
+ platform: Platform,
+): RollupBucket {
+ let bucket = buckets.get(key);
+ if (!bucket) {
+ bucket = {
+ day,
+ productId,
+ currency,
+ platform,
+ activeSubs: 0,
+ newSubs: 0,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 0,
+ };
+ buckets.set(key, bucket);
+ }
+ return bucket;
+}
+
+export function applyEventToBucket(
+ bucket: RollupBucket,
+ event: Doc<"webhookEvents">,
+): void {
+ const price = event.priceAmountMicros ?? 0;
+ switch (event.type) {
+ case "SubscriptionStarted":
+ bucket.newSubs += 1;
+ bucket.revenueMicros += price;
+ break;
+ case "SubscriptionRenewed":
+ bucket.renewals += 1;
+ bucket.revenueMicros += price;
+ break;
+ case "SubscriptionCanceled":
+ // User-initiated cancellations. Counted only when the user
+ // turned off renewal — uncancellations are caught by
+ // `SubscriptionUncanceled` so a cancel-then-uncancel pair
+ // within the same window is net-zero in the chart.
+ bucket.cancellations += 1;
+ break;
+ case "SubscriptionUncanceled":
+ bucket.cancellations -= 1;
+ break;
+ case "PurchaseRefunded":
+ case "SubscriptionRevoked":
+ // Both are store-initiated reversals. Count under refunds so
+ // the dashboard's "money lost" line includes both routes.
+ bucket.refunds += 1;
+ break;
+ default:
+ // Lifecycle-only events (Expired, InGracePeriod, etc.) don't
+ // affect the financial counters — they're surfaced via the
+ // existing `metricsSummary` live counters instead.
+ break;
+ }
+ if (bucket.cancellations < 0) bucket.cancellations = 0;
+ if (bucket.revenueMicros < 0) bucket.revenueMicros = 0;
+}
+
+export function isActiveAt(sub: Doc<"subscriptions">, dayEnd: number): boolean {
+ if (sub.startedAt > dayEnd) return false;
+ if (!COUNTED_STATES.has(sub.state as "Active")) return false;
+ if (typeof sub.expiresAt === "number" && sub.expiresAt <= dayEnd) {
+ return false;
+ }
+ return true;
+}
+
+async function commitBuckets(
+ ctx: MutationCtx,
+ projectId: Id<"projects">,
+ days: string[],
+ buckets: Map,
+ now: number,
+): Promise {
+ // Delete every existing row in the window first, then insert the
+ // freshly computed set. Cleaner than a per-key upsert/delete diff
+ // because the window is bounded (TRAILING_DAYS × productCount ×
+ // currencyCount) — typically tens of rows per project, not
+ // thousands.
+ for (const day of days) {
+ const existing = await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q.eq("projectId", projectId).eq("day", day),
+ )
+ .collect();
+ for (const row of existing) {
+ await ctx.db.delete(row._id);
+ }
+ }
+
+ for (const bucket of buckets.values()) {
+ // Skip empty buckets — happens when a sub became active mid-day
+ // but was later refunded so its (newSubs, refunds) net to zero
+ // and it wasn't active at end-of-day either. No row beats an
+ // all-zero row in storage / scan cost.
+ if (
+ bucket.activeSubs === 0 &&
+ bucket.newSubs === 0 &&
+ bucket.renewals === 0 &&
+ bucket.cancellations === 0 &&
+ bucket.refunds === 0 &&
+ bucket.revenueMicros === 0
+ ) {
+ continue;
+ }
+ await ctx.db.insert("revenueMetricsDaily", {
+ projectId,
+ day: bucket.day,
+ productId: bucket.productId,
+ currency: bucket.currency,
+ platform: bucket.platform,
+ activeSubs: bucket.activeSubs,
+ newSubs: bucket.newSubs,
+ renewals: bucket.renewals,
+ cancellations: bucket.cancellations,
+ refunds: bucket.refunds,
+ revenueMicros: bucket.revenueMicros,
+ updatedAt: now,
+ });
+ }
+}
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 35debfa5..9318b713 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -53,6 +53,7 @@
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.7.1",
+ "recharts": "^2.13.3",
"remark-gfm": "^4.0.1",
"resend": "^4.8.0",
"sonner": "^2.0.3",
diff --git a/packages/kit/src/pages/auth/index.tsx b/packages/kit/src/pages/auth/index.tsx
index 6734981f..93812c49 100644
--- a/packages/kit/src/pages/auth/index.tsx
+++ b/packages/kit/src/pages/auth/index.tsx
@@ -14,6 +14,7 @@ import ProjectIndex from "./organization/project";
import ProjectPurchases from "./organization/project/purchases";
import ProjectApiKeys from "./organization/project/apikeys";
import ProjectSubscriptions from "./organization/project/subscriptions";
+import ProjectAnalytics from "./organization/project/analytics";
import ProjectProducts from "./organization/project/products";
import ProjectWebhooks from "./organization/project/webhooks";
import ProjectSettings from "./organization/project/settings";
@@ -271,6 +272,14 @@ export default function AuthenticatedPages() {
}
/>
+
+
+
+ }
+ />
};
+
+type Platform = "IOS" | "Android";
+type PlatformFilter = "all" | Platform;
+
+const RANGES = [
+ { id: "7d", label: "Last 7 days", days: 7 },
+ { id: "30d", label: "Last 30 days", days: 30 },
+ { id: "90d", label: "Last 90 days", days: 90 },
+] as const;
+
+type RangeId = (typeof RANGES)[number]["id"];
+
+const PERIODS = [
+ { id: "daily", label: "Daily" },
+ { id: "weekly", label: "Weekly" },
+ { id: "monthly", label: "Monthly" },
+] as const;
+
+type PeriodId = (typeof PERIODS)[number]["id"];
+
+type PlatformCardKey = "all" | "ios" | "android";
+
+const PLATFORM_CARDS: Array<{
+ key: PlatformCardKey;
+ label: string;
+ accent: string;
+ filter: PlatformFilter;
+}> = [
+ {
+ key: "all",
+ label: "All platforms",
+ accent: "from-violet-500/10 to-transparent",
+ filter: "all",
+ },
+ {
+ key: "ios",
+ label: "App Store",
+ accent: "from-blue-500/10 to-transparent",
+ filter: "IOS",
+ },
+ {
+ key: "android",
+ label: "Google Play",
+ accent: "from-green-500/10 to-transparent",
+ filter: "Android",
+ },
+];
+
+export default function ProjectAnalytics() {
+ const { project } = useOutletContext();
+ const { orgSlug, projectSlug } = useParams<{
+ orgSlug: string;
+ projectSlug: string;
+ }>();
+ const webhooksHref =
+ orgSlug && projectSlug
+ ? `/${orgSlug}/project/${projectSlug}/webhooks`
+ : null;
+ const [rangeId, setRangeId] = useState("30d");
+ const [periodId, setPeriodId] = useState("daily");
+ const [selectedCurrency, setSelectedCurrency] = useState(null);
+ const [selectedProduct, setSelectedProduct] = useState(null);
+ const [platformFilter, setPlatformFilter] = useState("all");
+
+ const range = RANGES.find((r) => r.id === rangeId) ?? RANGES[1];
+ const MAX_RANGE_DAYS = RANGES[RANGES.length - 1].days;
+
+ // Always fetch the largest range so flipping the range chiclet
+ // doesn't trigger a Convex refetch — we slice the result
+ // client-side. UTC-day boundaries are computed in the browser to
+ // keep the rollup table read-side consistent with the cron's
+ // writes (both use UTC); using local-day here would cause the
+ // chart's first/last column to half-cover when the user's tz is
+ // far from UTC, surfacing as a "missing yesterday" off-by-one.
+ const { maxFromDay, toDay } = useMemo(() => {
+ const today = utcDayKey(Date.now());
+ const from = utcDayKey(Date.now() - (MAX_RANGE_DAYS - 1) * 86400000);
+ return { maxFromDay: from, toDay: today };
+ }, [MAX_RANGE_DAYS]);
+
+ // Per-range start day, derived without re-querying. Slicing the
+ // unfiltered fetch in JS keeps the page below from flashing on
+ // range/period clicks; only the bottom (charts + summary cards)
+ // re-derives.
+ const fromDay = useMemo(
+ () => utcDayKey(Date.now() - (range.days - 1) * 86400000),
+ [range],
+ );
+
+ // Fetch unfiltered for the maximum range — all filtering (range,
+ // platform, product, currency, period) happens client-side below.
+ // Two reasons:
+ // 1. Filter clicks must NOT trigger a Convex refetch (the prior
+ // flicker the user reported was useQuery's stale→pending
+ // transition rebuilding the whole subtree).
+ // 2. The platform cards always show the full breakdown
+ // (All / iOS / Android), regardless of which filter is
+ // active — so the cards need the unfiltered data anyway.
+ const metrics = useQuery(api.subscriptions.query.getRevenueMetrics, {
+ apiKey: project.apiKey,
+ fromDay: maxFromDay,
+ toDay,
+ });
+
+ if (metrics === undefined) {
+ return ;
+ }
+
+ const currency = selectedCurrency ?? metrics.currencies[0] ?? "";
+
+ // Client-side filtering. Range is also a client filter now (we
+ // fetched the max range above), so flipping range chiclets stays
+ // free — only the chart subtree re-derives.
+ const filteredRows = metrics.days.filter((row) => {
+ if (row.day < fromDay) return false;
+ if (selectedCurrency && row.currency !== selectedCurrency) return false;
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ if (platformFilter !== "all" && row.platform !== platformFilter) {
+ return false;
+ }
+ return true;
+ });
+
+ const dailySeries = aggregateByDay(filteredRows, range.days, fromDay);
+ const series = bucketByPeriod(dailySeries, periodId);
+
+ const totals = series.reduce(
+ (acc, row) => {
+ acc.newSubs += row.newSubs;
+ acc.renewals += row.renewals;
+ acc.cancellations += row.cancellations;
+ acc.refunds += row.refunds;
+ acc.revenueMicros += row.revenueMicros;
+ acc.activeSubsLast = row.activeSubs;
+ return acc;
+ },
+ {
+ newSubs: 0,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 0,
+ activeSubsLast: 0,
+ },
+ );
+
+ // Churn = (cancellations + refunds) / activeSubs at end of window.
+ // Same definition Stripe / RevenueCat surface in their headline
+ // dashboards. Guard against div-by-zero on a pre-revenue project.
+ const churnRate =
+ totals.activeSubsLast > 0
+ ? ((totals.cancellations + totals.refunds) / totals.activeSubsLast) * 100
+ : 0;
+
+ return (
+
+
+
+
+ Analytics
+
+
+ Revenue and subscription lifecycle metrics, rolled up daily from
+ ingested webhook events. Updated every 24h on a trailing 3-day window
+ — late Apple ASN v2 / Google RTDN notifications fold into their
+ correct day automatically.
+
+
+
+ {/* Webhook prerequisite callout. Verify alone doesn't tell IAPKit
+ when a renewal/cancel/refund happens — only Apple ASN v2 /
+ Google RTDN do — so a project without webhooks set up will
+ forever see an empty chart. Surface this prominently above
+ the data so the empty-state isn't ambiguous. */}
+
+
+
+
+ Analytics requires Apple ASN v2 / Google RTDN webhooks
+
+
+ Without webhook integration this page will stay empty —{" "}
+ /v1/purchase/verify alone doesn't
+ tell IAPKit when renewals, cancellations, or refunds happen.{" "}
+ {webhooksHref && (
+
+ Open Webhooks tab
+
+ )}{" "}
+ ·{" "}
+
+ Read the setup guide
+
+
+
+
+
+
+ {PLATFORM_CARDS.map((card) => {
+ const active = platformFilter === card.filter;
+ // Cards reflect the selected range / currency / product
+ // (everything except the platform filter — the cards ARE
+ // the platform breakdown). Filtering to fromDay matches
+ // what the charts below show.
+ const cardRows = metrics.days.filter((row) => {
+ if (row.day < fromDay) return false;
+ if (selectedCurrency && row.currency !== selectedCurrency)
+ return false;
+ if (selectedProduct && row.productId !== selectedProduct)
+ return false;
+ return true;
+ });
+ const cardTotals = totalsForPlatform(cardRows, card.filter, currency);
+ return (
+
+ );
+}
+
+const tooltipStyle = {
+ backgroundColor: "var(--card)",
+ border: "1px solid var(--border)",
+ borderRadius: "8px",
+ fontSize: "12px",
+} as const;
+
+function ChicletGroup({
+ options,
+ value,
+ onChange,
+}: {
+ options: Array<{ id: string; label: string }>;
+ value: string;
+ onChange: (id: string) => void;
+}) {
+ // Active state: primary-bordered card on top of the muted track.
+ // Hover state on inactive options: muted fill + foreground text.
+ // Both are now visually distinct in both light and dark modes —
+ // the prior bg-card vs bg-muted/40 combo collapsed to identical
+ // gray on dark, which is what the user reported.
+ return (
+
+ Analytics roll up daily from ingested Apple ASN v2 / Google RTDN webhook
+ events. Once your first webhook arrives, the next cron tick (within 24h)
+ will populate this view.
+
+
+ );
+}
+
+// Aggregate the per-currency / per-product / per-platform rollup rows
+// the query returns into a single per-day series. The query has
+// already filtered by the user's selected currency / product /
+// platform, so summation here is safe — we're collapsing
+// multi-row days into one chart row.
+type DailyRow = {
+ day: string;
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+};
+
+function aggregateByDay(
+ rows: Array,
+ rangeDays: number,
+ fromDay: string,
+): Array {
+ const byDay = new Map();
+ for (const row of rows) {
+ const existing = byDay.get(row.day);
+ if (existing) {
+ existing.activeSubs += row.activeSubs;
+ existing.newSubs += row.newSubs;
+ existing.renewals += row.renewals;
+ existing.cancellations += row.cancellations;
+ existing.refunds += row.refunds;
+ existing.revenueMicros += row.revenueMicros;
+ } else {
+ byDay.set(row.day, { ...row });
+ }
+ }
+ const fromTs = Date.parse(`${fromDay}T00:00:00.000Z`);
+ const result: Array = [];
+ let lastActive = 0;
+ for (let i = 0; i < rangeDays; i++) {
+ const dayKey = utcDayKey(fromTs + i * 86400000);
+ const entry = byDay.get(dayKey);
+ if (entry) {
+ lastActive = entry.activeSubs;
+ result.push({ ...entry, dayKey });
+ } else {
+ // Carry the prior activeSubs forward (no event = no churn that
+ // period). For event-driven counters a no-event period is
+ // genuinely zero.
+ result.push({
+ day: dayKey,
+ dayKey,
+ activeSubs: lastActive,
+ newSubs: 0,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 0,
+ });
+ }
+ }
+ return result;
+}
+
+// Bucket the daily series into the selected period (Daily / Weekly /
+// Monthly). Weekly buckets are ISO week (Mon-Sun). Monthly buckets
+// are calendar month. Aggregation rules:
+// - Sum: newSubs / renewals / cancellations / refunds / revenueMicros
+// - End-of-period snapshot: activeSubs (last day's value in each bucket)
+//
+// Active subs is NOT summed across days — that would inflate by N.
+function bucketByPeriod(
+ daily: Array,
+ period: PeriodId,
+): Array {
+ if (period === "daily") {
+ return daily.map((row) => ({
+ ...row,
+ label: row.dayKey.slice(5), // MM-DD
+ churnPct:
+ row.activeSubs > 0
+ ? ((row.cancellations + row.refunds) / row.activeSubs) * 100
+ : 0,
+ }));
+ }
+
+ const buckets = new Map<
+ string,
+ {
+ label: string;
+ sortKey: string;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+ activeSubsLast: number;
+ activeSubsLastDay: string;
+ }
+ >();
+
+ for (const row of daily) {
+ const {
+ bucketKey: key,
+ label,
+ sortKey,
+ } = bucketLabelFor(row.dayKey, period);
+ const existing = buckets.get(key) ?? {
+ label,
+ sortKey,
+ newSubs: 0,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 0,
+ activeSubsLast: 0,
+ activeSubsLastDay: "",
+ };
+ existing.newSubs += row.newSubs;
+ existing.renewals += row.renewals;
+ existing.cancellations += row.cancellations;
+ existing.refunds += row.refunds;
+ existing.revenueMicros += row.revenueMicros;
+ if (row.dayKey >= existing.activeSubsLastDay) {
+ existing.activeSubsLast = row.activeSubs;
+ existing.activeSubsLastDay = row.dayKey;
+ }
+ buckets.set(key, existing);
+ }
+
+ const sorted = Array.from(buckets.values()).sort((a, b) =>
+ a.sortKey.localeCompare(b.sortKey),
+ );
+ return sorted.map((b) => ({
+ day: b.label,
+ dayKey: b.sortKey,
+ label: b.label,
+ activeSubs: b.activeSubsLast,
+ newSubs: b.newSubs,
+ renewals: b.renewals,
+ cancellations: b.cancellations,
+ refunds: b.refunds,
+ revenueMicros: b.revenueMicros,
+ churnPct:
+ b.activeSubsLast > 0
+ ? ((b.cancellations + b.refunds) / b.activeSubsLast) * 100
+ : 0,
+ }));
+}
+
+// Compute (bucketKey, label, sortKey) for a given day. sortKey
+// guarantees chronological order across years; label is the
+// short user-facing string drawn on the chart's x-axis.
+function bucketLabelFor(
+ dayKey: string,
+ period: PeriodId,
+): { bucketKey: string; label: string; sortKey: string } {
+ const date = new Date(`${dayKey}T00:00:00.000Z`);
+ if (period === "weekly") {
+ // Start of ISO week (Monday). UTC day 1=Mon … 0=Sun.
+ const weekday = (date.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
+ const monday = new Date(date.getTime() - weekday * 86400000);
+ const key = utcDayKey(monday.getTime());
+ return {
+ bucketKey: key,
+ label: `wk ${key.slice(5)}`, // wk MM-DD (Mon)
+ sortKey: key,
+ };
+ }
+ // Monthly
+ const month = dayKey.slice(0, 7); // YYYY-MM
+ return {
+ bucketKey: month,
+ label: month,
+ sortKey: `${month}-01`,
+ };
+}
+
+// Per-platform totals for the platform cards. Run on the unaggregated
+// `metrics.days` so the card numbers stay correct regardless of the
+// selected platform filter (the cards ARE the filter — they always
+// show the breakdown). `currency` is just for display formatting; the
+// rows have already been filtered to the selected currency upstream.
+function totalsForPlatform(
+ rows: Array<{
+ platform: Platform;
+ activeSubs: number;
+ newSubs: number;
+ revenueMicros: number;
+ day: string;
+ }>,
+ filter: PlatformFilter,
+ _currency: string,
+): { revenueMicros: number; activeSubs: number; newSubs: number } {
+ const matching =
+ filter === "all" ? rows : rows.filter((r) => r.platform === filter);
+ // For activeSubs, take the LAST day's snapshot per platform and sum.
+ // Otherwise summing across days would multiply by N and inflate.
+ const lastByPlatform = new Map();
+ let revenueMicros = 0;
+ let newSubs = 0;
+ for (const row of matching) {
+ revenueMicros += row.revenueMicros;
+ newSubs += row.newSubs;
+ const prior = lastByPlatform.get(row.platform);
+ if (!prior || row.day > prior.day) {
+ lastByPlatform.set(row.platform, {
+ day: row.day,
+ active: row.activeSubs,
+ });
+ }
+ }
+ let activeSubs = 0;
+ for (const v of lastByPlatform.values()) activeSubs += v.active;
+ return { revenueMicros, activeSubs, newSubs };
+}
+
+function utcDayKey(ts: number): string {
+ return new Date(ts).toISOString().slice(0, 10);
+}
+
+function formatMicros(
+ micros: number,
+ currency: string,
+ compact = false,
+): string {
+ if (!micros) return compact ? "0" : `${currency} 0`.trim();
+ const value = micros / 1_000_000;
+ if (compact) {
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
+ return value.toFixed(0);
+ }
+ return `${currency} ${value.toFixed(2)}`.trim();
+}
diff --git a/packages/kit/src/pages/auth/organization/project/index.tsx b/packages/kit/src/pages/auth/organization/project/index.tsx
index 723b98df..2c44bedc 100644
--- a/packages/kit/src/pages/auth/organization/project/index.tsx
+++ b/packages/kit/src/pages/auth/organization/project/index.tsx
@@ -12,6 +12,7 @@ import {
Activity,
Layers,
Webhook,
+ BarChart3,
} from "lucide-react";
import { PageLoading } from "@/components/LoadingSpinner";
@@ -21,6 +22,7 @@ const TAB_IDS = [
"dashboard",
"purchases",
"subscriptions",
+ "analytics",
"products",
"webhooks",
"apikeys",
@@ -66,6 +68,12 @@ export default function ProjectIndex() {
label: "Subscriptions",
icon: Activity,
},
+ {
+ id: "analytics",
+ label: "Analytics",
+ icon: BarChart3,
+ badge: "Beta",
+ },
{
id: "products",
label: "Products",
diff --git a/packages/kit/src/pages/auth/organization/project/purchases.tsx b/packages/kit/src/pages/auth/organization/project/purchases.tsx
index 22e53748..57f2e6c2 100644
--- a/packages/kit/src/pages/auth/organization/project/purchases.tsx
+++ b/packages/kit/src/pages/auth/organization/project/purchases.tsx
@@ -316,32 +316,36 @@ export default function ProjectPurchases() {
- {statConfig.map((stat) => (
-
{
- if (stat.key === "total") {
- resetFilters();
- } else if (stat.key === "apple") {
- applyStoreFilter("apple");
- } else if (stat.key === "google") {
- applyStoreFilter("google");
- } else if (stat.key === "valid") {
- applyValidityFilter(true);
- } else if (stat.key === "invalid") {
- applyValidityFilter(false);
- }
- }}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
+ {statConfig.map((stat) => {
+ // Determine which card matches the currently-active filter
+ // so the selected card is visually distinct from hover (the
+ // prior styling only highlighted on hover, so users couldn't
+ // tell which card they had already clicked).
+ const isActive =
+ stat.key === "total"
+ ? !storeFilter && isValidFilter === undefined
+ : stat.key === "apple"
+ ? storeFilter === "apple"
+ : stat.key === "google"
+ ? storeFilter === "google"
+ : stat.key === "valid"
+ ? isValidFilter === true
+ : isValidFilter === false;
+ return (
+
+ The Analytics tab visualizes revenue and subscription
+ lifecycle metrics across iOS and Android: total revenue, active
+ subscriptions, new subs, renewals, cancellations, refunds, and churn.
+ Data is sliced by date range (7 / 30 / 90 days), aggregated by period
+ (daily / weekly / monthly), and filterable by platform, product, and
+ currency.
+
+
+
+
+ The dashboard reads from a daily-rolled-up table populated from
+ ingested Apple App Store Server Notifications v2 and{" "}
+ Google Play Real-time Developer Notifications (RTDN).
+ Without webhooks, the Analytics tab will stay empty regardless of how
+ many /v1/purchase/verify calls you make — verification
+ alone doesn't tell IAPKit when a renewal, cancel, or refund happens.
+
+
+ Open the project's Webhooks tab to copy your
+ IAPKit-hosted webhook URLs and register them with the App Store / Play
+ Console. Once notifications start arriving, the next cron tick (within
+ 24h) will populate this view.
+
+
+
+
Setup checklist
+
+
+ Configure store credentials per the{" "}
+
+ Apple
+ {" "}
+ /{" "}
+
+ Google
+ {" "}
+ setup pages (otherwise the webhook receivers can't decode signed
+ payloads).
+
+
+ Open the project's Webhooks tab in the dashboard.
+ Copy the per-store URLs (one for Apple ASN v2, one for Google RTDN).
+
+
+ Apple: in App Store Connect →{" "}
+ App Store Server Notifications, paste the Apple webhook URL
+ and select Version 2 for both Production and Sandbox
+ environments.
+
+
+ Google: in Play Console → Monetization setup
+ , point Real-time Developer Notifications to a Pub/Sub topic that fans
+ out to the Google webhook URL (Pub/Sub push subscription with the
+ IAPKit URL as endpoint).
+
+
+ Trigger a test purchase or use the App Store Connect / Play Console
+ "Send test notification" feature. Confirm an event row appears in the
+ Webhooks tab's event log.
+
+
+ Wait up to 24h for the next analytics rollup tick — or trigger it
+ manually if you have access to the Convex dashboard (
+ recomputeRevenueMetricsForProject).
+
+
+
+
How the rollup works
+
+ Analytics data lives in a separate revenueMetricsDaily{" "}
+ table that the Analytics tab reads from directly — the dashboard never
+ scans the raw webhook event log on render. A daily cron walks each
+ project's recent webhookEvents and writes one row per{" "}
+ (day, productId, currency, platform) bucket.
+
+ activeSubs is an end-of-day snapshot computed from the
+ current subscriptions table, not from the event log
+
+
+
+
+
+ Each cron tick recomputes the trailing 3 days so a
+ late-arriving Apple ASN v2 / Google RTDN notification (which can retry
+ for up to 5 / 7 days respectively) still folds into its correct day's
+ bucket. RevenueCat uses the same 3-day reprocess window for the same
+ reason — real-world p99 webhook delivery is well under 48 hours.
+
+
+
+
Currency & FX
+
+ Rollup rows are keyed by currency: the same SKU sold in USD and EUR on
+ the same day produces two rows. The dashboard never sums revenue across
+ currencies (no built-in FX conversion). When a project has multiple
+ currencies, the Analytics tab surfaces a currency selector; each chart
+ renders for one currency at a time. If you need a unified revenue view,
+ apply your own FX rates downstream.
+
+
+
Churn definition
+
+ Churn rate = (cancellations + refunds) / activeSubs,
+ expressed as a percentage of the end-of-period active count. Same
+ definition Stripe and RevenueCat surface in their headline dashboards.
+ The chart recomputes per-period when you flip Daily / Weekly / Monthly.
+
+
+
Limitations
+
+
+ No country / region split. Apple ASN v2 and Google
+ RTDN don't expose buyer country in the notification payload.
+ Country-level breakdowns would require pulling App Store Connect /
+ Play Console reporting APIs separately.
+
+
+ Webhook retention is 30 days. The raw event log is
+ pruned after 30 days, but the daily rollup rows are kept indefinitely
+ — historical analytics survive the retention sweep.
+
+
+
+ 30-day retention applies to the trailing recompute window too.
+ {" "}
+ An event arriving more than 30 days late won't be visible to the
+ rollup at all (it's already gone from the event log). In practice this
+ never happens — Apple stops retrying after 5 days, Google after 7.
+
+
+
+ );
+}
From e8a698589b71f35d379093e78a44b5b2e85adda4 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Tue, 5 May 2026 23:25:25 +0900
Subject: [PATCH 02/14] chore: allow Bash(awk:*) in project settings
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.claude/settings.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.claude/settings.json b/.claude/settings.json
index eca93055..11b19470 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
- "Bash(python3 /tmp/add_kmp_tabs.py)"
+ "Bash(python3 /tmp/add_kmp_tabs.py)",
+ "Bash(awk:*)"
]
}
}
From 3b3cea18832a6284348593c46e2ddae558254755 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Tue, 5 May 2026 23:52:54 +0900
Subject: [PATCH 03/14] =?UTF-8?q?fix(kit):=20analytics=20review=20feedback?=
=?UTF-8?q?=20=E2=80=94=20bucket-by-occurredAt,=20picker=20rotation,=20mul?=
=?UTF-8?q?ti-currency=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Bucket revenue events by `occurredAt` (store-side time) instead of
`receivedAt` so retry-delayed Apple ASN v2 / Google RTDN
notifications fold into their correct day. Extend the scan
window backward by LATE_DELIVERY_GRACE_DAYS (7) so events that
arrived in a prior tick are re-read on each recompute (otherwise
the delete-then-insert in commitBuckets would silently drop them).
- Lower WEBHOOK_SCAN_CAP / SUBS_SCAN_CAP from 20k to 15k each so
the two scans + the per-day existing lookups inside commitBuckets
fit under Convex's 40k document-read mutation budget with headroom.
- Add `revenueMetricsRunStatus` table for picker rotation; bump
cadence to 10 minutes (was daily). Walks `by_run` ascending and
upserts `lastRunAt = now` after each per-project recompute, so
rotation is independent of the subscription-stats drift cron.
Bootstraps from `subscriptionStats` for new projects without a
status row yet.
- Drop unused `by_project_and_day_and_platform` index — nothing
queries through it; platform filtering happens in-memory after
the trailing-window range scan via
`by_project_and_day_and_currency` (project + day-range only).
- Fix `getRevenueMetrics` filter dropdowns: populate currency /
product / platform sets from the UNFILTERED window so the UI
keeps every available option visible regardless of which filter
is active.
- Multi-currency analytics page: drop `allowClear` on the currency
selector and always filter rows by the resolved `currency` (not
raw `selectedCurrency`). Fixes a bug where clearing the selector
on a multi-currency project summed USD + EUR + JPY into a single
number labeled with one currency code.
- Schema: drop the bogus `""` sentinel claim from the
`revenueMetricsDaily.platform` comment — the upstream
`webhookEvents` / `subscriptions` schemas don't allow empty
platform either, so the validator stays strict.
- Update `getRevenueMetrics` docstring to match actual return shape
(one row per (day, currency, productId, platform), not
per (day, currency)).
Tests: +3 covering occurredAt bucketing, out-of-window skip, and
late-delivery grace scan. 51/51 pass on revenueMetrics.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/kit/convex/crons.ts | 29 +++--
packages/kit/convex/schema.ts | 35 +++++-
packages/kit/convex/subscriptions/query.ts | 48 ++++---
.../subscriptions/revenueMetrics.test.ts | 68 +++++++++-
.../convex/subscriptions/revenueMetrics.ts | 117 +++++++++++++++---
.../auth/organization/project/analytics.tsx | 36 ++++--
6 files changed, 270 insertions(+), 63 deletions(-)
diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts
index 34bf8815..40ddccb1 100644
--- a/packages/kit/convex/crons.ts
+++ b/packages/kit/convex/crons.ts
@@ -81,20 +81,27 @@ crons.interval(
{ batchSize: 50 },
);
-// Daily revenue rollup. Walks `webhookEvents` over the trailing
-// 3-day window and refreshes the `revenueMetricsDaily` rows that
-// power the Analytics dashboard. Trailing window covers Apple ASN v2
-// and Google RTDN late-arrival retries (real-world p99 < 48h); each
+// Revenue rollup. Walks `webhookEvents` over the trailing 3-day
+// window and refreshes the `revenueMetricsDaily` rows that power
+// the Analytics dashboard. Trailing window covers Apple ASN v2 and
+// Google RTDN late-arrival retries (real-world p99 < 48h); each
// tick overwrites the trailing window so a webhook arriving up to 3
-// days late still lands in its correct day's bucket. 50 projects
-// per tick share the same self-paginating-via-staleness picker the
-// subscription stats cron uses (one mutation per project, each gets
-// its own 40k document-read budget).
+// days late still lands in its correct day's bucket.
+//
+// 10-minute cadence (vs. daily for the stats drift cron) keeps the
+// dashboard close to real time — at daily cadence with batchSize=50
+// a 500-project deployment cycled in 10 days, which is unacceptable
+// staleness for revenue analytics. The picker walks
+// `revenueMetricsRunStatus.by_run` so it self-rotates regardless
+// of how often it runs; each per-project recompute is its own
+// scheduled mutation with an independent 40k document-read budget.
+// 100 projects × 6 ticks/hour × 24h = 14,400 project-runs/day,
+// which keeps the typical deployment current within minutes.
crons.interval(
- "recompute revenue metrics daily",
- { hours: 24 },
+ "recompute revenue metrics",
+ { minutes: 10 },
internal.subscriptions.revenueMetrics.recomputeAllRevenueMetrics,
- { batchSize: 50 },
+ { batchSize: 100 },
);
// Mark stuck product-sync jobs as failed. Convex caps actions at
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index 91e2c2cc..e70f3ccf 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -724,10 +724,11 @@ const schema = defineSchema({
currency: v.string(),
// Platform split is part of the key — same SKU sold on iOS and
// Android on the same day produces two distinct rollup rows so
- // the dashboard can chart per-store revenue. Nullable platform
- // (the empty `""` sentinel) absorbs events that arrived before
- // the rollout — kept defensively so a partially-backfilled
- // window doesn't crash the read path.
+ // the dashboard can chart per-store revenue. The populator only
+ // ever writes `IOS` or `Android` (the upstream `webhookEvents`
+ // and `subscriptions` schemas enforce the same union), so the
+ // validator stays strict — there is no legacy / sentinel
+ // value to absorb.
platform: v.union(v.literal("IOS"), v.literal("Android")),
activeSubs: v.number(),
newSubs: v.number(),
@@ -737,14 +738,36 @@ const schema = defineSchema({
revenueMicros: v.number(),
updatedAt: v.number(),
})
+ // Primary range-scan index for the dashboard read path. Layout
+ // `[projectId, day, currency]` lets `getRevenueMetrics` do
+ // `eq(projectId).gte(day).lte(day)` for a project's full
+ // window in one index hit; product / platform / currency
+ // filters are applied in-memory afterward (the trailing
+ // window is small enough — TRAILING_DAYS × productCount ×
+ // currencyCount × platformCount, typically tens of rows).
.index("by_project_and_day_and_currency", ["projectId", "day", "currency"])
.index("by_project_and_product_and_day_and_currency", [
"projectId",
"productId",
"day",
"currency",
- ])
- .index("by_project_and_day_and_platform", ["projectId", "day", "platform"]),
+ ]),
+
+ // Per-project picker state for the `recomputeAllRevenueMetrics`
+ // cron. Walked by `lastRunAt` ascending so the picker rotates
+ // through every project regardless of how many subs / events the
+ // project has. Kept in a dedicated table (rather than piggybacking
+ // on `subscriptionStats.updatedAt`) so the revenue cron rotates
+ // independently of the subscription-stats drift cron — otherwise
+ // the picker that touches `subscriptionStats.updatedAt` last
+ // controls rotation for both, and a deployment that skews the two
+ // cadences ends up reprocessing the same projects.
+ revenueMetricsRunStatus: defineTable({
+ projectId: v.id("projects"),
+ lastRunAt: v.number(),
+ })
+ .index("by_project", ["projectId"])
+ .index("by_run", ["lastRunAt"]),
// Unified product catalog. Mirrors what onesub holds in @onesub/providers
// — the subset of App Store Connect / Play Console that kit can read /
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index 3b97b86c..5fed603e 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -444,11 +444,12 @@ export const metricsSummary = query({
// UTC) — same format `revenueMetricsDaily.day` is stored under, so
// the index range is a direct string comparison.
//
-// Currency split: the underlying table is keyed by currency because
-// MRR / revenue can't be summed across currencies without an FX
-// conversion (matches the `subscriptionStats` reasoning). The query
-// returns one entry per (day, currency) so the UI can either filter
-// to a single currency or stack the breakdown.
+// Return shape: one entry per rollup row, i.e. one per
+// (day, currency, productId, platform). Aggregation across rows
+// happens client-side (`analytics.tsx`) so the dashboard can switch
+// between filter combinations without re-querying. Summing across
+// currencies is a UI-side concern — `revenueMicros` from a USD row
+// and a EUR row cannot be added without an FX rate.
const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
export const getRevenueMetrics = query({
@@ -493,11 +494,13 @@ export const getRevenueMetrics = query({
};
}
- // Range scan via `by_project_and_day_and_currency`. We don't
- // anchor on currency in the index range because the dashboard
- // needs the multi-currency breakdown; filtering is applied in
- // the loop below.
- let rows = await ctx.db
+ // Range scan via `by_project_and_day_and_currency`. The index
+ // is `[projectId, day, currency]`, so `eq(projectId).gte(day)
+ // .lte(day)` resolves the entire window in one index hit;
+ // currency / product / platform filters are applied in-memory
+ // afterward (the trailing window is small — typically tens of
+ // rows per project).
+ const allRows = await ctx.db
.query("revenueMetricsDaily")
.withIndex("by_project_and_day_and_currency", (q) =>
q
@@ -507,6 +510,22 @@ export const getRevenueMetrics = query({
)
.collect();
+ // Populate filter-dropdown choices from the UNFILTERED set so the
+ // UI can keep showing every available currency / product /
+ // platform regardless of which filter is currently active —
+ // otherwise selecting one currency would prune the dropdown to
+ // just that currency and the user could never get back without
+ // clearing.
+ const currencies = new Set();
+ const productIds = new Set();
+ const platforms = new Set<"IOS" | "Android">();
+ for (const row of allRows) {
+ if (row.currency) currencies.add(row.currency);
+ productIds.add(row.productId);
+ platforms.add(row.platform);
+ }
+
+ let rows = allRows;
if (args.productId) {
rows = rows.filter((row) => row.productId === args.productId);
}
@@ -517,15 +536,6 @@ export const getRevenueMetrics = query({
rows = rows.filter((row) => row.platform === args.platform);
}
- const currencies = new Set();
- const productIds = new Set();
- const platforms = new Set<"IOS" | "Android">();
- for (const row of rows) {
- if (row.currency) currencies.add(row.currency);
- productIds.add(row.productId);
- platforms.add(row.platform);
- }
-
return {
days: rows.map((row) => ({
day: row.day,
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
index 1f0e66a5..355569b3 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.test.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -470,6 +470,13 @@ async function seedEvent(
db: MemDb,
partial: Partial> & Pick, "type">,
): Promise {
+ // Default `occurredAt` to `receivedAt` if the test only set the
+ // latter — older tests use `receivedAt` to control which day the
+ // event belongs to, and the production code now buckets by
+ // `occurredAt`. Mirroring the values keeps those tests valid
+ // without forcing each one to specify both timestamps.
+ const receivedAt = partial.receivedAt ?? NOW;
+ const occurredAt = partial.occurredAt ?? receivedAt;
await db.insert("webhookEvents", {
projectId: PROJECT_ID,
source: "AppleAppStoreServerNotificationsV2",
@@ -478,9 +485,9 @@ async function seedEvent(
sourceNotificationId: `notif_${Math.random()}`,
productId: "sub.monthly",
currency: "USD",
- occurredAt: NOW,
- receivedAt: NOW,
...partial,
+ receivedAt,
+ occurredAt,
});
}
@@ -907,6 +914,63 @@ describe("runRecompute — round-trip integration", () => {
expect(await rollupRows(db)).toEqual([]);
});
+ it("event arrived late (receivedAt > occurredAt) buckets by occurredAt", async () => {
+ // The whole point of separating `occurredAt` from `receivedAt`:
+ // a renewal that fired on D2 but landed in our webhook log today
+ // must contribute to D2's bucket, not today's. Otherwise a
+ // retry-delayed notification visibly flips its day on the
+ // dashboard.
+ await seedEvent(db, {
+ type: "SubscriptionRenewed",
+ priceAmountMicros: 9_990_000,
+ occurredAt: Date.parse(`${D2}T03:00:00Z`),
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ day: D2, renewals: 1 });
+ });
+
+ it("event whose occurredAt falls outside the trailing window is skipped", async () => {
+ // receivedAt is in the scan window (yesterday), but occurredAt
+ // is 10 days ago — outside [D2, TODAY]. The bucket for that
+ // older day isn't being recomputed this tick, so writing into
+ // it would either duplicate or stomp on a row not in the
+ // delete-then-insert window. Skip is correct.
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ occurredAt: Date.parse("2026-03-05T03:00:00Z"),
+ receivedAt: Date.parse(`${TODAY}T10:00:00Z`),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ it("event scan reaches back beyond the bucket window for late deliveries", async () => {
+ // receivedAt = D2 - 4 days (well outside the 3-day bucket
+ // window) but occurredAt = D2. Without the LATE_DELIVERY_GRACE
+ // backward extension on the scan, this event would be missed
+ // on every recompute after its tick of arrival, and the
+ // delete-then-insert in commitBuckets would erase it
+ // permanently. Verifies the grace window is wide enough to
+ // catch real Apple ASN v2 / Google RTDN retry tails.
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ occurredAt: Date.parse(`${D2}T03:00:00Z`),
+ receivedAt: Date.parse(`${D2}T03:00:00Z`) - 4 * 86400000,
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ day: D2, newSubs: 1 });
+ });
+
it("rollup rows OUTSIDE window are preserved (not blanket-deleted)", async () => {
// Old row from 30 days ago — must survive a daily recompute.
await db.insert("revenueMetricsDaily", {
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
index 6ddaf822..ccdc82b8 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -36,6 +36,16 @@ import type { Doc, Id } from "../_generated/dataModel";
// quarantine those into manual reconciliation paths instead).
const TRAILING_DAYS = 3;
+// Late-delivery grace days. Bucketing by `occurredAt` (the store-side
+// event time) means we have to scan further back on `receivedAt`
+// than the bucket window itself, otherwise an event that arrived
+// 4 days late but occurred yesterday would not be in this tick's
+// receivedAt scan and would silently undercount yesterday's bucket
+// after the delete-then-insert in `commitBuckets`. Apple ASN v2
+// retries up to 5 days; Google RTDN's Pub/Sub default is 7 days.
+// 7 days covers both stores' published retry policies.
+const LATE_DELIVERY_GRACE_DAYS = 7;
+
const DAY_MS = 24 * 60 * 60 * 1000;
// UTC day key (YYYY-MM-DD) for an epoch-millis timestamp. Keying in
@@ -87,27 +97,38 @@ const COUNTED_STATES = new Set([
"InBillingRetry",
] as const);
-// Daily entry point: schedule per-project recomputes. Mirrors the
-// `recomputeAllSubscriptionStats` pattern so each project gets its
-// own 40k document-read budget rather than sharing the picker's.
+// Tick entry point: schedule per-project recomputes. Each project
+// runs as its own scheduled mutation so the 40k document-read budget
+// is per-project, not shared with the picker.
+//
+// Rotation: walks `revenueMetricsRunStatus.by_run` ascending so the
+// least-recently-processed projects surface first. Each successful
+// per-project recompute upserts its own `revenueMetricsRunStatus`
+// row with `lastRunAt = now`, so the picker self-rotates without
+// piggybacking on the subscription-stats drift cron's freshness
+// signal.
+//
+// Bootstrap: a brand-new project has no `revenueMetricsRunStatus`
+// row yet. Pad the picker from `subscriptionStats` (any project
+// with at least one stats row has had a counted-state sub at some
+// point) so first-time analytics processing happens on the next
+// tick after a project's first sub instead of waiting for some
+// other code path to seed the status table.
export const recomputeAllRevenueMetrics = internalMutation({
args: {
batchSize: v.optional(v.number()),
},
returns: v.object({ scheduled: v.number() }),
handler: async (ctx, args) => {
- const limit = args.batchSize ?? 50;
- // Walk the same `subscriptionStats.by_updated_at` index the
- // existing drift cron uses, so the two crons stay in sync on
- // which projects need attention. A project without
- // `subscriptionStats` rows has never had a counted-state sub
- // either, so it has nothing to roll up.
+ const limit = args.batchSize ?? 100;
const SCAN_CAP = Math.max(limit * 3, 300);
+
const stale = await ctx.db
- .query("subscriptionStats")
- .withIndex("by_updated_at")
+ .query("revenueMetricsRunStatus")
+ .withIndex("by_run")
.order("asc")
.take(SCAN_CAP);
+
const seen = new Set();
const projects: Id<"projects">[] = [];
for (const row of stale) {
@@ -116,6 +137,20 @@ export const recomputeAllRevenueMetrics = internalMutation({
projects.push(row.projectId);
if (projects.length >= limit) break;
}
+
+ if (projects.length < limit) {
+ const fresh = await ctx.db
+ .query("subscriptionStats")
+ .withIndex("by_updated_at")
+ .take(SCAN_CAP);
+ for (const row of fresh) {
+ if (seen.has(row.projectId)) continue;
+ seen.add(row.projectId);
+ projects.push(row.projectId);
+ if (projects.length >= limit) break;
+ }
+ }
+
let scheduled = 0;
for (const projectId of projects) {
await ctx.scheduler.runAfter(
@@ -144,13 +179,40 @@ export const recomputeRevenueMetricsForProject = internalMutation({
args: { projectId: v.id("projects") },
returns: v.null(),
handler: async (ctx, args) => {
- await runRecompute(ctx, args.projectId, Date.now());
+ const now = Date.now();
+ await runRecompute(ctx, args.projectId, now);
+ await markRevenueMetricsRun(ctx, args.projectId, now);
return null;
},
});
-const WEBHOOK_SCAN_CAP = 20_000;
-const SUBS_SCAN_CAP = 20_000;
+// 15k each gives 30k total reads on the two big scans, leaving the
+// remaining ~10k of the 40k document-read mutation budget for the
+// per-day `existing` lookups inside `commitBuckets`, the implicit
+// index reads, and any project lookups the helpers add later. Set
+// conservatively because hitting the cap silently truncates a
+// project's window — undercounting is worse than slow.
+const WEBHOOK_SCAN_CAP = 15_000;
+const SUBS_SCAN_CAP = 15_000;
+
+async function markRevenueMetricsRun(
+ ctx: MutationCtx,
+ projectId: Id<"projects">,
+ now: number,
+): Promise {
+ const existing = await ctx.db
+ .query("revenueMetricsRunStatus")
+ .withIndex("by_project", (q) => q.eq("projectId", projectId))
+ .unique();
+ if (existing) {
+ await ctx.db.patch(existing._id, { lastRunAt: now });
+ } else {
+ await ctx.db.insert("revenueMetricsRunStatus", {
+ projectId,
+ lastRunAt: now,
+ });
+ }
+}
export async function runRecompute(
ctx: MutationCtx,
@@ -172,24 +234,47 @@ export async function runRecompute(
for (let i = 0; i < TRAILING_DAYS; i++) {
days.push(utcDayKey(windowStart + i * DAY_MS));
}
+ const firstDay = days[0];
+ const lastDay = days[days.length - 1];
const buckets = new Map();
// ---- Pass 1: webhookEvents → newSubs / renewals / cancellations
// / refunds / revenueMicros buckets.
+ //
+ // Scan by `receivedAt` (the index we have on webhookEvents) but
+ // bucket by `occurredAt` (the store-side event time). A renewal
+ // that occurred yesterday but arrived today must land in
+ // yesterday's bucket — otherwise a retry-delayed notification
+ // would flip its day on the dashboard, contradicting the
+ // "late notifications fold into their correct day" promise.
+ //
+ // The scan window extends `LATE_DELIVERY_GRACE_DAYS` beyond the
+ // bucket window so an event with `occurredAt` inside the bucket
+ // window but `receivedAt` from a prior tick still gets reread
+ // (after `commitBuckets` deletes the day's existing rows, anything
+ // not rescanned silently drops out of the rebuilt bucket).
+ const eventScanStart = windowStart - LATE_DELIVERY_GRACE_DAYS * DAY_MS;
const events = await ctx.db
.query("webhookEvents")
.withIndex("by_project_and_received", (q) =>
q
.eq("projectId", projectId)
- .gte("receivedAt", windowStart)
+ .gte("receivedAt", eventScanStart)
.lte("receivedAt", windowEnd),
)
.take(WEBHOOK_SCAN_CAP);
for (const event of events) {
if (!event.productId) continue;
- const day = utcDayKey(event.receivedAt);
+ const day = utcDayKey(event.occurredAt);
+ // Skip events whose store-side day falls outside the bucket
+ // window. Their bucket row (if any) lives outside the
+ // delete-then-insert window in `commitBuckets`, so writing
+ // here would either duplicate counters or stomp on a row
+ // that wasn't rescanned. Late-by-more-than-grace events are
+ // a rounding error — Apple/Google both quarantine those.
+ if (day < firstDay || day > lastDay) continue;
const currency = event.currency ?? "";
// The webhookEvents schema only allows `IOS` / `Android` for
// `platform`; the Meta Horizon reconciler synthesizes events
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index deee5df0..ea26402f 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -139,14 +139,26 @@ export default function ProjectAnalytics() {
return ;
}
+ // Multi-currency projects: we always pin to a single currency for
+ // chart rendering because revenueMicros can't be summed across
+ // currencies without an FX rate. `selectedCurrency` resolves to
+ // the explicit user choice, falling back to the first available
+ // currency. The currency selector below is REQUIRED (not
+ // clearable) when multiple currencies exist so a user can never
+ // end up in the broken "no currency selected, sum across all"
+ // state — otherwise the totals would mix USD + EUR + JPY into a
+ // single number labeled with one currency code.
const currency = selectedCurrency ?? metrics.currencies[0] ?? "";
// Client-side filtering. Range is also a client filter now (we
// fetched the max range above), so flipping range chiclets stays
- // free — only the chart subtree re-derives.
+ // free — only the chart subtree re-derives. We always filter by
+ // `currency` (the resolved value above), not `selectedCurrency`,
+ // so the default-currency case still produces a single-currency
+ // chart on multi-currency projects.
const filteredRows = metrics.days.filter((row) => {
if (row.day < fromDay) return false;
- if (selectedCurrency && row.currency !== selectedCurrency) return false;
+ if (currency && row.currency !== currency) return false;
if (selectedProduct && row.productId !== selectedProduct) return false;
if (platformFilter !== "all" && row.platform !== platformFilter) {
return false;
@@ -237,11 +249,13 @@ export default function ProjectAnalytics() {
// Cards reflect the selected range / currency / product
// (everything except the platform filter — the cards ARE
// the platform breakdown). Filtering to fromDay matches
- // what the charts below show.
+ // what the charts below show. Use the resolved `currency`
+ // (not raw `selectedCurrency`) so multi-currency projects
+ // pin to a single currency by default and never sum
+ // mismatched FX into one card.
const cardRows = metrics.days.filter((row) => {
if (row.day < fromDay) return false;
- if (selectedCurrency && row.currency !== selectedCurrency)
- return false;
+ if (currency && row.currency !== currency) return false;
if (selectedProduct && row.productId !== selectedProduct)
return false;
return true;
@@ -318,11 +332,15 @@ export default function ProjectAnalytics() {
{metrics.currencies.length > 1 && (
Currency:
+ {/* No allowClear: revenue can't be summed across
+ currencies without an FX rate, so the chart must
+ always be pinned to exactly one. We surface the
+ first available currency as the default rather
+ than letting the user end up in a "no currency,
+ sum across" state where amounts would be wrong. */}
);
@@ -692,7 +694,7 @@ function aggregateByDay(
const result: Array = [];
let lastActive = 0;
for (let i = 0; i < rangeDays; i++) {
- const dayKey = utcDayKey(fromTs + i * 86400000);
+ const dayKey = utcDayKey(fromTs + i * DAY_MS);
const entry = byDay.get(dayKey);
if (entry) {
lastActive = entry.activeSubs;
@@ -813,7 +815,7 @@ function bucketLabelFor(
if (period === "weekly") {
// Start of ISO week (Monday). UTC day 1=Mon … 0=Sun.
const weekday = (date.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
- const monday = new Date(date.getTime() - weekday * 86400000);
+ const monday = new Date(date.getTime() - weekday * DAY_MS);
const key = utcDayKey(monday.getTime());
return {
bucketKey: key,
From 7439e102dea2c39fa9f585e02c06a75632581324 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 00:36:37 +0900
Subject: [PATCH 05/14] fix(kit): paginate subs pass, parallelize commit
deletes, pin Date.now in analytics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace the unbounded `take(SUBS_SCAN_CAP)` on
`by_project_and_updated` (which truncated the OLDEST 15k rows —
exactly the wrong half on a project where active subs renew
frequently) with a state-filtered descending walk on
`by_project_and_state_and_updated`. Only `Active` /
`InGracePeriod` / `InBillingRetry` rows are read, paginated via
scheduler-chained mutations so each page gets its own 40k
document-read budget. A 50k-active-sub project now completes
across ~10 chained pages instead of silently losing 35k of them.
- New `recomputeRevenueMetricsPage` handler rehydrates the bucket
accumulator from scheduler args and resumes pagination from the
saved state-index + updatedAt watermark. Cursor format is
validator-typed so the scheduler's JSON round-trip survives.
- Replace per-row sequential deletes inside `commitBuckets` with
`Promise.all` over the per-day collected rows so the commit phase
amortizes round trips even though Convex still serializes the
underlying writes inside the transaction.
- Capture `Date.now()` once inside the analytics page `useMemo` so
the `today` and `from` UTC-day keys derive from the same instant
(otherwise a millisecond crossing midnight UTC could narrow the
fetch window by a day).
Tests: 51/51 still pass on revenueMetrics.test.ts; the small
fixtures complete inline because every counted state's row count
sits well under SUBS_PAGE_SIZE=5000, so no scheduler chain fires.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../convex/subscriptions/revenueMetrics.ts | 353 +++++++++++++-----
.../auth/organization/project/analytics.tsx | 9 +-
2 files changed, 257 insertions(+), 105 deletions(-)
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
index efdf6096..9d80636b 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -23,6 +23,15 @@
// the same 3-day reprocess window for the same reason. Each cron
// tick overwrites the trailing 3 days, so a webhook arriving up to
// 3 days late still gets folded into its correct day's bucket.
+//
+// Scaling pattern: per-project recompute uses the same scheduler-
+// chained pagination as `recomputeSubscriptionStats` so each page
+// gets its own 40k document-read budget. The events pass runs once
+// in the kickoff page; the subscriptions pass walks counted-state
+// rows only (Active / InGracePeriod / InBillingRetry) via
+// `by_project_and_state_and_updated` ordered descending — most-
+// recently-updated first — and chains continuation pages until
+// every state is exhausted before committing.
import { internalMutation } from "../_generated/server";
import type { MutationCtx } from "../_generated/server";
@@ -97,16 +106,23 @@ const COUNTED_STATES = new Set([
"InBillingRetry",
] as const);
-// Tick entry point: schedule per-project recomputes. Each project
-// runs as its own scheduled mutation so the 40k document-read budget
-// is per-project, not shared with the picker.
-//
-// Rotation: walks `revenueMetricsRunStatus.by_run` ascending so the
-// least-recently-processed projects surface first. Each successful
-// per-project recompute upserts its own `revenueMetricsRunStatus`
-// row with `lastRunAt = now`, so the picker self-rotates without
-// piggybacking on the subscription-stats drift cron's freshness
-// signal.
+// Order matters: pagination cursors index into this list. Adding a
+// state requires a migration of in-flight cursors stored on the
+// scheduler queue, so prepend new states to the END of the list,
+// never the middle.
+const COUNTED_STATES_ORDERED = [
+ "Active",
+ "InGracePeriod",
+ "InBillingRetry",
+] as const;
+type CountedState = (typeof COUNTED_STATES_ORDERED)[number];
+
+// Cron picker entry. Walks `revenueMetricsRunStatus.by_run` ascending
+// so the least-recently-processed projects surface first. Each
+// successful per-project recompute upserts its own
+// `revenueMetricsRunStatus` row with `lastRunAt = now`, so the
+// picker self-rotates without piggybacking on the subscription-stats
+// drift cron's freshness signal.
//
// Bootstrap: a brand-new project has no `revenueMetricsRunStatus`
// row yet. Pad the picker from `subscriptionStats` (any project
@@ -164,36 +180,57 @@ export const recomputeAllRevenueMetrics = internalMutation({
},
});
-// Per-project recompute. Scans the trailing-window slice of
-// `webhookEvents` once and the project's `subscriptions` table once,
-// then writes one rollup row per (day, productId, currency) bucket.
-//
-// Bounded reads:
-// - webhookEvents: trailing TRAILING_DAYS × per-project event rate.
-// Capped at WEBHOOK_SCAN_CAP so a runaway-loop project can't
-// exceed Convex's 40k document-read mutation budget.
-// - subscriptions: walks the project's full sub list once via
-// `by_project_and_updated`. Capped at SUBS_SCAN_CAP — projects
-// past the cap should switch to incremental maintenance in v2.
+// Per-project kickoff. Schedules itself by chaining
+// `recomputeRevenueMetricsPage` mutations, each with its own 40k
+// document-read budget, so a project with arbitrarily many active
+// subscriptions completes without ever exceeding the per-mutation
+// ceiling.
export const recomputeRevenueMetricsForProject = internalMutation({
args: { projectId: v.id("projects") },
returns: v.null(),
handler: async (ctx, args) => {
- const now = Date.now();
- await runRecompute(ctx, args.projectId, now);
- await markRevenueMetricsRun(ctx, args.projectId, now);
+ await runRecompute(ctx, args.projectId, Date.now());
return null;
},
});
-// 15k each gives 30k total reads on the two big scans, leaving the
-// remaining ~10k of the 40k document-read mutation budget for the
-// per-day `existing` lookups inside `commitBuckets`, the implicit
-// index reads, and any project lookups the helpers add later. Set
-// conservatively because hitting the cap silently truncates a
-// project's window — undercounting is worse than slow.
+// Cap on the events pass scan. Events arrive at the receivedAt
+// timestamp, so the index range is bounded by the trailing-window
+// + late-delivery-grace span (10 days). Generously sized for
+// realistic SaaS event rates — at 1k events/day a project burns
+// 10k of the cap in 10 days, well clear of the limit.
const WEBHOOK_SCAN_CAP = 15_000;
-const SUBS_SCAN_CAP = 15_000;
+
+// Per-page subscription scan size. Each page chains via the
+// scheduler so this caps reads PER MUTATION, not per project. A
+// project with 50k active subs paginates across ~10 chained
+// mutations, each comfortably under the 40k document-read budget
+// (page reads + the events pass on page 0 + commit-time existing-
+// row deletes all share the budget).
+const SUBS_PAGE_SIZE = 5_000;
+
+// Validators for the chained-page args. Buckets serialize as a flat
+// array so they survive the scheduler's JSON round-trip; the cursor
+// is a small enum + watermark.
+const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
+const accumulatorValidator = v.array(
+ v.object({
+ day: v.string(),
+ productId: v.string(),
+ currency: v.string(),
+ platform: platformValidator,
+ activeSubs: v.number(),
+ newSubs: v.number(),
+ renewals: v.number(),
+ cancellations: v.number(),
+ refunds: v.number(),
+ revenueMicros: v.number(),
+ }),
+);
+const cursorValidator = v.object({
+ stateIdx: v.number(),
+ updatedBefore: v.union(v.number(), v.null()),
+});
async function markRevenueMetricsRun(
ctx: MutationCtx,
@@ -214,6 +251,15 @@ async function markRevenueMetricsRun(
}
}
+// Kickoff: build window, run events pass, then process the first
+// subscriptions page. If all counted states fit in a single page
+// (typical for projects under SUBS_PAGE_SIZE active subs), commit
+// inline. Otherwise schedule continuation pages.
+//
+// Exported so tests can drive it directly without the cron scheduler.
+// In tests with small datasets the inline commit path always
+// triggers; the chained-page path only kicks in for projects that
+// exceed SUBS_PAGE_SIZE in any one counted state.
export async function runRecompute(
ctx: MutationCtx,
projectId: Id<"projects">,
@@ -304,58 +350,148 @@ export async function runRecompute(
}
// ---- Pass 2: subscriptions → activeSubs end-of-day snapshots.
- // We need every project sub (not just the trailing window)
- // because a sub started months ago can still be active "today".
- // Walk the by_project_and_updated index ascending for index-order
- // determinism; the activeSubs computation doesn't care about
- // order, but this avoids surprising tiebreak behaviour if a
- // future caller pages off the same handle.
- const subs = await ctx.db
- .query("subscriptions")
- .withIndex("by_project_and_updated", (q) => q.eq("projectId", projectId))
- .take(SUBS_SCAN_CAP);
- if (subs.length === SUBS_SCAN_CAP) {
- // Same reasoning as the webhookEvents cap above: an undercounted
- // `activeSubs` snapshot manifests as a sudden chart drop rather
- // than an obvious operational failure, so surface the truncation.
- console.warn(
- `[revenueMetrics] subscriptions scan hit SUBS_SCAN_CAP=${SUBS_SCAN_CAP} for project=${projectId}; activeSubs snapshot will undercount this tick.`,
- );
- }
+ // Walks ONLY counted-state rows via `by_project_and_state_and_updated`
+ // ordered descending — most-recently-updated first. Active subs
+ // tick `updatedAt` on every renewal so descending puts the rows
+ // most likely to satisfy `isActiveAt(...)` at the front of the
+ // page; an under-budget project gets its full picture from a
+ // single page, an over-budget project chains continuations.
+ await processSubsPage(ctx, {
+ projectId,
+ days,
+ windowStart,
+ buckets,
+ cursor: { stateIdx: 0, updatedBefore: null },
+ runStartedAt: now,
+ });
+}
- // Per-day end-of-day boundary timestamps. activeSubs snapshot is
- // taken at `dayEnd` (start of next UTC day - 1ms) so a sub that
- // expires at exactly midnight UTC counts toward the day it was
- // active during, not the day it expired into.
+// Process one page of counted-state subscriptions. Greedily
+// advances through states until either the page fills (chain
+// continuation) or every counted state is exhausted (commit).
+//
+// `buckets` is the live accumulator: pass-1 (events) populated it
+// in the kickoff, this function adds activeSubs contributions and
+// commits at the end of the last page.
+async function processSubsPage(
+ ctx: MutationCtx,
+ args: {
+ projectId: Id<"projects">;
+ days: string[];
+ windowStart: number;
+ buckets: Map;
+ cursor: { stateIdx: number; updatedBefore: number | null };
+ runStartedAt: number;
+ },
+): Promise {
+ const { projectId, days, buckets, runStartedAt } = args;
const dayEnds = days.map((day) => Date.parse(`${day}T23:59:59.999Z`));
- for (const sub of subs) {
- for (let i = 0; i < days.length; i++) {
- if (!isActiveAt(sub, dayEnds[i])) continue;
- // activeSubs key uses the sub's productId + currency + platform
- // so it composes with the event-driven counters that share the
- // same bucket key.
- const currency = sub.currency ?? "";
- const platform = sub.platform;
- const key = bucketKey(days[i], sub.productId, currency, platform);
- const bucket = getOrCreateBucket(
- buckets,
- key,
- days[i],
- sub.productId,
- currency,
- platform,
- );
- bucket.activeSubs += 1;
+ let { stateIdx, updatedBefore } = args.cursor;
+ let pageRemaining = SUBS_PAGE_SIZE;
+ let chainContinuation = false;
+
+ while (stateIdx < COUNTED_STATES_ORDERED.length && pageRemaining > 0) {
+ const state: CountedState = COUNTED_STATES_ORDERED[stateIdx];
+ const upperBound = updatedBefore;
+ const requested = pageRemaining;
+ const subs = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_state_and_updated", (q) => {
+ const base = q.eq("projectId", projectId).eq("state", state);
+ return upperBound === null ? base : base.lt("updatedAt", upperBound);
+ })
+ .order("desc")
+ .take(requested);
+
+ for (const sub of subs) {
+ for (let i = 0; i < days.length; i++) {
+ if (!isActiveAt(sub, dayEnds[i])) continue;
+ const currency = sub.currency ?? "";
+ const platform = sub.platform;
+ const key = bucketKey(days[i], sub.productId, currency, platform);
+ const bucket = getOrCreateBucket(
+ buckets,
+ key,
+ days[i],
+ sub.productId,
+ currency,
+ platform,
+ );
+ bucket.activeSubs += 1;
+ }
+ }
+
+ pageRemaining -= subs.length;
+ if (subs.length < requested) {
+ // Took less than we asked for → state exhausted, advance.
+ stateIdx += 1;
+ updatedBefore = null;
+ } else {
+ // Page is full and the current state may still have rows we
+ // haven't seen. Carry the watermark on this state and chain a
+ // continuation; the next page reads `lt(updatedAt, watermark)`
+ // so we skip the rows we already processed.
+ updatedBefore = subs[subs.length - 1].updatedAt;
+ chainContinuation = true;
+ break;
}
}
- // ---- Commit: upsert each bucket, delete any pre-existing row in
- // the window that's no longer in the recomputed set (otherwise a
- // sub that switched products would leave a stale bucket behind).
- await commitBuckets(ctx, projectId, days, buckets, now);
+ if (!chainContinuation && stateIdx >= COUNTED_STATES_ORDERED.length) {
+ // All counted states processed — commit.
+ await commitBuckets(ctx, projectId, days, buckets, runStartedAt);
+ await markRevenueMetricsRun(ctx, projectId, runStartedAt);
+ return;
+ }
+
+ // More work to do. Serialize the accumulator + cursor and chain
+ // a fresh mutation so the next page gets its own 40k budget.
+ const serialized = Array.from(buckets.values());
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics.recomputeRevenueMetricsPage,
+ {
+ projectId,
+ days,
+ buckets: serialized,
+ cursor: { stateIdx, updatedBefore },
+ runStartedAt,
+ },
+ );
}
+// Continuation page handler. Rehydrates the accumulator from the
+// scheduler args and resumes pagination from the saved cursor.
+export const recomputeRevenueMetricsPage = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ days: v.array(v.string()),
+ buckets: accumulatorValidator,
+ cursor: cursorValidator,
+ runStartedAt: v.number(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const buckets = new Map();
+ for (const row of args.buckets) {
+ const key = bucketKey(row.day, row.productId, row.currency, row.platform);
+ buckets.set(key, { ...row });
+ }
+ const todayStart = startOfUtcDay(args.runStartedAt);
+ const windowStart = todayStart - (TRAILING_DAYS - 1) * DAY_MS;
+ await processSubsPage(ctx, {
+ projectId: args.projectId,
+ days: args.days,
+ windowStart,
+ buckets,
+ cursor: args.cursor,
+ runStartedAt: args.runStartedAt,
+ });
+ return null;
+ },
+});
+
function getOrCreateBucket(
buckets: Map,
key: BucketKey,
@@ -442,20 +578,28 @@ async function commitBuckets(
// Delete every existing row in the window first, then insert the
// freshly computed set. Cleaner than a per-key upsert/delete diff
// because the window is bounded (TRAILING_DAYS × productCount ×
- // currencyCount) — typically tens of rows per project, not
- // thousands.
- for (const day of days) {
- const existing = await ctx.db
- .query("revenueMetricsDaily")
- .withIndex("by_project_and_day_and_currency", (q) =>
- q.eq("projectId", projectId).eq("day", day),
- )
- .collect();
- for (const row of existing) {
- await ctx.db.delete(row._id);
- }
- }
-
+ // currencyCount × platformCount) — typically tens of rows per
+ // project, not thousands.
+ //
+ // Both the per-day queries and the delete batches dispatch with
+ // `Promise.all` so the round trips overlap. Convex still
+ // serializes the underlying writes within the mutation
+ // transaction, but firing them concurrently shaves the wall-clock
+ // for the commit phase down to roughly one round-trip per day
+ // instead of (existing-row-count × days).
+ const existingPerDay = await Promise.all(
+ days.map((day) =>
+ ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q.eq("projectId", projectId).eq("day", day),
+ )
+ .collect(),
+ ),
+ );
+ await Promise.all(existingPerDay.flat().map((row) => ctx.db.delete(row._id)));
+
+ const inserts: Array> = [];
for (const bucket of buckets.values()) {
// Skip empty buckets — happens when a sub became active mid-day
// but was later refunded so its (newSubs, refunds) net to zero
@@ -471,19 +615,22 @@ async function commitBuckets(
) {
continue;
}
- await ctx.db.insert("revenueMetricsDaily", {
- projectId,
- day: bucket.day,
- productId: bucket.productId,
- currency: bucket.currency,
- platform: bucket.platform,
- activeSubs: bucket.activeSubs,
- newSubs: bucket.newSubs,
- renewals: bucket.renewals,
- cancellations: bucket.cancellations,
- refunds: bucket.refunds,
- revenueMicros: bucket.revenueMicros,
- updatedAt: now,
- });
+ inserts.push(
+ ctx.db.insert("revenueMetricsDaily", {
+ projectId,
+ day: bucket.day,
+ productId: bucket.productId,
+ currency: bucket.currency,
+ platform: bucket.platform,
+ activeSubs: bucket.activeSubs,
+ newSubs: bucket.newSubs,
+ renewals: bucket.renewals,
+ cancellations: bucket.cancellations,
+ refunds: bucket.refunds,
+ revenueMicros: bucket.revenueMicros,
+ updatedAt: now,
+ }),
+ );
}
+ await Promise.all(inserts);
}
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index f1ba37fd..c93a3b55 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -108,8 +108,13 @@ export default function ProjectAnalytics() {
// chart's first/last column to half-cover when the user's tz is
// far from UTC, surfacing as a "missing yesterday" off-by-one.
const { maxFromDay, toDay } = useMemo(() => {
- const today = utcDayKey(Date.now());
- const from = utcDayKey(Date.now() - (MAX_RANGE_DAYS - 1) * DAY_MS);
+ // Capture `now` once so the `today` and `from` keys derive from
+ // the same instant — calling `Date.now()` twice on either side
+ // of midnight UTC could return values whose `utcDayKey` differs
+ // by a day, producing a one-day-too-narrow window.
+ const now = Date.now();
+ const today = utcDayKey(now);
+ const from = utcDayKey(now - (MAX_RANGE_DAYS - 1) * DAY_MS);
return { maxFromDay: from, toDay: today };
}, [MAX_RANGE_DAYS]);
From 050754308be476c7e581030ea993c71064e48cfb Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 00:47:46 +0900
Subject: [PATCH 06/14] fix(kit): cap getRevenueMetrics scan + day-span
validation, doc + harness nits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Cap the `revenueMetricsDaily` range scan at REVENUE_SCAN_CAP=10_000
via `.take()` (was unbounded `.collect()`) so a 92-day range across
a maximalist multi-SKU project stays under Convex's 32k document-
scan limit. Hitting the cap surfaces a `console.warn` so a
truncated chart shows up in the log stream instead of silently
rendering a partial tail.
- Server-side validation on `args.fromDay` / `args.toDay`: reject
inverted ranges, malformed ISO dates, and spans longer than
MAX_RANGE_DAYS (92, matching `analytics.tsx` RANGES). Stops a
misbehaving client from forcing an unbounded scan via
`fromDay = "1970-01-01"`.
- Document the per-`projectId` uniqueness invariant on
`revenueMetricsRunStatus` directly on the schema definition —
Convex has no unique constraint, so callers must upsert via
`by_project` (the canonical pattern is `markRevenueMetricsRun`).
- Make `MemQuery.filter` in the revenueMetrics test harness throw
"not implemented" instead of silently no-oping, so a future
`.filter(...)` added to production code can't pass green against
unfiltered rows.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/kit/convex/schema.ts | 8 ++++
packages/kit/convex/subscriptions/query.ts | 45 +++++++++++++++++--
.../subscriptions/revenueMetrics.test.ts | 10 ++++-
3 files changed, 59 insertions(+), 4 deletions(-)
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index e70f3ccf..8931667d 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -762,6 +762,14 @@ const schema = defineSchema({
// the picker that touches `subscriptionStats.updatedAt` last
// controls rotation for both, and a deployment that skews the two
// cadences ends up reprocessing the same projects.
+ //
+ // INVARIANT: at most one row per `projectId`. Convex has no unique
+ // constraint, so callers must look the row up via `by_project`
+ // and patch it instead of inserting a second one — see
+ // `markRevenueMetricsRun` in `subscriptions/revenueMetrics.ts`
+ // for the canonical upsert pattern. Two rows for the same project
+ // would let the `by_run` picker double-pick that project until
+ // both rows rotate to the head, wasting budget.
revenueMetricsRunStatus: defineTable({
projectId: v.id("projects"),
lastRunAt: v.number(),
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index 5fed603e..e5e45565 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -494,12 +494,46 @@ export const getRevenueMetrics = query({
};
}
+ // Reject ranges past the dashboard's longest preset (90 days)
+ // before issuing the index scan. A misbehaving client can
+ // otherwise request `fromDay = "1970-01-01"` and force the
+ // server to materialize every rollup row in the project. The
+ // 90-day cap matches `RANGES` in `analytics.tsx`; widening
+ // there should bump this in lockstep.
+ const MAX_RANGE_DAYS = 92;
+ if (args.fromDay > args.toDay) {
+ throw new Error(
+ `getRevenueMetrics: fromDay (${args.fromDay}) is after toDay (${args.toDay}).`,
+ );
+ }
+ const fromMs = Date.parse(`${args.fromDay}T00:00:00.000Z`);
+ const toMs = Date.parse(`${args.toDay}T00:00:00.000Z`);
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
+ throw new Error(
+ `getRevenueMetrics: invalid ISO date(s) fromDay=${args.fromDay} toDay=${args.toDay}.`,
+ );
+ }
+ const spanDays = Math.round((toMs - fromMs) / 86_400_000) + 1;
+ if (spanDays > MAX_RANGE_DAYS) {
+ throw new Error(
+ `getRevenueMetrics: span of ${spanDays} days exceeds MAX_RANGE_DAYS=${MAX_RANGE_DAYS}.`,
+ );
+ }
+
// Range scan via `by_project_and_day_and_currency`. The index
// is `[projectId, day, currency]`, so `eq(projectId).gte(day)
// .lte(day)` resolves the entire window in one index hit;
// currency / product / platform filters are applied in-memory
- // afterward (the trailing window is small — typically tens of
- // rows per project).
+ // afterward.
+ //
+ // Capped at REVENUE_SCAN_CAP — same number `metricsSummary` uses
+ // for its FALLBACK_SCAN_CAP / ROLLING_SCAN_CAP — to stay under
+ // Convex's 32k document-scan limit per query. A 92-day range
+ // across a maximalist project (30 SKUs × 3 currencies × 2
+ // platforms = 180 rows/day → ~16.5k rows for 92 days) fits
+ // comfortably; truncation at the cap surfaces as the warning
+ // below so we never silently render a partial chart.
+ const REVENUE_SCAN_CAP = 10_000;
const allRows = await ctx.db
.query("revenueMetricsDaily")
.withIndex("by_project_and_day_and_currency", (q) =>
@@ -508,7 +542,12 @@ export const getRevenueMetrics = query({
.gte("day", args.fromDay)
.lte("day", args.toDay),
)
- .collect();
+ .take(REVENUE_SCAN_CAP);
+ if (allRows.length === REVENUE_SCAN_CAP) {
+ console.warn(
+ `[getRevenueMetrics] revenueMetricsDaily scan hit REVENUE_SCAN_CAP=${REVENUE_SCAN_CAP} for project=${project._id} range=${args.fromDay}..${args.toDay}; chart will undercount the tail.`,
+ );
+ }
// Populate filter-dropdown choices from the UNFILTERED set so the
// UI can keep showing every available currency / product /
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
index 355569b3..477c76c2 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.test.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -367,8 +367,16 @@ class MemQuery {
}
filter(_cb: unknown): MemQuery {
+ // No-op `.filter()` would let a future production code path that
+ // narrows results via `.filter()` silently pass against the
+ // in-memory harness while real Convex returned a different set.
+ // Throw so the next caller is forced to wire predicate support
+ // up explicitly instead of running a green test on a broken
+ // assumption.
void _cb;
- return this;
+ throw new Error(
+ "MemQuery.filter is not implemented — wire it up before adding a .filter() call to production code under test.",
+ );
}
async first(): Promise {
From a28f6da07a87c07d89cda62735355aa36f35c3c3 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 00:55:49 +0900
Subject: [PATCH 07/14] fix(kit): cross-period cancel netting + analytics chart
continuity + multi-product activeSubs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three correctness fixes from the latest Gemini review pass on PR #131:
1. Drop the per-day `cancellations < 0` clamp in `applyEventToBucket`.
A `SubscriptionCanceled` on day N and a `SubscriptionUncanceled`
on day N+1 must net to zero when the dashboard sums per-day rollup
rows into a weekly / monthly bucket; clamping silently lost the
offset. Removed the `revenueMicros` clamp at the same time — the
current event mapping never subtracts revenue, so the clamp was
dead code AND would mask the same cross-period netting issue if a
future event type ever did. Test that asserted the clamp now
asserts the negative-bucket behaviour instead.
2. Stop filtering out rows older than `fromDay` from `filteredRows`
in `analytics.tsx`. The pre-range rows are needed to seed the
`activeSubs` carry-forward at the start of the chart — without
them, a project with active subs but no events in the selected
range visibly dipped to zero on the first chart day.
3. Initialize `lastActive` in `aggregateByDay` from the most-recent
pre-`fromDay` snapshot in the byDay map (was hardcoded to 0).
Combines with #2 to give the chart a continuous activeSubs line
from day 1 even on event-quiet projects.
4. Fix `totalsForPlatform`: previously kept only the FIRST rollup
row encountered for each (platform, lastDay) tuple, silently
undercounting multi-product projects whose rollup table has one
row per (day, productId, currency, platform). Now sums sibling
rows that share `platform + day` so all SKUs show up in the App
Store / Google Play card totals.
Tests: 51/51 still pass on revenueMetrics.test.ts (the one clamp
test got rewritten to expect cancellations=-1).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../subscriptions/revenueMetrics.test.ts | 9 ++++-
.../convex/subscriptions/revenueMetrics.ts | 18 +++++++--
.../auth/organization/project/analytics.tsx | 37 +++++++++++++++++--
3 files changed, 55 insertions(+), 9 deletions(-)
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
index 477c76c2..346e9a3c 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.test.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -159,10 +159,15 @@ describe("applyEventToBucket", () => {
expect(bucket.cancellations).toBe(0);
});
- it("Uncancel without prior cancel clamps at 0 (no negative cancellations)", () => {
+ it("Uncancel without same-day cancel produces a negative bucket (cross-day offset)", () => {
+ // The day-bucket counter is intentionally allowed to go
+ // negative: a cancel on day N and an uncancel on day N+1 must
+ // still net to zero when the dashboard sums per-day rollup
+ // rows into a weekly / monthly bucket. Clamping here would
+ // silently drop the offset.
const bucket = emptyBucket();
applyEventToBucket(bucket, makeEvent({ type: "SubscriptionUncanceled" }));
- expect(bucket.cancellations).toBe(0);
+ expect(bucket.cancellations).toBe(-1);
});
it("PurchaseRefunded → refunds++", () => {
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
index 9d80636b..0ef7d1b7 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -536,8 +536,13 @@ export function applyEventToBucket(
case "SubscriptionCanceled":
// User-initiated cancellations. Counted only when the user
// turned off renewal — uncancellations are caught by
- // `SubscriptionUncanceled` so a cancel-then-uncancel pair
- // within the same window is net-zero in the chart.
+ // `SubscriptionUncanceled`. The day-bucket counter is allowed
+ // to go negative on its own: an uncancel that arrives on a
+ // different day than the original cancel must still net to
+ // zero when the dashboard sums across a weekly / monthly
+ // bucket (or across multiple per-day rollup rows for the
+ // same period). Clamping at zero per-day would silently lose
+ // cross-day cancel/uncancel pairs.
bucket.cancellations += 1;
break;
case "SubscriptionUncanceled":
@@ -555,8 +560,13 @@ export function applyEventToBucket(
// existing `metricsSummary` live counters instead.
break;
}
- if (bucket.cancellations < 0) bucket.cancellations = 0;
- if (bucket.revenueMicros < 0) bucket.revenueMicros = 0;
+ // No clamps. `cancellations` is intentionally allowed to go
+ // negative (cross-day uncancel offset, see above). `revenueMicros`
+ // never decreases under the current event mapping — refunds bump
+ // the `refunds` counter, not `revenueMicros` — so a clamp would
+ // be dead code; if a future event type ever subtracts revenue,
+ // the same cross-period reasoning applies and a clamp would hide
+ // the offset.
}
export function isActiveAt(sub: Doc<"subscriptions">, dayEnd: number): boolean {
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index c93a3b55..025a4262 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -163,8 +163,14 @@ export default function ProjectAnalytics() {
// `currency` (the resolved value above), not `selectedCurrency`,
// so the default-currency case still produces a single-currency
// chart on multi-currency projects.
+ //
+ // We deliberately KEEP rows older than `fromDay` in `filteredRows`
+ // (only attribute / range filters are applied here). `aggregateByDay`
+ // uses those older rows to seed the `activeSubs` carry-forward at
+ // the start of the chart — without them, a project with active
+ // subscriptions but no events in the selected range would dip
+ // visually to zero on the first day.
const filteredRows = metrics.days.filter((row) => {
- if (row.day < fromDay) return false;
if (currency && row.currency !== currency) return false;
if (selectedProduct && row.productId !== selectedProduct) return false;
if (platformFilter !== "all" && row.platform !== platformFilter) {
@@ -697,7 +703,22 @@ function aggregateByDay(
}
const fromTs = Date.parse(`${fromDay}T00:00:00.000Z`);
const result: Array = [];
+
+ // Seed `lastActive` from the most-recent pre-`fromDay` snapshot
+ // so a project with active subs but no events in the selected
+ // range doesn't visibly dip to zero on the first chart day. The
+ // caller passes through pre-range rows for exactly this reason;
+ // pick the latest one whose day is strictly older than `fromDay`.
let lastActive = 0;
+ let seedDay = "";
+ for (const [day, row] of byDay) {
+ if (day >= fromDay) continue;
+ if (day > seedDay) {
+ seedDay = day;
+ lastActive = row.activeSubs;
+ }
+ }
+
for (let i = 0; i < rangeDays; i++) {
const dayKey = utcDayKey(fromTs + i * DAY_MS);
const entry = byDay.get(dayKey);
@@ -855,8 +876,14 @@ function totalsForPlatform(
): { revenueMicros: number; activeSubs: number; newSubs: number } {
const matching =
filter === "all" ? rows : rows.filter((r) => r.platform === filter);
- // For activeSubs, take the LAST day's snapshot per platform and sum.
- // Otherwise summing across days would multiply by N and inflate.
+ // For activeSubs, sum every product/currency row that lands on the
+ // most recent day per platform. The rollup table is keyed by
+ // `(day, productId, currency, platform)`, so a multi-product
+ // project has multiple rows for the same day+platform — keeping
+ // only the first row encountered (the prior bug) silently
+ // undercounted projects with >1 product. Summing across days
+ // would inflate by N, so we still take only the LAST day's snapshot
+ // per platform.
const lastByPlatform = new Map();
let revenueMicros = 0;
let newSubs = 0;
@@ -869,6 +896,10 @@ function totalsForPlatform(
day: row.day,
active: row.activeSubs,
});
+ } else if (row.day === prior.day) {
+ // Same platform + same day = different (product, currency)
+ // tuple. Sum its activeSubs into the platform's total.
+ prior.active += row.activeSubs;
}
}
let activeSubs = 0;
From b1c5c60bdcbdb97d5860ce6280753c0190f3facb Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 01:10:14 +0900
Subject: [PATCH 08/14] fix(kit): surface revenueMetrics truncation in UI +
harden empty-currency fallback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add `truncated: boolean` to the `getRevenueMetrics` response set
whenever the underlying scan hit `REVENUE_SCAN_CAP`. The dashboard
surfaces it as an amber banner above the platform cards so an
operator doesn't read a partial chart as a real revenue trough —
the previous `console.warn` was server-only and invisible to anyone
without Convex log access.
- Resolve the currency fallback through an explicit length check
instead of `metrics.currencies[0] ?? ""` so the empty-project
case (`metrics.currencies` is `[]`) is obvious in code and the
`EmptyState` gate stays the single owner of the "no rows" UX.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/kit/convex/subscriptions/query.ts | 11 ++++++-
.../auth/organization/project/analytics.tsx | 30 ++++++++++++++++++-
2 files changed, 39 insertions(+), 2 deletions(-)
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index e5e45565..b9edeafb 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -482,6 +482,12 @@ export const getRevenueMetrics = query({
currencies: v.array(v.string()),
productIds: v.array(v.string()),
platforms: v.array(platformValidator),
+ // True when the underlying scan hit `REVENUE_SCAN_CAP` and the
+ // returned rows are a partial view of the requested window. The
+ // dashboard surfaces this as a banner so a truncated chart is
+ // visible to the operator instead of silently rendering a
+ // partial tail.
+ truncated: v.boolean(),
}),
handler: async (ctx, args) => {
const project = await projectByApiKey(ctx, args.apiKey);
@@ -491,6 +497,7 @@ export const getRevenueMetrics = query({
currencies: [],
productIds: [],
platforms: [],
+ truncated: false,
};
}
@@ -543,7 +550,8 @@ export const getRevenueMetrics = query({
.lte("day", args.toDay),
)
.take(REVENUE_SCAN_CAP);
- if (allRows.length === REVENUE_SCAN_CAP) {
+ const truncated = allRows.length === REVENUE_SCAN_CAP;
+ if (truncated) {
console.warn(
`[getRevenueMetrics] revenueMetricsDaily scan hit REVENUE_SCAN_CAP=${REVENUE_SCAN_CAP} for project=${project._id} range=${args.fromDay}..${args.toDay}; chart will undercount the tail.`,
);
@@ -591,6 +599,7 @@ export const getRevenueMetrics = query({
currencies: Array.from(currencies).sort(),
productIds: Array.from(productIds).sort(),
platforms: Array.from(platforms).sort(),
+ truncated,
};
},
});
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index 025a4262..24fe6571 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -155,7 +155,16 @@ export default function ProjectAnalytics() {
// end up in the broken "no currency selected, sum across all"
// state — otherwise the totals would mix USD + EUR + JPY into a
// single number labeled with one currency code.
- const currency = selectedCurrency ?? metrics.currencies[0] ?? "";
+ //
+ // Empty-project case (no rollup rows yet) leaves both
+ // `selectedCurrency` and `metrics.currencies[0]` undefined; we
+ // resolve to "" deliberately and let the `EmptyState` below take
+ // over rendering — the chart subtree is gated on
+ // `metrics.days.length > 0` so a "" currency never reaches the
+ // axis labels.
+ const currency =
+ selectedCurrency ??
+ (metrics.currencies.length > 0 ? metrics.currencies[0] : "");
// Client-side filtering. Range is also a client filter now (we
// fetched the max range above), so flipping range chiclets stays
@@ -256,6 +265,25 @@ export default function ProjectAnalytics() {
+ {metrics.truncated && (
+ // Server hit `REVENUE_SCAN_CAP` on the rollup scan, so the
+ // chart below is showing a partial view of the requested
+ // range. Surface this so an operator doesn't read flat
+ // numbers as a real revenue trough — they're a query-budget
+ // truncation, not a business signal.
+
+
+
+
Analytics partially loaded
+
+ This range exceeded the per-query scan limit. Numbers below cover
+ the most-recent slice of the window; tighten the range (7d / 30d)
+ to load every row, or narrow by product / currency.
+
+
+
+ )}
+
{PLATFORM_CARDS.map((card) => {
const active = platformFilter === card.filter;
From 8e1ad55e5a3ec0d6eb396072a9915c20d88be56b Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 01:44:30 +0900
Subject: [PATCH 09/14] fix(kit): use paginate() for subs walk + per-day commit
chain for write-budget safety
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace the hand-rolled `lt(updatedAt, watermark)` page boundary
in `processSubsPage` with Convex's `paginate({ numItems, cursor })`
API. Pagination cursors include a stable tiebreaker (the row
`_id`), so the prior approach silently dropped rows whenever two
subscriptions shared an `updatedAt` at the page boundary. Cursor
format on the chained-mutation args is now
`{ stateIdx: number, paginationCursor: string | null }`.
- Add `commitRevenueMetricsDay` and `commitOrSchedulePerDay`. When
the bucket accumulator exceeds `COMMIT_INLINE_BUCKET_LIMIT = 500`
(i.e. ~1000 ops at commit), the commit phase fans out one
scheduled mutation per day, each with its own 8192-writes budget.
A 200-SKU × 5-currency × 2-platform project (6000 buckets, 12000
ops) used to overflow the per-mutation write limit on a single
commit; it now lands across 3 chained mutations of ~4000 ops
each. `markRevenueMetricsRun` is OCC-safe so calling it from
every per-day commit converges to a single status row even if
the commits race.
- Test harness: add `MemQuery.paginate(...)` returning offset-based
cursors. Real Convex cursors are opaque and include `_id` as a
tiebreaker; the test fixtures stay well below the page boundary
where that matters, so an offset emulation is sufficient to
round-trip the production code path.
Tests: 51/51 still pass on revenueMetrics.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../subscriptions/revenueMetrics.test.ts | 18 ++
.../convex/subscriptions/revenueMetrics.ts | 234 ++++++++++++++----
2 files changed, 198 insertions(+), 54 deletions(-)
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
index 346e9a3c..bb7569f3 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.test.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -402,6 +402,24 @@ class MemQuery {
}
return this.rows[0] ?? null;
}
+
+ // Minimal stand-in for Convex's `paginate({ numItems, cursor })`.
+ // Cursor is the offset as a string. Real Convex returns an opaque
+ // cursor that includes a stable tiebreaker (the row `_id`); the
+ // test fixtures here are too small to ever exercise the
+ // tiebreaker, so an offset is sufficient to round-trip the
+ // production code path without forcing tests to wire up Convex's
+ // full pagination contract.
+ async paginate(opts: {
+ numItems: number;
+ cursor: string | null;
+ }): Promise<{ page: Row[]; isDone: boolean; continueCursor: string }> {
+ const start = opts.cursor === null ? 0 : Number.parseInt(opts.cursor, 10);
+ const end = start + opts.numItems;
+ const page = this.rows.slice(start, end);
+ const isDone = end >= this.rows.length;
+ return { page, isDone, continueCursor: String(end) };
+ }
}
class MemDb {
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
index 0ef7d1b7..bc3434e3 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -204,32 +204,50 @@ const WEBHOOK_SCAN_CAP = 15_000;
// Per-page subscription scan size. Each page chains via the
// scheduler so this caps reads PER MUTATION, not per project. A
// project with 50k active subs paginates across ~10 chained
-// mutations, each comfortably under the 40k document-read budget
+// mutations, each comfortably under the 32k document-read budget
// (page reads + the events pass on page 0 + commit-time existing-
// row deletes all share the budget).
const SUBS_PAGE_SIZE = 5_000;
+// Number of accumulator buckets above which the commit phase
+// chains per-day mutations instead of running inline. Below this
+// threshold, a single commit fits comfortably under Convex's 8192
+// writes-per-mutation budget (each bucket is one delete + one
+// insert = 2 ops, plus the per-day existing-row scan reads). For
+// large multi-product projects (e.g. 100 SKUs × 5 currencies × 2
+// platforms × 3 days = 3000 buckets → 6000 ops, breaking the cap),
+// we split by day so each commit mutation handles one day's
+// buckets and gets its own write budget.
+const COMMIT_INLINE_BUCKET_LIMIT = 500;
+
// Validators for the chained-page args. Buckets serialize as a flat
// array so they survive the scheduler's JSON round-trip; the cursor
-// is a small enum + watermark.
+// is the state index + the opaque cursor returned by Convex's
+// `paginate(...)` API (stable across `updatedAt` ties because it
+// includes the row `_id`, which a hand-rolled `lt(updatedAt, ...)`
+// watermark would silently skip when two rows share a timestamp).
const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
-const accumulatorValidator = v.array(
- v.object({
- day: v.string(),
- productId: v.string(),
- currency: v.string(),
- platform: platformValidator,
- activeSubs: v.number(),
- newSubs: v.number(),
- renewals: v.number(),
- cancellations: v.number(),
- refunds: v.number(),
- revenueMicros: v.number(),
- }),
-);
+const bucketValidator = v.object({
+ day: v.string(),
+ productId: v.string(),
+ currency: v.string(),
+ platform: platformValidator,
+ activeSubs: v.number(),
+ newSubs: v.number(),
+ renewals: v.number(),
+ cancellations: v.number(),
+ refunds: v.number(),
+ revenueMicros: v.number(),
+});
+const accumulatorValidator = v.array(bucketValidator);
const cursorValidator = v.object({
stateIdx: v.number(),
- updatedBefore: v.union(v.number(), v.null()),
+ // Opaque Convex pagination cursor for the current state. `null`
+ // means "start of state" (or "state finished" if `stateIdx` is
+ // also advancing). Using Convex's pagination cursor instead of a
+ // hand-rolled `updatedAt` watermark guarantees ordering stability
+ // when multiple subscriptions share an `updatedAt` timestamp.
+ paginationCursor: v.union(v.string(), v.null()),
});
async function markRevenueMetricsRun(
@@ -361,7 +379,7 @@ export async function runRecompute(
days,
windowStart,
buckets,
- cursor: { stateIdx: 0, updatedBefore: null },
+ cursor: { stateIdx: 0, paginationCursor: null },
runStartedAt: now,
});
}
@@ -380,31 +398,28 @@ async function processSubsPage(
days: string[];
windowStart: number;
buckets: Map;
- cursor: { stateIdx: number; updatedBefore: number | null };
+ cursor: { stateIdx: number; paginationCursor: string | null };
runStartedAt: number;
},
): Promise {
const { projectId, days, buckets, runStartedAt } = args;
const dayEnds = days.map((day) => Date.parse(`${day}T23:59:59.999Z`));
- let { stateIdx, updatedBefore } = args.cursor;
+ let { stateIdx, paginationCursor } = args.cursor;
let pageRemaining = SUBS_PAGE_SIZE;
let chainContinuation = false;
while (stateIdx < COUNTED_STATES_ORDERED.length && pageRemaining > 0) {
const state: CountedState = COUNTED_STATES_ORDERED[stateIdx];
- const upperBound = updatedBefore;
- const requested = pageRemaining;
- const subs = await ctx.db
+ const result = await ctx.db
.query("subscriptions")
- .withIndex("by_project_and_state_and_updated", (q) => {
- const base = q.eq("projectId", projectId).eq("state", state);
- return upperBound === null ? base : base.lt("updatedAt", upperBound);
- })
+ .withIndex("by_project_and_state_and_updated", (q) =>
+ q.eq("projectId", projectId).eq("state", state),
+ )
.order("desc")
- .take(requested);
+ .paginate({ numItems: pageRemaining, cursor: paginationCursor });
- for (const sub of subs) {
+ for (const sub of result.page) {
for (let i = 0; i < days.length; i++) {
if (!isActiveAt(sub, dayEnds[i])) continue;
const currency = sub.currency ?? "";
@@ -422,31 +437,31 @@ async function processSubsPage(
}
}
- pageRemaining -= subs.length;
- if (subs.length < requested) {
- // Took less than we asked for → state exhausted, advance.
+ pageRemaining -= result.page.length;
+ if (result.isDone) {
+ // State exhausted, advance to next state with a fresh cursor.
stateIdx += 1;
- updatedBefore = null;
+ paginationCursor = null;
} else {
- // Page is full and the current state may still have rows we
- // haven't seen. Carry the watermark on this state and chain a
- // continuation; the next page reads `lt(updatedAt, watermark)`
- // so we skip the rows we already processed.
- updatedBefore = subs[subs.length - 1].updatedAt;
- chainContinuation = true;
- break;
+ // Convex's `continueCursor` is opaque and includes a stable
+ // tiebreaker (the row `_id`), so subsequent pages won't drop
+ // rows that share an `updatedAt` with the page boundary.
+ paginationCursor = result.continueCursor;
+ if (pageRemaining <= 0) {
+ chainContinuation = true;
+ break;
+ }
}
}
if (!chainContinuation && stateIdx >= COUNTED_STATES_ORDERED.length) {
// All counted states processed — commit.
- await commitBuckets(ctx, projectId, days, buckets, runStartedAt);
- await markRevenueMetricsRun(ctx, projectId, runStartedAt);
+ await commitOrSchedulePerDay(ctx, projectId, days, buckets, runStartedAt);
return;
}
// More work to do. Serialize the accumulator + cursor and chain
- // a fresh mutation so the next page gets its own 40k budget.
+ // a fresh mutation so the next page gets its own 32k read budget.
const serialized = Array.from(buckets.values());
await ctx.scheduler.runAfter(
0,
@@ -455,7 +470,7 @@ async function processSubsPage(
projectId,
days,
buckets: serialized,
- cursor: { stateIdx, updatedBefore },
+ cursor: { stateIdx, paginationCursor },
runStartedAt,
},
);
@@ -492,6 +507,126 @@ export const recomputeRevenueMetricsPage = internalMutation({
},
});
+// Commit one day's worth of recomputed buckets. Used by the
+// scheduler-chained commit path for projects whose total bucket
+// count exceeds COMMIT_INLINE_BUCKET_LIMIT and would otherwise
+// blow Convex's 8192-writes-per-mutation budget on a single
+// commit. Each per-day commit:
+// - reads existing rollup rows for THIS day (bounded by
+// productCount × currencyCount × platformCount)
+// - deletes them and inserts the recomputed non-zero buckets
+// - upserts `revenueMetricsRunStatus` so the picker rotates
+// even if some other day's commit ran later or never
+// Multiple per-day commits run in parallel; OCC retries make the
+// shared `revenueMetricsRunStatus` upsert race-safe.
+export const commitRevenueMetricsDay = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ day: v.string(),
+ buckets: accumulatorValidator,
+ runStartedAt: v.number(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q.eq("projectId", args.projectId).eq("day", args.day),
+ )
+ .collect();
+ await Promise.all(existing.map((row) => ctx.db.delete(row._id)));
+
+ const inserts: Array> = [];
+ for (const bucket of args.buckets) {
+ if (isAllZeroBucket(bucket)) continue;
+ inserts.push(
+ ctx.db.insert("revenueMetricsDaily", {
+ projectId: args.projectId,
+ day: bucket.day,
+ productId: bucket.productId,
+ currency: bucket.currency,
+ platform: bucket.platform,
+ activeSubs: bucket.activeSubs,
+ newSubs: bucket.newSubs,
+ renewals: bucket.renewals,
+ cancellations: bucket.cancellations,
+ refunds: bucket.refunds,
+ revenueMicros: bucket.revenueMicros,
+ updatedAt: args.runStartedAt,
+ }),
+ );
+ }
+ await Promise.all(inserts);
+
+ await markRevenueMetricsRun(ctx, args.projectId, args.runStartedAt);
+ return null;
+ },
+});
+
+// Decide whether to commit inline (small projects) or fan out one
+// scheduled mutation per day (large projects). The threshold is
+// expressed in total bucket count because each bucket contributes
+// at most 2 writes (one delete of the prior row + one insert of
+// the new row); 500 buckets × 2 = 1000 writes per day worst-case
+// is comfortably under Convex's 8192-writes-per-mutation budget
+// even with the per-day existing-row reads.
+async function commitOrSchedulePerDay(
+ ctx: MutationCtx,
+ projectId: Id<"projects">,
+ days: string[],
+ buckets: Map,
+ runStartedAt: number,
+): Promise {
+ if (buckets.size <= COMMIT_INLINE_BUCKET_LIMIT) {
+ await commitBuckets(ctx, projectId, days, buckets, runStartedAt);
+ await markRevenueMetricsRun(ctx, projectId, runStartedAt);
+ return;
+ }
+
+ // Group buckets by day so each scheduled mutation receives only
+ // its own slice. Args size per scheduled mutation is therefore
+ // bounded by the per-day bucket count (productCount ×
+ // currencyCount × platformCount), well under the ~1MB scheduler
+ // arg limit at any realistic SaaS scale.
+ const bucketsByDay = new Map();
+ for (const day of days) bucketsByDay.set(day, []);
+ for (const bucket of buckets.values()) {
+ const list = bucketsByDay.get(bucket.day);
+ if (list) list.push(bucket);
+ }
+
+ for (const day of days) {
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics.commitRevenueMetricsDay,
+ {
+ projectId,
+ day,
+ buckets: bucketsByDay.get(day) ?? [],
+ runStartedAt,
+ },
+ );
+ }
+}
+
+function isAllZeroBucket(bucket: {
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+}): boolean {
+ return (
+ bucket.activeSubs === 0 &&
+ bucket.newSubs === 0 &&
+ bucket.renewals === 0 &&
+ bucket.cancellations === 0 &&
+ bucket.refunds === 0 &&
+ bucket.revenueMicros === 0
+ );
+}
+
function getOrCreateBucket(
buckets: Map,
key: BucketKey,
@@ -615,16 +750,7 @@ async function commitBuckets(
// but was later refunded so its (newSubs, refunds) net to zero
// and it wasn't active at end-of-day either. No row beats an
// all-zero row in storage / scan cost.
- if (
- bucket.activeSubs === 0 &&
- bucket.newSubs === 0 &&
- bucket.renewals === 0 &&
- bucket.cancellations === 0 &&
- bucket.refunds === 0 &&
- bucket.revenueMicros === 0
- ) {
- continue;
- }
+ if (isAllZeroBucket(bucket)) continue;
inserts.push(
ctx.db.insert("revenueMetricsDaily", {
projectId,
From b1592fa03212ed314190b40803b0bbe832935e47 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 01:59:42 +0900
Subject: [PATCH 10/14] fix(kit): drop redundant scan grace + refresh analytics
date pin every minute
- Drop the `LATE_DELIVERY_GRACE_DAYS` backward extension on the
`webhookEvents` scan window. The receivers in `webhooks/apple.ts`
and `webhooks/google.ts` always set `receivedAt >= occurredAt`
(Apple `signedDate` / Google `eventTimeMillis` is a store-side
timestamp, `receivedAt` is the HTTP receive time), so any event
whose `occurredAt` lands in the trailing window has `receivedAt`
in the same range too. The 7-day extension was burning read
budget on rows the `day < firstDay` filter immediately discarded.
Test that exercised the old grace was reshaped to assert the
realistic late-arrival shape (occurredAt=D2, receivedAt=TODAY).
- Tick the analytics page's date pin every 60s so a dashboard left
open across UTC midnight picks up the new day. `now` is held in
`useState` and refreshed via `setInterval`, with `useMemo`
keying on it; `utcDayKey(now)` only changes once per UTC day, so
Convex's `useQuery` doesn't refetch every minute (its arg
deep-equality keys on the stable day string).
Tests: 51/51 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../subscriptions/revenueMetrics.test.ts | 18 ++++++------
.../convex/subscriptions/revenueMetrics.ts | 28 ++++++++-----------
.../auth/organization/project/analytics.tsx | 27 ++++++++++++------
3 files changed, 38 insertions(+), 35 deletions(-)
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
index bb7569f3..37156b6a 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.test.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -981,19 +981,19 @@ describe("runRecompute — round-trip integration", () => {
expect(await rollupRows(db)).toEqual([]);
});
- it("event scan reaches back beyond the bucket window for late deliveries", async () => {
- // receivedAt = D2 - 4 days (well outside the 3-day bucket
- // window) but occurredAt = D2. Without the LATE_DELIVERY_GRACE
- // backward extension on the scan, this event would be missed
- // on every recompute after its tick of arrival, and the
- // delete-then-insert in commitBuckets would erase it
- // permanently. Verifies the grace window is wide enough to
- // catch real Apple ASN v2 / Google RTDN retry tails.
+ it("late-arrival event with receivedAt at end of window still buckets by occurredAt", async () => {
+ // The webhook receivers in `webhooks/apple.ts` and
+ // `webhooks/google.ts` always set `receivedAt >= occurredAt`,
+ // so an event whose `occurredAt` falls inside the trailing
+ // window necessarily has `receivedAt` inside it too. This test
+ // is the realistic shape: a renewal that occurred on D2 but
+ // didn't land in our DB until TODAY (Apple/Google retry tail).
+ // The bucket should attribute to D2.
await seedEvent(db, {
type: "SubscriptionStarted",
priceAmountMicros: 9_990_000,
occurredAt: Date.parse(`${D2}T03:00:00Z`),
- receivedAt: Date.parse(`${D2}T03:00:00Z`) - 4 * 86400000,
+ receivedAt: Date.parse(`${TODAY}T11:00:00Z`),
});
await runRecompute(ctx, PROJECT_ID, NOW);
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
index bc3434e3..53709ac9 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -45,16 +45,6 @@ import type { Doc, Id } from "../_generated/dataModel";
// quarantine those into manual reconciliation paths instead).
const TRAILING_DAYS = 3;
-// Late-delivery grace days. Bucketing by `occurredAt` (the store-side
-// event time) means we have to scan further back on `receivedAt`
-// than the bucket window itself, otherwise an event that arrived
-// 4 days late but occurred yesterday would not be in this tick's
-// receivedAt scan and would silently undercount yesterday's bucket
-// after the delete-then-insert in `commitBuckets`. Apple ASN v2
-// retries up to 5 days; Google RTDN's Pub/Sub default is 7 days.
-// 7 days covers both stores' published retry policies.
-const LATE_DELIVERY_GRACE_DAYS = 7;
-
const DAY_MS = 24 * 60 * 60 * 1000;
// UTC day key (YYYY-MM-DD) for an epoch-millis timestamp. Keying in
@@ -313,18 +303,22 @@ export async function runRecompute(
// would flip its day on the dashboard, contradicting the
// "late notifications fold into their correct day" promise.
//
- // The scan window extends `LATE_DELIVERY_GRACE_DAYS` beyond the
- // bucket window so an event with `occurredAt` inside the bucket
- // window but `receivedAt` from a prior tick still gets reread
- // (after `commitBuckets` deletes the day's existing rows, anything
- // not rescanned silently drops out of the rebuilt bucket).
- const eventScanStart = windowStart - LATE_DELIVERY_GRACE_DAYS * DAY_MS;
+ // Scan window matches the bucket window exactly. The webhook
+ // receivers in `webhooks/apple.ts` and `webhooks/google.ts` set
+ // `receivedAt` to the HTTP receive time and `occurredAt` to the
+ // store-side timestamp (Apple `signedDate` / Google
+ // `eventTimeMillis`), so by construction `receivedAt >=
+ // occurredAt`: any event whose `occurredAt` lands in
+ // `[windowStart, windowEnd]` necessarily has `receivedAt` in the
+ // same range too. Scanning further back on `receivedAt` would
+ // only read older rows that the `day < firstDay` filter below
+ // immediately discards, burning the read budget for nothing.
const events = await ctx.db
.query("webhookEvents")
.withIndex("by_project_and_received", (q) =>
q
.eq("projectId", projectId)
- .gte("receivedAt", eventScanStart)
+ .gte("receivedAt", windowStart)
.lte("receivedAt", windowEnd),
)
.take(WEBHOOK_SCAN_CAP);
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index 24fe6571..ddb111c5 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Link, useOutletContext, useParams } from "react-router-dom";
import { useQuery } from "convex/react";
import { Select } from "antd";
@@ -107,24 +107,33 @@ export default function ProjectAnalytics() {
// writes (both use UTC); using local-day here would cause the
// chart's first/last column to half-cover when the user's tz is
// far from UTC, surfacing as a "missing yesterday" off-by-one.
+ //
+ // `now` is held in state and refreshed every minute so a user who
+ // leaves the dashboard open across a UTC midnight rollover gets a
+ // re-render that picks up the new day. `useMemo` keys on `now`,
+ // but `utcDayKey(now)` only changes once per day, so the chart
+ // doesn't refetch every minute — Convex's `useQuery` deep-equals
+ // the args object, and the day-key string is stable inside the
+ // same UTC day.
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 60_000);
+ return () => clearInterval(id);
+ }, []);
+
const { maxFromDay, toDay } = useMemo(() => {
- // Capture `now` once so the `today` and `from` keys derive from
- // the same instant — calling `Date.now()` twice on either side
- // of midnight UTC could return values whose `utcDayKey` differs
- // by a day, producing a one-day-too-narrow window.
- const now = Date.now();
const today = utcDayKey(now);
const from = utcDayKey(now - (MAX_RANGE_DAYS - 1) * DAY_MS);
return { maxFromDay: from, toDay: today };
- }, [MAX_RANGE_DAYS]);
+ }, [now, MAX_RANGE_DAYS]);
// Per-range start day, derived without re-querying. Slicing the
// unfiltered fetch in JS keeps the page below from flashing on
// range/period clicks; only the bottom (charts + summary cards)
// re-derives.
const fromDay = useMemo(
- () => utcDayKey(Date.now() - (range.days - 1) * DAY_MS),
- [range],
+ () => utcDayKey(now - (range.days - 1) * DAY_MS),
+ [now, range],
);
// Fetch unfiltered for the maximum range — all filtering (range,
From 9fd27775986e75fb140d6836539c141c7cc5bead Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 02:18:57 +0900
Subject: [PATCH 11/14] fix(kit): bump REVENUE_SCAN_CAP, productId-narrowed
index, memoise analytics derivations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Bump `REVENUE_SCAN_CAP` from 10k to 20k. The maximalist 92-day
range described in the comment (30 SKUs × 3 currencies × 2
platforms ≈ 16.5k rows) was always tripping the prior cap, so
the chart silently truncated for any project that big. 20k still
sits well under Convex's 32k document-scan limit and gives the
documented use case real headroom.
- When `args.productId` is set, route the read through
`by_project_and_product_and_day_and_currency` so the index range
already filters down to the SKU's rows. The prior broad-index
scan + in-memory filter could exhaust the cap on irrelevant rows
(and therefore truncate the relevant ones) for projects with
many products.
- Wrap the analytics page's filter / aggregate / bucket / totals
pipeline and the platform-card totals in `useMemo`. Without it,
the per-minute `now` tick was re-running the full pipeline (and
three independent filter-then-totals walks per render inside the
`PLATFORM_CARDS` map) every minute. The memo deps target the
filters that actually drive the data, so a UTC-day-flip
recomputes once and idle minutes stay free.
- Hooks-rules-of-hooks compliance: hoisted the `metrics ===
undefined` early return below every hook call. Hooks consume
`metricsDays` / `metricsCurrencies` defaults that fall back to
shared empty constants on the loading render so deps stay
identity-stable.
Tests: 51/51 still pass on revenueMetrics.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/kit/convex/subscriptions/query.ts | 58 +++---
.../auth/organization/project/analytics.tsx | 165 ++++++++++++------
2 files changed, 151 insertions(+), 72 deletions(-)
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index b9edeafb..1c7fee55 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -527,29 +527,43 @@ export const getRevenueMetrics = query({
);
}
- // Range scan via `by_project_and_day_and_currency`. The index
- // is `[projectId, day, currency]`, so `eq(projectId).gte(day)
- // .lte(day)` resolves the entire window in one index hit;
- // currency / product / platform filters are applied in-memory
- // afterward.
+ // Range scan over `revenueMetricsDaily`. When the caller pins a
+ // specific `productId` we use `by_project_and_product_and_day_and_currency`
+ // (`[projectId, productId, day, currency]`) so the index range
+ // already filters down to a single SKU's rows; otherwise we fall
+ // back to `by_project_and_day_and_currency`
+ // (`[projectId, day, currency]`). Currency / platform filters are
+ // applied in-memory in either case (typically tens of rows after
+ // the index range narrows things down).
//
- // Capped at REVENUE_SCAN_CAP — same number `metricsSummary` uses
- // for its FALLBACK_SCAN_CAP / ROLLING_SCAN_CAP — to stay under
- // Convex's 32k document-scan limit per query. A 92-day range
- // across a maximalist project (30 SKUs × 3 currencies × 2
- // platforms = 180 rows/day → ~16.5k rows for 92 days) fits
- // comfortably; truncation at the cap surfaces as the warning
- // below so we never silently render a partial chart.
- const REVENUE_SCAN_CAP = 10_000;
- const allRows = await ctx.db
- .query("revenueMetricsDaily")
- .withIndex("by_project_and_day_and_currency", (q) =>
- q
- .eq("projectId", project._id)
- .gte("day", args.fromDay)
- .lte("day", args.toDay),
- )
- .take(REVENUE_SCAN_CAP);
+ // Capped at REVENUE_SCAN_CAP to stay under Convex's 32k
+ // document-scan limit per query. A 92-day range across a
+ // maximalist project (30 SKUs × 3 currencies × 2 platforms =
+ // 180 rows/day → ~16.5k rows for 92 days) fits inside this
+ // cap; truncation surfaces as the `truncated` flag below and
+ // an amber banner on the dashboard so a partial chart is
+ // never silently rendered.
+ const REVENUE_SCAN_CAP = 20_000;
+ const allRows = args.productId
+ ? await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_product_and_day_and_currency", (q) =>
+ q
+ .eq("projectId", project._id)
+ .eq("productId", args.productId as string)
+ .gte("day", args.fromDay)
+ .lte("day", args.toDay),
+ )
+ .take(REVENUE_SCAN_CAP)
+ : await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q
+ .eq("projectId", project._id)
+ .gte("day", args.fromDay)
+ .lte("day", args.toDay),
+ )
+ .take(REVENUE_SCAN_CAP);
const truncated = allRows.length === REVENUE_SCAN_CAP;
if (truncated) {
console.warn(
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index ddb111c5..bb6b7803 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -37,6 +37,26 @@ type PlatformFilter = "all" | Platform;
const DAY_MS = 86_400_000;
+// Stable empty defaults. The kickoff render before `useQuery`
+// returns has `metrics === undefined`; we still need to invoke
+// every memo in the same order on that render so React's
+// rules-of-hooks stay satisfied. Sharing these constants keeps the
+// memo dependency identity stable across renders so the memos
+// don't recompute when the empty defaults are passed in.
+const EMPTY_DAYS: ReadonlyArray<{
+ day: string;
+ currency: string;
+ productId: string;
+ platform: Platform;
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+}> = [];
+const EMPTY_STRINGS: ReadonlyArray = [];
+
const RANGES = [
{ id: "7d", label: "Last 7 days", days: 7 },
{ id: "30d", label: "Last 30 days", days: 30 },
@@ -151,9 +171,14 @@ export default function ProjectAnalytics() {
toDay,
});
- if (metrics === undefined) {
- return ;
- }
+ // The loading-state early return has to live below ALL hooks so
+ // React's rules-of-hooks (every hook called in the same order on
+ // every render) stays satisfied — the memos below would
+ // otherwise be conditional on `metrics !== undefined`. We work
+ // off a stable empty default until the real data arrives, then
+ // bail to `` after the hooks have been registered.
+ const metricsDays = metrics?.days ?? EMPTY_DAYS;
+ const metricsCurrencies = metrics?.currencies ?? EMPTY_STRINGS;
// Multi-currency projects: we always pin to a single currency for
// chart rendering because revenueMicros can't be summed across
@@ -166,14 +191,14 @@ export default function ProjectAnalytics() {
// single number labeled with one currency code.
//
// Empty-project case (no rollup rows yet) leaves both
- // `selectedCurrency` and `metrics.currencies[0]` undefined; we
+ // `selectedCurrency` and `metricsCurrencies[0]` undefined; we
// resolve to "" deliberately and let the `EmptyState` below take
// over rendering — the chart subtree is gated on
- // `metrics.days.length > 0` so a "" currency never reaches the
+ // `metricsDays.length > 0` so a "" currency never reaches the
// axis labels.
const currency =
selectedCurrency ??
- (metrics.currencies.length > 0 ? metrics.currencies[0] : "");
+ (metricsCurrencies.length > 0 ? metricsCurrencies[0] : "");
// Client-side filtering. Range is also a client filter now (we
// fetched the max range above), so flipping range chiclets stays
@@ -188,36 +213,54 @@ export default function ProjectAnalytics() {
// the start of the chart — without them, a project with active
// subscriptions but no events in the selected range would dip
// visually to zero on the first day.
- const filteredRows = metrics.days.filter((row) => {
- if (currency && row.currency !== currency) return false;
- if (selectedProduct && row.productId !== selectedProduct) return false;
- if (platformFilter !== "all" && row.platform !== platformFilter) {
- return false;
- }
- return true;
- });
+ //
+ // Memoised so the per-minute `now` tick (which only changes
+ // `fromDay` / `toDay` once per UTC day) doesn't re-run the full
+ // filter / aggregate / bucket pipeline on every render.
+ const filteredRows = useMemo(
+ () =>
+ metricsDays.filter((row) => {
+ if (currency && row.currency !== currency) return false;
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ if (platformFilter !== "all" && row.platform !== platformFilter) {
+ return false;
+ }
+ return true;
+ }),
+ [metricsDays, currency, selectedProduct, platformFilter],
+ );
- const dailySeries = aggregateByDay(filteredRows, range.days, fromDay);
- const series = bucketByPeriod(dailySeries, periodId);
-
- const totals = series.reduce(
- (acc, row) => {
- acc.newSubs += row.newSubs;
- acc.renewals += row.renewals;
- acc.cancellations += row.cancellations;
- acc.refunds += row.refunds;
- acc.revenueMicros += row.revenueMicros;
- acc.activeSubsLast = row.activeSubs;
- return acc;
- },
- {
- newSubs: 0,
- renewals: 0,
- cancellations: 0,
- refunds: 0,
- revenueMicros: 0,
- activeSubsLast: 0,
- },
+ const dailySeries = useMemo(
+ () => aggregateByDay(filteredRows, range.days, fromDay),
+ [filteredRows, range.days, fromDay],
+ );
+ const series = useMemo(
+ () => bucketByPeriod(dailySeries, periodId),
+ [dailySeries, periodId],
+ );
+
+ const totals = useMemo(
+ () =>
+ series.reduce(
+ (acc, row) => {
+ acc.newSubs += row.newSubs;
+ acc.renewals += row.renewals;
+ acc.cancellations += row.cancellations;
+ acc.refunds += row.refunds;
+ acc.revenueMicros += row.revenueMicros;
+ acc.activeSubsLast = row.activeSubs;
+ return acc;
+ },
+ {
+ newSubs: 0,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 0,
+ activeSubsLast: 0,
+ },
+ ),
+ [series],
);
// Churn = (cancellations + refunds) / activeSubs at end of window.
@@ -228,6 +271,38 @@ export default function ProjectAnalytics() {
? ((totals.cancellations + totals.refunds) / totals.activeSubsLast) * 100
: 0;
+ // Platform-card totals: one filter pass over `metrics.days`
+ // (without platform narrowing — the cards ARE the platform
+ // breakdown), then `totalsForPlatform` for each card variant.
+ // The prior implementation did this filter and `totalsForPlatform`
+ // walk three times *per render* inside `PLATFORM_CARDS.map`
+ // because of the per-minute `now` tick. Memoising collapses that
+ // to one filter + three small reductions, only re-running when a
+ // filter the cards actually depend on changes.
+ const platformTotals = useMemo(() => {
+ const baseRows = metricsDays.filter((row) => {
+ if (row.day < fromDay) return false;
+ if (currency && row.currency !== currency) return false;
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ return true;
+ });
+ const byFilter = new Map<
+ PlatformFilter,
+ { revenueMicros: number; activeSubs: number; newSubs: number }
+ >();
+ for (const card of PLATFORM_CARDS) {
+ byFilter.set(
+ card.filter,
+ totalsForPlatform(baseRows, card.filter, currency),
+ );
+ }
+ return byFilter;
+ }, [metricsDays, fromDay, currency, selectedProduct]);
+
+ if (metrics === undefined) {
+ return ;
+ }
+
return (
@@ -296,21 +371,11 @@ export default function ProjectAnalytics() {
{PLATFORM_CARDS.map((card) => {
const active = platformFilter === card.filter;
- // Cards reflect the selected range / currency / product
- // (everything except the platform filter — the cards ARE
- // the platform breakdown). Filtering to fromDay matches
- // what the charts below show. Use the resolved `currency`
- // (not raw `selectedCurrency`) so multi-currency projects
- // pin to a single currency by default and never sum
- // mismatched FX into one card.
- const cardRows = metrics.days.filter((row) => {
- if (row.day < fromDay) return false;
- if (currency && row.currency !== currency) return false;
- if (selectedProduct && row.productId !== selectedProduct)
- return false;
- return true;
- });
- const cardTotals = totalsForPlatform(cardRows, card.filter, currency);
+ const cardTotals = platformTotals.get(card.filter) ?? {
+ revenueMicros: 0,
+ activeSubs: 0,
+ newSubs: 0,
+ };
return (
Date: Wed, 6 May 2026 02:31:52 +0900
Subject: [PATCH 12/14] fix(kit): drop unused server-side filter args on
getRevenueMetrics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dashboard does all currency / product / platform filtering
client-side (the unfiltered fetch is what backs the filter-dropdown
population), and `getRevenueMetrics` has no other caller. The
optional `productId` / `currency` / `platform` args added in the
prior round were never actually exercised — and the productId
narrowed-index branch I added with them was incompatible with the
dropdown contract (when productId was pinned, the dropdown
silently collapsed to that SKU's currencies / platforms only).
Removed:
- All three optional args from the schema and the in-memory filter
fall-through that consumed them.
- The `by_project_and_product_and_day_and_currency` branch in the
range scan; the broad `by_project_and_day_and_currency` index now
always backs the read. Index itself stays defined on the table
for future per-product surfaces.
- The post-scan `if (args.productId) rows = rows.filter(...)` chain
(now fully redundant — `allRows` IS the returned `days`).
A future caller that actually needs server-side narrowing should
get its own dedicated query rather than reintroducing optional args
on this one (documented inline).
Tests: 51/51 still pass on revenueMetrics.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
packages/kit/convex/subscriptions/query.ts | 79 ++++++++--------------
1 file changed, 30 insertions(+), 49 deletions(-)
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index 1c7fee55..0bd904ae 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -457,9 +457,16 @@ export const getRevenueMetrics = query({
apiKey: v.string(),
fromDay: v.string(),
toDay: v.string(),
- productId: v.optional(v.string()),
- currency: v.optional(v.string()),
- platform: v.optional(platformValidator),
+ // Server-side `productId` / `currency` / `platform` filters were
+ // removed because the dashboard does all of that filtering
+ // client-side (the unfiltered fetch is what backs the filter-
+ // dropdown population — narrowing the scan would defeat that),
+ // and a server-side narrowing path was incompatible with that
+ // contract: when a productId was pinned the dropdowns silently
+ // collapsed to that SKU's currencies / platforms only. If a
+ // future caller needs server-side narrowing for a non-dashboard
+ // surface, add a separate query — don't reintroduce these as
+ // optional args on this one.
},
returns: v.object({
days: v.array(
@@ -527,14 +534,12 @@ export const getRevenueMetrics = query({
);
}
- // Range scan over `revenueMetricsDaily`. When the caller pins a
- // specific `productId` we use `by_project_and_product_and_day_and_currency`
- // (`[projectId, productId, day, currency]`) so the index range
- // already filters down to a single SKU's rows; otherwise we fall
- // back to `by_project_and_day_and_currency`
- // (`[projectId, day, currency]`). Currency / platform filters are
- // applied in-memory in either case (typically tens of rows after
- // the index range narrows things down).
+ // Range scan over `revenueMetricsDaily` via
+ // `by_project_and_day_and_currency` (`[projectId, day, currency]`).
+ // The dashboard does all filtering (currency / product /
+ // platform) client-side, so we deliberately return the full
+ // window — narrowing here would prune the data the dashboard
+ // needs to populate its filter dropdowns.
//
// Capped at REVENUE_SCAN_CAP to stay under Convex's 32k
// document-scan limit per query. A 92-day range across a
@@ -544,26 +549,15 @@ export const getRevenueMetrics = query({
// an amber banner on the dashboard so a partial chart is
// never silently rendered.
const REVENUE_SCAN_CAP = 20_000;
- const allRows = args.productId
- ? await ctx.db
- .query("revenueMetricsDaily")
- .withIndex("by_project_and_product_and_day_and_currency", (q) =>
- q
- .eq("projectId", project._id)
- .eq("productId", args.productId as string)
- .gte("day", args.fromDay)
- .lte("day", args.toDay),
- )
- .take(REVENUE_SCAN_CAP)
- : await ctx.db
- .query("revenueMetricsDaily")
- .withIndex("by_project_and_day_and_currency", (q) =>
- q
- .eq("projectId", project._id)
- .gte("day", args.fromDay)
- .lte("day", args.toDay),
- )
- .take(REVENUE_SCAN_CAP);
+ const allRows = await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q
+ .eq("projectId", project._id)
+ .gte("day", args.fromDay)
+ .lte("day", args.toDay),
+ )
+ .take(REVENUE_SCAN_CAP);
const truncated = allRows.length === REVENUE_SCAN_CAP;
if (truncated) {
console.warn(
@@ -571,12 +565,10 @@ export const getRevenueMetrics = query({
);
}
- // Populate filter-dropdown choices from the UNFILTERED set so the
- // UI can keep showing every available currency / product /
- // platform regardless of which filter is currently active —
- // otherwise selecting one currency would prune the dropdown to
- // just that currency and the user could never get back without
- // clearing.
+ // Populate filter-dropdown choices from the unfiltered range
+ // scan so the UI can render every available currency /
+ // productId / platform regardless of which filter the user
+ // currently has active.
const currencies = new Set();
const productIds = new Set();
const platforms = new Set<"IOS" | "Android">();
@@ -586,19 +578,8 @@ export const getRevenueMetrics = query({
platforms.add(row.platform);
}
- let rows = allRows;
- if (args.productId) {
- rows = rows.filter((row) => row.productId === args.productId);
- }
- if (args.currency) {
- rows = rows.filter((row) => row.currency === args.currency);
- }
- if (args.platform) {
- rows = rows.filter((row) => row.platform === args.platform);
- }
-
return {
- days: rows.map((row) => ({
+ days: allRows.map((row) => ({
day: row.day,
currency: row.currency,
productId: row.productId,
From 06c1dcbf9a3d48367a157b8e9373e6f5767957f4 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 02:44:01 +0900
Subject: [PATCH 13/14] fix(kit): paginate webhookEvents scan + per-day commit
chunk for >4k bucket days
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two scaling fixes from the latest Gemini review pass on PR #131:
- The events pass now paginates via the same scheduler-chained
pattern as the subs pass. `runRecompute` kicks off a phased
pipeline: first an `events` phase that walks `webhookEvents` via
Convex's `paginate({ numItems, cursor })` API, transitioning to
the existing `subs` phase once exhausted. The chained-page
cursor is now a tagged union (`{phase: "events", paginationCursor}`
| `{phase: "subs", stateIdx, paginationCursor}`) so a single
scheduler handler can resume from either phase. A noisy project
that emits 50k events in the trailing 3-day window now paginates
across ~10 chained event mutations instead of silently truncating
at the prior 15k cap.
- `commitRevenueMetricsDay` now checks whether
`existing + nonZero` would exceed COMMIT_DAY_WRITES_LIMIT (7000,
with headroom under Convex's 8192-writes-per-mutation budget).
Below the limit it commits inline as before; above it, it deletes
inline and fans inserts out across chained
`commitRevenueMetricsDayInsertChunk` mutations of size
INSERT_CHUNK_SIZE=3500 each. A project with ~10k buckets/day now
lands across ~3 chained chunks instead of blowing the cap.
`markRevenueMetricsRun` stays OCC-safe across the parallel
chunks and the kickoff so the picker rotates regardless of
chunk-completion order.
Side effect: removed the `WEBHOOK_SCAN_CAP` constant (the events
pass is no longer cap-bounded) and its associated truncation
warning. Truncation isn't a meaningful state anymore — the events
pass simply continues until done.
Tests: 51/51 still pass on revenueMetrics.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../convex/subscriptions/revenueMetrics.ts | 349 +++++++++++++-----
1 file changed, 248 insertions(+), 101 deletions(-)
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
index 53709ac9..5ef6776d 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -184,19 +184,17 @@ export const recomputeRevenueMetricsForProject = internalMutation({
},
});
-// Cap on the events pass scan. Events arrive at the receivedAt
-// timestamp, so the index range is bounded by the trailing-window
-// + late-delivery-grace span (10 days). Generously sized for
-// realistic SaaS event rates — at 1k events/day a project burns
-// 10k of the cap in 10 days, well clear of the limit.
-const WEBHOOK_SCAN_CAP = 15_000;
+// Per-page event scan size. The webhookEvents pass paginates via
+// the scheduler so this caps reads PER MUTATION, not per project.
+// A noisy project that emits 50k events in the trailing 3-day
+// window paginates across ~10 chained mutations, each well under
+// the 32k document-read budget.
+const EVENTS_PAGE_SIZE = 5_000;
// Per-page subscription scan size. Each page chains via the
// scheduler so this caps reads PER MUTATION, not per project. A
// project with 50k active subs paginates across ~10 chained
-// mutations, each comfortably under the 32k document-read budget
-// (page reads + the events pass on page 0 + commit-time existing-
-// row deletes all share the budget).
+// mutations, each comfortably under the 32k document-read budget.
const SUBS_PAGE_SIZE = 5_000;
// Number of accumulator buckets above which the commit phase
@@ -210,12 +208,27 @@ const SUBS_PAGE_SIZE = 5_000;
// buckets and gets its own write budget.
const COMMIT_INLINE_BUCKET_LIMIT = 500;
+// Per-day commit safety margin under Convex's 8192-writes-per-
+// mutation budget. Single-mutation per-day commits do
+// `existing.length` deletes + `nonZero.length` inserts, so any day
+// whose `existing + nonZero` exceeds this limit splits into a
+// delete pass on the kickoff mutation followed by chained
+// `commitRevenueMetricsDayInsertChunk` mutations of size
+// `INSERT_CHUNK_SIZE` each. 7000 leaves headroom for the
+// existing-row read pass and the `markRevenueMetricsRun` upsert.
+const COMMIT_DAY_WRITES_LIMIT = 7_000;
+const INSERT_CHUNK_SIZE = 3_500;
+
// Validators for the chained-page args. Buckets serialize as a flat
// array so they survive the scheduler's JSON round-trip; the cursor
-// is the state index + the opaque cursor returned by Convex's
-// `paginate(...)` API (stable across `updatedAt` ties because it
-// includes the row `_id`, which a hand-rolled `lt(updatedAt, ...)`
-// watermark would silently skip when two rows share a timestamp).
+// is a tagged union covering both phases of the recompute pipeline
+// (events scan first, then subscriptions scan) so a single
+// scheduled `recomputeRevenueMetricsPage` handler can resume from
+// either phase. Cursors come from Convex's `paginate(...)` API,
+// which includes the row `_id` as a stable tiebreaker (a
+// hand-rolled `lt(receivedAt, ...)` / `lt(updatedAt, ...)`
+// watermark would silently skip rows that share a timestamp at the
+// page boundary).
const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
const bucketValidator = v.object({
day: v.string(),
@@ -230,15 +243,17 @@ const bucketValidator = v.object({
revenueMicros: v.number(),
});
const accumulatorValidator = v.array(bucketValidator);
-const cursorValidator = v.object({
- stateIdx: v.number(),
- // Opaque Convex pagination cursor for the current state. `null`
- // means "start of state" (or "state finished" if `stateIdx` is
- // also advancing). Using Convex's pagination cursor instead of a
- // hand-rolled `updatedAt` watermark guarantees ordering stability
- // when multiple subscriptions share an `updatedAt` timestamp.
- paginationCursor: v.union(v.string(), v.null()),
-});
+const cursorValidator = v.union(
+ v.object({
+ phase: v.literal("events"),
+ paginationCursor: v.union(v.string(), v.null()),
+ }),
+ v.object({
+ phase: v.literal("subs"),
+ stateIdx: v.number(),
+ paginationCursor: v.union(v.string(), v.null()),
+ }),
+);
async function markRevenueMetricsRun(
ctx: MutationCtx,
@@ -259,15 +274,19 @@ async function markRevenueMetricsRun(
}
}
-// Kickoff: build window, run events pass, then process the first
-// subscriptions page. If all counted states fit in a single page
-// (typical for projects under SUBS_PAGE_SIZE active subs), commit
-// inline. Otherwise schedule continuation pages.
+// Kickoff: build window, then start the events-pagination phase.
+// Events scan paginates through `webhookEvents`; once exhausted the
+// pipeline transitions to the subscriptions phase, which paginates
+// through counted-state rows; once that's exhausted the commit
+// phase fans out per-day mutations. Each phase reads its accumulator
+// from / writes back to a single buckets map carried through the
+// scheduler chain.
//
// Exported so tests can drive it directly without the cron scheduler.
-// In tests with small datasets the inline commit path always
-// triggers; the chained-page path only kicks in for projects that
-// exceed SUBS_PAGE_SIZE in any one counted state.
+// In tests with small datasets every phase completes in one page
+// inline; the chained-page path only kicks in once the per-mutation
+// EVENTS_PAGE_SIZE / SUBS_PAGE_SIZE / COMMIT_INLINE_BUCKET_LIMIT
+// thresholds are exceeded.
export async function runRecompute(
ctx: MutationCtx,
projectId: Id<"projects">,
@@ -288,32 +307,57 @@ export async function runRecompute(
for (let i = 0; i < TRAILING_DAYS; i++) {
days.push(utcDayKey(windowStart + i * DAY_MS));
}
- const firstDay = days[0];
- const lastDay = days[days.length - 1];
const buckets = new Map();
+ await processEventsPage(ctx, {
+ projectId,
+ days,
+ windowStart,
+ windowEnd,
+ buckets,
+ paginationCursor: null,
+ runStartedAt: now,
+ });
+}
- // ---- Pass 1: webhookEvents → newSubs / renewals / cancellations
- // / refunds / revenueMicros buckets.
- //
- // Scan by `receivedAt` (the index we have on webhookEvents) but
- // bucket by `occurredAt` (the store-side event time). A renewal
- // that occurred yesterday but arrived today must land in
- // yesterday's bucket — otherwise a retry-delayed notification
- // would flip its day on the dashboard, contradicting the
- // "late notifications fold into their correct day" promise.
- //
- // Scan window matches the bucket window exactly. The webhook
- // receivers in `webhooks/apple.ts` and `webhooks/google.ts` set
- // `receivedAt` to the HTTP receive time and `occurredAt` to the
- // store-side timestamp (Apple `signedDate` / Google
- // `eventTimeMillis`), so by construction `receivedAt >=
- // occurredAt`: any event whose `occurredAt` lands in
- // `[windowStart, windowEnd]` necessarily has `receivedAt` in the
- // same range too. Scanning further back on `receivedAt` would
- // only read older rows that the `day < firstDay` filter below
- // immediately discards, burning the read budget for nothing.
- const events = await ctx.db
+// Process one page of `webhookEvents`. Buckets accumulate event-
+// driven counters (newSubs / renewals / cancellations / refunds /
+// revenueMicros) from `occurredAt`; `activeSubs` lands later in
+// `processSubsPage`. When the events scan finishes, transitions
+// to the subscriptions phase by calling `processSubsPage` directly.
+//
+// Scan by `receivedAt` (the index we have on webhookEvents) but
+// bucket by `occurredAt` (the store-side event time). A renewal
+// that occurred yesterday but arrived today must land in
+// yesterday's bucket — otherwise a retry-delayed notification
+// would flip its day on the dashboard.
+//
+// Scan window matches the bucket window exactly. The webhook
+// receivers in `webhooks/apple.ts` and `webhooks/google.ts` set
+// `receivedAt` to the HTTP receive time and `occurredAt` to the
+// store-side timestamp (Apple `signedDate` / Google
+// `eventTimeMillis`), so by construction `receivedAt >=
+// occurredAt`: any event whose `occurredAt` lands in
+// `[windowStart, windowEnd]` necessarily has `receivedAt` in the
+// same range too.
+async function processEventsPage(
+ ctx: MutationCtx,
+ args: {
+ projectId: Id<"projects">;
+ days: string[];
+ windowStart: number;
+ windowEnd: number;
+ buckets: Map;
+ paginationCursor: string | null;
+ runStartedAt: number;
+ },
+): Promise {
+ const { projectId, days, windowStart, windowEnd, buckets, runStartedAt } =
+ args;
+ const firstDay = days[0];
+ const lastDay = days[days.length - 1];
+
+ const result = await ctx.db
.query("webhookEvents")
.withIndex("by_project_and_received", (q) =>
q
@@ -321,19 +365,9 @@ export async function runRecompute(
.gte("receivedAt", windowStart)
.lte("receivedAt", windowEnd),
)
- .take(WEBHOOK_SCAN_CAP);
- if (events.length === WEBHOOK_SCAN_CAP) {
- // Hitting the cap means the scan was truncated and the project's
- // bucket counters are undercounted for this tick. Surface to
- // ops via the standard Convex log stream — silent truncation
- // would manifest as flat-line analytics that look like a quiet
- // day rather than a budget breach.
- console.warn(
- `[revenueMetrics] webhookEvents scan hit WEBHOOK_SCAN_CAP=${WEBHOOK_SCAN_CAP} for project=${projectId}; results truncated, bucket counters will undercount this tick.`,
- );
- }
+ .paginate({ numItems: EVENTS_PAGE_SIZE, cursor: args.paginationCursor });
- for (const event of events) {
+ for (const event of result.page) {
if (!event.productId) continue;
const day = utcDayKey(event.occurredAt);
// Skip events whose store-side day falls outside the bucket
@@ -361,21 +395,32 @@ export async function runRecompute(
applyEventToBucket(bucket, event);
}
- // ---- Pass 2: subscriptions → activeSubs end-of-day snapshots.
- // Walks ONLY counted-state rows via `by_project_and_state_and_updated`
- // ordered descending — most-recently-updated first. Active subs
- // tick `updatedAt` on every renewal so descending puts the rows
- // most likely to satisfy `isActiveAt(...)` at the front of the
- // page; an under-budget project gets its full picture from a
- // single page, an over-budget project chains continuations.
- await processSubsPage(ctx, {
- projectId,
- days,
- windowStart,
- buckets,
- cursor: { stateIdx: 0, paginationCursor: null },
- runStartedAt: now,
- });
+ if (result.isDone) {
+ // Events exhausted — transition to the subscriptions phase.
+ await processSubsPage(ctx, {
+ projectId,
+ days,
+ windowStart,
+ buckets,
+ cursor: { stateIdx: 0, paginationCursor: null },
+ runStartedAt,
+ });
+ return;
+ }
+
+ // More events to read. Serialize the accumulator + cursor and
+ // chain a fresh events page.
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics.recomputeRevenueMetricsPage,
+ {
+ projectId,
+ days,
+ buckets: Array.from(buckets.values()),
+ cursor: { phase: "events", paginationCursor: result.continueCursor },
+ runStartedAt,
+ },
+ );
}
// Process one page of counted-state subscriptions. Greedily
@@ -464,14 +509,15 @@ async function processSubsPage(
projectId,
days,
buckets: serialized,
- cursor: { stateIdx, paginationCursor },
+ cursor: { phase: "subs", stateIdx, paginationCursor },
runStartedAt,
},
);
}
// Continuation page handler. Rehydrates the accumulator from the
-// scheduler args and resumes pagination from the saved cursor.
+// scheduler args and dispatches to the right phase based on the
+// tagged cursor.
export const recomputeRevenueMetricsPage = internalMutation({
args: {
projectId: v.id("projects"),
@@ -489,12 +535,27 @@ export const recomputeRevenueMetricsPage = internalMutation({
}
const todayStart = startOfUtcDay(args.runStartedAt);
const windowStart = todayStart - (TRAILING_DAYS - 1) * DAY_MS;
+ if (args.cursor.phase === "events") {
+ await processEventsPage(ctx, {
+ projectId: args.projectId,
+ days: args.days,
+ windowStart,
+ windowEnd: args.runStartedAt,
+ buckets,
+ paginationCursor: args.cursor.paginationCursor,
+ runStartedAt: args.runStartedAt,
+ });
+ return null;
+ }
await processSubsPage(ctx, {
projectId: args.projectId,
days: args.days,
windowStart,
buckets,
- cursor: args.cursor,
+ cursor: {
+ stateIdx: args.cursor.stateIdx,
+ paginationCursor: args.cursor.paginationCursor,
+ },
runStartedAt: args.runStartedAt,
});
return null;
@@ -528,35 +589,121 @@ export const commitRevenueMetricsDay = internalMutation({
q.eq("projectId", args.projectId).eq("day", args.day),
)
.collect();
- await Promise.all(existing.map((row) => ctx.db.delete(row._id)));
+ const nonZero = args.buckets.filter((b) => !isAllZeroBucket(b));
+
+ // Stay under Convex's 8192-writes-per-mutation budget. Single
+ // mutation does `existing.length` deletes + `nonZero.length`
+ // inserts; if that combined sum exceeds COMMIT_DAY_WRITES_LIMIT
+ // we delete inline (existing.length is already bounded by the
+ // budget — a project that has stored more than 7000 rows for
+ // one day is pathological enough to trip Convex's hard ceiling
+ // on the prior commit, so this branch shouldn't be reached
+ // in practice) and fan inserts out across chained chunk
+ // mutations of size INSERT_CHUNK_SIZE.
+ if (existing.length + nonZero.length <= COMMIT_DAY_WRITES_LIMIT) {
+ await Promise.all(existing.map((row) => ctx.db.delete(row._id)));
+ await Promise.all(
+ nonZero.map((bucket) => insertBucket(ctx, args, bucket)),
+ );
+ await markRevenueMetricsRun(ctx, args.projectId, args.runStartedAt);
+ return null;
+ }
- const inserts: Array> = [];
- for (const bucket of args.buckets) {
- if (isAllZeroBucket(bucket)) continue;
- inserts.push(
- ctx.db.insert("revenueMetricsDaily", {
+ if (existing.length > COMMIT_DAY_WRITES_LIMIT) {
+ // Bigger than the per-mutation write budget can absorb in
+ // one shot. We could split the deletes across chained
+ // mutations too, but a single day's existing-row count
+ // crossing 7k requires somewhere north of 3500 distinct
+ // (productId, currency, platform) tuples already on disk
+ // for the same UTC day — operationally implausible for the
+ // SaaS workloads this dashboard targets, and it would have
+ // tripped the Convex write limit on the commit that wrote
+ // those rows in the first place. Surface the impossible
+ // state rather than silently succeeding with a partial
+ // delete.
+ throw new Error(
+ `commitRevenueMetricsDay: existing row count ${existing.length} for project=${args.projectId} day=${args.day} exceeds COMMIT_DAY_WRITES_LIMIT=${COMMIT_DAY_WRITES_LIMIT}; manual intervention required.`,
+ );
+ }
+
+ await Promise.all(existing.map((row) => ctx.db.delete(row._id)));
+ for (let i = 0; i < nonZero.length; i += INSERT_CHUNK_SIZE) {
+ const chunk = nonZero.slice(i, i + INSERT_CHUNK_SIZE);
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics
+ .commitRevenueMetricsDayInsertChunk,
+ {
projectId: args.projectId,
- day: bucket.day,
- productId: bucket.productId,
- currency: bucket.currency,
- platform: bucket.platform,
- activeSubs: bucket.activeSubs,
- newSubs: bucket.newSubs,
- renewals: bucket.renewals,
- cancellations: bucket.cancellations,
- refunds: bucket.refunds,
- revenueMicros: bucket.revenueMicros,
- updatedAt: args.runStartedAt,
- }),
+ day: args.day,
+ buckets: chunk,
+ runStartedAt: args.runStartedAt,
+ },
);
}
- await Promise.all(inserts);
+ await markRevenueMetricsRun(ctx, args.projectId, args.runStartedAt);
+ return null;
+ },
+});
+// Insert chunk for the per-day fan-out path. Used only when the
+// per-day commit's `existing + nonZero` would have exceeded
+// COMMIT_DAY_WRITES_LIMIT: the kickoff `commitRevenueMetricsDay`
+// performs the deletes and schedules these chunks afterwards. Each
+// chunk gets its own 8192-writes budget, so a project with
+// ~10k buckets/day fans out across ~3 chained chunks instead of
+// blowing the limit. `markRevenueMetricsRun` is OCC-safe across
+// the parallel chunks; the kickoff calls it too so even a 0-row
+// inserted-chunk path still marks the run.
+export const commitRevenueMetricsDayInsertChunk = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ day: v.string(),
+ buckets: accumulatorValidator,
+ runStartedAt: v.number(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await Promise.all(
+ args.buckets.map((bucket) => insertBucket(ctx, args, bucket)),
+ );
await markRevenueMetricsRun(ctx, args.projectId, args.runStartedAt);
return null;
},
});
+function insertBucket(
+ ctx: MutationCtx,
+ args: { projectId: Id<"projects">; runStartedAt: number },
+ bucket: {
+ day: string;
+ productId: string;
+ currency: string;
+ platform: Platform;
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+ },
+): Promise> {
+ return ctx.db.insert("revenueMetricsDaily", {
+ projectId: args.projectId,
+ day: bucket.day,
+ productId: bucket.productId,
+ currency: bucket.currency,
+ platform: bucket.platform,
+ activeSubs: bucket.activeSubs,
+ newSubs: bucket.newSubs,
+ renewals: bucket.renewals,
+ cancellations: bucket.cancellations,
+ refunds: bucket.refunds,
+ revenueMicros: bucket.revenueMicros,
+ updatedAt: args.runStartedAt,
+ });
+}
+
// Decide whether to commit inline (small projects) or fan out one
// scheduled mutation per day (large projects). The threshold is
// expressed in total bucket count because each bucket contributes
From 6480a82367a6614351c378b7f19caefb4112072b Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 6 May 2026 03:01:33 +0900
Subject: [PATCH 14/14] fix(kit): include Expired in activeSubs scan, split
lifecycle vs revenue, memo useQuery args
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three fixes from the round-12 reviewer pass on PR #131:
1. Historical activeSubs accuracy. `COUNTED_STATES` /
`COUNTED_STATES_ORDERED` now include `Expired`. A sub that was
active two days ago and has since expired must still contribute
to the activeSubs snapshot for the day it was actually active —
the prior code skipped it because the state walk filtered to
non-expired counted states. `isActiveAt` is the gatekeeper now:
`expiresAt > dayEnd` decides whether a counted-state sub is
active on a given day, so an Expired sub with `expiresAt` past
the snapshot day correctly counts, and an Expired sub with no
`expiresAt` defensively counts as inactive (no transition day
to anchor on). 4 new isActiveAt tests + 2 new round-trip tests.
2. Multi-currency dashboard split. The platform cards and
non-revenue charts (Active subs / New + renewed / Cancellations
+ refunds / Churn) used to filter by the selected currency,
under-reporting "All platforms" totals on multi-currency
projects. Lifecycle counters (activeSubs / newSubs / renewals /
cancellations / refunds) now flow through a `lifecycleRows`
pipeline that is currency-UNFILTERED — counts are
currency-agnostic by definition, so summing across currencies
is the correct project-wide story. Revenue stays pinned to the
selected currency (FX-incompatible across currencies). The
final chart `series` joins lifecycle (currency-unfiltered) +
revenue (currency-filtered) per bucket. Same split applies to
`platformTotals` — added `lifecycleForPlatform` and
`revenueForPlatform` helpers in place of the prior
`totalsForPlatform`.
3. `useQuery` args memoisation. `convex/react`'s `useQuery` does
NOT deep-compare args between renders — an inline `{ ... }`
literal triggers a fresh subscription every render. Wrapped the
args in a `useMemo` keyed on `[project.apiKey, maxFromDay,
toDay]` so the underlying `convex.watchQuery` only resubscribes
when an actual input changes (the per-minute `now` tick stays
off the dep list because `maxFromDay` / `toDay` are derived
from it via `utcDayKey(...)` and only change at UTC midnight).
Tests: 55/55 (was 51, +2 new isActiveAt tests, +2 new round-trip
tests for Expired inclusion).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../subscriptions/revenueMetrics.test.ts | 66 ++++++--
.../convex/subscriptions/revenueMetrics.ts | 29 +++-
.../auth/organization/project/analytics.tsx | 157 ++++++++++++------
3 files changed, 191 insertions(+), 61 deletions(-)
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.test.ts b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
index 37156b6a..d7991a56 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.test.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -287,14 +287,8 @@ describe("isActiveAt", () => {
expect(isActiveAt(sub, dayEnd)).toBe(true);
});
- it("Expired / Revoked / Refunded / Paused → false even if still in window", () => {
- for (const state of [
- "Expired",
- "Revoked",
- "Refunded",
- "Paused",
- "Unknown",
- ] as const) {
+ it("Revoked / Refunded / Paused / Unknown → false even if still in window", () => {
+ for (const state of ["Revoked", "Refunded", "Paused", "Unknown"] as const) {
const sub = makeSub({
state,
startedAt: Date.UTC(2026, 2, 1),
@@ -304,7 +298,38 @@ describe("isActiveAt", () => {
}
});
- it("undefined expiresAt + counted state → still active (matches steady-state semantics)", () => {
+ it("Expired with expiresAt > dayEnd → true (still active on the snapshot day)", () => {
+ // The whole point of including `Expired` in COUNTED_STATES: a
+ // sub that was active at end-of-day on a historical day, then
+ // expired later, must contribute to that historical day's
+ // activeSubs count.
+ const sub = makeSub({
+ state: "Expired",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 3, 1), // April 1, after dayEnd (March 15)
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(true);
+ });
+
+ it("Expired with expiresAt <= dayEnd → false (already gone by snapshot)", () => {
+ const sub = makeSub({
+ state: "Expired",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 2, 10), // March 10, before dayEnd
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(false);
+ });
+
+ it("Expired with no expiresAt timestamp → false (defensive: no anchor for transition day)", () => {
+ const sub = makeSub({
+ state: "Expired",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: undefined,
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(false);
+ });
+
+ it("undefined expiresAt + Active counted state → still active (matches steady-state semantics)", () => {
const sub = makeSub({
state: "Active",
startedAt: Date.UTC(2026, 2, 1),
@@ -829,13 +854,34 @@ describe("runRecompute — round-trip integration", () => {
expect(byDay.get(TODAY)).toBe(1);
});
- it("expired sub does not contribute to activeSubs even if window-overlapping", async () => {
+ it("Expired sub with expiresAt past the window contributes to activeSubs (was active on those days)", async () => {
+ // Sub expired April 1 — past every day in our window
+ // [TODAY-2, TODAY] which is centered on March 15. The sub was
+ // genuinely active at end-of-day on each of those days, so it
+ // must contribute to the activeSubs snapshot for them.
await seedSub(db, {
state: "Expired",
startedAt: Date.UTC(2026, 1, 1),
expiresAt: Date.UTC(2026, 3, 1),
});
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(3);
+ for (const row of rows) {
+ expect(row.activeSubs).toBe(1);
+ }
+ });
+
+ it("Expired sub whose expiry predates the window does not contribute", async () => {
+ // Expired well before the rollup window — no day in
+ // [TODAY-2, TODAY] saw this sub as active.
+ await seedSub(db, {
+ state: "Expired",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 2, 10), // March 10, before D2=March 13
+ });
+
await runRecompute(ctx, PROJECT_ID, NOW);
expect(await rollupRows(db)).toEqual([]);
});
diff --git a/packages/kit/convex/subscriptions/revenueMetrics.ts b/packages/kit/convex/subscriptions/revenueMetrics.ts
index 5ef6776d..f0fe34b8 100644
--- a/packages/kit/convex/subscriptions/revenueMetrics.ts
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -90,20 +90,30 @@ export type RollupBucket = {
revenueMicros: number;
};
+// States that are "counted" for the activeSubs scan over the
+// trailing window. `Expired` is included intentionally: a sub
+// that was active two days ago but has since expired must still
+// contribute to the activeSubs snapshot for those earlier days
+// (`isActiveAt` filters it back out for the days after its
+// expiry). Without it, the historical activeSubs line would
+// retro-actively drop subs as they aged out, even though they
+// were genuinely active on the days the chart is showing.
const COUNTED_STATES = new Set([
"Active",
"InGracePeriod",
"InBillingRetry",
+ "Expired",
] as const);
// Order matters: pagination cursors index into this list. Adding a
// state requires a migration of in-flight cursors stored on the
-// scheduler queue, so prepend new states to the END of the list,
+// scheduler queue, so append new states to the END of the list,
// never the middle.
const COUNTED_STATES_ORDERED = [
"Active",
"InGracePeriod",
"InBillingRetry",
+ "Expired",
] as const;
type CountedState = (typeof COUNTED_STATES_ORDERED)[number];
@@ -848,10 +858,21 @@ export function applyEventToBucket(
export function isActiveAt(sub: Doc<"subscriptions">, dayEnd: number): boolean {
if (sub.startedAt > dayEnd) return false;
if (!COUNTED_STATES.has(sub.state as "Active")) return false;
- if (typeof sub.expiresAt === "number" && sub.expiresAt <= dayEnd) {
- return false;
+ // Expiry-driven cutoff. When `expiresAt` is set we treat it as
+ // authoritative regardless of state: an Active sub past its
+ // expiry shouldn't count, and an Expired sub before its expiry
+ // SHOULD count (the snapshot day predates the expiry — that's
+ // the entire reason `Expired` is in COUNTED_STATES).
+ if (typeof sub.expiresAt === "number") {
+ return sub.expiresAt > dayEnd;
}
- return true;
+ // No expiry timestamp on file. The non-Expired counted states
+ // (Active / InGracePeriod / InBillingRetry) treat that as
+ // "still active indefinitely" — the steady-state semantics.
+ // For Expired, we have no day at which the sub stopped being
+ // active, so we conservatively count it as inactive for every
+ // day in the window rather than guessing.
+ return sub.state !== "Expired";
}
async function commitBuckets(
diff --git a/packages/kit/src/pages/auth/organization/project/analytics.tsx b/packages/kit/src/pages/auth/organization/project/analytics.tsx
index bb6b7803..371f9a49 100644
--- a/packages/kit/src/pages/auth/organization/project/analytics.tsx
+++ b/packages/kit/src/pages/auth/organization/project/analytics.tsx
@@ -165,11 +165,22 @@ export default function ProjectAnalytics() {
// 2. The platform cards always show the full breakdown
// (All / iOS / Android), regardless of which filter is
// active — so the cards need the unfiltered data anyway.
- const metrics = useQuery(api.subscriptions.query.getRevenueMetrics, {
- apiKey: project.apiKey,
- fromDay: maxFromDay,
- toDay,
- });
+ // `useQuery` in convex/react does not deep-compare its args
+ // between renders — an inline `{ ... }` literal allocates a new
+ // object every render and the underlying `convex.watchQuery`
+ // re-subscribes even when the values are identical. Memoise the
+ // args object so the subscription is only torn down and rebuilt
+ // when one of the actual inputs changes. The `now` tick stays
+ // off the dep list because `maxFromDay` / `toDay` are derived
+ // from it via `utcDayKey(...)` and only change at UTC midnight.
+ const queryArgs = useMemo(
+ () => ({ apiKey: project.apiKey, fromDay: maxFromDay, toDay }),
+ [project.apiKey, maxFromDay, toDay],
+ );
+ const metrics = useQuery(
+ api.subscriptions.query.getRevenueMetrics,
+ queryArgs,
+ );
// The loading-state early return has to live below ALL hooks so
// React's rules-of-hooks (every hook called in the same order on
@@ -207,17 +218,28 @@ export default function ProjectAnalytics() {
// so the default-currency case still produces a single-currency
// chart on multi-currency projects.
//
- // We deliberately KEEP rows older than `fromDay` in `filteredRows`
+ // We deliberately KEEP rows older than `fromDay` in the row sets
// (only attribute / range filters are applied here). `aggregateByDay`
// uses those older rows to seed the `activeSubs` carry-forward at
// the start of the chart — without them, a project with active
// subscriptions but no events in the selected range would dip
// visually to zero on the first day.
//
+ // Two parallel pipelines:
+ // - `revenueRows`: pinned to `currency` because `revenueMicros`
+ // can't be summed across currencies without an FX rate.
+ // - `lifecycleRows`: NOT pinned to currency. activeSubs / new /
+ // renewals / cancellations / refunds are counts, not money,
+ // and aggregating them across currencies gives the correct
+ // project-wide total. Pinning them to a single currency would
+ // make the "All platforms" card under-report on multi-currency
+ // projects (the user pays in USD, but the count of cancels is
+ // a single project-wide number regardless of where they paid).
+ //
// Memoised so the per-minute `now` tick (which only changes
// `fromDay` / `toDay` once per UTC day) doesn't re-run the full
// filter / aggregate / bucket pipeline on every render.
- const filteredRows = useMemo(
+ const revenueRows = useMemo(
() =>
metricsDays.filter((row) => {
if (currency && row.currency !== currency) return false;
@@ -229,14 +251,47 @@ export default function ProjectAnalytics() {
}),
[metricsDays, currency, selectedProduct, platformFilter],
);
+ const lifecycleRows = useMemo(
+ () =>
+ metricsDays.filter((row) => {
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ if (platformFilter !== "all" && row.platform !== platformFilter) {
+ return false;
+ }
+ return true;
+ }),
+ [metricsDays, selectedProduct, platformFilter],
+ );
- const dailySeries = useMemo(
- () => aggregateByDay(filteredRows, range.days, fromDay),
- [filteredRows, range.days, fromDay],
+ const revenueDaily = useMemo(
+ () => aggregateByDay(revenueRows, range.days, fromDay),
+ [revenueRows, range.days, fromDay],
+ );
+ const lifecycleDaily = useMemo(
+ () => aggregateByDay(lifecycleRows, range.days, fromDay),
+ [lifecycleRows, range.days, fromDay],
+ );
+ const revenueSeries = useMemo(
+ () => bucketByPeriod(revenueDaily, periodId),
+ [revenueDaily, periodId],
+ );
+ const lifecycleSeries = useMemo(
+ () => bucketByPeriod(lifecycleDaily, periodId),
+ [lifecycleDaily, periodId],
);
+
+ // Final chart series merges the lifecycle counters (cross-currency)
+ // with the revenue total (currency-pinned) per bucket. Same
+ // bucket-period axis on both sides means we can join positionally
+ // since `bucketByPeriod` produces deterministic output for a
+ // given `periodId` / `range`.
const series = useMemo(
- () => bucketByPeriod(dailySeries, periodId),
- [dailySeries, periodId],
+ () =>
+ lifecycleSeries.map((row, i) => ({
+ ...row,
+ revenueMicros: revenueSeries[i]?.revenueMicros ?? 0,
+ })),
+ [lifecycleSeries, revenueSeries],
);
const totals = useMemo(
@@ -271,16 +326,18 @@ export default function ProjectAnalytics() {
? ((totals.cancellations + totals.refunds) / totals.activeSubsLast) * 100
: 0;
- // Platform-card totals: one filter pass over `metrics.days`
- // (without platform narrowing — the cards ARE the platform
- // breakdown), then `totalsForPlatform` for each card variant.
- // The prior implementation did this filter and `totalsForPlatform`
- // walk three times *per render* inside `PLATFORM_CARDS.map`
- // because of the per-minute `now` tick. Memoising collapses that
- // to one filter + three small reductions, only re-running when a
- // filter the cards actually depend on changes.
+ // Platform-card totals. Lifecycle counters (activeSubs / newSubs)
+ // come from a currency-unfiltered pass — the cards reflect the
+ // project-wide story, so pinning to one currency would
+ // under-count the "All platforms" total on multi-currency
+ // projects. Revenue stays currency-filtered for the FX reason.
const platformTotals = useMemo(() => {
- const baseRows = metricsDays.filter((row) => {
+ const lifecycleBaseRows = metricsDays.filter((row) => {
+ if (row.day < fromDay) return false;
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ return true;
+ });
+ const revenueBaseRows = metricsDays.filter((row) => {
if (row.day < fromDay) return false;
if (currency && row.currency !== currency) return false;
if (selectedProduct && row.productId !== selectedProduct) return false;
@@ -291,10 +348,13 @@ export default function ProjectAnalytics() {
{ revenueMicros: number; activeSubs: number; newSubs: number }
>();
for (const card of PLATFORM_CARDS) {
- byFilter.set(
- card.filter,
- totalsForPlatform(baseRows, card.filter, currency),
- );
+ const lifecycle = lifecycleForPlatform(lifecycleBaseRows, card.filter);
+ const revenue = revenueForPlatform(revenueBaseRows, card.filter);
+ byFilter.set(card.filter, {
+ revenueMicros: revenue,
+ activeSubs: lifecycle.activeSubs,
+ newSubs: lifecycle.newSubs,
+ });
}
return byFilter;
}, [metricsDays, fromDay, currency, selectedProduct]);
@@ -960,37 +1020,28 @@ function bucketLabelFor(
};
}
-// Per-platform totals for the platform cards. Run on the unaggregated
-// `metrics.days` so the card numbers stay correct regardless of the
-// selected platform filter (the cards ARE the filter — they always
-// show the breakdown). `currency` is just for display formatting; the
-// rows have already been filtered to the selected currency upstream.
-function totalsForPlatform(
+// Per-platform lifecycle totals (activeSubs / newSubs) for the
+// platform cards. Walks an UNFILTERED-by-currency row set so the
+// "All platforms" card shows the project-wide count rather than
+// just the active-currency slice. The rollup table is keyed by
+// `(day, productId, currency, platform)`, so a multi-product /
+// multi-currency project has many rows for the same day+platform;
+// activeSubs are summed across siblings on the most-recent day per
+// platform (newSubs are summed across all matching rows).
+function lifecycleForPlatform(
rows: Array<{
platform: Platform;
activeSubs: number;
newSubs: number;
- revenueMicros: number;
day: string;
}>,
filter: PlatformFilter,
- _currency: string,
-): { revenueMicros: number; activeSubs: number; newSubs: number } {
+): { activeSubs: number; newSubs: number } {
const matching =
filter === "all" ? rows : rows.filter((r) => r.platform === filter);
- // For activeSubs, sum every product/currency row that lands on the
- // most recent day per platform. The rollup table is keyed by
- // `(day, productId, currency, platform)`, so a multi-product
- // project has multiple rows for the same day+platform — keeping
- // only the first row encountered (the prior bug) silently
- // undercounted projects with >1 product. Summing across days
- // would inflate by N, so we still take only the LAST day's snapshot
- // per platform.
const lastByPlatform = new Map();
- let revenueMicros = 0;
let newSubs = 0;
for (const row of matching) {
- revenueMicros += row.revenueMicros;
newSubs += row.newSubs;
const prior = lastByPlatform.get(row.platform);
if (!prior || row.day > prior.day) {
@@ -999,14 +1050,26 @@ function totalsForPlatform(
active: row.activeSubs,
});
} else if (row.day === prior.day) {
- // Same platform + same day = different (product, currency)
- // tuple. Sum its activeSubs into the platform's total.
prior.active += row.activeSubs;
}
}
let activeSubs = 0;
for (const v of lastByPlatform.values()) activeSubs += v.active;
- return { revenueMicros, activeSubs, newSubs };
+ return { activeSubs, newSubs };
+}
+
+// Per-platform revenue total for the platform cards. Walks a
+// CURRENCY-FILTERED row set because `revenueMicros` can't be
+// summed across currencies without an FX rate.
+function revenueForPlatform(
+ rows: Array<{ platform: Platform; revenueMicros: number }>,
+ filter: PlatformFilter,
+): number {
+ const matching =
+ filter === "all" ? rows : rows.filter((r) => r.platform === filter);
+ let revenueMicros = 0;
+ for (const row of matching) revenueMicros += row.revenueMicros;
+ return revenueMicros;
}
function utcDayKey(ts: number): string {