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 ( +
setPlatformFilter(card.filter)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setPlatformFilter(card.filter); + } + }} + className={cn( + "bg-card border rounded-xl p-4 shadow-sm relative overflow-hidden cursor-pointer transition", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40", + active + ? "border-primary ring-2 ring-primary/20" + : "border-border hover:border-primary/50", + )} + aria-pressed={active} + > +
+
+

{card.label}

+

+ {formatMicros(cardTotals.revenueMicros, currency)} +

+

+ {cardTotals.activeSubs} active · {cardTotals.newSubs} new +

+
+
+ ); + })} +
+ +
+ ({ id: r.id, label: r.label }))} + value={rangeId} + onChange={(v) => setRangeId(v as RangeId)} + /> + ({ id: p.id, label: p.label }))} + value={periodId} + onChange={(v) => setPeriodId(v as PeriodId)} + /> + {metrics.productIds.length > 0 && ( +
+ Product: + setSelectedCurrency(v || null)} + placeholder="All" + allowClear + className="min-w-[100px]" + options={metrics.currencies.map((c) => ({ value: c, label: c }))} + /> +
+ )} +
+ +
+ + + + + +
+ + {metrics.days.length === 0 ? ( + + ) : ( +
+ + + + + + formatMicros(v, currency, true)} + /> + formatMicros(value, currency)} + contentStyle={tooltipStyle} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `${v.toFixed(0)}%`} + /> + `${value.toFixed(2)}%`} + contentStyle={tooltipStyle} + /> + + + + +
+ )} +
+ ); +} + +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 ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function SummaryCard({ + icon: Icon, + label, + value, +}: { + icon: typeof TrendingUp; + label: string; + value: string; +}) { + return ( +
+
+ + {label} +
+
{value}
+
+ ); +} + +function ChartCard({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ {children} +
+ ); +} + +function EmptyState() { + return ( +
+ +

No data yet for this range

+

+ 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 ( +
{ if (stat.key === "total") { resetFilters(); } else if (stat.key === "apple") { @@ -353,26 +357,42 @@ export default function ProjectPurchases() { } else if (stat.key === "invalid") { applyValidityFilter(false); } - } - }} - aria-label={STATS_LABELS[stat.key]} - > -
-
-

- {STATS_LABELS[stat.key]} -

-

- {cardValues[stat.key].toLocaleString()} -

+ }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + 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); + } + } + }} + aria-label={STATS_LABELS[stat.key]} + > +
+
+

+ {STATS_LABELS[stat.key]} +

+

+ {cardValues[stat.key].toLocaleString()} +

+
-
- ))} + ); + })}
diff --git a/packages/kit/src/pages/docs/nav.ts b/packages/kit/src/pages/docs/nav.ts index 13ef9dfa..f8b60122 100644 --- a/packages/kit/src/pages/docs/nav.ts +++ b/packages/kit/src/pages/docs/nav.ts @@ -57,6 +57,11 @@ export const DOCS_NAV: DocsNavEntry[] = [ title: "API reference", summary: "POST /v1/purchase/verify — request shapes, responses, errors.", }, + { + slug: "analytics", + title: "Analytics", + summary: "Revenue / MRR / churn dashboard — requires webhook integration.", + }, { slug: "operations", title: "Operations", diff --git a/packages/kit/src/pages/docs/routes.tsx b/packages/kit/src/pages/docs/routes.tsx index 689b318c..bd0fed17 100644 --- a/packages/kit/src/pages/docs/routes.tsx +++ b/packages/kit/src/pages/docs/routes.tsx @@ -7,6 +7,7 @@ import VerificationApplePage from "./sections/verification-apple"; import VerificationGooglePage from "./sections/verification-google"; import VerificationHorizonPage from "./sections/verification-horizon"; import ApiReferencePage from "./sections/api"; +import AnalyticsPage from "./sections/analytics"; import OperationsPage from "./sections/operations"; import AiAssistantsPage from "./sections/ai-assistants"; import ReleaseNotesPage from "./sections/release-notes"; @@ -41,6 +42,7 @@ export const docsChildRoutes = ( } /> } /> + } /> } /> } /> } /> diff --git a/packages/kit/src/pages/docs/sections/analytics.tsx b/packages/kit/src/pages/docs/sections/analytics.tsx new file mode 100644 index 00000000..afa9f570 --- /dev/null +++ b/packages/kit/src/pages/docs/sections/analytics.tsx @@ -0,0 +1,166 @@ +import { Link } from "react-router-dom"; + +import { Callout } from "../components/Callout"; +import { DocsPage } from "../components/DocsPage"; + +export default function AnalyticsPage() { + 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

+
    +
  1. + Configure store credentials per the{" "} + + Apple + {" "} + /{" "} + + Google + {" "} + setup pages (otherwise the webhook receivers can't decode signed + payloads). +
  2. +
  3. + Open the project's Webhooks tab in the dashboard. + Copy the per-store URLs (one for Apple ASN v2, one for Google RTDN). +
  4. +
  5. + Apple: in App Store Connect →{" "} + App Store Server Notifications, paste the Apple webhook URL + and select Version 2 for both Production and Sandbox + environments. +
  6. +
  7. + 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). +
  8. +
  9. + 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. +
  10. +
  11. + Wait up to 24h for the next analytics rollup tick — or trigger it + manually if you have access to the Convex dashboard ( + recomputeRevenueMetricsForProject). +
  12. +
+ +

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. +

+

Counters map from event types as follows:

+
    +
  • + SubscriptionStarted →{" "} + newSubs += 1, revenueMicros += price +
  • +
  • + SubscriptionRenewed →{" "} + renewals += 1, revenueMicros += price +
  • +
  • + SubscriptionCanceledcancellations += 1{" "} + (paired with SubscriptionUncanceled for net-zero + handling) +
  • +
  • + PurchaseRefunded + SubscriptionRevoked →{" "} + refunds += 1 +
  • +
  • + 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. */}