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:*)"
]
}
}
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..40ddccb1 100644
--- a/packages/kit/convex/crons.ts
+++ b/packages/kit/convex/crons.ts
@@ -81,6 +81,29 @@ crons.interval(
{ batchSize: 50 },
);
+// 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.
+//
+// 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",
+ { minutes: 10 },
+ internal.subscriptions.revenueMetrics.recomputeAllRevenueMetrics,
+ { batchSize: 100 },
+);
+
// 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..8931667d 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -722,6 +722,14 @@ 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. 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(),
renewals: v.number(),
@@ -730,6 +738,13 @@ 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",
@@ -738,6 +753,30 @@ const schema = defineSchema({
"currency",
]),
+ // 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.
+ //
+ // INVARIANT: at most one row per `projectId`. Convex has no unique
+ // constraint, so callers must look the row up via `by_project`
+ // and patch it instead of inserting a second one — see
+ // `markRevenueMetricsRun` in `subscriptions/revenueMetrics.ts`
+ // for the canonical upsert pattern. Two rows for the same project
+ // would let the `by_run` picker double-pick that project until
+ // both rows rotate to the head, wasting budget.
+ revenueMetricsRunStatus: defineTable({
+ projectId: v.id("projects"),
+ lastRunAt: v.number(),
+ })
+ .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 /
// create / update on the project owner's behalf. The auth-credential
diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts
index 82337e85..0bd904ae 100644
--- a/packages/kit/convex/subscriptions/query.ts
+++ b/packages/kit/convex/subscriptions/query.ts
@@ -435,6 +435,170 @@ 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.
+//
+// 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({
+ args: {
+ apiKey: v.string(),
+ fromDay: v.string(),
+ toDay: v.string(),
+ // Server-side `productId` / `currency` / `platform` filters were
+ // removed because the dashboard does all of that filtering
+ // client-side (the unfiltered fetch is what backs the filter-
+ // dropdown population — narrowing the scan would defeat that),
+ // and a server-side narrowing path was incompatible with that
+ // contract: when a productId was pinned the dropdowns silently
+ // collapsed to that SKU's currencies / platforms only. If a
+ // future caller needs server-side narrowing for a non-dashboard
+ // surface, add a separate query — don't reintroduce these as
+ // optional args on this one.
+ },
+ returns: v.object({
+ days: v.array(
+ 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),
+ // True when the underlying scan hit `REVENUE_SCAN_CAP` and the
+ // returned rows are a partial view of the requested window. The
+ // dashboard surfaces this as a banner so a truncated chart is
+ // visible to the operator instead of silently rendering a
+ // partial tail.
+ truncated: v.boolean(),
+ }),
+ handler: async (ctx, args) => {
+ const project = await projectByApiKey(ctx, args.apiKey);
+ if (!project) {
+ return {
+ days: [],
+ currencies: [],
+ productIds: [],
+ platforms: [],
+ truncated: false,
+ };
+ }
+
+ // Reject ranges past the dashboard's longest preset (90 days)
+ // before issuing the index scan. A misbehaving client can
+ // otherwise request `fromDay = "1970-01-01"` and force the
+ // server to materialize every rollup row in the project. The
+ // 90-day cap matches `RANGES` in `analytics.tsx`; widening
+ // there should bump this in lockstep.
+ const MAX_RANGE_DAYS = 92;
+ if (args.fromDay > args.toDay) {
+ throw new Error(
+ `getRevenueMetrics: fromDay (${args.fromDay}) is after toDay (${args.toDay}).`,
+ );
+ }
+ const fromMs = Date.parse(`${args.fromDay}T00:00:00.000Z`);
+ const toMs = Date.parse(`${args.toDay}T00:00:00.000Z`);
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
+ throw new Error(
+ `getRevenueMetrics: invalid ISO date(s) fromDay=${args.fromDay} toDay=${args.toDay}.`,
+ );
+ }
+ const spanDays = Math.round((toMs - fromMs) / 86_400_000) + 1;
+ if (spanDays > MAX_RANGE_DAYS) {
+ throw new Error(
+ `getRevenueMetrics: span of ${spanDays} days exceeds MAX_RANGE_DAYS=${MAX_RANGE_DAYS}.`,
+ );
+ }
+
+ // Range scan over `revenueMetricsDaily` via
+ // `by_project_and_day_and_currency` (`[projectId, day, currency]`).
+ // The dashboard does all filtering (currency / product /
+ // platform) client-side, so we deliberately return the full
+ // window — narrowing here would prune the data the dashboard
+ // needs to populate its filter dropdowns.
+ //
+ // Capped at REVENUE_SCAN_CAP to stay under Convex's 32k
+ // document-scan limit per query. A 92-day range across a
+ // maximalist project (30 SKUs × 3 currencies × 2 platforms =
+ // 180 rows/day → ~16.5k rows for 92 days) fits inside this
+ // cap; truncation surfaces as the `truncated` flag below and
+ // an amber banner on the dashboard so a partial chart is
+ // never silently rendered.
+ const REVENUE_SCAN_CAP = 20_000;
+ const allRows = await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q
+ .eq("projectId", project._id)
+ .gte("day", args.fromDay)
+ .lte("day", args.toDay),
+ )
+ .take(REVENUE_SCAN_CAP);
+ const truncated = allRows.length === REVENUE_SCAN_CAP;
+ if (truncated) {
+ console.warn(
+ `[getRevenueMetrics] revenueMetricsDaily scan hit REVENUE_SCAN_CAP=${REVENUE_SCAN_CAP} for project=${project._id} range=${args.fromDay}..${args.toDay}; chart will undercount the tail.`,
+ );
+ }
+
+ // Populate filter-dropdown choices from the unfiltered range
+ // scan so the UI can render every available currency /
+ // productId / platform regardless of which filter the user
+ // currently has active.
+ const currencies = new Set();
+ const productIds = new Set();
+ const platforms = new Set<"IOS" | "Android">();
+ for (const row of allRows) {
+ if (row.currency) currencies.add(row.currency);
+ productIds.add(row.productId);
+ platforms.add(row.platform);
+ }
+
+ return {
+ days: allRows.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(),
+ truncated,
+ };
+ },
+});
+
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..d7991a56
--- /dev/null
+++ b/packages/kit/convex/subscriptions/revenueMetrics.test.ts
@@ -0,0 +1,1072 @@
+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 same-day cancel produces a negative bucket (cross-day offset)", () => {
+ // The day-bucket counter is intentionally allowed to go
+ // negative: a cancel on day N and an uncancel on day N+1 must
+ // still net to zero when the dashboard sums per-day rollup
+ // rows into a weekly / monthly bucket. Clamping here would
+ // silently drop the offset.
+ const bucket = emptyBucket();
+ applyEventToBucket(bucket, makeEvent({ type: "SubscriptionUncanceled" }));
+ expect(bucket.cancellations).toBe(-1);
+ });
+
+ 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("Revoked / Refunded / Paused / Unknown → false even if still in window", () => {
+ for (const state of ["Revoked", "Refunded", "Paused", "Unknown"] as const) {
+ const sub = makeSub({
+ state,
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+ expect(isActiveAt(sub, dayEnd), `state=${state}`).toBe(false);
+ }
+ });
+
+ it("Expired with expiresAt > dayEnd → true (still active on the snapshot day)", () => {
+ // The whole point of including `Expired` in COUNTED_STATES: a
+ // sub that was active at end-of-day on a historical day, then
+ // expired later, must contribute to that historical day's
+ // activeSubs count.
+ const sub = makeSub({
+ state: "Expired",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 3, 1), // April 1, after dayEnd (March 15)
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(true);
+ });
+
+ it("Expired with expiresAt <= dayEnd → false (already gone by snapshot)", () => {
+ const sub = makeSub({
+ state: "Expired",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: Date.UTC(2026, 2, 10), // March 10, before dayEnd
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(false);
+ });
+
+ it("Expired with no expiresAt timestamp → false (defensive: no anchor for transition day)", () => {
+ const sub = makeSub({
+ state: "Expired",
+ startedAt: Date.UTC(2026, 2, 1),
+ expiresAt: undefined,
+ });
+ expect(isActiveAt(sub, dayEnd)).toBe(false);
+ });
+
+ it("undefined expiresAt + Active counted state → still active (matches steady-state semantics)", () => {
+ const sub = makeSub({
+ state: "Active",
+ startedAt: Date.UTC(2026, 2, 1),
+ 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 {
+ // No-op `.filter()` would let a future production code path that
+ // narrows results via `.filter()` silently pass against the
+ // in-memory harness while real Convex returned a different set.
+ // Throw so the next caller is forced to wire predicate support
+ // up explicitly instead of running a green test on a broken
+ // assumption.
+ void _cb;
+ throw new Error(
+ "MemQuery.filter is not implemented — wire it up before adding a .filter() call to production code under test.",
+ );
+ }
+
+ 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;
+ }
+
+ // Minimal stand-in for Convex's `paginate({ numItems, cursor })`.
+ // Cursor is the offset as a string. Real Convex returns an opaque
+ // cursor that includes a stable tiebreaker (the row `_id`); the
+ // test fixtures here are too small to ever exercise the
+ // tiebreaker, so an offset is sufficient to round-trip the
+ // production code path without forcing tests to wire up Convex's
+ // full pagination contract.
+ async paginate(opts: {
+ numItems: number;
+ cursor: string | null;
+ }): Promise<{ page: Row[]; isDone: boolean; continueCursor: string }> {
+ const start = opts.cursor === null ? 0 : Number.parseInt(opts.cursor, 10);
+ const end = start + opts.numItems;
+ const page = this.rows.slice(start, end);
+ const isDone = end >= this.rows.length;
+ return { page, isDone, continueCursor: String(end) };
+ }
+}
+
+class MemDb {
+ 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 {
+ // 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",
+ platform: "IOS",
+ environment: "Production",
+ sourceNotificationId: `notif_${Math.random()}`,
+ productId: "sub.monthly",
+ currency: "USD",
+ ...partial,
+ receivedAt,
+ occurredAt,
+ });
+}
+
+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 with expiresAt past the window contributes to activeSubs (was active on those days)", async () => {
+ // Sub expired April 1 — past every day in our window
+ // [TODAY-2, TODAY] which is centered on March 15. The sub was
+ // genuinely active at end-of-day on each of those days, so it
+ // must contribute to the activeSubs snapshot for them.
+ await seedSub(db, {
+ state: "Expired",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 3, 1),
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ const rows = await rollupRows(db);
+ expect(rows).toHaveLength(3);
+ for (const row of rows) {
+ expect(row.activeSubs).toBe(1);
+ }
+ });
+
+ it("Expired sub whose expiry predates the window does not contribute", async () => {
+ // Expired well before the rollup window — no day in
+ // [TODAY-2, TODAY] saw this sub as active.
+ await seedSub(db, {
+ state: "Expired",
+ startedAt: Date.UTC(2026, 1, 1),
+ expiresAt: Date.UTC(2026, 2, 10), // March 10, before D2=March 13
+ });
+
+ await runRecompute(ctx, PROJECT_ID, NOW);
+ expect(await rollupRows(db)).toEqual([]);
+ });
+
+ 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("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("late-arrival event with receivedAt at end of window still buckets by occurredAt", async () => {
+ // The webhook receivers in `webhooks/apple.ts` and
+ // `webhooks/google.ts` always set `receivedAt >= occurredAt`,
+ // so an event whose `occurredAt` falls inside the trailing
+ // window necessarily has `receivedAt` inside it too. This test
+ // is the realistic shape: a renewal that occurred on D2 but
+ // didn't land in our DB until TODAY (Apple/Google retry tail).
+ // The bucket should attribute to D2.
+ await seedEvent(db, {
+ type: "SubscriptionStarted",
+ priceAmountMicros: 9_990_000,
+ occurredAt: Date.parse(`${D2}T03:00:00Z`),
+ receivedAt: Date.parse(`${TODAY}T11:00:00Z`),
+ });
+
+ 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", {
+ 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..f0fe34b8
--- /dev/null
+++ b/packages/kit/convex/subscriptions/revenueMetrics.ts
@@ -0,0 +1,934 @@
+// 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.
+//
+// Scaling pattern: per-project recompute uses the same scheduler-
+// chained pagination as `recomputeSubscriptionStats` so each page
+// gets its own 40k document-read budget. The events pass runs once
+// in the kickoff page; the subscriptions pass walks counted-state
+// rows only (Active / InGracePeriod / InBillingRetry) via
+// `by_project_and_state_and_updated` ordered descending — most-
+// recently-updated first — and chains continuation pages until
+// every state is exhausted before committing.
+
+import { internalMutation } from "../_generated/server";
+import type { MutationCtx } from "../_generated/server";
+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;
+};
+
+// States that are "counted" for the activeSubs scan over the
+// trailing window. `Expired` is included intentionally: a sub
+// that was active two days ago but has since expired must still
+// contribute to the activeSubs snapshot for those earlier days
+// (`isActiveAt` filters it back out for the days after its
+// expiry). Without it, the historical activeSubs line would
+// retro-actively drop subs as they aged out, even though they
+// were genuinely active on the days the chart is showing.
+const COUNTED_STATES = new Set([
+ "Active",
+ "InGracePeriod",
+ "InBillingRetry",
+ "Expired",
+] as const);
+
+// Order matters: pagination cursors index into this list. Adding a
+// state requires a migration of in-flight cursors stored on the
+// scheduler queue, so append new states to the END of the list,
+// never the middle.
+const COUNTED_STATES_ORDERED = [
+ "Active",
+ "InGracePeriod",
+ "InBillingRetry",
+ "Expired",
+] as const;
+type CountedState = (typeof COUNTED_STATES_ORDERED)[number];
+
+// Cron picker entry. Walks `revenueMetricsRunStatus.by_run` ascending
+// so the least-recently-processed projects surface first. Each
+// successful per-project recompute upserts its own
+// `revenueMetricsRunStatus` row with `lastRunAt = now`, so the
+// picker self-rotates without piggybacking on the subscription-stats
+// drift cron's freshness signal.
+//
+// Bootstrap: a brand-new project has no `revenueMetricsRunStatus`
+// row yet. Pad the picker from `subscriptionStats` (any project
+// 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 ?? 100;
+ const SCAN_CAP = Math.max(limit * 3, 300);
+
+ const stale = await ctx.db
+ .query("revenueMetricsRunStatus")
+ .withIndex("by_run")
+ .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;
+ }
+
+ 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(
+ 0,
+ internal.subscriptions.revenueMetrics.recomputeRevenueMetricsForProject,
+ { projectId },
+ );
+ scheduled += 1;
+ }
+ return { scheduled };
+ },
+});
+
+// Per-project kickoff. Schedules itself by chaining
+// `recomputeRevenueMetricsPage` mutations, each with its own 40k
+// document-read budget, so a project with arbitrarily many active
+// subscriptions completes without ever exceeding the per-mutation
+// ceiling.
+export const recomputeRevenueMetricsForProject = internalMutation({
+ args: { projectId: v.id("projects") },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await runRecompute(ctx, args.projectId, Date.now());
+ return null;
+ },
+});
+
+// Per-page event scan size. The webhookEvents pass paginates via
+// the scheduler so this caps reads PER MUTATION, not per project.
+// A noisy project that emits 50k events in the trailing 3-day
+// window paginates across ~10 chained mutations, each well under
+// the 32k document-read budget.
+const EVENTS_PAGE_SIZE = 5_000;
+
+// Per-page subscription scan size. Each page chains via the
+// scheduler so this caps reads PER MUTATION, not per project. A
+// project with 50k active subs paginates across ~10 chained
+// mutations, each comfortably under the 32k document-read budget.
+const SUBS_PAGE_SIZE = 5_000;
+
+// Number of accumulator buckets above which the commit phase
+// chains per-day mutations instead of running inline. Below this
+// threshold, a single commit fits comfortably under Convex's 8192
+// writes-per-mutation budget (each bucket is one delete + one
+// insert = 2 ops, plus the per-day existing-row scan reads). For
+// large multi-product projects (e.g. 100 SKUs × 5 currencies × 2
+// platforms × 3 days = 3000 buckets → 6000 ops, breaking the cap),
+// we split by day so each commit mutation handles one day's
+// buckets and gets its own write budget.
+const COMMIT_INLINE_BUCKET_LIMIT = 500;
+
+// Per-day commit safety margin under Convex's 8192-writes-per-
+// mutation budget. Single-mutation per-day commits do
+// `existing.length` deletes + `nonZero.length` inserts, so any day
+// whose `existing + nonZero` exceeds this limit splits into a
+// delete pass on the kickoff mutation followed by chained
+// `commitRevenueMetricsDayInsertChunk` mutations of size
+// `INSERT_CHUNK_SIZE` each. 7000 leaves headroom for the
+// existing-row read pass and the `markRevenueMetricsRun` upsert.
+const COMMIT_DAY_WRITES_LIMIT = 7_000;
+const INSERT_CHUNK_SIZE = 3_500;
+
+// Validators for the chained-page args. Buckets serialize as a flat
+// array so they survive the scheduler's JSON round-trip; the cursor
+// is a tagged union covering both phases of the recompute pipeline
+// (events scan first, then subscriptions scan) so a single
+// scheduled `recomputeRevenueMetricsPage` handler can resume from
+// either phase. Cursors come from Convex's `paginate(...)` API,
+// which includes the row `_id` as a stable tiebreaker (a
+// hand-rolled `lt(receivedAt, ...)` / `lt(updatedAt, ...)`
+// watermark would silently skip rows that share a timestamp at the
+// page boundary).
+const platformValidator = v.union(v.literal("IOS"), v.literal("Android"));
+const bucketValidator = v.object({
+ day: v.string(),
+ productId: v.string(),
+ currency: v.string(),
+ platform: platformValidator,
+ activeSubs: v.number(),
+ newSubs: v.number(),
+ renewals: v.number(),
+ cancellations: v.number(),
+ refunds: v.number(),
+ revenueMicros: v.number(),
+});
+const accumulatorValidator = v.array(bucketValidator);
+const cursorValidator = v.union(
+ v.object({
+ phase: v.literal("events"),
+ paginationCursor: v.union(v.string(), v.null()),
+ }),
+ v.object({
+ phase: v.literal("subs"),
+ stateIdx: v.number(),
+ paginationCursor: v.union(v.string(), v.null()),
+ }),
+);
+
+async function markRevenueMetricsRun(
+ ctx: MutationCtx,
+ 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,
+ });
+ }
+}
+
+// Kickoff: build window, then start the events-pagination phase.
+// Events scan paginates through `webhookEvents`; once exhausted the
+// pipeline transitions to the subscriptions phase, which paginates
+// through counted-state rows; once that's exhausted the commit
+// phase fans out per-day mutations. Each phase reads its accumulator
+// from / writes back to a single buckets map carried through the
+// scheduler chain.
+//
+// Exported so tests can drive it directly without the cron scheduler.
+// In tests with small datasets every phase completes in one page
+// inline; the chained-page path only kicks in once the per-mutation
+// EVENTS_PAGE_SIZE / SUBS_PAGE_SIZE / COMMIT_INLINE_BUCKET_LIMIT
+// thresholds are exceeded.
+export async function runRecompute(
+ ctx: MutationCtx,
+ projectId: Id<"projects">,
+ 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();
+ await processEventsPage(ctx, {
+ projectId,
+ days,
+ windowStart,
+ windowEnd,
+ buckets,
+ paginationCursor: null,
+ runStartedAt: now,
+ });
+}
+
+// Process one page of `webhookEvents`. Buckets accumulate event-
+// driven counters (newSubs / renewals / cancellations / refunds /
+// revenueMicros) from `occurredAt`; `activeSubs` lands later in
+// `processSubsPage`. When the events scan finishes, transitions
+// to the subscriptions phase by calling `processSubsPage` directly.
+//
+// Scan by `receivedAt` (the index we have on webhookEvents) but
+// bucket by `occurredAt` (the store-side event time). A renewal
+// that occurred yesterday but arrived today must land in
+// yesterday's bucket — otherwise a retry-delayed notification
+// would flip its day on the dashboard.
+//
+// Scan window matches the bucket window exactly. The webhook
+// receivers in `webhooks/apple.ts` and `webhooks/google.ts` set
+// `receivedAt` to the HTTP receive time and `occurredAt` to the
+// store-side timestamp (Apple `signedDate` / Google
+// `eventTimeMillis`), so by construction `receivedAt >=
+// occurredAt`: any event whose `occurredAt` lands in
+// `[windowStart, windowEnd]` necessarily has `receivedAt` in the
+// same range too.
+async function processEventsPage(
+ ctx: MutationCtx,
+ args: {
+ projectId: Id<"projects">;
+ days: string[];
+ windowStart: number;
+ windowEnd: number;
+ buckets: Map;
+ paginationCursor: string | null;
+ runStartedAt: number;
+ },
+): Promise {
+ const { projectId, days, windowStart, windowEnd, buckets, runStartedAt } =
+ args;
+ const firstDay = days[0];
+ const lastDay = days[days.length - 1];
+
+ const result = await ctx.db
+ .query("webhookEvents")
+ .withIndex("by_project_and_received", (q) =>
+ q
+ .eq("projectId", projectId)
+ .gte("receivedAt", windowStart)
+ .lte("receivedAt", windowEnd),
+ )
+ .paginate({ numItems: EVENTS_PAGE_SIZE, cursor: args.paginationCursor });
+
+ for (const event of result.page) {
+ if (!event.productId) continue;
+ const day = utcDayKey(event.occurredAt);
+ // Skip events whose store-side day falls outside the bucket
+ // 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
+ // 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);
+ }
+
+ if (result.isDone) {
+ // Events exhausted — transition to the subscriptions phase.
+ await processSubsPage(ctx, {
+ projectId,
+ days,
+ windowStart,
+ buckets,
+ cursor: { stateIdx: 0, paginationCursor: null },
+ runStartedAt,
+ });
+ return;
+ }
+
+ // More events to read. Serialize the accumulator + cursor and
+ // chain a fresh events page.
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics.recomputeRevenueMetricsPage,
+ {
+ projectId,
+ days,
+ buckets: Array.from(buckets.values()),
+ cursor: { phase: "events", paginationCursor: result.continueCursor },
+ runStartedAt,
+ },
+ );
+}
+
+// Process one page of counted-state subscriptions. Greedily
+// advances through states until either the page fills (chain
+// continuation) or every counted state is exhausted (commit).
+//
+// `buckets` is the live accumulator: pass-1 (events) populated it
+// in the kickoff, this function adds activeSubs contributions and
+// commits at the end of the last page.
+async function processSubsPage(
+ ctx: MutationCtx,
+ args: {
+ projectId: Id<"projects">;
+ days: string[];
+ windowStart: number;
+ buckets: Map;
+ cursor: { stateIdx: number; paginationCursor: string | null };
+ runStartedAt: number;
+ },
+): Promise {
+ const { projectId, days, buckets, runStartedAt } = args;
+ const dayEnds = days.map((day) => Date.parse(`${day}T23:59:59.999Z`));
+
+ let { stateIdx, paginationCursor } = args.cursor;
+ let pageRemaining = SUBS_PAGE_SIZE;
+ let chainContinuation = false;
+
+ while (stateIdx < COUNTED_STATES_ORDERED.length && pageRemaining > 0) {
+ const state: CountedState = COUNTED_STATES_ORDERED[stateIdx];
+ const result = await ctx.db
+ .query("subscriptions")
+ .withIndex("by_project_and_state_and_updated", (q) =>
+ q.eq("projectId", projectId).eq("state", state),
+ )
+ .order("desc")
+ .paginate({ numItems: pageRemaining, cursor: paginationCursor });
+
+ for (const sub of result.page) {
+ for (let i = 0; i < days.length; i++) {
+ if (!isActiveAt(sub, dayEnds[i])) continue;
+ const currency = sub.currency ?? "";
+ const platform = sub.platform;
+ const key = bucketKey(days[i], sub.productId, currency, platform);
+ const bucket = getOrCreateBucket(
+ buckets,
+ key,
+ days[i],
+ sub.productId,
+ currency,
+ platform,
+ );
+ bucket.activeSubs += 1;
+ }
+ }
+
+ pageRemaining -= result.page.length;
+ if (result.isDone) {
+ // State exhausted, advance to next state with a fresh cursor.
+ stateIdx += 1;
+ paginationCursor = null;
+ } else {
+ // Convex's `continueCursor` is opaque and includes a stable
+ // tiebreaker (the row `_id`), so subsequent pages won't drop
+ // rows that share an `updatedAt` with the page boundary.
+ paginationCursor = result.continueCursor;
+ if (pageRemaining <= 0) {
+ chainContinuation = true;
+ break;
+ }
+ }
+ }
+
+ if (!chainContinuation && stateIdx >= COUNTED_STATES_ORDERED.length) {
+ // All counted states processed — commit.
+ await commitOrSchedulePerDay(ctx, projectId, days, buckets, runStartedAt);
+ return;
+ }
+
+ // More work to do. Serialize the accumulator + cursor and chain
+ // a fresh mutation so the next page gets its own 32k read budget.
+ const serialized = Array.from(buckets.values());
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics.recomputeRevenueMetricsPage,
+ {
+ projectId,
+ days,
+ buckets: serialized,
+ cursor: { phase: "subs", stateIdx, paginationCursor },
+ runStartedAt,
+ },
+ );
+}
+
+// Continuation page handler. Rehydrates the accumulator from the
+// scheduler args and dispatches to the right phase based on the
+// tagged cursor.
+export const recomputeRevenueMetricsPage = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ days: v.array(v.string()),
+ buckets: accumulatorValidator,
+ cursor: cursorValidator,
+ runStartedAt: v.number(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const buckets = new Map();
+ for (const row of args.buckets) {
+ const key = bucketKey(row.day, row.productId, row.currency, row.platform);
+ buckets.set(key, { ...row });
+ }
+ const todayStart = startOfUtcDay(args.runStartedAt);
+ const windowStart = todayStart - (TRAILING_DAYS - 1) * DAY_MS;
+ if (args.cursor.phase === "events") {
+ await processEventsPage(ctx, {
+ projectId: args.projectId,
+ days: args.days,
+ windowStart,
+ windowEnd: args.runStartedAt,
+ buckets,
+ paginationCursor: args.cursor.paginationCursor,
+ runStartedAt: args.runStartedAt,
+ });
+ return null;
+ }
+ await processSubsPage(ctx, {
+ projectId: args.projectId,
+ days: args.days,
+ windowStart,
+ buckets,
+ cursor: {
+ stateIdx: args.cursor.stateIdx,
+ paginationCursor: args.cursor.paginationCursor,
+ },
+ runStartedAt: args.runStartedAt,
+ });
+ return null;
+ },
+});
+
+// Commit one day's worth of recomputed buckets. Used by the
+// scheduler-chained commit path for projects whose total bucket
+// count exceeds COMMIT_INLINE_BUCKET_LIMIT and would otherwise
+// blow Convex's 8192-writes-per-mutation budget on a single
+// commit. Each per-day commit:
+// - reads existing rollup rows for THIS day (bounded by
+// productCount × currencyCount × platformCount)
+// - deletes them and inserts the recomputed non-zero buckets
+// - upserts `revenueMetricsRunStatus` so the picker rotates
+// even if some other day's commit ran later or never
+// Multiple per-day commits run in parallel; OCC retries make the
+// shared `revenueMetricsRunStatus` upsert race-safe.
+export const commitRevenueMetricsDay = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ day: v.string(),
+ buckets: accumulatorValidator,
+ runStartedAt: v.number(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q.eq("projectId", args.projectId).eq("day", args.day),
+ )
+ .collect();
+ const nonZero = args.buckets.filter((b) => !isAllZeroBucket(b));
+
+ // Stay under Convex's 8192-writes-per-mutation budget. Single
+ // mutation does `existing.length` deletes + `nonZero.length`
+ // inserts; if that combined sum exceeds COMMIT_DAY_WRITES_LIMIT
+ // we delete inline (existing.length is already bounded by the
+ // budget — a project that has stored more than 7000 rows for
+ // one day is pathological enough to trip Convex's hard ceiling
+ // on the prior commit, so this branch shouldn't be reached
+ // in practice) and fan inserts out across chained chunk
+ // mutations of size INSERT_CHUNK_SIZE.
+ if (existing.length + nonZero.length <= COMMIT_DAY_WRITES_LIMIT) {
+ await Promise.all(existing.map((row) => ctx.db.delete(row._id)));
+ await Promise.all(
+ nonZero.map((bucket) => insertBucket(ctx, args, bucket)),
+ );
+ await markRevenueMetricsRun(ctx, args.projectId, args.runStartedAt);
+ return null;
+ }
+
+ if (existing.length > COMMIT_DAY_WRITES_LIMIT) {
+ // Bigger than the per-mutation write budget can absorb in
+ // one shot. We could split the deletes across chained
+ // mutations too, but a single day's existing-row count
+ // crossing 7k requires somewhere north of 3500 distinct
+ // (productId, currency, platform) tuples already on disk
+ // for the same UTC day — operationally implausible for the
+ // SaaS workloads this dashboard targets, and it would have
+ // tripped the Convex write limit on the commit that wrote
+ // those rows in the first place. Surface the impossible
+ // state rather than silently succeeding with a partial
+ // delete.
+ throw new Error(
+ `commitRevenueMetricsDay: existing row count ${existing.length} for project=${args.projectId} day=${args.day} exceeds COMMIT_DAY_WRITES_LIMIT=${COMMIT_DAY_WRITES_LIMIT}; manual intervention required.`,
+ );
+ }
+
+ await Promise.all(existing.map((row) => ctx.db.delete(row._id)));
+ for (let i = 0; i < nonZero.length; i += INSERT_CHUNK_SIZE) {
+ const chunk = nonZero.slice(i, i + INSERT_CHUNK_SIZE);
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics
+ .commitRevenueMetricsDayInsertChunk,
+ {
+ projectId: args.projectId,
+ day: args.day,
+ buckets: chunk,
+ runStartedAt: args.runStartedAt,
+ },
+ );
+ }
+ await markRevenueMetricsRun(ctx, args.projectId, args.runStartedAt);
+ return null;
+ },
+});
+
+// Insert chunk for the per-day fan-out path. Used only when the
+// per-day commit's `existing + nonZero` would have exceeded
+// COMMIT_DAY_WRITES_LIMIT: the kickoff `commitRevenueMetricsDay`
+// performs the deletes and schedules these chunks afterwards. Each
+// chunk gets its own 8192-writes budget, so a project with
+// ~10k buckets/day fans out across ~3 chained chunks instead of
+// blowing the limit. `markRevenueMetricsRun` is OCC-safe across
+// the parallel chunks; the kickoff calls it too so even a 0-row
+// inserted-chunk path still marks the run.
+export const commitRevenueMetricsDayInsertChunk = internalMutation({
+ args: {
+ projectId: v.id("projects"),
+ day: v.string(),
+ buckets: accumulatorValidator,
+ runStartedAt: v.number(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await Promise.all(
+ args.buckets.map((bucket) => insertBucket(ctx, args, bucket)),
+ );
+ await markRevenueMetricsRun(ctx, args.projectId, args.runStartedAt);
+ return null;
+ },
+});
+
+function insertBucket(
+ ctx: MutationCtx,
+ args: { projectId: Id<"projects">; runStartedAt: number },
+ bucket: {
+ day: string;
+ productId: string;
+ currency: string;
+ platform: Platform;
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+ },
+): Promise> {
+ return ctx.db.insert("revenueMetricsDaily", {
+ projectId: args.projectId,
+ day: bucket.day,
+ productId: bucket.productId,
+ currency: bucket.currency,
+ platform: bucket.platform,
+ activeSubs: bucket.activeSubs,
+ newSubs: bucket.newSubs,
+ renewals: bucket.renewals,
+ cancellations: bucket.cancellations,
+ refunds: bucket.refunds,
+ revenueMicros: bucket.revenueMicros,
+ updatedAt: args.runStartedAt,
+ });
+}
+
+// Decide whether to commit inline (small projects) or fan out one
+// scheduled mutation per day (large projects). The threshold is
+// expressed in total bucket count because each bucket contributes
+// at most 2 writes (one delete of the prior row + one insert of
+// the new row); 500 buckets × 2 = 1000 writes per day worst-case
+// is comfortably under Convex's 8192-writes-per-mutation budget
+// even with the per-day existing-row reads.
+async function commitOrSchedulePerDay(
+ ctx: MutationCtx,
+ projectId: Id<"projects">,
+ days: string[],
+ buckets: Map,
+ runStartedAt: number,
+): Promise {
+ if (buckets.size <= COMMIT_INLINE_BUCKET_LIMIT) {
+ await commitBuckets(ctx, projectId, days, buckets, runStartedAt);
+ await markRevenueMetricsRun(ctx, projectId, runStartedAt);
+ return;
+ }
+
+ // Group buckets by day so each scheduled mutation receives only
+ // its own slice. Args size per scheduled mutation is therefore
+ // bounded by the per-day bucket count (productCount ×
+ // currencyCount × platformCount), well under the ~1MB scheduler
+ // arg limit at any realistic SaaS scale.
+ const bucketsByDay = new Map();
+ for (const day of days) bucketsByDay.set(day, []);
+ for (const bucket of buckets.values()) {
+ const list = bucketsByDay.get(bucket.day);
+ if (list) list.push(bucket);
+ }
+
+ for (const day of days) {
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.subscriptions.revenueMetrics.commitRevenueMetricsDay,
+ {
+ projectId,
+ day,
+ buckets: bucketsByDay.get(day) ?? [],
+ runStartedAt,
+ },
+ );
+ }
+}
+
+function isAllZeroBucket(bucket: {
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+}): boolean {
+ return (
+ bucket.activeSubs === 0 &&
+ bucket.newSubs === 0 &&
+ bucket.renewals === 0 &&
+ bucket.cancellations === 0 &&
+ bucket.refunds === 0 &&
+ bucket.revenueMicros === 0
+ );
+}
+
+function getOrCreateBucket(
+ buckets: Map,
+ key: BucketKey,
+ 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`. The day-bucket counter is allowed
+ // to go negative on its own: an uncancel that arrives on a
+ // different day than the original cancel must still net to
+ // zero when the dashboard sums across a weekly / monthly
+ // bucket (or across multiple per-day rollup rows for the
+ // same period). Clamping at zero per-day would silently lose
+ // cross-day cancel/uncancel pairs.
+ bucket.cancellations += 1;
+ break;
+ case "SubscriptionUncanceled":
+ 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;
+ }
+ // No clamps. `cancellations` is intentionally allowed to go
+ // negative (cross-day uncancel offset, see above). `revenueMicros`
+ // never decreases under the current event mapping — refunds bump
+ // the `refunds` counter, not `revenueMicros` — so a clamp would
+ // be dead code; if a future event type ever subtracts revenue,
+ // the same cross-period reasoning applies and a clamp would hide
+ // the offset.
+}
+
+export function isActiveAt(sub: Doc<"subscriptions">, dayEnd: number): boolean {
+ if (sub.startedAt > dayEnd) return false;
+ if (!COUNTED_STATES.has(sub.state as "Active")) return false;
+ // Expiry-driven cutoff. When `expiresAt` is set we treat it as
+ // authoritative regardless of state: an Active sub past its
+ // expiry shouldn't count, and an Expired sub before its expiry
+ // SHOULD count (the snapshot day predates the expiry — that's
+ // the entire reason `Expired` is in COUNTED_STATES).
+ if (typeof sub.expiresAt === "number") {
+ return sub.expiresAt > dayEnd;
+ }
+ // No expiry timestamp on file. The non-Expired counted states
+ // (Active / InGracePeriod / InBillingRetry) treat that as
+ // "still active indefinitely" — the steady-state semantics.
+ // For Expired, we have no day at which the sub stopped being
+ // active, so we conservatively count it as inactive for every
+ // day in the window rather than guessing.
+ return sub.state !== "Expired";
+}
+
+async function commitBuckets(
+ 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 × platformCount) — typically tens of rows per
+ // project, not thousands.
+ //
+ // Both the per-day queries and the delete batches dispatch with
+ // `Promise.all` so the round trips overlap. Convex still
+ // serializes the underlying writes within the mutation
+ // transaction, but firing them concurrently shaves the wall-clock
+ // for the commit phase down to roughly one round-trip per day
+ // instead of (existing-row-count × days).
+ const existingPerDay = await Promise.all(
+ days.map((day) =>
+ ctx.db
+ .query("revenueMetricsDaily")
+ .withIndex("by_project_and_day_and_currency", (q) =>
+ q.eq("projectId", projectId).eq("day", day),
+ )
+ .collect(),
+ ),
+ );
+ await Promise.all(existingPerDay.flat().map((row) => ctx.db.delete(row._id)));
+
+ const inserts: Array> = [];
+ for (const bucket of buckets.values()) {
+ // Skip empty buckets — happens when a sub became active mid-day
+ // but was later refunded so its (newSubs, refunds) net to zero
+ // and it wasn't active at end-of-day either. No row beats an
+ // all-zero row in storage / scan cost.
+ if (isAllZeroBucket(bucket)) continue;
+ inserts.push(
+ ctx.db.insert("revenueMetricsDaily", {
+ projectId,
+ day: bucket.day,
+ productId: bucket.productId,
+ currency: bucket.currency,
+ platform: bucket.platform,
+ activeSubs: bucket.activeSubs,
+ newSubs: bucket.newSubs,
+ renewals: bucket.renewals,
+ cancellations: bucket.cancellations,
+ refunds: bucket.refunds,
+ revenueMicros: bucket.revenueMicros,
+ updatedAt: now,
+ }),
+ );
+ }
+ await Promise.all(inserts);
+}
diff --git a/packages/kit/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 DAY_MS = 86_400_000;
+
+// Stable empty defaults. The kickoff render before `useQuery`
+// returns has `metrics === undefined`; we still need to invoke
+// every memo in the same order on that render so React's
+// rules-of-hooks stay satisfied. Sharing these constants keeps the
+// memo dependency identity stable across renders so the memos
+// don't recompute when the empty defaults are passed in.
+const EMPTY_DAYS: ReadonlyArray<{
+ day: string;
+ currency: string;
+ productId: string;
+ platform: Platform;
+ activeSubs: number;
+ newSubs: number;
+ renewals: number;
+ cancellations: number;
+ refunds: number;
+ revenueMicros: number;
+}> = [];
+const EMPTY_STRINGS: ReadonlyArray = [];
+
+const RANGES = [
+ { id: "7d", label: "Last 7 days", days: 7 },
+ { id: "30d", label: "Last 30 days", days: 30 },
+ { 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.
+ //
+ // `now` is held in state and refreshed every minute so a user who
+ // leaves the dashboard open across a UTC midnight rollover gets a
+ // re-render that picks up the new day. `useMemo` keys on `now`,
+ // but `utcDayKey(now)` only changes once per day, so the chart
+ // doesn't refetch every minute — Convex's `useQuery` deep-equals
+ // the args object, and the day-key string is stable inside the
+ // same UTC day.
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 60_000);
+ return () => clearInterval(id);
+ }, []);
+
+ const { maxFromDay, toDay } = useMemo(() => {
+ const today = utcDayKey(now);
+ const from = utcDayKey(now - (MAX_RANGE_DAYS - 1) * DAY_MS);
+ return { maxFromDay: from, toDay: today };
+ }, [now, MAX_RANGE_DAYS]);
+
+ // Per-range start day, derived without re-querying. Slicing the
+ // unfiltered fetch in JS keeps the page below from flashing on
+ // range/period clicks; only the bottom (charts + summary cards)
+ // re-derives.
+ const fromDay = useMemo(
+ () => utcDayKey(now - (range.days - 1) * DAY_MS),
+ [now, 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.
+ // `useQuery` in convex/react does not deep-compare its args
+ // between renders — an inline `{ ... }` literal allocates a new
+ // object every render and the underlying `convex.watchQuery`
+ // re-subscribes even when the values are identical. Memoise the
+ // args object so the subscription is only torn down and rebuilt
+ // when one of the actual inputs changes. The `now` tick stays
+ // off the dep list because `maxFromDay` / `toDay` are derived
+ // from it via `utcDayKey(...)` and only change at UTC midnight.
+ const queryArgs = useMemo(
+ () => ({ apiKey: project.apiKey, fromDay: maxFromDay, toDay }),
+ [project.apiKey, maxFromDay, toDay],
+ );
+ const metrics = useQuery(
+ api.subscriptions.query.getRevenueMetrics,
+ queryArgs,
+ );
+
+ // The loading-state early return has to live below ALL hooks so
+ // React's rules-of-hooks (every hook called in the same order on
+ // every render) stays satisfied — the memos below would
+ // otherwise be conditional on `metrics !== undefined`. We work
+ // off a stable empty default until the real data arrives, then
+ // bail to `` after the hooks have been registered.
+ const metricsDays = metrics?.days ?? EMPTY_DAYS;
+ const metricsCurrencies = metrics?.currencies ?? EMPTY_STRINGS;
+
+ // Multi-currency projects: we always pin to a single currency for
+ // chart rendering because revenueMicros can't be summed across
+ // 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.
+ //
+ // Empty-project case (no rollup rows yet) leaves both
+ // `selectedCurrency` and `metricsCurrencies[0]` undefined; we
+ // resolve to "" deliberately and let the `EmptyState` below take
+ // over rendering — the chart subtree is gated on
+ // `metricsDays.length > 0` so a "" currency never reaches the
+ // axis labels.
+ const currency =
+ selectedCurrency ??
+ (metricsCurrencies.length > 0 ? metricsCurrencies[0] : "");
+
+ // Client-side filtering. Range is also a client filter now (we
+ // fetched the max range above), so flipping range chiclets stays
+ // 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.
+ //
+ // We deliberately KEEP rows older than `fromDay` in the row sets
+ // (only attribute / range filters are applied here). `aggregateByDay`
+ // uses those older rows to seed the `activeSubs` carry-forward at
+ // the start of the chart — without them, a project with active
+ // subscriptions but no events in the selected range would dip
+ // visually to zero on the first day.
+ //
+ // Two parallel pipelines:
+ // - `revenueRows`: pinned to `currency` because `revenueMicros`
+ // can't be summed across currencies without an FX rate.
+ // - `lifecycleRows`: NOT pinned to currency. activeSubs / new /
+ // renewals / cancellations / refunds are counts, not money,
+ // and aggregating them across currencies gives the correct
+ // project-wide total. Pinning them to a single currency would
+ // make the "All platforms" card under-report on multi-currency
+ // projects (the user pays in USD, but the count of cancels is
+ // a single project-wide number regardless of where they paid).
+ //
+ // Memoised so the per-minute `now` tick (which only changes
+ // `fromDay` / `toDay` once per UTC day) doesn't re-run the full
+ // filter / aggregate / bucket pipeline on every render.
+ const revenueRows = useMemo(
+ () =>
+ metricsDays.filter((row) => {
+ if (currency && row.currency !== currency) return false;
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ if (platformFilter !== "all" && row.platform !== platformFilter) {
+ return false;
+ }
+ return true;
+ }),
+ [metricsDays, currency, selectedProduct, platformFilter],
+ );
+ const lifecycleRows = useMemo(
+ () =>
+ metricsDays.filter((row) => {
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ if (platformFilter !== "all" && row.platform !== platformFilter) {
+ return false;
+ }
+ return true;
+ }),
+ [metricsDays, selectedProduct, platformFilter],
+ );
+
+ const revenueDaily = useMemo(
+ () => aggregateByDay(revenueRows, range.days, fromDay),
+ [revenueRows, range.days, fromDay],
+ );
+ const lifecycleDaily = useMemo(
+ () => aggregateByDay(lifecycleRows, range.days, fromDay),
+ [lifecycleRows, range.days, fromDay],
+ );
+ const revenueSeries = useMemo(
+ () => bucketByPeriod(revenueDaily, periodId),
+ [revenueDaily, periodId],
+ );
+ const lifecycleSeries = useMemo(
+ () => bucketByPeriod(lifecycleDaily, periodId),
+ [lifecycleDaily, periodId],
+ );
+
+ // Final chart series merges the lifecycle counters (cross-currency)
+ // with the revenue total (currency-pinned) per bucket. Same
+ // bucket-period axis on both sides means we can join positionally
+ // since `bucketByPeriod` produces deterministic output for a
+ // given `periodId` / `range`.
+ const series = useMemo(
+ () =>
+ lifecycleSeries.map((row, i) => ({
+ ...row,
+ revenueMicros: revenueSeries[i]?.revenueMicros ?? 0,
+ })),
+ [lifecycleSeries, revenueSeries],
+ );
+
+ const totals = useMemo(
+ () =>
+ series.reduce(
+ (acc, row) => {
+ acc.newSubs += row.newSubs;
+ acc.renewals += row.renewals;
+ acc.cancellations += row.cancellations;
+ acc.refunds += row.refunds;
+ acc.revenueMicros += row.revenueMicros;
+ acc.activeSubsLast = row.activeSubs;
+ return acc;
+ },
+ {
+ newSubs: 0,
+ renewals: 0,
+ cancellations: 0,
+ refunds: 0,
+ revenueMicros: 0,
+ activeSubsLast: 0,
+ },
+ ),
+ [series],
+ );
+
+ // Churn = (cancellations + refunds) / activeSubs at end of window.
+ // 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;
+
+ // Platform-card totals. Lifecycle counters (activeSubs / newSubs)
+ // come from a currency-unfiltered pass — the cards reflect the
+ // project-wide story, so pinning to one currency would
+ // under-count the "All platforms" total on multi-currency
+ // projects. Revenue stays currency-filtered for the FX reason.
+ const platformTotals = useMemo(() => {
+ const lifecycleBaseRows = metricsDays.filter((row) => {
+ if (row.day < fromDay) return false;
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ return true;
+ });
+ const revenueBaseRows = metricsDays.filter((row) => {
+ if (row.day < fromDay) return false;
+ if (currency && row.currency !== currency) return false;
+ if (selectedProduct && row.productId !== selectedProduct) return false;
+ return true;
+ });
+ const byFilter = new Map<
+ PlatformFilter,
+ { revenueMicros: number; activeSubs: number; newSubs: number }
+ >();
+ for (const card of PLATFORM_CARDS) {
+ const lifecycle = lifecycleForPlatform(lifecycleBaseRows, card.filter);
+ const revenue = revenueForPlatform(revenueBaseRows, card.filter);
+ byFilter.set(card.filter, {
+ revenueMicros: revenue,
+ activeSubs: lifecycle.activeSubs,
+ newSubs: lifecycle.newSubs,
+ });
+ }
+ return byFilter;
+ }, [metricsDays, fromDay, currency, selectedProduct]);
+
+ if (metrics === undefined) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Analytics
+
+
+ Revenue and subscription lifecycle metrics, rolled up from ingested
+ webhook events. Refreshed every ~10 minutes 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
+
+
+
+
+
+ {metrics.truncated && (
+ // Server hit `REVENUE_SCAN_CAP` on the rollup scan, so the
+ // chart below is showing a partial view of the requested
+ // range. Surface this so an operator doesn't read flat
+ // numbers as a real revenue trough — they're a query-budget
+ // truncation, not a business signal.
+
+
+
+
Analytics partially loaded
+
+ This range exceeded the per-query scan limit. Numbers below cover
+ the most-recent slice of the window; tighten the range (7d / 30d)
+ to load every row, or narrow by product / currency.
+
+ 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. */}
+
+ );
+}
+
+const tooltipStyle = {
+ backgroundColor: "var(--card)",
+ border: "1px solid var(--border)",
+ borderRadius: "8px",
+ fontSize: "12px",
+} as const;
+
+function ChicletGroup({
+ options,
+ value,
+ onChange,
+}: {
+ options: Array<{ id: string; label: string }>;
+ value: string;
+ onChange: (id: string) => void;
+}) {
+ // Active state: primary-bordered card on top of the muted track.
+ // Hover state on inactive options: muted fill + foreground text.
+ // Both are now visually distinct in both light and dark modes —
+ // the prior bg-card vs bg-muted/40 combo collapsed to identical
+ // gray on dark, which is what the user reported.
+ return (
+
+ Analytics roll up daily from ingested Apple ASN v2 / Google RTDN webhook
+ events. Once your first webhook arrives, the next cron tick (within 10
+ min) 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 = [];
+
+ // Seed `lastActive` from the most-recent pre-`fromDay` snapshot
+ // so a project with active subs but no events in the selected
+ // range doesn't visibly dip to zero on the first chart day. The
+ // caller passes through pre-range rows for exactly this reason;
+ // pick the latest one whose day is strictly older than `fromDay`.
+ let lastActive = 0;
+ let seedDay = "";
+ for (const [day, row] of byDay) {
+ if (day >= fromDay) continue;
+ if (day > seedDay) {
+ seedDay = day;
+ lastActive = row.activeSubs;
+ }
+ }
+
+ for (let i = 0; i < rangeDays; i++) {
+ const dayKey = utcDayKey(fromTs + i * DAY_MS);
+ const entry = byDay.get(dayKey);
+ 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 * DAY_MS);
+ 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 lifecycle totals (activeSubs / newSubs) for the
+// platform cards. Walks an UNFILTERED-by-currency row set so the
+// "All platforms" card shows the project-wide count rather than
+// just the active-currency slice. The rollup table is keyed by
+// `(day, productId, currency, platform)`, so a multi-product /
+// multi-currency project has many rows for the same day+platform;
+// activeSubs are summed across siblings on the most-recent day per
+// platform (newSubs are summed across all matching rows).
+function lifecycleForPlatform(
+ rows: Array<{
+ platform: Platform;
+ activeSubs: number;
+ newSubs: number;
+ day: string;
+ }>,
+ filter: PlatformFilter,
+): { activeSubs: number; newSubs: number } {
+ const matching =
+ filter === "all" ? rows : rows.filter((r) => r.platform === filter);
+ const lastByPlatform = new Map();
+ let newSubs = 0;
+ for (const row of matching) {
+ 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,
+ });
+ } else if (row.day === prior.day) {
+ prior.active += row.activeSubs;
+ }
+ }
+ let activeSubs = 0;
+ for (const v of lastByPlatform.values()) activeSubs += v.active;
+ return { activeSubs, newSubs };
+}
+
+// Per-platform revenue total for the platform cards. Walks a
+// CURRENCY-FILTERED row set because `revenueMicros` can't be
+// summed across currencies without an FX rate.
+function revenueForPlatform(
+ rows: Array<{ platform: Platform; revenueMicros: number }>,
+ filter: PlatformFilter,
+): number {
+ const matching =
+ filter === "all" ? rows : rows.filter((r) => r.platform === filter);
+ let revenueMicros = 0;
+ for (const row of matching) revenueMicros += row.revenueMicros;
+ return revenueMicros;
+}
+
+function utcDayKey(ts: number): string {
+ return new Date(ts).toISOString().slice(0, 10);
+}
+
+function formatMicros(
+ micros: number,
+ currency: string,
+ compact = false,
+): string {
+ if (!micros) return compact ? "0" : `${currency} 0`.trim();
+ const value = micros / 1_000_000;
+ if (compact) {
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
+ return value.toFixed(0);
+ }
+ return `${currency} ${value.toFixed(2)}`.trim();
+}
diff --git a/packages/kit/src/pages/auth/organization/project/index.tsx b/packages/kit/src/pages/auth/organization/project/index.tsx
index 723b98df..2c44bedc 100644
--- a/packages/kit/src/pages/auth/organization/project/index.tsx
+++ b/packages/kit/src/pages/auth/organization/project/index.tsx
@@ -12,6 +12,7 @@ import {
Activity,
Layers,
Webhook,
+ BarChart3,
} from "lucide-react";
import { PageLoading } from "@/components/LoadingSpinner";
@@ -21,6 +22,7 @@ const TAB_IDS = [
"dashboard",
"purchases",
"subscriptions",
+ "analytics",
"products",
"webhooks",
"apikeys",
@@ -66,6 +68,12 @@ export default function ProjectIndex() {
label: "Subscriptions",
icon: Activity,
},
+ {
+ id: "analytics",
+ label: "Analytics",
+ icon: BarChart3,
+ badge: "Beta",
+ },
{
id: "products",
label: "Products",
diff --git a/packages/kit/src/pages/auth/organization/project/purchases.tsx b/packages/kit/src/pages/auth/organization/project/purchases.tsx
index 22e53748..57f2e6c2 100644
--- a/packages/kit/src/pages/auth/organization/project/purchases.tsx
+++ b/packages/kit/src/pages/auth/organization/project/purchases.tsx
@@ -316,32 +316,36 @@ export default function ProjectPurchases() {
- {statConfig.map((stat) => (
-
{
- if (stat.key === "total") {
- resetFilters();
- } else if (stat.key === "apple") {
- applyStoreFilter("apple");
- } else if (stat.key === "google") {
- applyStoreFilter("google");
- } else if (stat.key === "valid") {
- applyValidityFilter(true);
- } else if (stat.key === "invalid") {
- applyValidityFilter(false);
- }
- }}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
+ {statConfig.map((stat) => {
+ // Determine which card matches the currently-active filter
+ // so the selected card is visually distinct from hover (the
+ // prior styling only highlighted on hover, so users couldn't
+ // tell which card they had already clicked).
+ const isActive =
+ stat.key === "total"
+ ? !storeFilter && isValidFilter === undefined
+ : stat.key === "apple"
+ ? storeFilter === "apple"
+ : stat.key === "google"
+ ? storeFilter === "google"
+ : stat.key === "valid"
+ ? isValidFilter === true
+ : isValidFilter === false;
+ return (
+
+ The Analytics tab visualizes revenue and subscription
+ lifecycle metrics across iOS and Android: total revenue, active
+ subscriptions, new subs, renewals, cancellations, refunds, and churn.
+ Data is sliced by date range (7 / 30 / 90 days), aggregated by period
+ (daily / weekly / monthly), and filterable by platform, product, and
+ currency.
+
+
+
+
+ The dashboard reads from a daily-rolled-up table populated from
+ ingested Apple App Store Server Notifications v2 and{" "}
+ Google Play Real-time Developer Notifications (RTDN).
+ Without webhooks, the Analytics tab will stay empty regardless of how
+ many /v1/purchase/verify calls you make — verification
+ alone doesn't tell IAPKit when a renewal, cancel, or refund happens.
+
+
+ Open the project's Webhooks tab to copy your
+ IAPKit-hosted webhook URLs and register them with the App Store / Play
+ Console. Once notifications start arriving, the next cron tick (within
+ 24h) will populate this view.
+
+
+
+
Setup checklist
+
+
+ Configure store credentials per the{" "}
+
+ Apple
+ {" "}
+ /{" "}
+
+ Google
+ {" "}
+ setup pages (otherwise the webhook receivers can't decode signed
+ payloads).
+
+
+ Open the project's Webhooks tab in the dashboard.
+ Copy the per-store URLs (one for Apple ASN v2, one for Google RTDN).
+
+
+ Apple: in App Store Connect →{" "}
+ App Store Server Notifications, paste the Apple webhook URL
+ and select Version 2 for both Production and Sandbox
+ environments.
+
+
+ Google: in Play Console → Monetization setup
+ , point Real-time Developer Notifications to a Pub/Sub topic that fans
+ out to the Google webhook URL (Pub/Sub push subscription with the
+ IAPKit URL as endpoint).
+
+
+ Trigger a test purchase or use the App Store Connect / Play Console
+ "Send test notification" feature. Confirm an event row appears in the
+ Webhooks tab's event log.
+
+
+ Wait up to 24h for the next analytics rollup tick — or trigger it
+ manually if you have access to the Convex dashboard (
+ recomputeRevenueMetricsForProject).
+
+
+
+
How the rollup works
+
+ Analytics data lives in a separate revenueMetricsDaily{" "}
+ table that the Analytics tab reads from directly — the dashboard never
+ scans the raw webhook event log on render. A daily cron walks each
+ project's recent webhookEvents and writes one row per{" "}
+ (day, productId, currency, platform) bucket.
+
+ activeSubs is an end-of-day snapshot computed from the
+ current subscriptions table, not from the event log
+
+
+
+
+
+ Each cron tick recomputes the trailing 3 days so a
+ late-arriving Apple ASN v2 / Google RTDN notification (which can retry
+ for up to 5 / 7 days respectively) still folds into its correct day's
+ bucket. RevenueCat uses the same 3-day reprocess window for the same
+ reason — real-world p99 webhook delivery is well under 48 hours.
+
+
+
+
Currency & FX
+
+ Rollup rows are keyed by currency: the same SKU sold in USD and EUR on
+ the same day produces two rows. The dashboard never sums revenue across
+ currencies (no built-in FX conversion). When a project has multiple
+ currencies, the Analytics tab surfaces a currency selector; each chart
+ renders for one currency at a time. If you need a unified revenue view,
+ apply your own FX rates downstream.
+
+
+
Churn definition
+
+ Churn rate = (cancellations + refunds) / activeSubs,
+ expressed as a percentage of the end-of-period active count. Same
+ definition Stripe and RevenueCat surface in their headline dashboards.
+ The chart recomputes per-period when you flip Daily / Weekly / Monthly.
+
+
+
Limitations
+
+
+ No country / region split. Apple ASN v2 and Google
+ RTDN don't expose buyer country in the notification payload.
+ Country-level breakdowns would require pulling App Store Connect /
+ Play Console reporting APIs separately.
+
+
+ Webhook retention is 30 days. The raw event log is
+ pruned after 30 days, but the daily rollup rows are kept indefinitely
+ — historical analytics survive the retention sweep.
+
+
+
+ 30-day retention applies to the trailing recompute window too.
+ {" "}
+ An event arriving more than 30 days late won't be visible to the
+ rollup at all (it's already gone from the event log). In practice this
+ never happens — Apple stops retrying after 5 days, Google after 7.
+