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

+
+
+ )} + +
+ {PLATFORM_CARDS.map((card) => { + const active = platformFilter === card.filter; + const cardTotals = platformTotals.get(card.filter) ?? { + revenueMicros: 0, + activeSubs: 0, + newSubs: 0, + }; + return ( +
setPlatformFilter(card.filter)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setPlatformFilter(card.filter); + } + }} + className={cn( + "bg-card border rounded-xl p-4 shadow-sm relative overflow-hidden cursor-pointer transition", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40", + active + ? "border-primary ring-2 ring-primary/20" + : "border-border hover:border-primary/50", + )} + aria-pressed={active} + > +
+
+

{card.label}

+

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

+

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

+
+
+ ); + })} +
+ +
+ ({ id: r.id, label: r.label }))} + value={rangeId} + onChange={(v) => setRangeId(v as RangeId)} + /> + ({ id: p.id, label: p.label }))} + value={periodId} + onChange={(v) => setPeriodId(v as PeriodId)} + /> + {metrics.productIds.length > 0 && ( +
+ Product: + setSelectedCurrency(v ?? null)} + className="min-w-[100px]" + options={metrics.currencies.map((c) => ({ value: c, label: c }))} + /> +
+ )} +
+ +
+ + + + + +
+ + {metrics.days.length === 0 ? ( + + ) : ( +
+ + + + + + formatMicros(v, currency, true)} + /> + formatMicros(value, currency)} + contentStyle={tooltipStyle} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `${v.toFixed(0)}%`} + /> + `${value.toFixed(2)}%`} + contentStyle={tooltipStyle} + /> + + + + +
+ )} +
+ ); +} + +const tooltipStyle = { + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + fontSize: "12px", +} as const; + +function ChicletGroup({ + options, + value, + onChange, +}: { + options: Array<{ id: string; label: string }>; + value: string; + onChange: (id: string) => void; +}) { + // Active state: primary-bordered card on top of the muted track. + // Hover state on inactive options: muted fill + foreground text. + // Both are now visually distinct in both light and dark modes — + // the prior bg-card vs bg-muted/40 combo collapsed to identical + // gray on dark, which is what the user reported. + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function SummaryCard({ + icon: Icon, + label, + value, +}: { + icon: typeof TrendingUp; + label: string; + value: string; +}) { + return ( +
+
+ + {label} +
+
{value}
+
+ ); +} + +function ChartCard({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle && ( +

{subtitle}

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

No data yet for this range

+

+ Analytics roll up daily from ingested Apple ASN v2 / Google RTDN webhook + events. Once your first webhook arrives, the next cron tick (within 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 ( +
{ if (stat.key === "total") { resetFilters(); } else if (stat.key === "apple") { @@ -353,26 +357,42 @@ export default function ProjectPurchases() { } else if (stat.key === "invalid") { applyValidityFilter(false); } - } - }} - aria-label={STATS_LABELS[stat.key]} - > -
-
-

- {STATS_LABELS[stat.key]} -

-

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

+ }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (stat.key === "total") { + resetFilters(); + } else if (stat.key === "apple") { + applyStoreFilter("apple"); + } else if (stat.key === "google") { + applyStoreFilter("google"); + } else if (stat.key === "valid") { + applyValidityFilter(true); + } else if (stat.key === "invalid") { + applyValidityFilter(false); + } + } + }} + aria-label={STATS_LABELS[stat.key]} + > +
+
+

+ {STATS_LABELS[stat.key]} +

+

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

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

+ The Analytics tab visualizes revenue and subscription + lifecycle metrics across iOS and Android: total revenue, active + subscriptions, new subs, renewals, cancellations, refunds, and churn. + Data is sliced by date range (7 / 30 / 90 days), aggregated by period + (daily / weekly / monthly), and filterable by platform, product, and + currency. +

+ + +

+ The dashboard reads from a daily-rolled-up table populated from + ingested Apple App Store Server Notifications v2 and{" "} + Google Play Real-time Developer Notifications (RTDN). + Without webhooks, the Analytics tab will stay empty regardless of how + many /v1/purchase/verify calls you make — verification + alone doesn't tell IAPKit when a renewal, cancel, or refund happens. +

+

+ Open the project's Webhooks tab to copy your + IAPKit-hosted webhook URLs and register them with the App Store / Play + Console. Once notifications start arriving, the next cron tick (within + 24h) will populate this view. +

+
+ +

Setup checklist

+
    +
  1. + Configure store credentials per the{" "} + + Apple + {" "} + /{" "} + + Google + {" "} + setup pages (otherwise the webhook receivers can't decode signed + payloads). +
  2. +
  3. + Open the project's Webhooks tab in the dashboard. + Copy the per-store URLs (one for Apple ASN v2, one for Google RTDN). +
  4. +
  5. + Apple: in App Store Connect →{" "} + App Store Server Notifications, paste the Apple webhook URL + and select Version 2 for both Production and Sandbox + environments. +
  6. +
  7. + Google: in Play Console → Monetization setup + , point Real-time Developer Notifications to a Pub/Sub topic that fans + out to the Google webhook URL (Pub/Sub push subscription with the + IAPKit URL as endpoint). +
  8. +
  9. + Trigger a test purchase or use the App Store Connect / Play Console + "Send test notification" feature. Confirm an event row appears in the + Webhooks tab's event log. +
  10. +
  11. + Wait up to 24h for the next analytics rollup tick — or trigger it + manually if you have access to the Convex dashboard ( + recomputeRevenueMetricsForProject). +
  12. +
+ +

How the rollup works

+

+ Analytics data lives in a separate revenueMetricsDaily{" "} + table that the Analytics tab reads from directly — the dashboard never + scans the raw webhook event log on render. A daily cron walks each + project's recent webhookEvents and writes one row per{" "} + (day, productId, currency, platform) bucket. +

+

Counters map from event types as follows:

+
    +
  • + SubscriptionStarted →{" "} + newSubs += 1, revenueMicros += price +
  • +
  • + SubscriptionRenewed →{" "} + renewals += 1, revenueMicros += price +
  • +
  • + SubscriptionCanceledcancellations += 1{" "} + (paired with SubscriptionUncanceled for net-zero + handling) +
  • +
  • + PurchaseRefunded + SubscriptionRevoked →{" "} + refunds += 1 +
  • +
  • + activeSubs is an end-of-day snapshot computed from the + current subscriptions table, not from the event log +
  • +
+ + +

+ Each cron tick recomputes the trailing 3 days so a + late-arriving Apple ASN v2 / Google RTDN notification (which can retry + for up to 5 / 7 days respectively) still folds into its correct day's + bucket. RevenueCat uses the same 3-day reprocess window for the same + reason — real-world p99 webhook delivery is well under 48 hours. +

+
+ +

Currency & FX

+

+ Rollup rows are keyed by currency: the same SKU sold in USD and EUR on + the same day produces two rows. The dashboard never sums revenue across + currencies (no built-in FX conversion). When a project has multiple + currencies, the Analytics tab surfaces a currency selector; each chart + renders for one currency at a time. If you need a unified revenue view, + apply your own FX rates downstream. +

+ +

Churn definition

+

+ Churn rate = (cancellations + refunds) / activeSubs, + expressed as a percentage of the end-of-period active count. Same + definition Stripe and RevenueCat surface in their headline dashboards. + The chart recomputes per-period when you flip Daily / Weekly / Monthly. +

+ +

Limitations

+
    +
  • + No country / region split. Apple ASN v2 and Google + RTDN don't expose buyer country in the notification payload. + Country-level breakdowns would require pulling App Store Connect / + Play Console reporting APIs separately. +
  • +
  • + Webhook retention is 30 days. The raw event log is + pruned after 30 days, but the daily rollup rows are kept indefinitely + — historical analytics survive the retention sweep. +
  • +
  • + + 30-day retention applies to the trailing recompute window too. + {" "} + An event arriving more than 30 days late won't be visible to the + rollup at all (it's already gone from the event log). In practice this + never happens — Apple stops retrying after 5 days, Google after 7. +
  • +
+
+ ); +}