diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 000000000..abded7b2a --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.4.3" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz", + "integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.4.3", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.97", + "@opentui/solid": ">=0.1.97" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz", + "integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/bun.lock b/bun.lock index 6f231dc0c..416b75010 100644 --- a/bun.lock +++ b/bun.lock @@ -5416,8 +5416,14 @@ "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], + "@autumn/openapi/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@autumn/openapi/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "@autumn/scripts/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@autumn/server/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@autumn/server/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260326.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260326.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260326.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260326.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260326.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260326.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260326.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260326.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-oq2UShxAa+CS4aalhQjntuxuzTv/ud54So3lqfYcJDiQabmpGk/95rGvW5PXi28JIBlZ6AbUL6gEj0gRvIDJcQ=="], @@ -5428,6 +5434,8 @@ "@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="], + "@autumn/shared/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@autumn/vite/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -6236,6 +6244,8 @@ "atmn/@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="], + "atmn/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "atmn/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "atmn/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260326.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260326.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260326.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260326.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260326.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260326.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260326.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260326.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-oq2UShxAa+CS4aalhQjntuxuzTv/ud54So3lqfYcJDiQabmpGk/95rGvW5PXi28JIBlZ6AbUL6gEj0gRvIDJcQ=="], @@ -6250,6 +6260,8 @@ "atmn/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "atmn-tests/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "autumn-js/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "autumn-js/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -6304,6 +6316,8 @@ "checkout/@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="], + "checkout/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "checkout/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "checkout/@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -6952,6 +6966,12 @@ "@asyncapi/parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@autumn/openapi/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "@autumn/scripts/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "@autumn/server/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@autumn/server/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "@autumn/server/@typescript/native-preview/@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260326.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eEMQnArhP/9cLOR3kkShEmT2y1dUs/kHLpwOdEHFmkXwJ477V7P0eZEtabsF5xefU9GwF+cMw4cw60ANVGPgeA=="], @@ -6990,6 +7010,8 @@ "@autumn/server/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + "@autumn/shared/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -7790,8 +7812,12 @@ "artillery/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "atmn-tests/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "atmn/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="], + "atmn/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "atmn/@typescript/native-preview/@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260326.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eEMQnArhP/9cLOR3kkShEmT2y1dUs/kHLpwOdEHFmkXwJ477V7P0eZEtabsF5xefU9GwF+cMw4cw60ANVGPgeA=="], "atmn/@typescript/native-preview/@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260326.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUjpUTe95X4pmxIJb354UY/+1/+0Zh3z0J9ekNSWhhWgZpqQQi5rS1C5BfSJbg2aH8OXYVaEFWzxddXc8qeILg=="], @@ -7856,6 +7882,8 @@ "checkout/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="], + "checkout/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "checkout/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "chevrotain-allstar/chevrotain/@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.2", "", { "dependencies": { "@chevrotain/gast": "11.1.2", "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q=="], @@ -8330,10 +8358,16 @@ "@artilleryio/int-core/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "@autumn/openapi/@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@autumn/scripts/@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@autumn/server/ink/@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "@autumn/server/ink/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "@autumn/shared/@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -8818,6 +8852,10 @@ "artillery/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "atmn-tests/@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "atmn/@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "atmn/eslint-plugin-react-hooks/eslint/@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], "atmn/eslint-plugin-react-hooks/eslint/@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], @@ -8850,6 +8888,8 @@ "ava/cli-truncate/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "checkout/@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "eslint-formatter-pretty/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -8984,6 +9024,12 @@ "@artilleryio/int-core/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "@autumn/openapi/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@autumn/scripts/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "@autumn/shared/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@4.1.3", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/querystring-builder": "^3.0.11", "@smithy/types": "^3.7.2", "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA=="], "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], @@ -9156,6 +9202,10 @@ "artillery/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "atmn-tests/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "atmn/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "atmn/eslint-plugin-react-hooks/eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "atmn/eslint-plugin-react-hooks/eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -9170,6 +9220,8 @@ "atmn/eslint-plugin-react-hooks/eslint/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "checkout/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "find-cache-dir/pkg-dir/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], "ink-confirm-input/ink-text-input/ink/@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], diff --git a/server/experiments/compareFullCustomer.ts b/server/experiments/compareFullCustomer.ts deleted file mode 100644 index b44f3d17e..000000000 --- a/server/experiments/compareFullCustomer.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { AppEnv, type FullCustomer } from "@autumn/shared"; -import { initDrizzle } from "@server/db/initDrizzle.js"; -import { RELEVANT_STATUSES } from "@server/internal/customers/cusProducts/CusProductService.js"; -import { getFullCusQuery } from "@server/internal/customers/getFullCusQuery.js"; -import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js"; -import { resultToFullCustomer } from "@server/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.js"; -import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js"; -import { loadLocalEnv } from "@server/utils/envUtils.js"; -import { prodTestCustomerId, prodTestOrgId } from "./experimentEnv"; - -loadLocalEnv(); - -const ORG_ID = prodTestOrgId; -const ENV = AppEnv.Live; -const CUSTOMER_ID = prodTestCustomerId; - -const jsonSize = ({ data }: { data: unknown }) => { - const json = JSON.stringify(data); - const bytes = Buffer.byteLength(json, "utf8"); - return { bytes, kb: (bytes / 1024).toFixed(2) }; -}; - -async function main() { - const { db, client } = initDrizzle({ maxConnections: 2 }); - - try { - console.log(`Comparing: ${CUSTOMER_ID} (org: ${ORG_ID}, env: ${ENV})\n`); - - // --- V1: getFullCusQuery (CusService.getFull path) --- - const v1Query = getFullCusQuery( - CUSTOMER_ID, - ORG_ID, - ENV, - RELEVANT_STATUSES, - false, - false, - false, - false, - false, - ); - - const v1Start = performance.now(); - const v1Result = await db.execute(v1Query); - const v1QueryMs = (performance.now() - v1Start).toFixed(2); - - let v1Size = { bytes: 0, kb: "0" }; - if (v1Result && v1Result.length > 0) { - const v1Data = v1Result[0] as FullCustomer; - v1Size = jsonSize({ data: v1Data }); - } - - // --- V2: getSubjectCoreQuery + resultToFullCustomer --- - const v2Query = getSubjectCoreQuery({ - orgId: ORG_ID, - env: ENV, - customerId: CUSTOMER_ID, - }); - - const v2QueryStart = performance.now(); - const v2Result = await db.execute(v2Query); - const v2QueryMs = (performance.now() - v2QueryStart).toFixed(2); - - let v2HydrateMs = "0"; - let v2Size = { bytes: 0, kb: "0" }; - if (v2Result && v2Result.length > 0) { - const row = v2Result[0] as unknown as SubjectCoreRow; - const hydrateStart = performance.now(); - const fullCustomer = resultToFullCustomer({ row }); - v2HydrateMs = (performance.now() - hydrateStart).toFixed(2); - v2Size = jsonSize({ data: fullCustomer }); - } - - // --- Results --- - console.log("=== V1 (getFullCusQuery) ==="); - console.log(` Query: ${v1QueryMs}ms`); - console.log(` Size: ${v1Size.kb} KB (${v1Size.bytes} bytes)`); - - console.log("\n=== V2 (getSubjectCoreQuery + hydrate) ==="); - console.log(` Query: ${v2QueryMs}ms`); - console.log(` Hydrate: ${v2HydrateMs}ms`); - console.log(` Size: ${v2Size.kb} KB (${v2Size.bytes} bytes)`); - - console.log("\n=== Comparison ==="); - const queryDiff = (Number(v1QueryMs) - Number(v2QueryMs)).toFixed(2); - const sizeDiff = v1Size.bytes - v2Size.bytes; - const sizePct = - v1Size.bytes > 0 - ? ((sizeDiff / v1Size.bytes) * 100).toFixed(1) - : "N/A"; - console.log( - ` Query: V1 is ${queryDiff}ms ${Number(queryDiff) > 0 ? "slower" : "faster"}`, - ); - console.log( - ` Size: V2 is ${(sizeDiff / 1024).toFixed(2)} KB ${sizeDiff > 0 ? "smaller" : "larger"} (${sizePct}%)`, - ); - } finally { - await client.end(); - } -} - -main().catch(console.error); diff --git a/server/experiments/explainGetByStripeId.ts b/server/experiments/explainGetByStripeId.ts deleted file mode 100644 index 773d9f259..000000000 --- a/server/experiments/explainGetByStripeId.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { AppEnv, customers } from "@autumn/shared"; -import { and, eq, sql } from "drizzle-orm"; -import { initDrizzle, prodTestCustomerId, prodTestOrgId } from "./experimentEnv"; - -// Run with `bun run experiments/explainGetByStripeId.ts` - -const main = async () => { - const orgId = prodTestOrgId; - const env = AppEnv.Live; - - const { db } = initDrizzle(); - - // Grab a real stripe ID from the test customer - const testCus = await db.query.customers.findFirst({ - where: and( - eq(customers.id, prodTestCustomerId), - eq(customers.org_id, orgId), - eq(customers.env, env), - ), - }); - - const stripeId = testCus?.processor?.id; - if (!stripeId) { - console.error("Test customer has no processor.id"); - process.exit(1); - } - - console.log(`Using stripeId: ${stripeId}`); - console.log(`Org: ${orgId} | Env: ${env}\n`); - - // ── Check index validity ───────────────────────────────────────── - console.log("=== INDEX VALIDITY ===\n"); - const idxValid = await db.execute(sql` - SELECT c.relname, i.indisvalid, i.indisready, i.indislive, - pg_size_pretty(pg_relation_size(c.oid)) as size - FROM pg_index i - JOIN pg_class c ON c.oid = i.indexrelid - WHERE c.relname LIKE '%processor%' - `); - for (const row of idxValid) { - console.log(row); - } - - // ── Query with org/env (matches CusService.getByStripeId) ──────── - const withOrgEnv = sql` - SELECT * FROM customers - WHERE processor->>'id' = ${stripeId} - AND org_id = ${orgId} - AND env = ${env} - LIMIT 1 - `; - - console.log("\n=== QUERY: processor->>'id' + org/env ===\n"); - - const start1 = performance.now(); - const result1 = await db.execute(withOrgEnv); - const elapsed1 = performance.now() - start1; - console.log(`Rows: ${result1.length} | Time: ${elapsed1.toFixed(2)}ms\n`); - - const explain1 = await db.execute( - sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${withOrgEnv}`, - ); - for (const row of explain1) { - console.log((row as Record)["QUERY PLAN"]); - } - - // ── Query without org/env (isolate expression index usage) ─────── - const withoutOrgEnv = sql` - SELECT * FROM customers - WHERE processor->>'id' = ${stripeId} - LIMIT 1 - `; - - console.log("\n=== QUERY: processor->>'id' only ===\n"); - - const start2 = performance.now(); - const result2 = await db.execute(withoutOrgEnv); - const elapsed2 = performance.now() - start2; - console.log(`Rows: ${result2.length} | Time: ${elapsed2.toFixed(2)}ms\n`); - - const explain2 = await db.execute( - sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${withoutOrgEnv}`, - ); - for (const row of explain2) { - console.log((row as Record)["QUERY PLAN"]); - } - - process.exit(0); -}; - -await main(); diff --git a/server/experiments/explainGetFullV2.ts b/server/experiments/explainGetFullV2.ts deleted file mode 100644 index 9efdae8bb..000000000 --- a/server/experiments/explainGetFullV2.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { sql } from "drizzle-orm"; -import { - initDrizzle, - prodTestCustomerId, - prodTestOrgId, -} from "./experimentEnv"; -const { getSubjectCoreQuery } = await import( - "../src/internal/customers/repos/sql/getSubjectCoreQuery" -); -import { AppEnv } from "@autumn/shared"; - -// Run with: bun run experiments/explainGetFullV2.ts - -const main = async () => { - const orgId = prodTestOrgId; - const env = AppEnv.Live; - const customerId = prodTestCustomerId; - - const { db } = initDrizzle(); - - // Warm up connection pool - await db.execute(sql`SELECT 1`); - - const query = getSubjectCoreQuery({ customerId, orgId, env }); - - console.log("=== V2: getSubjectCoreQuery ===\n"); - - const start = performance.now(); - const result = await db.execute(query); - const elapsed = performance.now() - start; - - const row = result[0] as Record; - console.log(`Rows returned: ${result.length}`); - console.log(`Wall-clock time: ${elapsed.toFixed(2)}ms`); - console.log( - `customer_products: ${(row?.customer_products as unknown[])?.length ?? 0}`, - ); - console.log( - `customer_entitlements: ${(row?.customer_entitlements as unknown[])?.length ?? 0}`, - ); - console.log( - `extra_customer_entitlements: ${(row?.extra_customer_entitlements as unknown[])?.length ?? 0}`, - ); - console.log(`products: ${(row?.products as unknown[])?.length ?? 0}`); - console.log( - `entitlements: ${(row?.entitlements as unknown[])?.length ?? 0}`, - ); - console.log(`prices: ${(row?.prices as unknown[])?.length ?? 0}`); - console.log(`rollovers: ${(row?.rollovers as unknown[])?.length ?? 0}`); - console.log( - `free_trials: ${(row?.free_trials as unknown[])?.length ?? 0}\n`, - ); - - console.log("--- EXPLAIN (ANALYZE, BUFFERS) ---\n"); - const explain = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`; - const explainResult = await db.execute(explain); - for (const r of explainResult) { - console.log((r as Record)["QUERY PLAN"]); - } - - process.exit(0); -}; - -await main(); diff --git a/server/experiments/getFullCustomerV2Experiment.ts b/server/experiments/getFullCustomerV2Experiment.ts deleted file mode 100644 index ebe701fd0..000000000 --- a/server/experiments/getFullCustomerV2Experiment.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { AppEnv } from "@autumn/shared"; -import { initDrizzle } from "@server/db/initDrizzle.js"; -import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js"; -import { resultToFullCustomer } from "@server/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.js"; -import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js"; -import { loadLocalEnv } from "@server/utils/envUtils.js"; -import { logFullCustomer } from "@shared/utils/cusUtils/fullCusUtils/logFullCustomer.js"; -import { prodTestCustomerId, prodTestOrgId } from "./experimentEnv"; - -loadLocalEnv(); - -const ORG_ID = prodTestOrgId; -const ENV = AppEnv.Live; -const CUSTOMER_ID = prodTestCustomerId; - -async function main() { - const { db, client } = initDrizzle({ maxConnections: 2 }); - - try { - const query = getSubjectCoreQuery({ - orgId: ORG_ID, - env: ENV, - customerId: CUSTOMER_ID, - }); - - console.log("Fetching full customer V2..."); - console.log(` orgId: ${ORG_ID}`); - console.log(` env: ${ENV}`); - console.log(` customerId: ${CUSTOMER_ID}`); - console.log(""); - - const start = performance.now(); - const result = await db.execute(query); - const queryElapsed = (performance.now() - start).toFixed(2); - - if (!result || result.length === 0) { - console.log("No customer found."); - return; - } - - const row = result[0] as unknown as SubjectCoreRow; - console.log(`Query completed in ${queryElapsed}ms`); - - const hydrateStart = performance.now(); - const fullCustomer = resultToFullCustomer({ row }); - const hydrateElapsed = (performance.now() - hydrateStart).toFixed(2); - console.log(`Hydration completed in ${hydrateElapsed}ms`); - - const jsonOutput = JSON.stringify(fullCustomer); - const sizeBytes = Buffer.byteLength(jsonOutput, "utf8"); - const sizeKb = (sizeBytes / 1024).toFixed(2); - console.log(`JSON size: ${sizeKb} KB (${sizeBytes} bytes)\n`); - - logFullCustomer({ fullCustomer }); - } finally { - await client.end(); - } -} - -main().catch(console.error); diff --git a/server/experiments/getFullEntityV2Experiment.ts b/server/experiments/getFullEntityV2Experiment.ts deleted file mode 100644 index 0f30c7227..000000000 --- a/server/experiments/getFullEntityV2Experiment.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { AppEnv } from "@autumn/shared"; -import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js"; -import { resultToFullCustomer } from "@server/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.js"; -import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js"; -import { filterCusProductsByEntity } from "@shared/utils/cusProductUtils/filterCusProductUtils.js"; -import { logFullCustomer } from "@shared/utils/cusUtils/fullCusUtils/logFullCustomer.js"; -import Redis from "ioredis"; -import { - initDrizzle, - prodTestCustomerId, - prodTestEntityId, - prodTestOrgId, -} from "./experimentEnv"; - -const ORG_ID = prodTestOrgId; -const ENV = AppEnv.Live; -const CUSTOMER_ID = prodTestCustomerId; -const ENTITY_ID = prodTestEntityId; - -async function main() { - const redis = new Redis(process.env.CACHE_URL!); - const { db, client } = initDrizzle({ maxConnections: 2 }); - - try { - // 1. Run entity-scoped V2 query (current behavior: entity products only) - const entityQuery = getSubjectCoreQuery({ - orgId: ORG_ID, - env: ENV, - customerId: CUSTOMER_ID, - entityId: ENTITY_ID, - }); - - console.log("=== Full Entity V2 Experiment ==="); - console.log(` orgId: ${ORG_ID}`); - console.log(` env: ${ENV}`); - console.log(` customerId: ${CUSTOMER_ID}`); - console.log(` entityId: ${ENTITY_ID}`); - console.log(""); - - const entityStart = performance.now(); - const entityResult = await db.execute(entityQuery); - const entityQueryMs = (performance.now() - entityStart).toFixed(2); - - if (!entityResult || entityResult.length === 0) { - console.log("No entity found."); - return; - } - - const entityRow = entityResult[0] as unknown as SubjectCoreRow; - const entityHydrateStart = performance.now(); - const entityFullCustomer = resultToFullCustomer({ row: entityRow }); - const entityHydrateMs = (performance.now() - entityHydrateStart).toFixed(2); - - const entityJson = JSON.stringify(entityFullCustomer); - const entitySizeBytes = Buffer.byteLength(entityJson, "utf8"); - const entitySizeKb = (entitySizeBytes / 1024).toFixed(2); - - await redis.call("JSON.SET", `entity-hydrated-${ENTITY_ID}`, "$", entityJson); - - const normalizedDoc = JSON.stringify(entityRow); - const normalizedSizeBytes = Buffer.byteLength(normalizedDoc, "utf8"); - const normalizedSizeKb = (normalizedSizeBytes / 1024).toFixed(2); - await redis.call("JSON.SET", `entity-normalized-${ENTITY_ID}`, "$", normalizedDoc); - - console.log("--- Entity-scoped query (entity products only) ---"); - console.log(` Query: ${entityQueryMs}ms`); - console.log(` Hydration: ${entityHydrateMs}ms`); - console.log(` Hydrated JSON size: ${entitySizeKb} KB (${entitySizeBytes} bytes)`); - console.log(` Normalized JSON size: ${normalizedSizeKb} KB (${normalizedSizeBytes} bytes)`); - console.log(` Savings: ${(100 - (normalizedSizeBytes / entitySizeBytes) * 100).toFixed(1)}%`); - console.log(` Products: ${entityFullCustomer.customer_products.length}`); - console.log( - ` CusEnts: ${entityFullCustomer.customer_products.reduce((sum, cp) => sum + cp.customer_entitlements.length, 0)}`, - ); - console.log( - ` Extra CusEnts: ${entityFullCustomer.extra_customer_entitlements.length}`, - ); - console.log(""); - - // 2. Run customer-level V2 query (no entityId — bounded customer) - const customerQuery = getSubjectCoreQuery({ - orgId: ORG_ID, - env: ENV, - customerId: CUSTOMER_ID, - }); - - const customerStart = performance.now(); - const customerResult = await db.execute(customerQuery); - const customerQueryMs = (performance.now() - customerStart).toFixed(2); - - const customerRow = customerResult[0] as unknown as SubjectCoreRow; - const customerHydrateStart = performance.now(); - const customerFullCustomer = resultToFullCustomer({ row: customerRow }); - const customerHydrateMs = ( - performance.now() - customerHydrateStart - ).toFixed(2); - - const customerJson = JSON.stringify(customerFullCustomer); - const customerSizeBytes = Buffer.byteLength(customerJson, "utf8"); - const customerSizeKb = (customerSizeBytes / 1024).toFixed(2); - - console.log("--- Customer-level query (no entity filter) ---"); - console.log(` Query: ${customerQueryMs}ms`); - console.log(` Hydration: ${customerHydrateMs}ms`); - console.log( - ` JSON size: ${customerSizeKb} KB (${customerSizeBytes} bytes)`, - ); - console.log( - ` Products: ${customerFullCustomer.customer_products.length}`, - ); - console.log( - ` Entities: ${customerFullCustomer.entities?.length ?? 0}`, - ); - console.log(""); - - // 3. Simulate what a FullEntity would look like (entity products + inherited customer-level) - const entity = customerFullCustomer.entities?.find( - (e) => e.id === ENTITY_ID || e.internal_id === ENTITY_ID, - ); - - if (entity) { - const inheritedProducts = filterCusProductsByEntity({ - cusProducts: customerFullCustomer.customer_products, - entity, - }); - - const inheritedJson = JSON.stringify({ - customer: { - id: customerFullCustomer.id, - internal_id: customerFullCustomer.internal_id, - processor: customerFullCustomer.processor, - }, - entity, - customer_products: inheritedProducts, - extra_customer_entitlements: - customerFullCustomer.extra_customer_entitlements, - }); - const inheritedSizeBytes = Buffer.byteLength(inheritedJson, "utf8"); - const inheritedSizeKb = (inheritedSizeBytes / 1024).toFixed(2); - - const customerLevelProducts = inheritedProducts.filter( - (p) => !p.internal_entity_id, - ); - const entityScopedProducts = inheritedProducts.filter( - (p) => p.internal_entity_id, - ); - - console.log( - "--- Simulated FullEntity (entity + inherited customer products) ---", - ); - console.log( - ` JSON size: ${inheritedSizeKb} KB (${inheritedSizeBytes} bytes)`, - ); - console.log(` Total products: ${inheritedProducts.length}`); - console.log( - ` Customer-level (inherited): ${customerLevelProducts.length}`, - ); - console.log( - ` Entity-scoped (own): ${entityScopedProducts.length}`, - ); - console.log(""); - - - } else { - console.log( - `Entity ${ENTITY_ID} not found in customer's entities array.`, - ); - } - - // 4. Size breakdown by category - console.log("--- Size breakdown (customer-level query) ---"); - const breakdown = { - customer_core: Buffer.byteLength( - JSON.stringify({ - id: customerFullCustomer.id, - internal_id: customerFullCustomer.internal_id, - name: customerFullCustomer.name, - email: customerFullCustomer.email, - processor: customerFullCustomer.processor, - }), - "utf8", - ), - entities_array: Buffer.byteLength( - JSON.stringify(customerFullCustomer.entities ?? []), - "utf8", - ), - customer_products: Buffer.byteLength( - JSON.stringify(customerFullCustomer.customer_products), - "utf8", - ), - extra_customer_entitlements: Buffer.byteLength( - JSON.stringify(customerFullCustomer.extra_customer_entitlements), - "utf8", - ), - aggregated_customer_products: Buffer.byteLength( - JSON.stringify( - (customerFullCustomer as Record) - .aggregated_customer_products ?? [], - ), - "utf8", - ), - aggregated_customer_entitlements: Buffer.byteLength( - JSON.stringify( - (customerFullCustomer as Record) - .aggregated_customer_entitlements ?? [], - ), - "utf8", - ), - }; - - for (const [key, bytes] of Object.entries(breakdown)) { - console.log(` ${key}: ${(bytes / 1024).toFixed(2)} KB`); - } - console.log( - ` TOTAL: ${(Object.values(breakdown).reduce((a, b) => a + b, 0) / 1024).toFixed(2)} KB`, - ); - console.log(""); - - // 5. Per-product size stats - const productSizes = customerFullCustomer.customer_products.map((cp) => ({ - productId: cp.product_id, - entityId: cp.internal_entity_id ?? "(customer-level)", - sizeBytes: Buffer.byteLength(JSON.stringify(cp), "utf8"), - entitlements: cp.customer_entitlements.length, - prices: cp.customer_prices.length, - })); - productSizes.sort((a, b) => b.sizeBytes - a.sizeBytes); - - console.log( - `--- Top 10 largest products (of ${productSizes.length} total) ---`, - ); - for (const p of productSizes.slice(0, 10)) { - console.log( - ` ${p.productId} [${p.entityId}]: ${(p.sizeBytes / 1024).toFixed(2)} KB (${p.entitlements} ents, ${p.prices} prices)`, - ); - } - - console.log(""); - logFullCustomer({ fullCustomer: customerFullCustomer }); - } finally { - await redis.quit(); - await client.end(); - } -} - -main().catch(console.error); diff --git a/server/experiments/getFullSubjectExperiment.ts b/server/experiments/getFullSubjectExperiment.ts index 0ff50508f..4e20061be 100644 --- a/server/experiments/getFullSubjectExperiment.ts +++ b/server/experiments/getFullSubjectExperiment.ts @@ -1,7 +1,5 @@ -import { AppEnv } from "@autumn/shared"; -import type { SubjectCoreRow } from "@server/internal/customers/repos/getFullSubject.js"; -import { resultToFullSubject } from "@server/internal/customers/repos/getFullSubject.js"; -import { getSubjectCoreQuery } from "@server/internal/customers/repos/sql/getSubjectCoreQuery.js"; +import { AppEnv, type SubjectQueryRow } from "@autumn/shared"; +import { resultToFullSubject, getFullSubjectQuery } from "@server/internal/customers/repos/getFullSubject/index.js"; import { sql } from "drizzle-orm"; import { initDrizzle, @@ -28,7 +26,7 @@ const runPath = async ({ customerId?: string; entityId?: string; }) => { - const query = getSubjectCoreQuery({ + const query = getFullSubjectQuery({ orgId: ORG_ID, env: ENV, customerId, @@ -50,7 +48,7 @@ const runPath = async ({ return; } - const row = result[0] as unknown as SubjectCoreRow; + const row = result[0] as unknown as SubjectQueryRow; console.log(` Query: ${queryMs}ms`); console.log(` Rows returned: ${result.length}`); console.log( diff --git a/server/experiments/normalizedSubjectCacheExperiment.ts b/server/experiments/normalizedSubjectCacheExperiment.ts new file mode 100644 index 000000000..1ad4ae845 --- /dev/null +++ b/server/experiments/normalizedSubjectCacheExperiment.ts @@ -0,0 +1,705 @@ +import { loadLocalEnv } from "../src/utils/envUtils"; + +loadLocalEnv(); + +import { + AppEnv, + type NormalizedFullSubject, + type SubjectBalance, + type SubjectFlag, + normalizedToFullSubject, +} from "@autumn/shared"; +import Redis from "ioredis"; +import { + CollectionMethod, + CusProductStatus, +} from "@shared/models/cusProductModels/cusProductEnums.js"; +import { FeatureType } from "@shared/models/featureModels/featureEnums.js"; +import { EntInterval } from "@shared/models/productModels/intervals/entitlementInterval.js"; +import { AllowanceType } from "@shared/models/productModels/entModels/entModels.js"; +import { featureBalancesToHashFields } from "@/internal/customers/cache/fullSubject/balances/featureBalancesToHashFields.js"; +import { buildFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectBalanceKey.js"; +import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js"; +import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; +import { + cachedFullSubjectToNormalized, + normalizedToCachedFullSubject, + type CachedFullSubject, +} from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; + +// Run with: bun run experiments/normalizedSubjectCacheExperiment.ts + +const NUM_CUSTOMER_PRODUCTS = 1_000; +const METERED_CES_PER_PRODUCT = 3; +const NUM_METERED_FEATURES = 3; +const NUM_BOOLEAN_FEATURES = 5; +const ROLLOVERS_PER_CE = 1; +const NUM_READS = 10; + +const FAKE_ORG_ID = "exp-ns-cache-org"; +const FAKE_ENV = AppEnv.Live; +const FAKE_CUSTOMER_ID = "exp-ns-cache-cus"; + +let idCounter = 0; +const nextId = (prefix: string) => `${prefix}_${++idCounter}`; +const now = Date.now(); + +const formatBytes = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(2)} KB`; + return `${(kb / 1024).toFixed(2)} MB`; +}; + +const summarize = (samples: number[]) => ({ + avgMs: ( + samples.reduce((sum, sample) => sum + sample, 0) / samples.length + ).toFixed(2), + minMs: Math.min(...samples).toFixed(2), + maxMs: Math.max(...samples).toFixed(2), +}); + +type SlowlogRow = { + phase: string; + id: number; + serverMs: string; + commandName: string; + target: string; + label: string; +}; + +const keyToAlias = ({ key }: { key: string }) => { + if (key.includes(":full_subject:balances:")) { + return `balances:${key.split(":balances:")[1] ?? "unknown"}`; + } + + if (key.includes(":full_subject")) { + return "full_subject"; + } + + return key.length > 48 ? `${key.slice(0, 48)}...` : key; +}; + +const getSlowlogRows = async ({ + redisClient, + phase, +}: { + redisClient: Redis; + phase: string; +}): Promise => { + const rows = (await redisClient.call("SLOWLOG", "GET", "100")) as unknown[][]; + return rows + .map((entry) => { + const [id, , durationMicros, command] = entry as [ + number, + number, + number, + string[], + ]; + const commandParts = Array.isArray(command) ? command : [String(command)]; + const commandName = String(commandParts[0] ?? "").toUpperCase(); + const target = commandParts[1] + ? keyToAlias({ key: String(commandParts[1]) }) + : ""; + + return { + phase, + id, + serverMs: (durationMicros / 1000).toFixed(3), + commandName, + target, + label: `${commandName.toLowerCase()}${target ? ` ${target}` : ""}`, + }; + }) + .filter( + (row) => + row.commandName !== "SLOWLOG" && + row.commandName !== "CONFIG" && + row.commandName !== "MULTI" && + row.commandName !== "EXEC", + ); +}; + +const summarizeSlowlogRows = ({ + rows, +}: { + rows: SlowlogRow[]; +}) => ({ + totalServerMs: rows + .reduce((sum, row) => sum + Number.parseFloat(row.serverMs), 0) + .toFixed(3), + totalCalls: rows.length, +}); + +const groupSlowlogRowsByCommand = ({ + rows, +}: { + rows: SlowlogRow[]; +}) => { + return Object.entries( + rows.reduce>( + (acc, row) => { + const key = `${row.phase}:${row.label}`; + const existing = acc[key] ?? { calls: 0, totalServerMs: 0 }; + acc[key] = { + calls: existing.calls + 1, + totalServerMs: + existing.totalServerMs + Number.parseFloat(row.serverMs), + }; + return acc; + }, + {}, + ), + ).map(([key, stats]) => { + const [phase, ...labelParts] = key.split(":"); + const label = labelParts.join(":"); + return { + phase, + operation: label, + calls: stats.calls, + totalServerMs: stats.totalServerMs.toFixed(3), + avgServerMsPerCall: (stats.totalServerMs / stats.calls).toFixed(3), + }; + }); +}; + +const meteredFeatures = Array.from({ length: NUM_METERED_FEATURES }, (_, i) => ({ + featureId: `metered_feat_${i}`, + internalFeatureId: `int_feat_metered_${i}`, + entitlementId: `ent_metered_${i}`, +})); + +const booleanFeatures = Array.from({ length: NUM_BOOLEAN_FEATURES }, (_, i) => ({ + featureId: `boolean_feat_${i}`, + internalFeatureId: `int_feat_boolean_${i}`, + entitlementId: `ent_boolean_${i}`, +})); + +const generateNormalized = (): NormalizedFullSubject => { + const internalCustomerId = nextId("int_cus"); + + const customerProducts = Array.from({ length: NUM_CUSTOMER_PRODUCTS }, (_, i) => ({ + id: nextId("cus_prod"), + internal_product_id: `int_prod_${i % 10}`, + product_id: `product_${i % 10}`, + internal_customer_id: internalCustomerId, + customer_id: FAKE_CUSTOMER_ID, + internal_entity_id: null, + entity_id: null, + created_at: now - 86400000 * i, + status: CusProductStatus.Active as string, + processor: null as unknown, + canceled: false, + canceled_at: null, + ended_at: null, + starts_at: now - 86400000 * i, + options: [], + free_trial_id: null, + trial_ends_at: null, + collection_method: CollectionMethod.ChargeAutomatically as string, + subscription_ids: [`sub_${i}`], + scheduled_ids: [], + quantity: 1, + version: 0, + usage_limit: null, + metadata: {}, + billing_version: "v2", + api_version: null, + api_semver: "2.2", + external_id: null, + })); + + const customerEntitlements = [] as SubjectBalance[]; + + for (const customerProduct of customerProducts) { + for (let i = 0; i < METERED_CES_PER_PRODUCT; i++) { + const feature = meteredFeatures[i % NUM_METERED_FEATURES]; + const customerEntitlementId = nextId("cus_ent"); + + customerEntitlements.push({ + id: customerEntitlementId, + internal_customer_id: internalCustomerId, + internal_entity_id: null, + internal_feature_id: feature.internalFeatureId, + customer_id: FAKE_CUSTOMER_ID, + feature_id: feature.featureId, + customer_product_id: customerProduct.id, + entitlement_id: feature.entitlementId, + created_at: now - 86400000, + unlimited: false, + balance: 1000 - i, + additional_balance: 0, + usage_allowed: true, + next_reset_at: now + 86400000 * 30, + adjustment: 50, + expires_at: null, + cache_version: 0, + entities: null, + external_id: null, + entitlement: { + id: feature.entitlementId, + created_at: now, + internal_feature_id: feature.internalFeatureId, + internal_product_id: "int_prod_0", + is_custom: false, + allowance_type: AllowanceType.Fixed, + allowance: 1000, + interval: EntInterval.Month, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: null, + org_id: FAKE_ORG_ID, + feature_id: feature.featureId, + usage_limit: null, + rollover: null, + feature: { + internal_id: feature.internalFeatureId, + org_id: FAKE_ORG_ID, + created_at: now, + env: FAKE_ENV, + id: feature.featureId, + name: `Feature ${feature.featureId}`, + type: FeatureType.Metered, + config: null, + display: null, + archived: false, + event_names: [], + }, + }, + rollovers: [] as any, + customerPrice: null as any, + customerProductOptions: [] as any, + customerProductQuantity: customerProduct.quantity, + } as SubjectBalance); + } + } + + const flags = Object.fromEntries( + booleanFeatures.map((feature, i) => [ + feature.featureId, + { + id: nextId("flag"), + featureId: feature.featureId, + internalFeatureId: feature.internalFeatureId, + entitlementId: feature.entitlementId, + customerEntitlementId: null, + customerId: FAKE_CUSTOMER_ID, + customerProductId: customerProducts[i % customerProducts.length]!.id, + internalCustomerId: internalCustomerId, + internalEntityId: null, + createdAt: now, + expiresAt: null, + } as unknown as SubjectFlag, + ]), + ) as Record; + + return { + subjectType: "customer", + customerId: FAKE_CUSTOMER_ID, + internalCustomerId: internalCustomerId, + customer: { + id: FAKE_CUSTOMER_ID, + internal_id: internalCustomerId, + org_id: FAKE_ORG_ID, + env: FAKE_ENV, + created_at: now, + name: "Big Subject Customer", + email: "bench@example.com", + fingerprint: null, + processor: null, + processors: {}, + metadata: {}, + send_email_receipts: true, + auto_topups: null, + spend_limits: null, + usage_alerts: null, + overage_allowed: null, + }, + entity: undefined, + entityId: undefined, + internalEntityId: undefined, + customer_products: customerProducts as any, + customer_entitlements: customerEntitlements, + customer_prices: [], + flags, + products: Array.from({ length: 10 }, (_, i) => ({ + id: `product_${i}`, + internal_id: `int_prod_${i}`, + org_id: FAKE_ORG_ID, + env: FAKE_ENV, + name: `Product ${i}`, + created_at: now, + is_default: false, + group: null, + version: 1, + singular: false, + is_add_on: false, + items: [], + processor: null, + default_free_trial: null, + archived: false, + })) as any, + entitlements: [], + prices: [], + free_trials: [], + subscriptions: customerProducts.map((customerProduct, i) => ({ + id: `sub_${i}`, + customer_id: FAKE_CUSTOMER_ID, + processor: "stripe", + processor_id: `sub_${i}`, + status: "active", + current_period_start: now, + current_period_end: now + 86400000 * 30, + cancel_at_period_end: false, + canceled_at: null, + created_at: now, + updated_at: now, + product_ids: [customerProduct.product_id], + })) as any, + invoices: [ + { + id: nextId("inv"), + stripe_id: "in_stripe_1", + status: "paid", + total: 1000, + subtotal: 1000, + currency: "usd", + customer_id: FAKE_CUSTOMER_ID, + internal_entity_id: null, + product_ids: ["product_0"], + internal_product_ids: ["int_prod_0"], + created_at: now, + }, + ] as any, + entity_aggregations: undefined, + } as unknown as NormalizedFullSubject; +}; + +const getBalancePayloads = ({ + normalized, +}: { + normalized: NormalizedFullSubject; +}) => { + const byFeature = new Map(); + for (const customerEntitlement of normalized.customer_entitlements) { + const existing = byFeature.get(customerEntitlement.feature_id) ?? []; + existing.push(customerEntitlement); + byFeature.set(customerEntitlement.feature_id, existing); + } + + return Array.from(byFeature.entries()).map(([featureId, balances]) => ({ + featureId, + fields: featureBalancesToHashFields({ featureId, balances }), + })); +}; + +const main = async () => { + const redisClient = new Redis(process.env.CACHE_URL!); + let originalSlowlogThreshold = "10000"; + + try { + const slowlogConfig = (await redisClient.call( + "CONFIG", + "GET", + "slowlog-log-slower-than", + )) as string[]; + originalSlowlogThreshold = slowlogConfig?.[1] ?? originalSlowlogThreshold; + await redisClient.call("CONFIG", "SET", "slowlog-log-slower-than", "0"); + + const normalized = generateNormalized(); + const cached = normalizedToCachedFullSubject({ normalized }); + const subjectKey = buildFullSubjectKey({ + orgId: FAKE_ORG_ID, + env: FAKE_ENV, + customerId: FAKE_CUSTOMER_ID, + }); + const balancePayloads = getBalancePayloads({ normalized }); + + const domainSizes = [ + { + domain: "full_subject", + size: formatBytes(Buffer.byteLength(JSON.stringify(cached), "utf8")), + }, + ...balancePayloads.map(({ featureId, fields }) => ({ + domain: `balances:${featureId}`, + size: formatBytes(Buffer.byteLength(JSON.stringify(fields), "utf8")), + })), + ]; + + const publishTimes: number[] = []; + const subjectReadTimes: number[] = []; + const balanceReadTimes: number[] = []; + const hydrateTimes: number[] = []; + + const publishCache = async () => { + const multi = redisClient.multi(); + for (const { featureId, fields } of balancePayloads) { + const balanceKey = buildFullSubjectBalanceKey({ + orgId: FAKE_ORG_ID, + env: FAKE_ENV, + customerId: FAKE_CUSTOMER_ID, + featureId, + }); + multi.del(balanceKey); + multi.hset(balanceKey, fields); + multi.expire(balanceKey, FULL_SUBJECT_CACHE_TTL_SECONDS); + } + multi.set( + subjectKey, + JSON.stringify(cached), + "EX", + FULL_SUBJECT_CACHE_TTL_SECONDS, + ); + await multi.exec(); + }; + + const readSubjectKey = async () => await redisClient.get(subjectKey); + + const readBalanceHashes = async ({ + meteredFeatures, + }: { + meteredFeatures: string[]; + }) => { + const pipeline = redisClient.pipeline(); + for (const featureId of meteredFeatures) { + pipeline.hgetall( + buildFullSubjectBalanceKey({ + orgId: FAKE_ORG_ID, + env: FAKE_ENV, + customerId: FAKE_CUSTOMER_ID, + featureId, + }), + ); + } + return (await pipeline.exec()) ?? []; + }; + + await redisClient.del(subjectKey); + for (const { featureId } of balancePayloads) { + await redisClient.del( + buildFullSubjectBalanceKey({ + orgId: FAKE_ORG_ID, + env: FAKE_ENV, + customerId: FAKE_CUSTOMER_ID, + featureId, + }), + ); + } + + for (let i = 0; i < NUM_READS; i++) { + const publishStart = performance.now(); + await publishCache(); + publishTimes.push(performance.now() - publishStart); + + const subjectStart = performance.now(); + const subjectRaw = await readSubjectKey(); + subjectReadTimes.push(performance.now() - subjectStart); + + const parsedCached = JSON.parse(subjectRaw!) as CachedFullSubject; + + const balanceStart = performance.now(); + const balanceResults = await readBalanceHashes({ + meteredFeatures: parsedCached.meteredFeatures, + }); + balanceReadTimes.push(performance.now() - balanceStart); + + const customerEntitlements = balanceResults.flatMap((entry) => { + const fields = (entry?.[1] ?? {}) as Record; + if (!fields._meta) return []; + const meta = JSON.parse(fields._meta) as { + customerEntitlementIds: string[]; + }; + + return meta.customerEntitlementIds + .map((customerEntitlementId) => fields[customerEntitlementId]) + .filter(Boolean) + .map( + (balance) => JSON.parse(balance as string) as SubjectBalance, + ); + }); + + const hydrateStart = performance.now(); + const reconstructed = cachedFullSubjectToNormalized({ + cached: parsedCached, + customerEntitlements, + }); + normalizedToFullSubject({ normalized: reconstructed }); + hydrateTimes.push(performance.now() - hydrateStart); + } + + await redisClient.call("SLOWLOG", "RESET"); + await publishCache(); + const publishSlowlogRows = await getSlowlogRows({ + redisClient, + phase: "publish_full_subject", + }); + + await redisClient.call("SLOWLOG", "RESET"); + await readSubjectKey(); + const subjectReadSlowlogRows = await getSlowlogRows({ + redisClient, + phase: "read_subject_key", + }); + + await redisClient.call("SLOWLOG", "RESET"); + await readBalanceHashes({ + meteredFeatures: cached.meteredFeatures, + }); + const balanceReadSlowlogRows = await getSlowlogRows({ + redisClient, + phase: "read_balance_hashes", + }); + + const readServerSideMs = ( + Number.parseFloat( + summarizeSlowlogRows({ rows: subjectReadSlowlogRows }).totalServerMs, + ) + + Number.parseFloat( + summarizeSlowlogRows({ rows: balanceReadSlowlogRows }).totalServerMs, + ) + ).toFixed(3); + + const allSlowlogRows = [ + ...publishSlowlogRows, + ...subjectReadSlowlogRows, + ...balanceReadSlowlogRows, + ]; + + console.log("\nSerialized Domain Sizes"); + console.table(domainSizes); + + console.log("\nLatency Summary"); + console.table([ + { + operation: "publish_full_subject", + scope: "client_rtt", + ...summarize(publishTimes), + }, + { + operation: "read_subject_key", + scope: "client_rtt", + ...summarize(subjectReadTimes), + }, + { + operation: "read_balance_hashes", + scope: "client_rtt", + ...summarize(balanceReadTimes), + }, + { + operation: "hydrate_full_subject_local", + scope: "local_cpu", + ...summarize(hydrateTimes), + }, + { + operation: "full_subject_read_end_to_end", + scope: "client_rtt_plus_local_cpu", + ...summarize( + subjectReadTimes.map( + (subjectMs, index) => + subjectMs + balanceReadTimes[index]! + hydrateTimes[index]!, + ), + ), + }, + ]); + + console.log("\nRedis Server Time By Phase"); + console.table([ + { + phase: "publish_full_subject", + scope: "redis_server_slowlog", + ...summarizeSlowlogRows({ rows: publishSlowlogRows }), + }, + { + phase: "read_subject_key", + scope: "redis_server_slowlog", + ...summarizeSlowlogRows({ rows: subjectReadSlowlogRows }), + }, + { + phase: "read_balance_hashes", + scope: "redis_server_slowlog", + ...summarizeSlowlogRows({ rows: balanceReadSlowlogRows }), + }, + { + phase: "read_full_subject_total", + scope: "redis_server_slowlog", + totalServerMs: readServerSideMs, + totalCalls: + summarizeSlowlogRows({ rows: subjectReadSlowlogRows }).totalCalls + + summarizeSlowlogRows({ rows: balanceReadSlowlogRows }).totalCalls, + }, + ]); + + console.log("\nRedis Server Time By Command"); + console.table(groupSlowlogRowsByCommand({ rows: allSlowlogRows })); + + console.log( + "\nRedis SLOWLOG Command Samples (threshold forced to 0 during measurement)", + ); + if (allSlowlogRows.length > 0) { + console.table( + allSlowlogRows.map((row) => ({ + phase: row.phase, + serverMs: row.serverMs, + operation: row.label, + })), + ); + } else { + console.log("No slowlog entries captured for the measured phases."); + } + + console.log("\nRead Path Summary"); + console.table([ + { + metric: "redis_server_read_only", + scope: "redis_server_slowlog", + avgMs: readServerSideMs, + }, + { + metric: "client_rtt_read_only", + scope: "client_rtt", + avgMs: ( + Number.parseFloat(summarize(subjectReadTimes).avgMs) + + Number.parseFloat(summarize(balanceReadTimes).avgMs) + ).toFixed(2), + }, + { + metric: "hydrate_full_subject_local", + scope: "local_cpu", + avgMs: summarize(hydrateTimes).avgMs, + }, + { + metric: "full_subject_read_end_to_end", + scope: "client_rtt_plus_local_cpu", + avgMs: ( + Number.parseFloat(summarize(subjectReadTimes).avgMs) + + Number.parseFloat(summarize(balanceReadTimes).avgMs) + + Number.parseFloat(summarize(hydrateTimes).avgMs) + ).toFixed(2), + }, + ]); + + console.log("\nLegend"); + console.log( + "- client_rtt: wall-clock time observed by the Bun process around awaited Redis calls", + ); + console.log( + "- redis_server_slowlog: Redis command execution time measured from SLOWLOG entries", + ); + console.log( + "- local_cpu: in-process decode + normalizedToFullSubject() time only", + ); + } finally { + try { + await redisClient.call( + "CONFIG", + "SET", + "slowlog-log-slower-than", + originalSlowlogThreshold, + ); + } catch {} + + await redisClient.quit(); + } +}; + +await main(); diff --git a/server/experiments/normalizedSubjectDbExperiment.ts b/server/experiments/normalizedSubjectDbExperiment.ts new file mode 100644 index 000000000..92c09c789 --- /dev/null +++ b/server/experiments/normalizedSubjectDbExperiment.ts @@ -0,0 +1,153 @@ +import { AppEnv, type NormalizedFullSubject, type SubjectQueryRow, normalizedToFullSubject, logFullSubject } from "@autumn/shared"; +import { subjectQueryRowToNormalized, getFullSubjectQuery } from "@server/internal/customers/repos/getFullSubject/index.js"; +import { sql } from "drizzle-orm"; +import { + initDrizzle, + prodTestCustomerId, + prodTestEntityId, + prodTestOrgId, +} from "./experimentEnv"; + +// Run with: bun run experiments/normalizedSubjectDbExperiment.ts + +const ORG_ID = prodTestOrgId; +const ENV = AppEnv.Live; +const CUSTOMER_ID = prodTestCustomerId; +const ENTITY_ID = prodTestEntityId; + +const formatBytes = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(2)} KB`; + return `${(kb / 1024).toFixed(4)} MB`; +}; + +const printNormalizedStats = ({ normalized, label }: { normalized: NormalizedFullSubject; label: string }) => { + const fullJson = JSON.stringify(normalized); + const fullSize = Buffer.byteLength(fullJson, "utf8"); + + const { customer_entitlements, ...subjectPart } = normalized; + const subjectJson = JSON.stringify(subjectPart); + const subjectSize = Buffer.byteLength(subjectJson, "utf8"); + + const cesJson = JSON.stringify(customer_entitlements); + const cesSize = Buffer.byteLength(cesJson, "utf8"); + const allRollovers = customer_entitlements.flatMap( + (customerEntitlement) => customerEntitlement.rollovers ?? [], + ); + const rolloversJson = JSON.stringify(allRollovers); + const rolloversSize = Buffer.byteLength(rolloversJson, "utf8"); + + const featureIds = new Set(customer_entitlements.map((ce) => ce.feature_id).filter(Boolean) as string[]); + const cesByFeature: Record = {}; + for (const featureId of featureIds) { + cesByFeature[featureId] = customer_entitlements.filter((ce) => ce.feature_id === featureId).length; + } + + console.log(`\n --- ${label} ---`); + console.log(` Full NormalizedFullSubject: ${formatBytes(fullSize)}`); + console.log(` Subject (no CEs): ${formatBytes(subjectSize)}`); + console.log(` Customer entitlements: ${formatBytes(cesSize)} (${customer_entitlements.length} CEs)`); + console.log(` Rollovers (inside CEs): ${formatBytes(rolloversSize)} (${allRollovers.length})`); + console.log(` Flags (booleans): ${Object.keys(normalized.flags).length}`); + console.log(` Customer products: ${normalized.customer_products.length}`); + console.log(` Catalog products: ${normalized.products.length}`); + console.log(` Catalog entitlements: ${normalized.entitlements.length}`); + console.log(` Catalog prices: ${normalized.prices.length}`); + console.log(` Metered features: ${featureIds.size}`); + for (const [featureId, count] of Object.entries(cesByFeature)) { + console.log(` ${featureId}: ${count} CEs`); + } +}; + +const runPath = async ({ + db, + label, + customerId, + entityId, +}: { + db: ReturnType["db"]; + label: string; + customerId?: string; + entityId?: string; +}) => { + const query = getFullSubjectQuery({ + orgId: ORG_ID, + env: ENV, + customerId, + entityId, + }); + + console.log(`\n=== ${label} ===`); + console.log(` orgId: ${ORG_ID}`); + console.log(` customerId: ${customerId ?? "(none)"}`); + console.log(` entityId: ${entityId ?? "(none)"}`); + + const queryStart = performance.now(); + const result = await db.execute(query); + const queryMs = (performance.now() - queryStart).toFixed(2); + + if (!result || result.length === 0) { + console.log(` No rows returned. Query: ${queryMs}ms`); + return; + } + + const row = result[0] as unknown as SubjectQueryRow; + console.log(` Query: ${queryMs}ms`); + console.log(` Rows returned: ${result.length}`); + + const normalizeStart = performance.now(); + const normalized = subjectQueryRowToNormalized({ row }); + const normalizeMs = (performance.now() - normalizeStart).toFixed(2); + console.log(` Normalize: ${normalizeMs}ms`); + + printNormalizedStats({ normalized, label: "Size Breakdown" }); + + const hydrateStart = performance.now(); + const fullSubject = normalizedToFullSubject({ normalized }); + const hydrateMs = (performance.now() - hydrateStart).toFixed(2); + + const fullSubjectJson = JSON.stringify(fullSubject); + const fullSubjectSize = Buffer.byteLength(fullSubjectJson, "utf8"); + + console.log(`\n --- Hydration ---`); + console.log(` normalizedToFullSubject(): ${hydrateMs}ms`); + console.log(` FullSubject JSON size: ${formatBytes(fullSubjectSize)}`); + console.log(` FullSubject type: ${fullSubject.subjectType}`); + console.log(` FullSubject customer_prods: ${fullSubject.customer_products.length}`); + console.log(` FullSubject extra_cus_ents: ${fullSubject.extra_customer_entitlements.length}`); + + console.log(`\n --- FullSubject Summary ---`); + logFullSubject({ fullSubject }); +}; + +async function main() { + const { db, client } = initDrizzle({ maxConnections: 2 }); + + try { + await db.execute(sql`SELECT 1`); + + await runPath({ + db, + label: "Path 1: Customer subject (customerId only)", + customerId: CUSTOMER_ID, + }); + + await runPath({ + db, + label: "Path 2: Entity subject (customerId + entityId)", + customerId: CUSTOMER_ID, + entityId: ENTITY_ID, + }); + + await runPath({ + db, + label: "Path 3: Entity subject (entityId only)", + entityId: ENTITY_ID, + }); + } finally { + await client.end(); + } +} + +main().catch(console.error); diff --git a/server/src/_luaScriptsV2/fullSubject/releaseFullSubjectReservation.lua b/server/src/_luaScriptsV2/fullSubject/releaseFullSubjectReservation.lua new file mode 100644 index 000000000..dc4c7dc37 --- /dev/null +++ b/server/src/_luaScriptsV2/fullSubject/releaseFullSubjectReservation.lua @@ -0,0 +1,24 @@ +--[[ + Release a FullSubject write reservation if the token matches. + + KEYS: + [1] reserveKey + + ARGV: + [1] token + + Returns: + "RELEASED" = reservation matched and was deleted + "SKIPPED" = key missing or token mismatch +]] + +local reserveKey = KEYS[1] +local token = ARGV[1] + +local existingToken = redis.call("GET", reserveKey) +if existingToken ~= token then + return "SKIPPED" +end + +redis.call("DEL", reserveKey) +return "RELEASED" diff --git a/server/src/_luaScriptsV2/fullSubject/reserveFullSubjectWrite.lua b/server/src/_luaScriptsV2/fullSubject/reserveFullSubjectWrite.lua new file mode 100644 index 000000000..5be1d0510 --- /dev/null +++ b/server/src/_luaScriptsV2/fullSubject/reserveFullSubjectWrite.lua @@ -0,0 +1,50 @@ +--[[ + Reserve a FullSubject write so only one non-overwrite writer proceeds. + + KEYS: + [1] subjectKey + [2] reserveKey + [3] guardKey + + ARGV: + [1] token + [2] reserveTtl + [3] overwrite - "true" to bypass reservation, "false" to reserve if missing + [4] fetchTimeMs + + Returns: + "RESERVED" = caller may proceed with the write + "CACHE_EXISTS" = subject already exists or another writer already reserved it + "STALE_WRITE" = guard exists with newer timestamp than this write +]] + +local subjectKey = KEYS[1] +local reserveKey = KEYS[2] +local guardKey = KEYS[3] +local token = ARGV[1] +local reserveTtl = tonumber(ARGV[2]) +local overwrite = ARGV[3] == "true" +local fetchTimeMs = tonumber(ARGV[4]) + +if overwrite then + return "RESERVED" +end + +local guardTime = redis.call("GET", guardKey) +if guardTime and guardTime ~= cjson.null and fetchTimeMs then + local guardTimeNum = tonumber(guardTime) + if guardTimeNum and guardTimeNum > fetchTimeMs then + return "STALE_WRITE" + end +end + +if redis.call("EXISTS", subjectKey) == 1 then + return "CACHE_EXISTS" +end + +local reserved = redis.call("SET", reserveKey, token, "EX", reserveTtl, "NX") +if not reserved then + return "CACHE_EXISTS" +end + +return "RESERVED" diff --git a/server/src/_luaScriptsV2/fullSubject/updateCustomerDataV2.lua b/server/src/_luaScriptsV2/fullSubject/updateCustomerDataV2.lua new file mode 100644 index 000000000..e6430d437 --- /dev/null +++ b/server/src/_luaScriptsV2/fullSubject/updateCustomerDataV2.lua @@ -0,0 +1,50 @@ +--[[ + Lua Script: Update Customer Data in FullSubject V2 Redis Cache + + Atomically updates top-level customer fields in the cached FullSubject JSON. + + KEYS[1] = FullSubject cache key + + ARGV[1] = updates JSON object + ARGV[2] = cache TTL in seconds + ARGV[3] = current timestamp in ms + + Returns JSON: + { "success": true, "updated_fields": ["name", "email"] } + or + { "success": false, "cache_miss": true } +]] + +local subject_key = KEYS[1] +local updates = cjson.decode(ARGV[1]) +local cache_ttl = tonumber(ARGV[2]) +local now_ms = tonumber(ARGV[3]) + +local has_updates = false +for _ in pairs(updates) do + has_updates = true + break +end + +if not has_updates then + return cjson.encode({ success = true, updated_fields = {} }) +end + +local current_raw = redis.call("GET", subject_key) +if not current_raw then + return cjson.encode({ success = false, cache_miss = true }) +end + +local cached = cjson.decode(current_raw) +local updated_fields = {} + +for field_name, field_value in pairs(updates) do + cached.customer[field_name] = field_value + table.insert(updated_fields, field_name) +end + +-- cached._cachedAt = now_ms + +redis.call("SET", subject_key, cjson.encode(cached), "EX", cache_ttl) + +return cjson.encode({ success = true, updated_fields = updated_fields }) diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index a31e4d5f6..869b67292 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -13,6 +13,7 @@ const LOCK_DIR = join(DEDUCTION_DIR, "lock"); const DELETE_CACHE_DIR = join(__dirname, "deleteFullCustomerCache"); const RESET_DIR = join(__dirname, "resetCustomerEntitlements"); const UPDATE_DIR = join(__dirname, "updateCustomerEntitlements"); +const FULL_SUBJECT_DIR = join(__dirname, "fullSubject"); // ============================================================================ // HELPER MODULES @@ -165,6 +166,31 @@ const setFullCustomerCacheScript = readFileSync( export const SET_FULL_CUSTOMER_CACHE_SCRIPT = `${FULL_CUSTOMER_KEY_BUILDERS} ${setFullCustomerCacheScript}`; +const reserveFullSubjectWriteScript = readFileSync( + join(FULL_SUBJECT_DIR, "reserveFullSubjectWrite.lua"), + "utf-8", +); + +/** Reserve a FullSubject write so only one non-overwrite writer proceeds. */ +export const RESERVE_FULL_SUBJECT_WRITE_SCRIPT = reserveFullSubjectWriteScript; + +const releaseFullSubjectReservationScript = readFileSync( + join(FULL_SUBJECT_DIR, "releaseFullSubjectReservation.lua"), + "utf-8", +); + +/** Release a FullSubject write reservation if the token still matches. */ +export const RELEASE_FULL_SUBJECT_RESERVATION_SCRIPT = + releaseFullSubjectReservationScript; + +const updateCustomerDataV2Script = readFileSync( + join(FULL_SUBJECT_DIR, "updateCustomerDataV2.lua"), + "utf-8", +); + +/** Atomically update top-level customer fields in the cached FullSubject. */ +export const UPDATE_CUSTOMER_DATA_V2_SCRIPT = updateCustomerDataV2Script; + // ============================================================================ // RESET CUSTOMER ENTITLEMENTS SCRIPT (deprecated — kept for backward compat) // ============================================================================ diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index a6eeba0b3..bc6162f12 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -19,9 +19,12 @@ import { CLAIM_LOCK_RECEIPT_SCRIPT, DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, DELETE_FULL_CUSTOMER_CACHE_SCRIPT, + RELEASE_FULL_SUBJECT_RESERVATION_SCRIPT, + RESERVE_FULL_SUBJECT_WRITE_SCRIPT, RESET_CUSTOMER_ENTITLEMENTS_SCRIPT, SET_FULL_CUSTOMER_CACHE_SCRIPT, UPDATE_CUSTOMER_DATA_SCRIPT, + UPDATE_CUSTOMER_DATA_V2_SCRIPT, UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT, UPDATE_CUSTOMER_PRODUCT_SCRIPT, UPDATE_ENTITY_IN_CUSTOMER_SCRIPT, @@ -62,7 +65,7 @@ export const getConfiguredRegions = (): string[] => { }; /** Wait for a Redis instance to be ready */ -const waitForRedisReady = ( +export const waitForRedisReady = ( instance: Redis, region: string, timeoutMs = 10000, @@ -111,12 +114,20 @@ export const warmupRegionalRedis = async (): Promise => { }); await Promise.all(warmupPromises); + + try { + const { warmupRedisV2 } = await import("./initRedisV2.js"); + await warmupRedisV2(); + } catch (error) { + console.error("[Redis] v2: warmup failed -", error); + } + console.timeEnd("redis:warmup-total"); console.log(`[Redis] Warmup complete`); }; /** Configure a Redis instance with custom commands */ -const configureRedisInstance = (redisInstance: Redis): Redis => { +export const configureRedisInstance = (redisInstance: Redis): Redis => { const batchDeductionScript = getBatchDeductionScript(); redisInstance.defineCommand("batchDeduction", { @@ -194,6 +205,16 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: SET_FULL_CUSTOMER_CACHE_SCRIPT, }); + redisInstance.defineCommand("reserveFullSubjectWrite", { + numberOfKeys: 3, + lua: RESERVE_FULL_SUBJECT_WRITE_SCRIPT, + }); + + redisInstance.defineCommand("releaseFullSubjectReservation", { + numberOfKeys: 1, + lua: RELEASE_FULL_SUBJECT_RESERVATION_SCRIPT, + }); + redisInstance.defineCommand("resetCustomerEntitlements", { numberOfKeys: 1, lua: RESET_CUSTOMER_ENTITLEMENTS_SCRIPT, @@ -209,6 +230,11 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: UPDATE_CUSTOMER_DATA_SCRIPT, }); + redisInstance.defineCommand("updateFullSubjectCustomerDataV2", { + numberOfKeys: 1, + lua: UPDATE_CUSTOMER_DATA_V2_SCRIPT, + }); + redisInstance.defineCommand("appendEntityToCustomer", { numberOfKeys: 1, lua: APPEND_ENTITY_TO_CUSTOMER_SCRIPT, @@ -247,7 +273,7 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { }; /** Create a Redis connection for a specific region */ -const createRedisConnection = ({ +export const createRedisConnection = ({ cacheUrl, region, }: { @@ -440,6 +466,19 @@ declare module "ioredis" { overwrite: string, pathIndexJson: string, ): Promise<"STALE_WRITE" | "CACHE_EXISTS" | "OK">; + reserveFullSubjectWrite( + subjectKey: string, + reserveKey: string, + guardKey: string, + token: string, + reserveTtl: string, + overwrite: string, + fetchTimeMs: string, + ): Promise<"CACHE_EXISTS" | "RESERVED" | "STALE_WRITE">; + releaseFullSubjectReservation( + reserveKey: string, + token: string, + ): Promise<"RELEASED" | "SKIPPED">; resetCustomerEntitlements( cacheKey: string, paramsJson: string, @@ -453,6 +492,12 @@ declare module "ioredis" { paramsJson: string, ): Promise; updateCustomerData(cacheKey: string, paramsJson: string): Promise; + updateFullSubjectCustomerDataV2( + subjectKey: string, + updatesJson: string, + cacheTtlSeconds: string, + nowMs: string, + ): Promise; appendEntityToCustomer( cacheKey: string, entityJson: string, diff --git a/server/src/external/redis/initRedisV2.ts b/server/src/external/redis/initRedisV2.ts new file mode 100644 index 000000000..9116361a0 --- /dev/null +++ b/server/src/external/redis/initRedisV2.ts @@ -0,0 +1,24 @@ +import type { Redis } from "ioredis"; +import { + createRedisConnection, + currentRegion, + redis, + waitForRedisReady, +} from "./initRedis.js"; + +const cacheV2Url = process.env.CACHE_V2_URL?.trim(); +const primaryCacheUrl = process.env.CACHE_URL?.trim(); + +export const redisV2: Redis = + cacheV2Url && cacheV2Url !== primaryCacheUrl + ? createRedisConnection({ + cacheUrl: cacheV2Url, + region: `${currentRegion}:v2`, + }) + : redis; + +export const warmupRedisV2 = async (): Promise => { + if (redisV2 === redis) return; + + await waitForRedisReady(redisV2, "v2"); +}; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts index d9943780b..9a8371fe5 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts @@ -58,11 +58,14 @@ export const stripeToAutumnCustomerMiddleware = async ( const ctx = c.get("ctx") as StripeWebhookContext; await getAutumnCustomerId({ ctx }); - if (ctx.fullCustomer?.id) { - ctx.customerId = ctx.fullCustomer.id; + const customerId = + ctx.fullCustomer?.id || ctx.fullCustomer?.internal_id || undefined; + + if (customerId) { + ctx.customerId = customerId; ctx.rolloutSnapshot = computeRolloutSnapshot({ orgId: ctx.org.id, - customerId: ctx.customerId, + customerId, }); } diff --git a/server/src/external/vercel/misc/vercelCustomerMiddleware.ts b/server/src/external/vercel/misc/vercelCustomerMiddleware.ts index 0a022f713..8ab7ec7f9 100644 --- a/server/src/external/vercel/misc/vercelCustomerMiddleware.ts +++ b/server/src/external/vercel/misc/vercelCustomerMiddleware.ts @@ -1,6 +1,7 @@ import type { Context, Next } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; +import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js"; /** TTL for vercel installation ID -> customer ID cache (1 day) */ export const VERCEL_INSTALLATION_CACHE_TTL_SECONDS = 24 * 60 * 60; @@ -35,5 +36,14 @@ export const vercelCustomerMiddleware = async ( ctx.fullCustomer = customer ?? undefined; + const customerId = customer?.id || customer?.internal_id || undefined; + if (customerId) { + ctx.customerId = customerId; + ctx.rolloutSnapshot = computeRolloutSnapshot({ + orgId: ctx.org.id, + customerId, + }); + } + await next(); }; diff --git a/server/src/honoMiddlewares/baseMiddleware.ts b/server/src/honoMiddlewares/baseMiddleware.ts index 4fd47cf20..ebbc736a2 100644 --- a/server/src/honoMiddlewares/baseMiddleware.ts +++ b/server/src/honoMiddlewares/baseMiddleware.ts @@ -3,6 +3,7 @@ import { AppEnv, AuthType, LATEST_VERSION, + type Organization, tryCatch, } from "@autumn/shared"; import type { Context, Next } from "hono"; @@ -69,7 +70,7 @@ export const baseMiddleware = async (c: Context, next: Next) => { apiVersion: new ApiVersionClass(LATEST_VERSION), // Auth (will be populated by auth middleware) - org: undefined as any, + org: undefined as unknown as Organization, features: [], userId: undefined, customerId, diff --git a/server/src/init.ts b/server/src/init.ts index 75b7ea2fe..880f9e366 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -37,7 +37,7 @@ const init = async () => { console.timeEnd("init:pg-health-monitor"); console.time("init:redis-warmup"); - await Promise.all([warmupRegionalRedis()]); + await warmupRegionalRedis(); console.timeEnd("init:redis-warmup"); await startAllEdgeConfigPolling({ logger }); diff --git a/server/src/internal/customers/actions/getApiCustomerByRollout.ts b/server/src/internal/customers/actions/getApiCustomerByRollout.ts new file mode 100644 index 000000000..32599fc23 --- /dev/null +++ b/server/src/internal/customers/actions/getApiCustomerByRollout.ts @@ -0,0 +1,47 @@ +import type { CheckParams, TrackParams } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getOrCreateCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js"; +import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; +import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; +import { getOrCreateCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; +import { getApiCustomerV2 } from "../cusUtils/getApiCustomerV2/index.js"; + +export const getApiCustomerByRollout = async ({ + ctx, + params, + source, + withAutumnId, +}: { + ctx: AutumnContext; + params: Omit & { + customer_id: string | null; + }; + source?: string; + withAutumnId?: boolean; +}) => { + if (isFullSubjectRolloutEnabled({ ctx })) { + const fullSubject = await getOrCreateCachedFullSubject({ + ctx, + params, + source, + }); + + return getApiCustomerV2({ + ctx, + fullSubject, + withAutumnId, + }); + } + + const fullCustomer = await getOrCreateCachedFullCustomer({ + ctx, + params, + source, + }); + + return getApiCustomer({ + ctx, + fullCustomer, + withAutumnId, + }); +}; diff --git a/server/src/internal/customers/actions/updateCustomerData.ts b/server/src/internal/customers/actions/updateCustomerData.ts new file mode 100644 index 000000000..ad17c8067 --- /dev/null +++ b/server/src/internal/customers/actions/updateCustomerData.ts @@ -0,0 +1,60 @@ +import type { Customer, CustomerData, FullSubject } from "@autumn/shared"; +import { z } from "zod/v4"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { updateCachedCustomerData } from "@/internal/customers/cache/fullSubject/index.js"; + +export const updateCustomerData = async ({ + ctx, + fullSubject, + customerData, +}: { + ctx: AutumnContext; + fullSubject: FullSubject; + customerData?: CustomerData; +}) => { + const { logger } = ctx; + const idOrInternalId = + fullSubject.customer.id || fullSubject.customer.internal_id; + + const updates: Partial = {}; + if (!fullSubject.customer.name && customerData?.name) { + updates.name = customerData.name; + } + if (!fullSubject.customer.email && customerData?.email) { + if (z.string().email().safeParse(customerData.email).error) { + logger.info(`Invalid email ${customerData.email}, skipping update`); + } else { + updates.email = customerData.email; + } + } + if ( + customerData?.send_email_receipts !== undefined && + fullSubject.customer.send_email_receipts !== + customerData.send_email_receipts + ) { + updates.send_email_receipts = customerData.send_email_receipts; + } + + if (Object.keys(updates).length === 0) return false; + + logger.info(`Updating customer details:`, { + data: updates, + }); + + await CusService.update({ + ctx, + idOrInternalId, + update: updates, + }); + + Object.assign(fullSubject.customer, updates); + + await updateCachedCustomerData({ + ctx, + customerId: idOrInternalId, + updates, + }); + + return true; +}; diff --git a/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts new file mode 100644 index 000000000..0d3a7138a --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts @@ -0,0 +1,88 @@ +import { type FullSubject, normalizedToFullSubject } from "@autumn/shared"; +import { redisV2 } from "@/external/redis/initRedisV2.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; +import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js"; +import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js"; +import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js"; +import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; +import { + type CachedFullSubject, + cachedFullSubjectToNormalized, +} from "../fullSubjectCacheModel.js"; +import { invalidateCachedFullSubject } from "./invalidateCachedFullSubject.js"; + +export const getCachedFullSubject = async ({ + ctx, + customerId, + entityId, + source, +}: { + ctx: AutumnContext; + customerId: string; + entityId?: string; + source?: string; +}): Promise => { + const { org, env, logger } = ctx; + const subjectKey = buildFullSubjectKey({ + orgId: org.id, + env, + customerId, + entityId, + }); + + const cachedRaw = await tryRedisRead(() => redisV2.get(subjectKey), redisV2); + if (!cachedRaw) return undefined; + + let cached: CachedFullSubject; + try { + cached = JSON.parse(cachedRaw) as CachedFullSubject; + } catch (error) { + logger.warn( + `[getCachedFullSubject] Failed to parse cached subject for ${customerId}${entityId ? `:${entityId}` : ""}, source: ${source}, error: ${error}`, + ); + return undefined; + } + + const rolloutSnapshot = getFullSubjectRolloutSnapshot({ ctx }); + if ( + rolloutSnapshot && + isSnapshotCacheStale({ + snapshot: rolloutSnapshot, + cachedAt: cached._cachedAt, + }) + ) { + logger.warn( + `[getCachedFullSubject] Stale rollout cache for ${customerId}${entityId ? `:${entityId}` : ""}, evicting`, + ); + await invalidateCachedFullSubject({ + ctx, + customerId, + entityId, + source: "stale-rollout", + }); + return undefined; + } + + const balances = await getCachedFeatureBalancesBatch({ + orgId: org.id, + env, + customerId, + entityId, + featureIds: cached.meteredFeatures, + }); + + if (balances.length !== cached.meteredFeatures.length) { + logger.warn( + `[getCachedFullSubject] Incomplete cache for ${customerId}${entityId ? `:${entityId}` : ""}: expected ${cached.meteredFeatures.length} balance keys, got ${balances.length}. Rebuilding from DB, source: ${source}`, + ); + return undefined; + } + + const normalized = cachedFullSubjectToNormalized({ + cached, + customerEntitlements: balances.flatMap((balance) => balance.balances), + }); + + return normalizedToFullSubject({ normalized }); +}; diff --git a/server/src/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.ts new file mode 100644 index 000000000..28eaeb148 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/actions/getOrCreateCachedFullSubject.ts @@ -0,0 +1,126 @@ +import { + type CheckParams, + type FullSubject, + fullCustomerToFullSubject, + normalizedToFullSubject, + SubjectType, + type TrackParams, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { customerActions } from "@/internal/customers/actions/index.js"; +import { updateCustomerData } from "@/internal/customers/actions/updateCustomerData.js"; +import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js"; +import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js"; +import { getCachedFullSubject } from "./getCachedFullSubject.js"; +import { setCachedFullSubject } from "./setCachedFullSubject.js"; + +export const getOrCreateCachedFullSubject = async ({ + ctx, + params, + source, +}: { + ctx: AutumnContext; + params: Omit & { + customer_id: string | null; + }; + source?: string; +}): Promise => { + const { skipCache, logger } = ctx; + const fetchTimeMs = Date.now(); + const { + customer_id: customerId, + customer_data: customerData, + entity_id: entityId, + entity_data: entityData, + } = params; + + let fullSubject: FullSubject | undefined; + let normalized: Awaited>; + let setCache = true; + + if (customerId && !skipCache) { + fullSubject = await getCachedFullSubject({ + ctx, + customerId, + entityId, + source, + }); + + if (fullSubject) { + logger.debug(`[getOrCreateCachedFullSubject] Cache hit: ${customerId}`); + setCache = false; + } + } + + if (!fullSubject && customerId) { + normalized = await getFullSubjectNormalized({ + ctx, + customerId, + entityId, + }); + if (normalized) { + fullSubject = normalizedToFullSubject({ normalized }); + } + } + + if (!fullSubject) { + const fullCustomer = await customerActions.createWithDefaults({ + ctx, + customerId, + customerData, + }); + + fullSubject = fullCustomerToFullSubject({ + fullCustomer, + }); + } + + await updateCustomerData({ + ctx, + fullSubject, + customerData, + }); + + if (entityId && !fullSubject.entity) { + const newEntity = await autoCreateEntity({ + ctx, + customerId: fullSubject.customer.id || fullSubject.customer.internal_id, + entityId, + entityData: { + name: entityData?.name, + feature_id: entityData?.feature_id || "", + }, + }); + + if (newEntity) { + fullSubject.entity = newEntity; + fullSubject.entityId = newEntity.id || undefined; + fullSubject.internalEntityId = newEntity.internal_id; + fullSubject.subjectType = SubjectType.Entity; + setCache = true; + } + } + + if (!skipCache && setCache) { + if (!normalized) { + normalized = await getFullSubjectNormalized({ + ctx, + customerId: fullSubject.customer.id || fullSubject.customer.internal_id, + entityId: fullSubject.entity?.id || entityId, + }); + } + + if (normalized) { + await setCachedFullSubject({ + ctx, + normalized, + fetchTimeMs, + }).catch((error) => + logger.error(`Failed to set full subject cache: ${error}`), + ); + fullSubject = normalizedToFullSubject({ normalized }); + } + } + + return fullSubject; +}; diff --git a/server/src/internal/customers/cache/fullSubject/getOrSetCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts similarity index 67% rename from server/src/internal/customers/cache/fullSubject/getOrSetCachedFullSubject.ts rename to server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts index 0602d58f3..088d842c0 100644 --- a/server/src/internal/customers/cache/fullSubject/getOrSetCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts @@ -2,16 +2,13 @@ import { CustomerNotFoundError, EntityNotFoundError, type FullSubject, + normalizedToFullSubject, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { getFullSubject } from "@/internal/customers/repos/getFullSubject.js"; +import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js"; import { getCachedFullSubject } from "./getCachedFullSubject.js"; import { setCachedFullSubject } from "./setCachedFullSubject.js"; -/** - * Get FullSubject from Redis cache, or fetch from DB and set cache if not found. - * Throws CustomerNotFoundError / EntityNotFoundError if subject doesn't exist. - */ export const getOrSetCachedFullSubject = async ({ ctx, customerId, @@ -24,17 +21,19 @@ export const getOrSetCachedFullSubject = async ({ source?: string; }): Promise => { const { skipCache, logger } = ctx; + const fetchTimeMs = Date.now(); if (!skipCache) { const cached = await getCachedFullSubject({ ctx, customerId, entityId, + source, }); if (cached) { logger.debug( - `[getOrSetCachedFullSubject] Cache hit for ${customerId}${entityId ? `:${entityId}` : ""}, source: ${source}`, + `[getOrSetCachedFullSubject] Subject hit for ${customerId}${entityId ? `:${entityId}` : ""}, source: ${source}`, ); return cached; } @@ -44,27 +43,20 @@ export const getOrSetCachedFullSubject = async ({ `[getOrSetCachedFullSubject] Cache miss for ${customerId}${entityId ? `:${entityId}` : ""}, fetching from DB, source: ${source}`, ); - const fetchTimeMs = Date.now(); - - const fullSubject = await getFullSubject({ + const normalized = await getFullSubjectNormalized({ ctx, customerId, entityId, }); - if (!fullSubject) { + if (!normalized) { if (entityId) throw new EntityNotFoundError({ entityId }); throw new CustomerNotFoundError({ customerId }); } if (!skipCache) { - await setCachedFullSubject({ - ctx, - fullSubject, - fetchTimeMs, - source, - }); + await setCachedFullSubject({ ctx, normalized, fetchTimeMs }); } - return fullSubject; + return normalizedToFullSubject({ normalized }); }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/invalidateCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/invalidateCachedFullSubject.ts new file mode 100644 index 000000000..390d7aaa8 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/actions/invalidateCachedFullSubject.ts @@ -0,0 +1,98 @@ +import { redisV2 } from "@/external/redis/initRedisV2.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { buildFullSubjectBalanceKey } from "../builders/buildFullSubjectBalanceKey.js"; +import { buildFullSubjectGuardKey } from "../builders/buildFullSubjectGuardKey.js"; +import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; +import { buildFullSubjectReserveKey } from "../builders/buildFullSubjectReserveKey.js"; +import { FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS } from "../config/fullSubjectCacheConfig.js"; + +export const invalidateCachedFullSubject = async ({ + customerId, + entityId, + ctx, + source, + featureIds, + skipGuard = false, +}: { + customerId: string; + entityId?: string; + ctx: AutumnContext; + source?: string; + featureIds?: string[]; + skipGuard?: boolean; +}): Promise => { + const { org, env, logger } = ctx; + if (!customerId || redisV2.status !== "ready") return; + + const subjectKey = buildFullSubjectKey({ + orgId: org.id, + env, + customerId, + entityId, + }); + const guardKey = buildFullSubjectGuardKey({ + orgId: org.id, + env, + customerId, + entityId, + }); + const reserveKey = buildFullSubjectReserveKey({ + orgId: org.id, + env, + customerId, + entityId, + }); + const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId; + const guardTimestamp = Date.now().toString(); + + try { + await tryRedisWrite(async () => { + let featureIdsToDelete = featureIds; + + if (!featureIdsToDelete?.length) { + const subjectRaw = await redisV2.get(subjectKey); + if (subjectRaw) { + try { + const parsed = JSON.parse(subjectRaw) as { + meteredFeatures?: string[]; + }; + featureIdsToDelete = parsed.meteredFeatures ?? []; + } catch {} + } + } + + const multi = redisV2.multi(); + if (!skipGuard) { + multi.set( + guardKey, + guardTimestamp, + "EX", + FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS, + ); + } + multi.del(subjectKey); + multi.del(reserveKey); + for (const featureId of featureIdsToDelete ?? []) { + multi.del( + buildFullSubjectBalanceKey({ + orgId: org.id, + env, + customerId, + entityId, + featureId, + }), + ); + } + await multi.exec(); + }, redisV2); + + logger.info( + `[invalidateCachedFullSubject] subject: ${subjectLabel}, source: ${source}, skipGuard: ${skipGuard}`, + ); + } catch (error) { + logger.error( + `[invalidateCachedFullSubject] subject: ${subjectLabel}, source: ${source}, error: ${error}`, + ); + } +}; diff --git a/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject.ts new file mode 100644 index 000000000..f62c825dd --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject.ts @@ -0,0 +1,143 @@ +import type { NormalizedFullSubject } from "@autumn/shared"; +import { redisV2 } from "@/external/redis/initRedisV2.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { generateId } from "@/utils/genUtils.js"; +import { featureBalancesToHashFields } from "../balances/featureBalancesToHashFields.js"; +import { buildFullSubjectBalanceKey } from "../builders/buildFullSubjectBalanceKey.js"; +import { buildFullSubjectGuardKey } from "../builders/buildFullSubjectGuardKey.js"; +import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; +import { buildFullSubjectReserveKey } from "../builders/buildFullSubjectReserveKey.js"; +import { + FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS, + FULL_SUBJECT_CACHE_TTL_SECONDS, +} from "../config/fullSubjectCacheConfig.js"; +import { normalizedToCachedFullSubject } from "../fullSubjectCacheModel.js"; + +export type SetCachedFullSubjectResult = + | "OK" + | "STALE_WRITE" + | "CACHE_EXISTS" + | "FAILED"; + +export const setCachedFullSubject = async ({ + ctx, + normalized, + fetchTimeMs, + overwrite = false, +}: { + ctx: AutumnContext; + normalized: NormalizedFullSubject; + fetchTimeMs: number; + overwrite?: boolean; +}): Promise => { + const { org, env, logger } = ctx; + const { customerId, entityId } = normalized; + const subjectKey = buildFullSubjectKey({ + orgId: org.id, + env, + customerId, + entityId, + }); + const reserveKey = buildFullSubjectReserveKey({ + orgId: org.id, + env, + customerId, + entityId, + }); + const guardKey = buildFullSubjectGuardKey({ + orgId: org.id, + env, + customerId, + entityId, + }); + const cached = normalizedToCachedFullSubject({ normalized }); + const token = generateId("full_subject_res"); + + const balancesByFeatureId = new Map< + string, + typeof normalized.customer_entitlements + >(); + for (const customerEntitlement of normalized.customer_entitlements) { + const existing = + balancesByFeatureId.get(customerEntitlement.feature_id) ?? []; + existing.push(customerEntitlement); + balancesByFeatureId.set(customerEntitlement.feature_id, existing); + } + + const balanceWrites = Array.from(balancesByFeatureId.entries()).map( + ([featureId, balances]) => { + const balanceKey = buildFullSubjectBalanceKey({ + orgId: org.id, + env, + customerId, + entityId, + featureId, + }); + + return { + balanceKey, + fields: featureBalancesToHashFields({ featureId, balances }), + }; + }, + ); + + let reserved = false; + + const result = await tryRedisWrite(async () => { + if (!overwrite) { + const reserveResult = await redisV2.reserveFullSubjectWrite( + subjectKey, + reserveKey, + guardKey, + token, + String(FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS), + String(overwrite), + String(fetchTimeMs), + ); + + if (reserveResult === "CACHE_EXISTS") { + return "CACHE_EXISTS" as const; + } + if (reserveResult === "STALE_WRITE") { + return "STALE_WRITE" as const; + } + + reserved = true; + } + + const multi = redisV2.multi(); + + for (const { balanceKey, fields } of balanceWrites) { + multi.del(balanceKey); + multi.hset(balanceKey, fields); + multi.expire(balanceKey, FULL_SUBJECT_CACHE_TTL_SECONDS); + } + + multi.set( + subjectKey, + JSON.stringify(cached), + "EX", + FULL_SUBJECT_CACHE_TTL_SECONDS, + ); + + await multi.exec(); + return "OK" as const; + }, redisV2); + + const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId; + try { + logger.info( + `[setCachedFullSubject] ${subjectLabel}: ${result ?? "FAILED"}, balances=${cached.meteredFeatures.length}`, + ); + } finally { + if (reserved) { + await tryRedisWrite( + () => redisV2.releaseFullSubjectReservation(reserveKey, token), + redisV2, + ); + } + } + + return result ?? "FAILED"; +}; diff --git a/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.ts b/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.ts new file mode 100644 index 000000000..300e90a15 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/actions/updateCachedCustomerData.ts @@ -0,0 +1,128 @@ +import type { Customer } from "@autumn/shared"; +import { redisV2 } from "@/external/redis/initRedisV2.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import { logAlertEvent } from "@/utils/logging/logAlertEvent.js"; +import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; +import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "../config/fullSubjectCacheConfig.js"; +import { invalidateCachedFullSubject } from "./invalidateCachedFullSubject.js"; + +const FULL_SUBJECT_ALERT_BYTES_THRESHOLD = 1024 * 1024; +const FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS = 100; + +export const updateCachedCustomerData = async ({ + ctx, + customerId, + updates, +}: { + ctx: AutumnContext; + customerId: string; + updates: Partial; +}): Promise => { + if (Object.keys(updates).length === 0) return; + + const { org, env, logger } = ctx; + const subjectKey = buildFullSubjectKey({ + orgId: org.id, + env, + customerId, + }); + + try { + const currentRaw = await tryRedisRead( + () => redisV2.get(subjectKey), + redisV2, + ); + if (!currentRaw) return; + + const payloadBytes = Buffer.byteLength(currentRaw, "utf8"); + if (payloadBytes > FULL_SUBJECT_ALERT_BYTES_THRESHOLD) { + logAlertEvent({ + ctx, + severity: "warning", + category: "redis", + alertKey: "redis_full_subject_payload_large", + message: `FullSubject payload exceeded soft limit during customer cache update for ${customerId}`, + source: "updateCachedCustomerData", + component: "full_subject_cache", + data: { + subjectKey, + payload_bytes: payloadBytes, + threshold_bytes: FULL_SUBJECT_ALERT_BYTES_THRESHOLD, + redis_command: "updateFullSubjectCustomerDataV2", + }, + }); + } + + const updatesJson = JSON.stringify(updates); + const startTime = Date.now(); + const result = await tryRedisWrite( + () => + redisV2.updateFullSubjectCustomerDataV2( + subjectKey, + updatesJson, + String(FULL_SUBJECT_CACHE_TTL_SECONDS), + String(Date.now()), + ), + redisV2, + ); + const durationMs = Date.now() - startTime; + + if (durationMs > FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS) { + logAlertEvent({ + ctx, + severity: "warning", + category: "redis", + alertKey: "redis_full_subject_customer_update_slow", + message: `FullSubject customer cache update was slow for ${customerId}`, + source: "updateCachedCustomerData", + component: "full_subject_cache", + data: { + subjectKey, + duration_ms: durationMs, + threshold_ms: FULL_SUBJECT_UPDATE_SLOW_THRESHOLD_MS, + redis_command: "updateFullSubjectCustomerDataV2", + payload_bytes: payloadBytes, + }, + }); + } + + if (result === null) { + logger.warn( + `[updateCachedCustomerData] Redis write failed for ${customerId}, invalidating cache`, + ); + await invalidateCachedFullSubject({ + ctx, + customerId, + source: "updateCachedCustomerData:redis_write_failed", + }); + return; + } + + const parsed = JSON.parse(result) as { + success: boolean; + updated_fields?: string[]; + cache_miss?: boolean; + }; + if (parsed.cache_miss) return; + if (parsed.success) return; + + logger.warn( + `[updateCachedCustomerData] Lua update returned unsuccessful result for ${customerId}, invalidating cache`, + ); + await invalidateCachedFullSubject({ + ctx, + customerId, + source: "updateCachedCustomerData:lua_unsuccessful", + }); + } catch (error) { + logger.error( + `[updateCachedCustomerData] Failed to update subject for ${customerId}: ${error}`, + ); + await invalidateCachedFullSubject({ + ctx, + customerId, + source: "updateCachedCustomerData:error", + }); + } +}; diff --git a/server/src/internal/customers/cache/fullSubject/balances/featureBalancesToHashFields.ts b/server/src/internal/customers/cache/fullSubject/balances/featureBalancesToHashFields.ts new file mode 100644 index 000000000..9e56792c6 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/balances/featureBalancesToHashFields.ts @@ -0,0 +1,29 @@ +import type { SubjectBalance } from "@autumn/shared"; + +type BalanceHashMeta = { + featureId: string; + customerEntitlementIds: string[]; +}; + +export const featureBalancesToHashFields = ({ + featureId, + balances, +}: { + featureId: string; + balances: SubjectBalance[]; +}): Record => { + const meta: BalanceHashMeta = { + featureId, + customerEntitlementIds: balances.map((balance) => balance.id), + }; + + const hashFields: Record = { + _meta: JSON.stringify(meta), + }; + + for (const balance of balances) { + hashFields[balance.id] = JSON.stringify(balance); + } + + return hashFields; +}; diff --git a/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts b/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts new file mode 100644 index 000000000..3ab8e75b4 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts @@ -0,0 +1,105 @@ +import type { SubjectBalance } from "@autumn/shared"; +import { redisV2 } from "@/external/redis/initRedisV2.js"; +import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js"; +import { buildFullSubjectBalanceKey } from "../builders/buildFullSubjectBalanceKey.js"; + +type BalanceHashMeta = { + featureId: string; + customerEntitlementIds: string[]; +}; + +export type FeatureBalanceResult = { + featureId: string; + balances: SubjectBalance[]; +}; + +export const getCachedFeatureBalance = async ({ + orgId, + env, + customerId, + entityId, + featureId, +}: { + orgId: string; + env: string; + customerId: string; + entityId?: string; + featureId: string; +}): Promise => { + const balanceKey = buildFullSubjectBalanceKey({ + orgId, + env, + customerId, + entityId, + featureId, + }); + + const fields = await tryRedisRead(() => redisV2.hgetall(balanceKey), redisV2); + if (!fields?._meta) return undefined; + + const meta = JSON.parse(fields._meta) as BalanceHashMeta; + const balances: SubjectBalance[] = []; + + for (const customerEntitlementId of meta.customerEntitlementIds) { + const entryJson = fields[customerEntitlementId]; + if (!entryJson) continue; + balances.push(JSON.parse(entryJson) as SubjectBalance); + } + + return { featureId, balances }; +}; + +export const getCachedFeatureBalancesBatch = async ({ + orgId, + env, + customerId, + entityId, + featureIds, +}: { + orgId: string; + env: string; + customerId: string; + entityId?: string; + featureIds: string[]; +}): Promise => { + if (featureIds.length === 0) return []; + + const pipeline = redisV2.pipeline(); + for (const featureId of featureIds) { + pipeline.hgetall( + buildFullSubjectBalanceKey({ + orgId, + env, + customerId, + entityId, + featureId, + }), + ); + } + + const results = await tryRedisRead(() => pipeline.exec(), redisV2); + if (!results) return []; + + const featureBalances: FeatureBalanceResult[] = []; + + for (let i = 0; i < featureIds.length; i++) { + const fields = results[i]?.[1] as Record | null; + if (!fields?._meta) continue; + + const meta = JSON.parse(fields._meta) as BalanceHashMeta; + const balances: SubjectBalance[] = []; + + for (const customerEntitlementId of meta.customerEntitlementIds) { + const entryJson = fields[customerEntitlementId]; + if (!entryJson) continue; + balances.push(JSON.parse(entryJson) as SubjectBalance); + } + + featureBalances.push({ + featureId: featureIds[i], + balances, + }); + } + + return featureBalances; +}; diff --git a/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectBalanceKey.ts b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectBalanceKey.ts new file mode 100644 index 000000000..02287ef92 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectBalanceKey.ts @@ -0,0 +1,21 @@ +import { buildFullSubjectKey } from "./buildFullSubjectKey.js"; + +export const buildFullSubjectBalanceKey = ({ + orgId, + env, + customerId, + featureId, + entityId, +}: { + orgId: string; + env: string; + customerId: string; + featureId: string; + entityId?: string; +}) => + `${buildFullSubjectKey({ + orgId, + env, + customerId, + entityId, + })}:balances:${featureId}`; diff --git a/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectGuardKey.ts b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectGuardKey.ts new file mode 100644 index 000000000..52777bfae --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectGuardKey.ts @@ -0,0 +1,19 @@ +import { buildFullSubjectKey } from "./buildFullSubjectKey.js"; + +export const buildFullSubjectGuardKey = ({ + orgId, + env, + customerId, + entityId, +}: { + orgId: string; + env: string; + customerId: string; + entityId?: string; +}) => + `${buildFullSubjectKey({ + orgId, + env, + customerId, + entityId, + })}:guard`; diff --git a/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.ts b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.ts new file mode 100644 index 000000000..0c2cfc035 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.ts @@ -0,0 +1,14 @@ +export const buildFullSubjectKey = ({ + orgId, + env, + customerId, + entityId, +}: { + orgId: string; + env: string; + customerId: string; + entityId?: string; +}) => + entityId + ? `{${customerId}}:${orgId}:${env}:entity:${entityId}:full_subject` + : `{${customerId}}:${orgId}:${env}:full_subject`; diff --git a/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectReserveKey.ts b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectReserveKey.ts new file mode 100644 index 000000000..db4f92c40 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/builders/buildFullSubjectReserveKey.ts @@ -0,0 +1,19 @@ +import { buildFullSubjectKey } from "./buildFullSubjectKey.js"; + +export const buildFullSubjectReserveKey = ({ + orgId, + env, + customerId, + entityId, +}: { + orgId: string; + env: string; + customerId: string; + entityId?: string; +}) => + `${buildFullSubjectKey({ + orgId, + env, + customerId, + entityId, + })}:reserve`; diff --git a/server/src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.ts b/server/src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.ts new file mode 100644 index 000000000..f210a7a39 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.ts @@ -0,0 +1,5 @@ +import { seconds } from "@autumn/shared"; + +export const FULL_SUBJECT_CACHE_TTL_SECONDS = seconds.days(3); +export const FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS = 60; +export const FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS = 1; diff --git a/server/src/internal/customers/cache/fullSubject/deleteCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/deleteCachedFullSubject.ts deleted file mode 100644 index 4ba80616f..000000000 --- a/server/src/internal/customers/cache/fullSubject/deleteCachedFullSubject.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - getConfiguredRegions, - getRegionalRedis, - redis, -} from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { - buildFullSubjectCacheKey, - FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS, -} from "./fullSubjectCacheConfig.js"; - -/** - * Delete FullSubject from Redis cache across ALL regions. - * Routes key based on entityId presence. - */ -export const deleteCachedFullSubject = async ({ - customerId, - entityId, - ctx, - source, - skipGuard = false, -}: { - customerId: string; - entityId?: string; - ctx: AutumnContext; - source?: string; - skipGuard?: boolean; -}): Promise => { - const { org, env, logger } = ctx; - - if (redis.status !== "ready" || !customerId) return; - - const cacheKey = buildFullSubjectCacheKey({ - orgId: org.id, - env, - customerId, - entityId, - }); - const regions = getConfiguredRegions(); - const guardTimestamp = Date.now().toString(); - const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId; - - const deletePromises = regions.map(async (region) => { - try { - const regionalRedis = getRegionalRedis(region); - - if (regionalRedis.status !== "ready") { - logger.warn(`[deleteCachedFullSubject] ${region}: not_ready`); - return; - } - - const result = await regionalRedis.deleteFullCustomerCache( - cacheKey, - org.id, - env, - customerId, - guardTimestamp, - FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS.toString(), - skipGuard.toString(), - ); - - logger.info( - `[deleteCachedFullSubject] ${region}: ${result}, subject: ${subjectLabel}, source: ${source}`, - ); - } catch (error) { - logger.error( - `[deleteCachedFullSubject] ${region}: error, subject: ${subjectLabel}, source: ${source}, error: ${error}`, - ); - } - }); - - await Promise.all(deletePromises); -}; diff --git a/server/src/internal/customers/cache/fullSubject/fullSubjectCacheConfig.ts b/server/src/internal/customers/cache/fullSubject/fullSubjectCacheConfig.ts deleted file mode 100644 index 52e200374..000000000 --- a/server/src/internal/customers/cache/fullSubject/fullSubjectCacheConfig.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { seconds } from "@autumn/shared"; - -/** Cache TTL in seconds (3 days) */ -export const FULL_SUBJECT_CACHE_TTL_SECONDS = seconds.days(3); - -/** Guard TTL in seconds — prevents stale writes after deletion */ -export const FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS = 1; - -export const buildFullSubjectCacheKey = ({ - orgId, - env, - customerId, - entityId, -}: { - orgId: string; - env: string; - customerId: string; - entityId?: string; -}) => - entityId - ? `{${orgId}}:${env}:fullentity:1.0.0:${customerId}:${entityId}` - : `{${orgId}}:${env}:fullcustomer:2.0.0:${customerId}`; - -export const buildFullSubjectGuardKey = ({ - orgId, - env, - customerId, - entityId, -}: { - orgId: string; - env: string; - customerId: string; - entityId?: string; -}) => - entityId - ? `{${orgId}}:${env}:fullentity:guard:${customerId}:${entityId}` - : `{${orgId}}:${env}:fullcustomer:guard:v2:${customerId}`; diff --git a/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts b/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts new file mode 100644 index 000000000..94e92e2bf --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts @@ -0,0 +1,71 @@ +import type { NormalizedFullSubject } from "@autumn/shared"; + +export type CachedFullSubject = Omit< + NormalizedFullSubject, + "customer_entitlements" | "customer_prices" +> & { + _cachedAt: number; + meteredFeatures: string[]; +}; + +export const normalizedToCachedFullSubject = ({ + normalized, +}: { + normalized: NormalizedFullSubject; +}): CachedFullSubject => { + const meteredFeatures = Array.from( + new Set( + normalized.customer_entitlements.map((balance) => balance.feature_id), + ), + ); + + return { + subjectType: normalized.subjectType, + customerId: normalized.customerId, + internalCustomerId: normalized.internalCustomerId, + entityId: normalized.entityId, + internalEntityId: normalized.internalEntityId, + customer: normalized.customer, + entity: normalized.entity, + customer_products: normalized.customer_products, + flags: normalized.flags, + products: normalized.products, + entitlements: normalized.entitlements, + prices: normalized.prices, + free_trials: normalized.free_trials, + subscriptions: normalized.subscriptions, + invoices: normalized.invoices, + entity_aggregations: normalized.entity_aggregations, + _cachedAt: Date.now(), + meteredFeatures, + }; +}; + +export const cachedFullSubjectToNormalized = ({ + cached, + customerEntitlements, +}: { + cached: CachedFullSubject; + customerEntitlements: NormalizedFullSubject["customer_entitlements"]; +}): NormalizedFullSubject => { + return { + subjectType: cached.subjectType, + customerId: cached.customerId, + internalCustomerId: cached.internalCustomerId, + entityId: cached.entityId, + internalEntityId: cached.internalEntityId, + customer: cached.customer, + entity: cached.entity, + customer_products: cached.customer_products, + customer_entitlements: customerEntitlements, + customer_prices: [], + flags: cached.flags, + products: cached.products, + entitlements: cached.entitlements, + prices: cached.prices, + free_trials: cached.free_trials, + subscriptions: cached.subscriptions, + invoices: cached.invoices, + entity_aggregations: cached.entity_aggregations, + }; +}; diff --git a/server/src/internal/customers/cache/fullSubject/getCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/getCachedFullSubject.ts deleted file mode 100644 index 4d1a2a434..000000000 --- a/server/src/internal/customers/cache/fullSubject/getCachedFullSubject.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - CusProductStatus, - type FullCusProduct, - type FullSubject, - FullSubjectSchema, - type Invoice, -} from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import type { Redis } from "ioredis"; -import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js"; -import { redis } from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { resetCustomerEntitlements } from "@/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.js"; -import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js"; -import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js"; -import { normalizeFromSchema } from "@/utils/cacheUtils/normalizeFromSchema.js"; -import { deleteCachedFullSubject } from "./deleteCachedFullSubject.js"; -import { buildFullSubjectCacheKey } from "./fullSubjectCacheConfig.js"; - -const roundBalance = (value: number | null | undefined): number => { - if (value === null || value === undefined) return 0; - return new Decimal(value).toDecimalPlaces(10).toNumber(); -}; - -const roundFullSubjectBalances = (fullSubject: FullSubject): FullSubject => { - if (!fullSubject.customer_products) return fullSubject; - - for (const cusProduct of fullSubject.customer_products) { - if (!cusProduct.customer_entitlements) continue; - - for (const cusEnt of cusProduct.customer_entitlements) { - if (cusEnt.balance !== null && cusEnt.balance !== undefined) - cusEnt.balance = roundBalance(cusEnt.balance); - if (cusEnt.adjustment !== null && cusEnt.adjustment !== undefined) - cusEnt.adjustment = roundBalance(cusEnt.adjustment); - if ( - cusEnt.additional_balance !== null && - cusEnt.additional_balance !== undefined - ) - cusEnt.additional_balance = roundBalance(cusEnt.additional_balance); - - if (cusEnt.entities && typeof cusEnt.entities === "object") { - for (const entityId of Object.keys(cusEnt.entities)) { - const entityData = cusEnt.entities[entityId]; - if (entityData && typeof entityData === "object") { - if (entityData.balance !== null && entityData.balance !== undefined) - entityData.balance = roundBalance(entityData.balance); - if ( - entityData.adjustment !== null && - entityData.adjustment !== undefined - ) - entityData.adjustment = roundBalance(entityData.adjustment); - } - } - } - - if (cusEnt.rollovers && Array.isArray(cusEnt.rollovers)) { - for (const rollover of cusEnt.rollovers) { - if (rollover.balance !== null && rollover.balance !== undefined) - rollover.balance = roundBalance(rollover.balance); - } - } - } - } - - return fullSubject; -}; - -const deduplicateInvoices = (fullSubject: FullSubject): Invoice[] => { - const idToInvoice = new Map(); - for (const invoice of fullSubject.invoices ?? []) { - idToInvoice.set(invoice.id, invoice); - } - - return Array.from(idToInvoice.values()).sort((a, b) => { - if (b.created_at !== a.created_at) return b.created_at - a.created_at; - return b.id < a.id ? -1 : b.id > a.id ? 1 : 0; - }); -}; - -const filterExpiredCustomerProducts = ( - fullSubject: FullSubject, -): FullCusProduct[] => { - return ( - fullSubject.customer_products?.filter((cusProduct) => { - return cusProduct.status !== CusProductStatus.Expired; - }) ?? [] - ); -}; - -/** - * Get FullSubject from Redis cache. Lazily resets stale entitlements. - * @returns FullSubject if found, undefined if not in cache - */ -export const getCachedFullSubject = async ({ - ctx, - customerId, - entityId, - redisInstance, -}: { - ctx: AutumnContext; - customerId: string; - entityId?: string; - redisInstance?: Redis; -}): Promise => { - const { org, env } = ctx; - const cacheKey = buildFullSubjectCacheKey({ - orgId: org.id, - env, - customerId, - entityId, - }); - const redisClient = redisInstance || redis; - - const cached = await tryRedisRead( - () => redisClient.call("JSON.GET", cacheKey) as Promise, - ); - - if (!cached) return undefined; - - const parsed = JSON.parse(cached); - const cachedAt = parsed._cachedAt as number | undefined; - delete parsed._cachedAt; - - if ( - ctx.rolloutSnapshot && - isSnapshotCacheStale({ - snapshot: ctx.rolloutSnapshot, - cachedAt, - }) - ) { - ctx.logger.warn( - `[getCachedFullSubject] Stale rollout cache for ${customerId}, evicting`, - ); - await deleteCachedFullSubject({ - ctx, - customerId, - entityId, - source: "stale-rollout", - skipGuard: true, - }); - return undefined; - } - - const fullSubject = normalizeFromSchema({ - schema: FullSubjectSchema, - data: parsed, - }); - - if (!fullSubject.extra_customer_entitlements) { - fullSubject.extra_customer_entitlements = []; - } - - if (fullSubject.subjectType === "customer") { - fullSubject.invoices = deduplicateInvoices(fullSubject); - - if (!fullSubject.customer.send_email_receipts) { - fullSubject.customer.send_email_receipts = false; - } - } - - fullSubject.customer_products = filterExpiredCustomerProducts(fullSubject); - - if (getDbHealth() !== PgHealth.Degraded) { - await resetCustomerEntitlements({ - ctx, - fullCus: { - ...fullSubject.customer, - customer_products: fullSubject.customer_products, - extra_customer_entitlements: fullSubject.extra_customer_entitlements, - entities: [], - }, - }); - } - - return roundFullSubjectBalances(fullSubject); -}; diff --git a/server/src/internal/customers/cache/fullSubject/index.ts b/server/src/internal/customers/cache/fullSubject/index.ts new file mode 100644 index 000000000..2b3d52b32 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/index.ts @@ -0,0 +1,20 @@ +export { getCachedFullSubject } from "./actions/getCachedFullSubject.js"; +export { getOrCreateCachedFullSubject } from "./actions/getOrCreateCachedFullSubject.js"; +export { getOrSetCachedFullSubject } from "./actions/getOrSetCachedFullSubject.js"; +export { invalidateCachedFullSubject } from "./actions/invalidateCachedFullSubject.js"; +export { setCachedFullSubject } from "./actions/setCachedFullSubject.js"; +export { updateCachedCustomerData } from "./actions/updateCachedCustomerData.js"; +export type { FeatureBalanceResult } from "./balances/getCachedFeatureBalances.js"; +export { + getCachedFeatureBalance, + getCachedFeatureBalancesBatch, +} from "./balances/getCachedFeatureBalances.js"; +export { buildFullSubjectBalanceKey } from "./builders/buildFullSubjectBalanceKey.js"; +export { buildFullSubjectGuardKey } from "./builders/buildFullSubjectGuardKey.js"; +export { buildFullSubjectKey } from "./builders/buildFullSubjectKey.js"; +export { buildFullSubjectReserveKey } from "./builders/buildFullSubjectReserveKey.js"; +export { + FULL_SUBJECT_CACHE_GUARD_TTL_SECONDS, + FULL_SUBJECT_CACHE_RESERVE_TTL_SECONDS, + FULL_SUBJECT_CACHE_TTL_SECONDS, +} from "./config/fullSubjectCacheConfig.js"; diff --git a/server/src/internal/customers/cache/fullSubject/setCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/setCachedFullSubject.ts deleted file mode 100644 index 48a9adb13..000000000 --- a/server/src/internal/customers/cache/fullSubject/setCachedFullSubject.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { FullCustomer, FullSubject } from "@autumn/shared"; -import { redis } from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { buildPathIndex } from "@/internal/customers/cache/pathIndex/buildPathIndex.js"; -import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; -import { addToExtraLogs } from "@/utils/logging/addToExtraLogs.js"; -import { - buildFullSubjectCacheKey, - FULL_SUBJECT_CACHE_TTL_SECONDS, -} from "./fullSubjectCacheConfig.js"; - -type SetCacheResult = "OK" | "STALE_WRITE" | "CACHE_EXISTS" | "FAILED"; - -/** - * Set FullSubject in Redis cache. - * Reuses the existing setFullCustomerCache Lua script — it's key-agnostic. - */ -export const setCachedFullSubject = async ({ - ctx, - fullSubject, - fetchTimeMs, - source, - overwrite = false, -}: { - ctx: AutumnContext; - fullSubject: FullSubject; - fetchTimeMs: number; - source?: string; - overwrite?: boolean; -}): Promise => { - const { org, env, logger } = ctx; - - const cacheKey = buildFullSubjectCacheKey({ - orgId: org.id, - env, - customerId: fullSubject.customerId, - entityId: fullSubject.entityId, - }); - - const pathIndexEntries = buildPathIndex({ - fullCustomer: { - customer_products: fullSubject.customer_products, - extra_customer_entitlements: fullSubject.extra_customer_entitlements, - } as Pick< - FullCustomer, - "customer_products" | "extra_customer_entitlements" - > as FullCustomer, - }); - const pathIndexJson = JSON.stringify(pathIndexEntries); - - const payload = { ...fullSubject, _cachedAt: Date.now() }; - - const result = await tryRedisWrite(async () => { - return await redis.setFullCustomerCache( - cacheKey, - org.id, - env, - fullSubject.customerId, - String(fetchTimeMs), - String(FULL_SUBJECT_CACHE_TTL_SECONDS), - JSON.stringify(payload), - String(overwrite), - pathIndexJson, - ); - }); - - if (result === null) { - logger.warn( - `[setCachedFullSubject] Redis write failed for ${fullSubject.customerId}${fullSubject.entityId ? `:${fullSubject.entityId}` : ""}`, - ); - return "FAILED"; - } - - const subjectLabel = fullSubject.entityId - ? `${fullSubject.customerId}:${fullSubject.entityId}` - : fullSubject.customerId; - - logger.info( - `[setCachedFullSubject] ${subjectLabel}: ${result}, source: ${source}`, - ); - addToExtraLogs({ - ctx, - extras: { - setCacheSubject: { - result, - subjectType: fullSubject.subjectType, - }, - }, - }); - - return result; -}; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts index f3ea2089f..8d82455aa 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts @@ -1,7 +1,6 @@ import { type CheckParams, CustomerExpand, - CustomerNotFoundError, type FullCustomer, type TrackParams, } from "@autumn/shared"; @@ -20,14 +19,12 @@ export const getOrCreateCachedFullCustomer = async ({ ctx, params, source, - skipCreate = false, }: { ctx: AutumnContext; params: Omit & { customer_id: string | null; }; source?: string; - skipCreate?: boolean; }): Promise => { const { skipCache, logger } = ctx; const { @@ -53,10 +50,6 @@ export const getOrCreateCachedFullCustomer = async ({ logger.debug(`[getOrCreateCachedFullCustomer] Cache hit: ${customerId}`); setCache = false; } - - if (skipCreate && !fullCustomer) { - throw new CustomerNotFoundError({ customerId }); - } } // 2. Try DB if not in cache (CusService.getFull handles lazy reset internally) diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts new file mode 100644 index 000000000..fe4617d45 --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts @@ -0,0 +1,77 @@ +import type { + ApiBalanceV1, + FullAggregatedFeatureBalance, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; + +export const mergeAggregatedBalanceIntoApiBalanceV2 = ({ + apiBalance, + aggregatedFeatureBalance, +}: { + apiBalance: ApiBalanceV1; + aggregatedFeatureBalance?: FullAggregatedFeatureBalance; +}): ApiBalanceV1 => { + if (!aggregatedFeatureBalance) return apiBalance; + + if (apiBalance.unlimited || aggregatedFeatureBalance.unlimited) { + return { + ...apiBalance, + granted: 0, + remaining: 0, + usage: 0, + unlimited: true, + overage_allowed: false, + breakdown: apiBalance.breakdown ?? [], + }; + } + + const aggregatedAllowance = aggregatedFeatureBalance.allowance_total ?? 0; + const aggregatedAdjustment = aggregatedFeatureBalance.adjustment ?? 0; + const aggregatedBalance = aggregatedFeatureBalance.balance ?? 0; + + // Aggregate rows do not retain the full per-entity/per-product breakdown, so + // the top-level summary is merged from the coarse aggregate values only. + const granted = new Decimal(aggregatedAllowance) + .add(aggregatedAdjustment) + .toNumber(); + + const remaining = Decimal.max(0, new Decimal(aggregatedBalance)).toNumber(); + + const usage = new Decimal(granted).sub(aggregatedBalance); + + return { + ...apiBalance, + granted: new Decimal(apiBalance.granted).add(granted).toNumber(), + remaining: new Decimal(apiBalance.remaining).add(remaining).toNumber(), + usage: new Decimal(apiBalance.usage).add(usage).toNumber(), + unlimited: apiBalance.unlimited || aggregatedFeatureBalance.unlimited, + overage_allowed: + apiBalance.overage_allowed || + aggregatedFeatureBalance.usage_allowed || + false, + breakdown: apiBalance.breakdown ?? [], + }; +}; + +export const getEmptyApiBalanceV2 = ({ + featureId, + feature, +}: { + featureId: string; + feature?: ApiBalanceV1["feature"]; +}): ApiBalanceV1 => { + return { + object: "balance", + feature_id: featureId, + feature, + granted: 0, + remaining: 0, + usage: 0, + unlimited: false, + overage_allowed: false, + max_purchase: null, + next_reset_at: null, + breakdown: [], + rollovers: undefined, + }; +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalanceV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalanceV2.ts new file mode 100644 index 000000000..af51067fd --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalanceV2.ts @@ -0,0 +1,244 @@ +import { getUnlimitedApiBalance } from "@api/customers/cusFeatures/utils/apiBalanceUtils.js"; +import { + type ApiBalanceBreakdownV1, + type ApiBalanceV1, + CheckExpand, + CustomerExpand, + cusEntsHaveUnlimited, + cusEntsHaveUsageAllowed, + cusEntsToAdjustment, + cusEntsToAllowance, + cusEntsToCurrentBalance, + cusEntsToMaxPurchase, + cusEntsToNextResetAt, + cusEntsToPlanId, + cusEntsToPrepaidQuantity, + cusEntsToReset, + cusEntsToRolloverBalance, + cusEntsToRolloverGranted, + cusEntsToRollovers, + cusEntsToRolloverUsage, + cusEntsToUsage, + cusEntToInvoiceOverage, + customerEntitlementToBalancePrice, + dbToApiFeatureV1, + expandIncludes, + type Feature, + type FullAggregatedFeatureBalance, + type FullCusEntWithFullCusProduct, + type FullSubject, + getCusEntBalance, + isUnlimitedCusEnt, + nullish, + type SharedContext, + sumValues, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import { + getEmptyApiBalanceV2, + mergeAggregatedBalanceIntoApiBalanceV2, +} from "./apiBalanceV2Utils.js"; + +const getApiBalanceBreakdownItemV2 = ({ + fullSubject, + customerEntitlement, +}: { + fullSubject: FullSubject; + customerEntitlement: FullCusEntWithFullCusProduct; +}): ApiBalanceBreakdownV1 => { + const entityId = fullSubject.entity?.id ?? fullSubject.entity?.internal_id; + const planId = cusEntsToPlanId({ + cusEnts: [customerEntitlement], + }); + const allowance = cusEntsToAllowance({ + cusEnts: [customerEntitlement], + entityId, + }); + const adjustment = cusEntsToAdjustment({ + cusEnts: [customerEntitlement], + entityId, + }); + const includedGrant = new Decimal(allowance).add(adjustment).toNumber(); + const prepaidGrant = cusEntsToPrepaidQuantity({ + cusEnts: [customerEntitlement], + sumAcrossEntities: nullish(entityId), + }); + const remaining = cusEntsToCurrentBalance({ + cusEnts: [customerEntitlement], + entityId, + }); + const usage = cusEntsToUsage({ + cusEnts: [customerEntitlement], + entityId, + }); + const unlimited = isUnlimitedCusEnt(customerEntitlement); + const reset = cusEntsToReset({ + cusEnts: [customerEntitlement], + }); + const price = customerEntitlementToBalancePrice({ + customerEntitlement, + }); + const overage = cusEntToInvoiceOverage({ + cusEnt: customerEntitlement, + entityId, + }); + const apiId = customerEntitlement.external_id ?? customerEntitlement.id; + + return { + object: "balance_breakdown", + id: apiId, + plan_id: planId, + included_grant: includedGrant, + prepaid_grant: prepaidGrant, + remaining, + usage, + unlimited, + reset, + price, + expires_at: customerEntitlement.expires_at, + overage, + }; +}; + +export const getApiBalanceV2 = ({ + ctx, + fullSubject, + customerEntitlements, + feature, + aggregatedFeatureBalance, +}: { + ctx: SharedContext; + fullSubject: FullSubject; + customerEntitlements: FullCusEntWithFullCusProduct[]; + feature: Feature; + aggregatedFeatureBalance?: FullAggregatedFeatureBalance; +}): { data: ApiBalanceV1 } => { + const entityId = fullSubject.entity?.id ?? fullSubject.entity?.internal_id; + + const apiFeature = expandIncludes({ + expand: ctx.expand, + includes: [ + CheckExpand.BalanceFeature, + CustomerExpand.BalancesFeature, + "feature", + ], + }) + ? dbToApiFeatureV1({ + ctx, + dbFeature: feature, + }) + : undefined; + + if (customerEntitlements.length === 0) { + return { + data: mergeAggregatedBalanceIntoApiBalanceV2({ + apiBalance: getEmptyApiBalanceV2({ + featureId: feature.id, + feature: apiFeature, + }), + aggregatedFeatureBalance, + }), + }; + } + + const unlimited = cusEntsHaveUnlimited({ + cusEnts: customerEntitlements, + internalFeatureId: feature.internal_id, + }); + const usageAllowed = cusEntsHaveUsageAllowed({ + cusEnts: customerEntitlements, + internalFeatureId: feature.internal_id, + includeUsageLimit: false, + }); + + if (unlimited) { + return { + data: mergeAggregatedBalanceIntoApiBalanceV2({ + apiBalance: getUnlimitedApiBalance({ + apiFeature, + cusEnts: customerEntitlements, + }), + aggregatedFeatureBalance, + }), + }; + } + + const totalUnused = sumValues( + customerEntitlements.map((customerEntitlement) => { + const { unused } = getCusEntBalance({ + cusEnt: customerEntitlement, + entityId, + }); + + return unused; + }), + ); + const breakdownItems = customerEntitlements.map((customerEntitlement) => + getApiBalanceBreakdownItemV2({ + fullSubject, + customerEntitlement, + }), + ); + const totalGranted = sumValues( + breakdownItems.map((breakdownItem) => + new Decimal(breakdownItem.included_grant) + .add(breakdownItem.prepaid_grant) + .toNumber(), + ), + ); + const totalUsage = sumValues( + breakdownItems.map((breakdownItem) => breakdownItem.usage), + ); + const totalRemaining = sumValues( + breakdownItems.map((breakdownItem) => breakdownItem.remaining), + ); + const totalMaxPurchase = cusEntsToMaxPurchase({ + cusEnts: customerEntitlements, + entityId, + }); + const nextResetAt = cusEntsToNextResetAt({ + cusEnts: customerEntitlements, + }); + const totalRollovers = cusEntsToRollovers({ + cusEnts: customerEntitlements, + entityId, + }); + const totalRolloverGranted = cusEntsToRolloverGranted({ + cusEnts: customerEntitlements, + entityId, + }); + const totalRolloverBalance = cusEntsToRolloverBalance({ + cusEnts: customerEntitlements, + entityId, + }); + const totalRolloverUsage = cusEntsToRolloverUsage({ + cusEnts: customerEntitlements, + entityId, + }); + + return { + data: mergeAggregatedBalanceIntoApiBalanceV2({ + apiBalance: { + object: "balance", + feature_id: feature.id, + feature: apiFeature, + granted: new Decimal(totalGranted).add(totalRolloverGranted).toNumber(), + remaining: new Decimal(totalRemaining) + .add(totalRolloverBalance) + .add(totalUnused) + .toNumber(), + usage: new Decimal(totalUsage) + .add(totalRolloverUsage) + .sub(totalUnused) + .toNumber(), + unlimited, + overage_allowed: usageAllowed ?? false, + max_purchase: totalMaxPurchase, + next_reset_at: nextResetAt, + breakdown: breakdownItems, + rollovers: totalRollovers, + }, + aggregatedFeatureBalance, + }), + }; +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalancesV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalancesV2.ts new file mode 100644 index 000000000..903d9500e --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalancesV2.ts @@ -0,0 +1,157 @@ +import { + type ApiBalanceV1, + type ApiFlagV0, + type Feature, + FeatureType, + type FullAggregatedFeatureBalance, + type FullCusEntWithFullCusProduct, + type FullSubject, + fullSubjectToCustomerEntitlements, + orgToInStatuses, + scopeExpandForCtx, +} from "@autumn/shared"; +import type { RequestContext } from "@/honoUtils/HonoEnv.js"; +import { getApiBalanceV2 } from "./getApiBalanceV2.js"; +import { getApiFlagV2 } from "./getApiFlag.js"; + +type FeatureInput = { + featureId: string; + feature: Feature; + customerEntitlements: FullCusEntWithFullCusProduct[]; + aggregatedFeatureBalance?: FullAggregatedFeatureBalance; +}; + +const getFeatureInputs = ({ + customerEntitlements, + fullSubject, +}: { + customerEntitlements: FullCusEntWithFullCusProduct[]; + fullSubject: FullSubject; +}): FeatureInput[] => { + const customerEntitlementsByFeatureId: Record< + string, + FullCusEntWithFullCusProduct[] + > = {}; + + for (const customerEntitlement of customerEntitlements) { + const featureId = customerEntitlement.entitlement.feature.id; + customerEntitlementsByFeatureId[featureId] = [ + ...(customerEntitlementsByFeatureId[featureId] ?? []), + customerEntitlement, + ]; + } + + const aggregatedFeatureBalanceByFeatureId: Record< + string, + FullAggregatedFeatureBalance + > = {}; + + if (fullSubject.subjectType === "customer") { + for (const aggregatedFeatureBalance of fullSubject.aggregated_customer_entitlements ?? + []) { + aggregatedFeatureBalanceByFeatureId[aggregatedFeatureBalance.feature_id] = + aggregatedFeatureBalance; + } + } + + const featureIds = new Set([ + ...Object.keys(customerEntitlementsByFeatureId), + ...Object.keys(aggregatedFeatureBalanceByFeatureId), + ]); + + const featureInputs: FeatureInput[] = []; + + for (const featureId of featureIds) { + const customerEntitlements = + customerEntitlementsByFeatureId[featureId] ?? []; + const aggregatedFeatureBalance = + aggregatedFeatureBalanceByFeatureId[featureId]; + const feature = + customerEntitlements[0]?.entitlement.feature ?? + aggregatedFeatureBalance?.feature; + + if (!feature) continue; + + featureInputs.push({ + featureId, + feature, + customerEntitlements, + aggregatedFeatureBalance, + }); + } + + return featureInputs; +}; + +export const getApiBalancesV2 = ({ + ctx, + fullSubject, +}: { + ctx: RequestContext; + fullSubject: FullSubject; +}): { + balances: Record; + flags: Record; +} => { + const customerEntitlements = fullSubjectToCustomerEntitlements({ + fullSubject, + inStatuses: orgToInStatuses({ + org: ctx.org, + }), + }); + const featureInputs = getFeatureInputs({ + customerEntitlements, + fullSubject, + }); + + const apiBalances: Record = {}; + const apiFlags: Record = {}; + + const flagScopedCtx = scopeExpandForCtx({ + ctx, + prefix: ["flags", "flag"], + }); + const balancesScopedCtx = scopeExpandForCtx({ + ctx, + prefix: ["balances", "balance"], + }); + + for (const featureInput of featureInputs) { + const { + featureId, + feature, + customerEntitlements, + aggregatedFeatureBalance, + } = featureInput; + + if (feature.type === FeatureType.Boolean) { + const apiFlag = getApiFlagV2({ + ctx: flagScopedCtx, + customerEntitlements, + feature, + aggregatedFeatureBalance, + }); + + if (apiFlag) { + apiFlags[featureId] = apiFlag; + } + + continue; + } + + const { data } = getApiBalanceV2({ + ctx: balancesScopedCtx, + fullSubject, + customerEntitlements, + feature, + aggregatedFeatureBalance, + }); + + apiBalances[featureId] = data; + } + + return { + balances: apiBalances, + flags: apiFlags, + }; +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiFlag.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiFlag.ts new file mode 100644 index 000000000..ed1ac0052 --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiFlag.ts @@ -0,0 +1,59 @@ +import { getApiFlag } from "@api/customers/flags/utils/getApiFlag.js"; +import type { ApiFlagV0 } from "@autumn/shared"; +import { + dbToApiFeatureV1, + expandPathIncludes, + type Feature, + type FullAggregatedFeatureBalance, + type FullCusEntWithFullCusProduct, + type SharedContext, + scopeExpandForCtx, +} from "@autumn/shared"; + +export const getApiFlagV2 = ({ + ctx, + customerEntitlements, + feature, + aggregatedFeatureBalance, +}: { + ctx: SharedContext; + customerEntitlements: FullCusEntWithFullCusProduct[]; + feature: Feature; + aggregatedFeatureBalance?: FullAggregatedFeatureBalance; +}): ApiFlagV0 | undefined => { + if (customerEntitlements.length > 0) { + const { data } = getApiFlag({ + ctx, + cusEnts: customerEntitlements, + feature, + }); + + return data; + } + + if (!aggregatedFeatureBalance) return undefined; + + const featureCtx = scopeExpandForCtx({ + ctx, + prefix: "feature", + }); + + const apiFeature = expandPathIncludes({ + expand: ctx.expand, + includes: ["feature"], + }) + ? dbToApiFeatureV1({ + ctx: featureCtx, + dbFeature: aggregatedFeatureBalance.feature, + }) + : undefined; + + return { + object: "flag", + id: aggregatedFeatureBalance.api_id, + plan_id: null, + expires_at: null, + feature_id: aggregatedFeatureBalance.feature_id, + feature: apiFeature, + }; +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerBaseV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerBaseV2.ts new file mode 100644 index 000000000..cae59b39c --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerBaseV2.ts @@ -0,0 +1,86 @@ +import { + type ApiCustomerV5, + ApiCustomerV5Schema, + CustomerExpand, + type CustomerLegacyData, + type FullSubject, + scopeExpandForCtx, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import type { RequestContext } from "@/honoUtils/HonoEnv.js"; +import { invoicesToResponse } from "../../../invoices/invoiceUtils.js"; +import { getApiBalancesV2 } from "./getApiBalance/getApiBalancesV2.js"; +import { getApiSubscriptionsV2 } from "./getApiSubscription/getApiSubscriptionsV2.js"; + +/** + * Get base ApiCustomer without expand fields from FullSubject. + * By default, it includes the autumn_id. + */ +export const getApiCustomerBaseV2 = async ({ + ctx, + fullSubject, + withAutumnId = true, +}: { + ctx: RequestContext; + fullSubject: FullSubject; + withAutumnId?: boolean; +}): Promise<{ apiCustomer: ApiCustomerV5; legacyData: CustomerLegacyData }> => { + const { balances: apiBalances, flags: apiFlags } = getApiBalancesV2({ + ctx, + fullSubject, + }); + + const subscriptionsScopedCtx = scopeExpandForCtx({ + ctx, + prefix: "subscriptions", + }); + + const { + subscriptions: apiSubscriptions, + purchases: apiPurchases, + legacyData: cusProductLegacyData, + } = await getApiSubscriptionsV2({ + ctx: subscriptionsScopedCtx, + fullSubject, + }); + + const customer = fullSubject.customer; + + const apiCustomer = ApiCustomerV5Schema.extend({ + autumn_id: z.string().optional(), + }).parse({ + autumn_id: withAutumnId ? customer.internal_id : undefined, + id: customer.id || null, + created_at: customer.created_at, + name: customer.name || null, + email: customer.email || null, + fingerprint: customer.fingerprint || null, + stripe_id: customer.processor?.id || null, + env: customer.env, + metadata: customer.metadata ?? {}, + subscriptions: apiSubscriptions, + purchases: apiPurchases, + balances: apiBalances, + flags: apiFlags, + send_email_receipts: customer.send_email_receipts ?? false, + billing_controls: { + auto_topups: customer.auto_topups ?? undefined, + spend_limits: customer.spend_limits ?? undefined, + usage_alerts: customer.usage_alerts ?? undefined, + overage_allowed: customer.overage_allowed ?? undefined, + }, + invoices: + fullSubject.invoices && ctx.expand.includes(CustomerExpand.Invoices) + ? invoicesToResponse({ + invoices: fullSubject.invoices, + }) + : undefined, + } satisfies ApiCustomerV5); + + return { + apiCustomer, + legacyData: { + cusProductLegacyData, + }, + }; +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerV2.ts new file mode 100644 index 000000000..85302af6a --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerV2.ts @@ -0,0 +1,56 @@ +import { + AffectedResource, + type ApiCustomerV5, + applyResponseVersionChanges, + CustomerExpand, + type FullSubject, +} from "@autumn/shared"; +import type { RequestContext } from "@/honoUtils/HonoEnv.js"; +import { getApiCustomerExpand } from "../apiCusUtils/getApiCustomerExpand.js"; +import { getApiCustomerBaseV2 } from "./getApiCustomerBaseV2.js"; + +/** + * Transform FullSubject to ApiCustomer with expand fields and version changes applied. + */ +export const getApiCustomerV2 = async ({ + ctx, + fullSubject, + withAutumnId = false, +}: { + ctx: RequestContext; + fullSubject: FullSubject; + withAutumnId?: boolean; +}): Promise => { + const { apiCustomer: baseCustomer, legacyData } = await getApiCustomerBaseV2({ + ctx, + fullSubject, + withAutumnId, + }); + + const cleanedBaseCustomer: ApiCustomerV5 = { + ...baseCustomer, + entities: undefined, + autumn_id: withAutumnId ? baseCustomer.autumn_id : undefined, + invoices: ctx.expand.includes(CustomerExpand.Invoices) + ? (baseCustomer.invoices ?? []) + : undefined, + }; + + const apiCustomerExpand = await getApiCustomerExpand({ + ctx, + customerId: fullSubject.customer.id || fullSubject.customer.internal_id, + }); + + const apiCustomer: ApiCustomerV5 = { + ...cleanedBaseCustomer, + ...apiCustomerExpand, + }; + + return applyResponseVersionChanges({ + input: apiCustomer, + targetVersion: ctx.apiVersion, + resource: AffectedResource.Customer, + legacyData, + ctx, + }); +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts new file mode 100644 index 000000000..b50765d0e --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts @@ -0,0 +1,142 @@ +import { + type ApiSubscriptionV1, + ApiSubscriptionV1Schema, + type CusProductLegacyData, + CusProductStatus, + CustomerExpand, + cusProductToPlanStatus, + cusProductToProduct, + expandIncludes, + expandPathIncludes, + type FullCusProduct, + type FullSubject, + isCustomerProductOneOff, + isCustomerProductTrialing, + type Subscription, + scopeExpandForCtx, +} from "@autumn/shared"; +import type { AutumnContext, RequestContext } from "@/honoUtils/HonoEnv.js"; +import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js"; + +type ApiSubscriptionResult = { + data: ApiSubscriptionV1; + legacyData: CusProductLegacyData; +}; + +const handlePlanExpand = ({ + ctx, + customerProduct, +}: { + ctx: AutumnContext; + customerProduct: FullCusProduct; +}) => { + const planCtx = scopeExpandForCtx({ + ctx, + prefix: "plan", + }); + + const shouldExpandPlanFromScopedCtx = expandPathIncludes({ + expand: ctx.expand, + includes: ["plan"], + }); + const shouldExpandPlanFromLegacyCtx = isCustomerProductOneOff(customerProduct) + ? expandIncludes({ + expand: ctx.expand, + includes: [CustomerExpand.PurchasesPlan], + }) + : expandIncludes({ + expand: ctx.expand, + includes: [CustomerExpand.SubscriptionsPlan], + }); + + return { + planCtx, + shouldExpandPlan: + shouldExpandPlanFromScopedCtx || shouldExpandPlanFromLegacyCtx, + }; +}; + +export const getApiSubscriptionV2 = async ({ + ctx, + fullSubject, + customerProduct, +}: { + ctx: RequestContext; + fullSubject: FullSubject; + customerProduct: FullCusProduct; +}): Promise => { + const fullProduct = cusProductToProduct({ + cusProduct: customerProduct, + }); + const subId = customerProduct.subscription_ids?.[0]; + const autumnSubscription = fullSubject.subscriptions?.find( + (subscription) => + subscription.id === subId || + (subscription as Subscription).stripe_id === subId, + ); + + let subscriptionPeriod = { + current_period_start: null as number | null, + current_period_end: null as number | null, + }; + + if (autumnSubscription) { + subscriptionPeriod = { + current_period_start: autumnSubscription.current_period_start + ? autumnSubscription.current_period_start * 1000 + : null, + current_period_end: autumnSubscription.current_period_end + ? autumnSubscription.current_period_end * 1000 + : null, + }; + } + + if (!subId && isCustomerProductTrialing(customerProduct)) { + subscriptionPeriod = { + current_period_start: customerProduct.starts_at, + current_period_end: customerProduct.trial_ends_at ?? null, + }; + } + + const { planCtx, shouldExpandPlan } = handlePlanExpand({ + ctx, + customerProduct, + }); + + const apiPlan = shouldExpandPlan + ? await getPlanResponse({ + product: fullProduct, + features: ctx.features, + expand: planCtx.expand.filter((entry) => entry.length > 0), + }) + : undefined; + + const status = cusProductToPlanStatus({ + status: customerProduct.status, + }); + + return { + data: ApiSubscriptionV1Schema.parse({ + id: customerProduct.external_id ?? customerProduct.id ?? "", + plan: apiPlan, + plan_id: fullProduct.id, + add_on: fullProduct.is_add_on, + auto_enable: fullProduct.is_default, + status: status === CusProductStatus.Active ? "active" : "scheduled", + past_due: customerProduct.status === CusProductStatus.PastDue, + canceled_at: customerProduct.canceled_at || null, + expires_at: customerProduct.ended_at || null, + trial_ends_at: isCustomerProductTrialing(customerProduct) + ? (customerProduct.trial_ends_at ?? null) + : null, + started_at: customerProduct.starts_at, + quantity: customerProduct.quantity, + current_period_start: subscriptionPeriod.current_period_start, + current_period_end: subscriptionPeriod.current_period_end, + } satisfies ApiSubscriptionV1), + legacyData: { + subscription_id: subId || undefined, + options: customerProduct.options, + } satisfies CusProductLegacyData, + }; +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionsV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionsV2.ts new file mode 100644 index 000000000..37bcfe5b1 --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionsV2.ts @@ -0,0 +1,52 @@ +import { + type ApiSubscriptionV1, + apiSubscription, + type CusProductLegacyData, + type FullSubject, + fullSubjectToApiCustomerProducts, + isCustomerProductOneOff, +} from "@autumn/shared"; +import type { RequestContext } from "@/honoUtils/HonoEnv.js"; +import { getApiSubscriptionV2 } from "./getApiSubscriptionV2.js"; + +export const getApiSubscriptionsV2 = async ({ + ctx, + fullSubject, +}: { + ctx: RequestContext; + fullSubject: FullSubject; +}) => { + const apiSubscriptions: ApiSubscriptionV1[] = []; + const apiPurchasesAsSubscriptions: ApiSubscriptionV1[] = []; + const customerProducts = fullSubjectToApiCustomerProducts({ + fullSubject, + }); + const legacyData: Record = {}; + + for (const customerProduct of customerProducts) { + const { data, legacyData: customerProductLegacyData } = + await getApiSubscriptionV2({ + ctx, + fullSubject, + customerProduct, + }); + + if (isCustomerProductOneOff(customerProduct)) { + apiPurchasesAsSubscriptions.push(data); + } else { + apiSubscriptions.push(data); + } + + legacyData[data.plan_id] = customerProductLegacyData; + } + + return { + subscriptions: apiSubscriptions, + purchases: apiPurchasesAsSubscriptions.map((subscription) => + apiSubscription.map.v1ToPurchaseV0({ + apiSubscriptionV1: subscription, + }), + ), + legacyData, + }; +}; diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/index.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/index.ts new file mode 100644 index 000000000..8e91c1001 --- /dev/null +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/index.ts @@ -0,0 +1,2 @@ +export { getApiCustomerBaseV2 } from "./getApiCustomerBaseV2.js"; +export { getApiCustomerV2 } from "./getApiCustomerV2.js"; diff --git a/server/src/internal/customers/handlers/handleGetOrCreateCustomer/handleGetOrCreateCustomerV2.ts b/server/src/internal/customers/handlers/handleGetOrCreateCustomer/handleGetOrCreateCustomerV2.ts index 304adaf19..a726d843b 100644 --- a/server/src/internal/customers/handlers/handleGetOrCreateCustomer/handleGetOrCreateCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleGetOrCreateCustomer/handleGetOrCreateCustomerV2.ts @@ -4,8 +4,8 @@ import { CustomerDataSchema, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { getApiCustomer } from "../../cusUtils/apiCusUtils/getApiCustomer.js"; -import { getOrCreateCachedFullCustomer } from "../../cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; +import { getApiCustomerByRollout } from "@/internal/customers/actions/getApiCustomerByRollout.js"; +import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; export const handleGetOrCreateCustomerV2 = createRoute({ resource: AffectedResource.Customer, @@ -19,7 +19,7 @@ export const handleGetOrCreateCustomerV2 = createRoute({ const customerData = CustomerDataSchema.parse(createCusParams); const customerId = createCusParams.customer_id; - const fullCustomer = await getOrCreateCachedFullCustomer({ + const apiCustomer = await getApiCustomerByRollout({ ctx, params: { customer_id: customerId, @@ -28,16 +28,13 @@ export const handleGetOrCreateCustomerV2 = createRoute({ entity_data: createCusParams.entity_data, }, source: "handleGetOrCreateCustomerV2", - }); - - const apiCustomer = await getApiCustomer({ - ctx, - fullCustomer, withAutumnId: createCusParams.with_autumn_id, }); const duration = Date.now() - start; - ctx.logger.debug(`[post-customer] duration: ${duration}ms`); + ctx.logger.debug( + `[post-customer] path=${isFullSubjectRolloutEnabled({ ctx }) ? "v2" : "v1"} duration: ${duration}ms`, + ); return c.json(apiCustomer); }, diff --git a/server/src/internal/customers/repos/customerRepo.ts b/server/src/internal/customers/repos/customerRepo.ts deleted file mode 100644 index 6be6a5f28..000000000 --- a/server/src/internal/customers/repos/customerRepo.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getFullSubject } from "./getFullSubject.js"; - -export const customerRepo = { - getFullSubject, -} as const; diff --git a/server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts b/server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts deleted file mode 100644 index e6bcb6b27..000000000 --- a/server/src/internal/customers/repos/getFullCustomerV2/resultToFullCustomer.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { - CustomerPrice, - DbPrice, - DbRollover, - Entity, - FullAggregatedCustomerEntitlement, - FullCusProduct, - FullCustomer, - FullCustomerEntitlement, - FullCustomerPrice, - Invoice, - Replaceable, - Subscription, -} from "@autumn/shared"; -import type { SubjectCoreRow } from "../getFullSubject.js"; - -const getRolloverSortValue = ({ rollover }: { rollover: DbRollover }) => - rollover.expires_at ?? Number.POSITIVE_INFINITY; - -const buildFullCustomerEntitlement = ({ - customerEntitlement, - entitlement, - rollovers, -}: { - customerEntitlement: SubjectCoreRow["customer_entitlements"][number]; - entitlement: SubjectCoreRow["entitlements"][number] | undefined; - rollovers: DbRollover[]; -}): FullCustomerEntitlement | null => { - if (!entitlement) return null; - - return { - ...customerEntitlement, - entitlement, - replaceables: [] as Replaceable[], - rollovers: [...rollovers].sort( - (left, right) => - getRolloverSortValue({ rollover: left }) - - getRolloverSortValue({ rollover: right }), - ), - } as FullCustomerEntitlement; -}; - -const buildFullCustomerPrice = ({ - customerPrice, - price, -}: { - customerPrice: SubjectCoreRow["customer_prices"][number]; - price: DbPrice | undefined; -}): FullCustomerPrice | null => { - if (!price) return null; - - return { - ...customerPrice, - price, - } as FullCustomerPrice; -}; - -export const resultToFullCustomer = ({ - row, - entities = [], - invoices, - subscriptions, -}: { - row: SubjectCoreRow; - entities?: Entity[]; - invoices?: Invoice[]; - subscriptions?: Subscription[]; -}): FullCustomer => { - const productsByInternalId = new Map( - row.products.map((product) => [product.internal_id, product] as const), - ); - const entitlementsById = new Map( - row.entitlements.map( - (entitlement) => [entitlement.id, entitlement] as const, - ), - ); - const pricesById = new Map( - row.prices.map((price) => [price.id, price] as const), - ); - const freeTrialsById = new Map( - row.free_trials.map((freeTrial) => [freeTrial.id, freeTrial] as const), - ); - - const rolloversByCustomerEntitlementId = new Map(); - for (const rollover of row.rollovers) { - const existingRollovers = - rolloversByCustomerEntitlementId.get(rollover.cus_ent_id) ?? []; - existingRollovers.push(rollover); - rolloversByCustomerEntitlementId.set( - rollover.cus_ent_id, - existingRollovers, - ); - } - - const customerPricesByCustomerProductId = new Map< - string, - FullCustomerPrice[] - >(); - for (const customerPrice of row.customer_prices) { - if (!customerPrice.customer_product_id) continue; - - const fullCustomerPrice = buildFullCustomerPrice({ - customerPrice, - price: customerPrice.price_id - ? pricesById.get(customerPrice.price_id) - : undefined, - }); - - if (!fullCustomerPrice) continue; - - const existingCustomerPrices = - customerPricesByCustomerProductId.get( - customerPrice.customer_product_id, - ) ?? []; - existingCustomerPrices.push(fullCustomerPrice); - customerPricesByCustomerProductId.set( - customerPrice.customer_product_id, - existingCustomerPrices, - ); - } - - const customerEntitlementsByCustomerProductId = new Map< - string, - FullCustomerEntitlement[] - >(); - for (const customerEntitlement of row.customer_entitlements) { - if (!customerEntitlement.customer_product_id) continue; - - const fullCustomerEntitlement = buildFullCustomerEntitlement({ - customerEntitlement, - entitlement: entitlementsById.get(customerEntitlement.entitlement_id), - rollovers: - rolloversByCustomerEntitlementId.get(customerEntitlement.id) ?? [], - }); - - if (!fullCustomerEntitlement) continue; - - const existingCustomerEntitlements = - customerEntitlementsByCustomerProductId.get( - customerEntitlement.customer_product_id, - ) ?? []; - existingCustomerEntitlements.push(fullCustomerEntitlement); - customerEntitlementsByCustomerProductId.set( - customerEntitlement.customer_product_id, - existingCustomerEntitlements, - ); - } - - const customerProducts: FullCusProduct[] = []; - for (const customerProduct of row.customer_products) { - const product = productsByInternalId.get( - customerProduct.internal_product_id, - ); - if (!product) continue; - - customerProducts.push({ - ...customerProduct, - product, - free_trial: customerProduct.free_trial_id - ? (freeTrialsById.get(customerProduct.free_trial_id) ?? null) - : null, - customer_prices: - customerPricesByCustomerProductId.get(customerProduct.id) ?? [], - customer_entitlements: - customerEntitlementsByCustomerProductId.get(customerProduct.id) ?? [], - } as FullCusProduct); - } - - const extraCustomerEntitlements = row.extra_customer_entitlements - .map((customerEntitlement) => - buildFullCustomerEntitlement({ - customerEntitlement, - entitlement: entitlementsById.get(customerEntitlement.entitlement_id), - rollovers: - rolloversByCustomerEntitlementId.get(customerEntitlement.id) ?? [], - }), - ) - .filter( - (customerEntitlement): customerEntitlement is FullCustomerEntitlement => - customerEntitlement !== null, - ); - - let aggregatedCustomerProducts: FullCusProduct[] | undefined; - let aggregatedCustomerEntitlements: - | FullAggregatedCustomerEntitlement[] - | undefined; - let aggregatedCustomerPrices: CustomerPrice[] | undefined; - - if (row.entity_aggregations) { - const entityAgg = row.entity_aggregations; - - const entityCusPricesByProductId = new Map(); - for (const entityCusPrice of entityAgg.aggregated_customer_prices) { - if (!entityCusPrice.customer_product_id) continue; - - const fullPrice = buildFullCustomerPrice({ - customerPrice: entityCusPrice, - price: entityCusPrice.price_id - ? pricesById.get(entityCusPrice.price_id) - : undefined, - }); - if (!fullPrice) continue; - - const existing = - entityCusPricesByProductId.get(entityCusPrice.customer_product_id) ?? - []; - existing.push(fullPrice); - entityCusPricesByProductId.set( - entityCusPrice.customer_product_id, - existing, - ); - } - - aggregatedCustomerProducts = []; - for (const entityCusProduct of entityAgg.aggregated_customer_products) { - const product = productsByInternalId.get( - entityCusProduct.internal_product_id, - ); - if (!product) continue; - - aggregatedCustomerProducts.push({ - ...entityCusProduct, - product, - free_trial: entityCusProduct.free_trial_id - ? (freeTrialsById.get(entityCusProduct.free_trial_id) ?? null) - : null, - customer_prices: - entityCusPricesByProductId.get(entityCusProduct.id) ?? [], - customer_entitlements: [], - } as FullCusProduct); - } - - aggregatedCustomerEntitlements = ( - entityAgg.aggregated_customer_entitlements ?? [] - ) - .map((aggregatedCusEnt) => { - const entitlement = row.entitlements.find( - (e) => e.internal_feature_id === aggregatedCusEnt.internal_feature_id, - ); - if (!entitlement) return null; - return { - ...aggregatedCusEnt, - entitlement, - } as FullAggregatedCustomerEntitlement; - }) - .filter((e): e is FullAggregatedCustomerEntitlement => e !== null); - } - - return { - ...row.customer, - customer_products: customerProducts, - extra_customer_entitlements: extraCustomerEntitlements, - entities, - ...(invoices ? { invoices } : {}), - ...(subscriptions ? { subscriptions } : {}), - ...(aggregatedCustomerProducts - ? { aggregated_customer_products: aggregatedCustomerProducts } - : {}), - ...(aggregatedCustomerEntitlements - ? { aggregated_customer_entitlements: aggregatedCustomerEntitlements } - : {}), - ...(aggregatedCustomerPrices - ? { aggregated_customer_prices: aggregatedCustomerPrices } - : {}), - } as FullCustomer; -}; diff --git a/server/src/internal/customers/repos/getFullSubject.ts b/server/src/internal/customers/repos/getFullSubject.ts deleted file mode 100644 index ec989612e..000000000 --- a/server/src/internal/customers/repos/getFullSubject.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { - AggregatedCustomerEntitlement, - CusProductStatus, - Customer, - CustomerPrice, - DbCustomer, - DbCustomerEntitlement, - DbCustomerPrice, - DbCustomerProduct, - DbEntitlement, - DbFeature, - DbFreeTrial, - DbPrice, - DbProduct, - DbRollover, - Entity, - FullAggregatedCustomerEntitlement, - FullCusProduct, - FullCustomerEntitlement, - FullCustomerPrice, - FullSubject, - Invoice, - Replaceable, - Subscription, - SubjectType, -} from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js"; -import { getSubjectCoreQuery } from "./sql/getSubjectCoreQuery.js"; - -type EntitlementWithFeatureRow = DbEntitlement & { - feature: DbFeature; -}; - -export interface EntityAggregations { - aggregated_customer_products: DbCustomerProduct[]; - aggregated_customer_entitlements: AggregatedCustomerEntitlement[]; - aggregated_customer_prices: DbCustomerPrice[]; -} - -/** Raw row shape returned by getSubjectCoreQuery. */ -export interface SubjectCoreRow { - customer: DbCustomer; - customer_products: DbCustomerProduct[]; - customer_entitlements: DbCustomerEntitlement[]; - customer_prices: DbCustomerPrice[]; - extra_customer_entitlements: DbCustomerEntitlement[]; - rollovers: DbRollover[]; - products: DbProduct[]; - entitlements: EntitlementWithFeatureRow[]; - prices: DbPrice[]; - free_trials: DbFreeTrial[]; - entity_aggregations?: EntityAggregations; - subscriptions: Subscription[]; - invoices?: Invoice[]; - entity?: Entity; -} - -const getRolloverSortValue = ({ rollover }: { rollover: DbRollover }) => - rollover.expires_at ?? Number.POSITIVE_INFINITY; - -const buildFullCustomerEntitlement = ({ - customerEntitlement, - entitlement, - rollovers, -}: { - customerEntitlement: SubjectCoreRow["customer_entitlements"][number]; - entitlement: SubjectCoreRow["entitlements"][number] | undefined; - rollovers: DbRollover[]; -}): FullCustomerEntitlement | null => { - if (!entitlement) return null; - - return { - ...customerEntitlement, - entitlement, - replaceables: [] as Replaceable[], - rollovers: [...rollovers].sort( - (left, right) => - getRolloverSortValue({ rollover: left }) - - getRolloverSortValue({ rollover: right }), - ), - } as FullCustomerEntitlement; -}; - -const buildFullCustomerPrice = ({ - customerPrice, - price, -}: { - customerPrice: SubjectCoreRow["customer_prices"][number]; - price: DbPrice | undefined; -}): FullCustomerPrice | null => { - if (!price) return null; - - return { - ...customerPrice, - price, - } as FullCustomerPrice; -}; - -export const resultToFullSubject = ({ - row, -}: { - row: SubjectCoreRow; -}): FullSubject => { - const entity = row.entity as Entity | undefined; - const isEntitySubject = !!entity; - - const productsByInternalId = new Map( - row.products.map((product) => [product.internal_id, product] as const), - ); - const entitlementsById = new Map( - row.entitlements.map( - (entitlement) => [entitlement.id, entitlement] as const, - ), - ); - const pricesById = new Map( - row.prices.map((price) => [price.id, price] as const), - ); - const freeTrialsById = new Map( - row.free_trials.map((freeTrial) => [freeTrial.id, freeTrial] as const), - ); - - const rolloversByCustomerEntitlementId = new Map(); - for (const rollover of row.rollovers) { - const existing = - rolloversByCustomerEntitlementId.get(rollover.cus_ent_id) ?? []; - existing.push(rollover); - rolloversByCustomerEntitlementId.set(rollover.cus_ent_id, existing); - } - - const customerPricesByCustomerProductId = new Map< - string, - FullCustomerPrice[] - >(); - for (const customerPrice of row.customer_prices) { - if (!customerPrice.customer_product_id) continue; - - const fullCustomerPrice = buildFullCustomerPrice({ - customerPrice, - price: customerPrice.price_id - ? pricesById.get(customerPrice.price_id) - : undefined, - }); - if (!fullCustomerPrice) continue; - - const existing = - customerPricesByCustomerProductId.get( - customerPrice.customer_product_id, - ) ?? []; - existing.push(fullCustomerPrice); - customerPricesByCustomerProductId.set( - customerPrice.customer_product_id, - existing, - ); - } - - const customerEntitlementsByCustomerProductId = new Map< - string, - FullCustomerEntitlement[] - >(); - for (const customerEntitlement of row.customer_entitlements) { - if (!customerEntitlement.customer_product_id) continue; - - const fullCustomerEntitlement = buildFullCustomerEntitlement({ - customerEntitlement, - entitlement: entitlementsById.get(customerEntitlement.entitlement_id), - rollovers: - rolloversByCustomerEntitlementId.get(customerEntitlement.id) ?? [], - }); - if (!fullCustomerEntitlement) continue; - - const existing = - customerEntitlementsByCustomerProductId.get( - customerEntitlement.customer_product_id, - ) ?? []; - existing.push(fullCustomerEntitlement); - customerEntitlementsByCustomerProductId.set( - customerEntitlement.customer_product_id, - existing, - ); - } - - const customerProducts: FullCusProduct[] = []; - for (const customerProduct of row.customer_products) { - const product = productsByInternalId.get( - customerProduct.internal_product_id, - ); - if (!product) continue; - - customerProducts.push({ - ...customerProduct, - product, - free_trial: customerProduct.free_trial_id - ? (freeTrialsById.get(customerProduct.free_trial_id) ?? null) - : null, - customer_prices: - customerPricesByCustomerProductId.get(customerProduct.id) ?? [], - customer_entitlements: - customerEntitlementsByCustomerProductId.get(customerProduct.id) ?? [], - } as FullCusProduct); - } - - const extraCustomerEntitlements = row.extra_customer_entitlements - .map((customerEntitlement) => - buildFullCustomerEntitlement({ - customerEntitlement, - entitlement: entitlementsById.get(customerEntitlement.entitlement_id), - rollovers: - rolloversByCustomerEntitlementId.get(customerEntitlement.id) ?? [], - }), - ) - .filter( - (customerEntitlement): customerEntitlement is FullCustomerEntitlement => - customerEntitlement !== null, - ); - - let aggregatedCustomerProducts: FullCusProduct[] | undefined; - let aggregatedCustomerEntitlements: - | FullAggregatedCustomerEntitlement[] - | undefined; - let aggregatedCustomerPrices: CustomerPrice[] | undefined; - - if (row.entity_aggregations) { - const entityAgg = row.entity_aggregations; - - const entityCusPricesByProductId = new Map(); - for (const entityCusPrice of entityAgg.aggregated_customer_prices) { - if (!entityCusPrice.customer_product_id) continue; - - const fullPrice = buildFullCustomerPrice({ - customerPrice: entityCusPrice, - price: entityCusPrice.price_id - ? pricesById.get(entityCusPrice.price_id) - : undefined, - }); - if (!fullPrice) continue; - - const existing = - entityCusPricesByProductId.get(entityCusPrice.customer_product_id) ?? - []; - existing.push(fullPrice); - entityCusPricesByProductId.set( - entityCusPrice.customer_product_id, - existing, - ); - } - - aggregatedCustomerProducts = []; - for (const entityCusProduct of entityAgg.aggregated_customer_products) { - const product = productsByInternalId.get( - entityCusProduct.internal_product_id, - ); - if (!product) continue; - - aggregatedCustomerProducts.push({ - ...entityCusProduct, - product, - free_trial: entityCusProduct.free_trial_id - ? (freeTrialsById.get(entityCusProduct.free_trial_id) ?? null) - : null, - customer_prices: - entityCusPricesByProductId.get(entityCusProduct.id) ?? [], - customer_entitlements: [], - } as FullCusProduct); - } - - aggregatedCustomerEntitlements = ( - entityAgg.aggregated_customer_entitlements ?? [] - ) - .map((aggregatedCusEnt) => { - const entitlement = row.entitlements.find( - (e) => e.internal_feature_id === aggregatedCusEnt.internal_feature_id, - ); - if (!entitlement) return null; - return { - ...aggregatedCusEnt, - entitlement, - } as FullAggregatedCustomerEntitlement; - }) - .filter((e): e is FullAggregatedCustomerEntitlement => e !== null); - } - - const customer = row.customer as unknown as Customer; - - return { - subjectType: (isEntitySubject ? "entity" : "customer") as SubjectType, - customerId: customer.id ?? customer.internal_id, - internalCustomerId: customer.internal_id, - ...(entity - ? { - entityId: entity.id ?? entity.internal_id, - internalEntityId: entity.internal_id, - entity, - } - : {}), - customer, - customer_products: customerProducts, - extra_customer_entitlements: extraCustomerEntitlements, - subscriptions: row.subscriptions ?? [], - invoices: row.invoices ?? [], - ...(aggregatedCustomerProducts - ? { aggregated_customer_products: aggregatedCustomerProducts } - : {}), - ...(aggregatedCustomerEntitlements - ? { aggregated_customer_entitlements: aggregatedCustomerEntitlements } - : {}), - ...(aggregatedCustomerPrices - ? { aggregated_customer_prices: aggregatedCustomerPrices } - : {}), - } as FullSubject; -}; - -export async function getFullSubject({ - ctx, - customerId, - entityId, - inStatuses = RELEVANT_STATUSES, -}: { - ctx: AutumnContext; - customerId?: string; - entityId?: string; - inStatuses?: CusProductStatus[]; -}): Promise { - const { db, org, env } = ctx; - - const result = await db.execute( - getSubjectCoreQuery({ - orgId: org.id, - env, - customerId, - entityId, - inStatuses, - }), - ); - - if (!result?.length) return null; - - return resultToFullSubject({ row: result[0] as unknown as SubjectCoreRow }); -} diff --git a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts new file mode 100644 index 000000000..203647523 --- /dev/null +++ b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts @@ -0,0 +1,253 @@ +import { type SQL, sql } from "drizzle-orm"; + +/** + * Builds all entity-scoped SQL fragments for customer-level queries. + * Returns empty fragments when `entityId` is set (entity-level query). + */ +export const getEntityAggregateFragments = ({ + entityId, + statusFilter, +}: { + entityId?: string; + statusFilter: SQL; +}) => { + if (entityId) { + return { + ctes: sql``, + productRefsUnion: sql``, + entitlementRefsUnion: sql``, + priceRefsUnion: sql``, + freeTrialRefsUnion: sql``, + selectColumns: sql``, + }; + } + + const ctes = sql`, + + entity_distinct_product_ids AS ( + SELECT DISTINCT cp.internal_product_id, cp.internal_customer_id + FROM customer_products cp + JOIN subject_customer_records scr + ON cp.internal_customer_id = scr.internal_id + WHERE cp.internal_entity_id IS NOT NULL + ${statusFilter} + ), + + entity_distinct_cus_products AS ( + SELECT sub.* + FROM entity_distinct_product_ids edpi + JOIN LATERAL ( + SELECT cp.* + FROM customer_products cp + WHERE cp.internal_customer_id = edpi.internal_customer_id + AND cp.internal_product_id = edpi.internal_product_id + AND cp.internal_entity_id IS NOT NULL + ${statusFilter} + ORDER BY cp.created_at DESC + LIMIT 1 + ) sub ON true + ), + + entity_cus_prices AS ( + SELECT cpr.* + FROM customer_prices cpr + WHERE cpr.customer_product_id IN (SELECT id FROM entity_distinct_cus_products) + ), + + entity_balance_rows AS ( + SELECT + COALESCE(ce.external_id, ce.id) AS api_id, + ce.internal_feature_id, + ce.internal_customer_id, + ce.feature_id, + COALESCE(ent.allowance, 0)::numeric AS allowance, + ce.balance::numeric AS balance, + ce.adjustment::numeric AS adjustment, + COALESCE(ce.additional_balance, 0)::numeric AS additional_balance, + ce.unlimited, + ce.usage_allowed, + cp.internal_entity_id AS entity_key, + ce.balance::numeric AS entity_balance, + COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment, + COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance + FROM customer_entitlements ce + JOIN customer_products cp ON ce.customer_product_id = cp.id + JOIN entitlements ent ON ce.entitlement_id = ent.id + WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) + AND cp.internal_entity_id IS NOT NULL + ${statusFilter} + + UNION ALL + + SELECT + COALESCE(ce.external_id, ce.id) AS api_id, + ce.internal_feature_id, + ce.internal_customer_id, + ce.feature_id, + COALESCE(ent.allowance, 0)::numeric AS allowance, + 0::numeric AS balance, + 0::numeric AS adjustment, + 0::numeric AS additional_balance, + ce.unlimited, + ce.usage_allowed, + kv.entity_key AS entity_key, + (kv.entity_value->>'balance')::numeric AS entity_balance, + COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS entity_adjustment, + COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS entity_additional_balance + FROM cus_entitlements ce + JOIN entitlements ent ON ce.entitlement_id = ent.id + CROSS JOIN LATERAL jsonb_each(ce.entities) AS kv(entity_key, entity_value) + WHERE jsonb_typeof(ce.entities) = 'object' + + UNION ALL + + SELECT + COALESCE(ce.external_id, ce.id) AS api_id, + ce.internal_feature_id, + ce.internal_customer_id, + ce.feature_id, + COALESCE(ent.allowance, 0)::numeric AS allowance, + ce.balance::numeric AS balance, + COALESCE(ce.adjustment, 0)::numeric AS adjustment, + COALESCE(ce.additional_balance, 0)::numeric AS additional_balance, + ce.unlimited, + ce.usage_allowed, + ce.internal_entity_id AS entity_key, + ce.balance::numeric AS entity_balance, + COALESCE(ce.adjustment, 0)::numeric AS entity_adjustment, + COALESCE(ce.additional_balance, 0)::numeric AS entity_additional_balance + FROM customer_entitlements ce + JOIN entitlements ent ON ce.entitlement_id = ent.id + WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) + AND ce.customer_product_id IS NULL + AND ce.internal_entity_id IS NOT NULL + AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) + ), + + entity_aggregate_keys AS ( + SELECT + internal_feature_id, + internal_customer_id, + entity_key, + SUM(entity_balance) AS balance, + SUM(entity_adjustment) AS adjustment, + SUM(entity_additional_balance) AS additional_balance + FROM entity_balance_rows + WHERE entity_key IS NOT NULL + GROUP BY internal_feature_id, internal_customer_id, entity_key + ), + + entity_aggregate_map AS ( + SELECT + internal_feature_id, + internal_customer_id, + jsonb_object_agg( + entity_key, + jsonb_build_object( + 'id', entity_key, + 'balance', balance, + 'adjustment', adjustment, + 'additional_balance', additional_balance + ) + ) AS entities + FROM entity_aggregate_keys + GROUP BY internal_feature_id, internal_customer_id + ), + + entity_aggregated_cus_entitlements AS ( + SELECT + MIN(ebr.api_id) AS api_id, + ebr.internal_feature_id, + ebr.internal_customer_id, + MIN(ebr.feature_id) AS feature_id, + SUM(ebr.allowance) AS allowance_total, + SUM(ebr.balance) AS balance, + SUM(ebr.adjustment) AS adjustment, + SUM(ebr.additional_balance) AS additional_balance, + BOOL_OR(ebr.unlimited) AS unlimited, + BOOL_OR(ebr.usage_allowed) AS usage_allowed, + COUNT(DISTINCT ebr.entity_key) FILTER (WHERE ebr.entity_key IS NOT NULL) AS entity_count, + eam.entities + FROM entity_balance_rows ebr + LEFT JOIN entity_aggregate_map eam + ON eam.internal_feature_id = ebr.internal_feature_id + AND eam.internal_customer_id = ebr.internal_customer_id + GROUP BY + ebr.internal_feature_id, + ebr.internal_customer_id, + eam.entities + ) + `; + + const productRefsUnion = sql` + UNION ALL + SELECT ecp.internal_customer_id, ecp.internal_product_id + FROM entity_distinct_cus_products ecp + `; + + const entitlementRefsUnion = sql` + UNION + SELECT DISTINCT + ce.internal_customer_id, + ce.entitlement_id + FROM customer_entitlements ce + JOIN customer_products cp ON ce.customer_product_id = cp.id + WHERE cp.internal_entity_id IS NOT NULL + ${statusFilter} + + UNION + SELECT DISTINCT + ce.internal_customer_id, + ce.entitlement_id + FROM customer_entitlements ce + WHERE ce.customer_product_id IS NULL + AND ce.internal_entity_id IS NOT NULL + AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) + `; + + const priceRefsUnion = sql` + UNION ALL + SELECT ecpr.price_id, ecp.internal_customer_id + FROM entity_cus_prices ecpr + JOIN entity_distinct_cus_products ecp + ON ecp.id = ecpr.customer_product_id + `; + + const freeTrialRefsUnion = sql` + UNION ALL + SELECT ecp.free_trial_id, ecp.internal_customer_id + FROM entity_distinct_cus_products ecp + WHERE ecp.free_trial_id IS NOT NULL + `; + + const selectColumns = sql`, + + json_build_object( + 'aggregated_customer_products', COALESCE( + ( + SELECT json_agg(row_to_json(ecp)) + FROM entity_distinct_cus_products ecp + WHERE ecp.internal_customer_id = scr.internal_id + ), + '[]'::json + ), + 'aggregated_customer_entitlements', COALESCE( + ( + SELECT json_agg(row_to_json(eace)) + FROM entity_aggregated_cus_entitlements eace + WHERE eace.internal_customer_id = scr.internal_id + ), + '[]'::json + ) + ) AS entity_aggregations + `; + + return { + ctes, + productRefsUnion, + entitlementRefsUnion, + priceRefsUnion, + freeTrialRefsUnion, + selectColumns, + }; +}; diff --git a/server/src/internal/customers/repos/getFullSubject/getFullSubject.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubject.ts new file mode 100644 index 000000000..2c6d69292 --- /dev/null +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubject.ts @@ -0,0 +1,73 @@ +import type { + CusProductStatus, + FullSubject, + NormalizedFullSubject, + SubjectQueryRow, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js"; +import { getFullSubjectQuery } from "./getFullSubjectQuery.js"; +import { + resultToFullSubject, + subjectQueryRowToNormalized, +} from "./subjectQueryRowToNormalized.js"; + +/** Fetch full subject from DB and return as FullSubject. */ +export async function getFullSubject({ + ctx, + customerId, + entityId, + inStatuses = RELEVANT_STATUSES, +}: { + ctx: AutumnContext; + customerId?: string; + entityId?: string; + inStatuses?: CusProductStatus[]; +}): Promise { + const { db, org, env } = ctx; + + const result = await db.execute( + getFullSubjectQuery({ + orgId: org.id, + env, + customerId, + entityId, + inStatuses, + }), + ); + + if (!result?.length) return undefined; + + return resultToFullSubject({ row: result[0] as unknown as SubjectQueryRow }); +} + +/** Fetch full subject from DB and return as NormalizedFullSubject (for cache write). */ +export async function getFullSubjectNormalized({ + ctx, + customerId, + entityId, + inStatuses = RELEVANT_STATUSES, +}: { + ctx: AutumnContext; + customerId?: string; + entityId?: string; + inStatuses?: CusProductStatus[]; +}): Promise { + const { db, org, env } = ctx; + + const result = await db.execute( + getFullSubjectQuery({ + orgId: org.id, + env, + customerId, + entityId, + inStatuses, + }), + ); + + if (!result?.length) return undefined; + + return subjectQueryRowToNormalized({ + row: result[0] as unknown as SubjectQueryRow, + }); +} diff --git a/server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubjectQuery.ts similarity index 60% rename from server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts rename to server/src/internal/customers/repos/getFullSubject/getFullSubjectQuery.ts index 2193848fe..f7f84d0fa 100644 --- a/server/src/internal/customers/repos/sql/getSubjectCoreQuery.ts +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubjectQuery.ts @@ -1,228 +1,9 @@ import type { AppEnv, CusProductStatus } from "@autumn/shared"; import { type SQL, sql } from "drizzle-orm"; import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js"; +import { getEntityAggregateFragments } from "./getEntityAggregateFragments.js"; -/** - * Builds all entity-scoped SQL fragments for customer-level queries. - * Returns empty fragments when `entityId` is set (entity-level query). - */ -const getEntityAggregateFragments = ({ - entityId, - statusFilter, -}: { - entityId?: string; - statusFilter: SQL; -}) => { - if (entityId) { - return { - ctes: sql``, - productRefsUnion: sql``, - entitlementRefsUnion: sql``, - priceRefsUnion: sql``, - freeTrialRefsUnion: sql``, - selectColumns: sql``, - }; - } - - const ctes = sql`, - - entity_distinct_product_ids AS ( - SELECT DISTINCT cp.internal_product_id, cp.internal_customer_id - FROM customer_products cp - JOIN subject_customer_records scr - ON cp.internal_customer_id = scr.internal_id - WHERE cp.internal_entity_id IS NOT NULL - ${statusFilter} - ), - - entity_distinct_cus_products AS ( - SELECT sub.* - FROM entity_distinct_product_ids edpi - JOIN LATERAL ( - SELECT cp.* - FROM customer_products cp - WHERE cp.internal_customer_id = edpi.internal_customer_id - AND cp.internal_product_id = edpi.internal_product_id - AND cp.internal_entity_id IS NOT NULL - ORDER BY cp.created_at DESC - LIMIT 1 - ) sub ON true - ), - - entity_cus_prices AS ( - SELECT cpr.* - FROM customer_prices cpr - WHERE cpr.customer_product_id IN (SELECT id FROM entity_distinct_cus_products) - ), - - entity_balance_rows AS ( - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - ce.balance::numeric AS balance, - ce.adjustment::numeric AS adjustment, - COALESCE(ce.additional_balance, 0)::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - 1 AS entity_row_count, - NULL::text AS old_entity_key, - NULL::numeric AS old_entity_balance, - NULL::numeric AS old_entity_adjustment, - NULL::numeric AS old_entity_additional_balance - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - WHERE ce.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - AND cp.internal_entity_id IS NOT NULL - - UNION ALL - - SELECT - ce.internal_feature_id, - ce.internal_customer_id, - ce.feature_id, - 0::numeric AS balance, - 0::numeric AS adjustment, - 0::numeric AS additional_balance, - ce.unlimited, - ce.usage_allowed, - 0 AS entity_row_count, - kv.entity_key AS old_entity_key, - (kv.entity_value->>'balance')::numeric AS old_entity_balance, - COALESCE((kv.entity_value->>'adjustment')::numeric, 0) AS old_entity_adjustment, - COALESCE((kv.entity_value->>'additional_balance')::numeric, 0) AS old_entity_additional_balance - FROM cus_entitlements ce, - jsonb_each(ce.entities) AS kv(entity_key, entity_value) - WHERE jsonb_typeof(ce.entities) = 'object' - ), - - entity_old_style_keys AS ( - SELECT - internal_feature_id, - internal_customer_id, - old_entity_key, - SUM(old_entity_balance) AS balance, - SUM(old_entity_adjustment) AS adjustment, - SUM(old_entity_additional_balance) AS additional_balance - FROM entity_balance_rows - WHERE old_entity_key IS NOT NULL - GROUP BY internal_feature_id, internal_customer_id, old_entity_key - ), - - entity_old_style_map AS ( - SELECT - internal_feature_id, - internal_customer_id, - jsonb_object_agg( - old_entity_key, - jsonb_build_object( - 'id', old_entity_key, - 'balance', balance, - 'adjustment', adjustment, - 'additional_balance', additional_balance - ) - ) AS entities - FROM entity_old_style_keys - GROUP BY internal_feature_id, internal_customer_id - ), - - entity_aggregated_cus_entitlements AS ( - SELECT - ebr.internal_feature_id, - ebr.internal_customer_id, - MIN(ebr.feature_id) AS feature_id, - SUM(ebr.balance) AS balance, - SUM(ebr.adjustment) AS adjustment, - SUM(ebr.additional_balance) AS additional_balance, - BOOL_OR(ebr.unlimited) AS unlimited, - BOOL_OR(ebr.usage_allowed) AS usage_allowed, - SUM(ebr.entity_row_count) AS entity_count, - eom.entities - FROM entity_balance_rows ebr - LEFT JOIN entity_old_style_map eom - ON eom.internal_feature_id = ebr.internal_feature_id - AND eom.internal_customer_id = ebr.internal_customer_id - GROUP BY - ebr.internal_feature_id, - ebr.internal_customer_id, - eom.entities - ) - `; - - const productRefsUnion = sql` - UNION ALL - SELECT ecp.internal_customer_id, ecp.internal_product_id - FROM entity_distinct_cus_products ecp - `; - - const entitlementRefsUnion = sql` - UNION - SELECT DISTINCT - ce.internal_customer_id, - ce.entitlement_id - FROM customer_entitlements ce - JOIN customer_products cp ON ce.customer_product_id = cp.id - WHERE cp.internal_entity_id IS NOT NULL - `; - - const priceRefsUnion = sql` - UNION ALL - SELECT ecpr.price_id, ecp.internal_customer_id - FROM entity_cus_prices ecpr - JOIN entity_distinct_cus_products ecp - ON ecp.id = ecpr.customer_product_id - `; - - const freeTrialRefsUnion = sql` - UNION ALL - SELECT ecp.free_trial_id, ecp.internal_customer_id - FROM entity_distinct_cus_products ecp - WHERE ecp.free_trial_id IS NOT NULL - `; - - const selectColumns = sql`, - - json_build_object( - 'aggregated_customer_products', COALESCE( - ( - SELECT json_agg(row_to_json(ecp)) - FROM entity_distinct_cus_products ecp - WHERE ecp.internal_customer_id = scr.internal_id - ), - '[]'::json - ), - 'aggregated_customer_entitlements', COALESCE( - ( - SELECT json_agg(row_to_json(eace)) - FROM entity_aggregated_cus_entitlements eace - WHERE eace.internal_customer_id = scr.internal_id - ), - '[]'::json - ), - 'aggregated_customer_prices', COALESCE( - ( - SELECT json_agg(row_to_json(ecpr)) - FROM entity_cus_prices ecpr - JOIN entity_distinct_cus_products ecp - ON ecp.id = ecpr.customer_product_id - WHERE ecp.internal_customer_id = scr.internal_id - ), - '[]'::json - ) - ) AS entity_aggregations - `; - - return { - ctes, - productRefsUnion, - entitlementRefsUnion, - priceRefsUnion, - freeTrialRefsUnion, - selectColumns, - }; -}; - -export const getSubjectCoreQuery = ({ +export const getFullSubjectQuery = ({ orgId, env, customerId, @@ -338,6 +119,19 @@ export const getSubjectCoreQuery = ({ ` : sql``; + const extraCustomerEntitlementEntityFilter = entityId + ? sql` + AND ( + ce.internal_entity_id IS NULL + OR ce.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1) + OR ( + jsonb_typeof(ce.entities) = 'object' + AND ce.entities ? (SELECT id FROM entity_record LIMIT 1) + ) + ) + ` + : sql`AND ce.internal_entity_id IS NULL`; + const subscriptionsCte = sql`, customer_subscriptions AS ( @@ -416,6 +210,7 @@ export const getSubjectCoreQuery = ({ ON ce.internal_customer_id = scr.internal_id WHERE ce.customer_product_id IS NULL AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) + ${extraCustomerEntitlementEntityFilter} ), all_cus_ent_ids AS ( diff --git a/server/src/internal/customers/repos/getFullSubject/index.ts b/server/src/internal/customers/repos/getFullSubject/index.ts new file mode 100644 index 000000000..f1c4a771a --- /dev/null +++ b/server/src/internal/customers/repos/getFullSubject/index.ts @@ -0,0 +1,6 @@ +export { getFullSubject, getFullSubjectNormalized } from "./getFullSubject.js"; +export { getFullSubjectQuery } from "./getFullSubjectQuery.js"; +export { + resultToFullSubject, + subjectQueryRowToNormalized, +} from "./subjectQueryRowToNormalized.js"; diff --git a/server/src/internal/customers/repos/getFullSubject/subjectQueryRowToNormalized.ts b/server/src/internal/customers/repos/getFullSubject/subjectQueryRowToNormalized.ts new file mode 100644 index 000000000..d0a619a0e --- /dev/null +++ b/server/src/internal/customers/repos/getFullSubject/subjectQueryRowToNormalized.ts @@ -0,0 +1,221 @@ +import type { FeatureOptions } from "@autumn/shared"; +import { + type Customer, + type DbCustomerEntitlement, + type DbCustomerPrice, + type DbFreeTrial, + type DbPrice, + type DbProduct, + type DbRollover, + type EntitlementWithFeature, + type Entity, + type EntityAggregations, + FeatureType, + type FullCustomerPrice, + type FullSubject, + type NormalizedFullSubject, + normalizedToFullSubject, + type SubjectBalance, + type SubjectFlag, + type SubjectQueryRow, + type SubjectType, +} from "@autumn/shared"; + +/** + * Convert raw DB query row into NormalizedFullSubject. + * Partitions boolean CEs into flags and builds self-contained SubjectBalance + * objects for metered entitlements. + */ +export const subjectQueryRowToNormalized = ({ + row, +}: { + row: SubjectQueryRow; +}): NormalizedFullSubject => { + const customer = row.customer as unknown as Customer; + const entity = row.entity as Entity | undefined; + const isEntitySubject = !!entity; + + const entitlementsByEntitlementId = new Map( + row.entitlements.map((e) => [e.id, e] as const), + ); + + const flags: Record = {}; + const meteredCustomerEntitlements: SubjectBalance[] = []; + + const rolloversByCusEntId = new Map(); + for (const rollover of row.rollovers) { + const existing = rolloversByCusEntId.get(rollover.cus_ent_id) ?? []; + existing.push(rollover); + rolloversByCusEntId.set(rollover.cus_ent_id, existing); + } + + const customerProductsById = new Map( + row.customer_products.map( + (customerProduct) => [customerProduct.id, customerProduct] as const, + ), + ); + + const pricesById = new Map( + row.prices.map((price) => [price.id, price] as const), + ); + + const resolveCustomerPrice = ({ + customerEntitlement, + entitlement, + }: { + customerEntitlement: DbCustomerEntitlement; + entitlement: EntitlementWithFeature; + }): FullCustomerPrice | null => { + if (!customerEntitlement.customer_product_id) return null; + + const customerPrice = row.customer_prices.find( + (candidate: DbCustomerPrice) => { + if ( + candidate.customer_product_id !== + customerEntitlement.customer_product_id + ) { + return false; + } + + if (!candidate.price_id) return false; + const price = pricesById.get(candidate.price_id); + return price?.entitlement_id === entitlement.id; + }, + ); + + if (!customerPrice?.price_id) return null; + const price = pricesById.get(customerPrice.price_id); + if (!price) return null; + + return { + ...customerPrice, + price, + } as FullCustomerPrice; + }; + + const resolveCustomerProductOptions = ({ + customerEntitlement, + entitlement, + }: { + customerEntitlement: DbCustomerEntitlement; + entitlement: EntitlementWithFeature; + }): FeatureOptions | null => { + if (!customerEntitlement.customer_product_id) return null; + const customerProduct = customerProductsById.get( + customerEntitlement.customer_product_id, + ); + if (!customerProduct) return null; + + const options = customerProduct.options as FeatureOptions[] | null; + if (!options) return null; + + return ( + options.find( + (option) => + option.internal_feature_id === entitlement.internal_feature_id || + option.feature_id === entitlement.feature.id, + ) ?? null + ); + }; + + const partitionCustomerEntitlement = ( + customerEntitlement: DbCustomerEntitlement, + ) => { + const catalogEntitlement = entitlementsByEntitlementId.get( + customerEntitlement.entitlement_id, + ); + if (!catalogEntitlement) return; + + if (catalogEntitlement.feature.type === FeatureType.Boolean) { + flags[catalogEntitlement.feature.id] = { + featureId: catalogEntitlement.feature.id, + internalFeatureId: customerEntitlement.internal_feature_id, + entitlementId: catalogEntitlement.id, + customerEntitlementId: customerEntitlement.id, + customerProductId: customerEntitlement.customer_product_id, + internalCustomerId: customerEntitlement.internal_customer_id, + internalEntityId: customerEntitlement.internal_entity_id, + expiresAt: customerEntitlement.expires_at, + externalId: customerEntitlement.external_id, + }; + } else { + const customerProduct = customerEntitlement.customer_product_id + ? customerProductsById.get(customerEntitlement.customer_product_id) + : undefined; + + meteredCustomerEntitlements.push({ + ...customerEntitlement, + internal_feature_id: catalogEntitlement.internal_feature_id, + feature_id: catalogEntitlement.feature.id, + balance: customerEntitlement.balance ?? 0, + adjustment: customerEntitlement.adjustment ?? 0, + additional_balance: customerEntitlement.additional_balance ?? 0, + cache_version: customerEntitlement.cache_version ?? 0, + entities: customerEntitlement.entities ?? null, + entitlement: catalogEntitlement as EntitlementWithFeature, + rollovers: rolloversByCusEntId.get(customerEntitlement.id) ?? [], + customerPrice: resolveCustomerPrice({ + customerEntitlement, + entitlement: catalogEntitlement as EntitlementWithFeature, + }), + customerProductOptions: resolveCustomerProductOptions({ + customerEntitlement, + entitlement: catalogEntitlement as EntitlementWithFeature, + }), + customerProductQuantity: customerProduct?.quantity ?? 1, + }); + } + }; + + for (const customerEntitlement of row.customer_entitlements) { + partitionCustomerEntitlement(customerEntitlement); + } + for (const customerEntitlement of row.extra_customer_entitlements) { + partitionCustomerEntitlement(customerEntitlement); + } + + let entityAggregations: EntityAggregations | undefined; + if (row.entity_aggregations) { + entityAggregations = { + aggregated_customer_products: + row.entity_aggregations.aggregated_customer_products, + aggregated_customer_entitlements: + row.entity_aggregations.aggregated_customer_entitlements, + }; + } + + return { + subjectType: (isEntitySubject ? "entity" : "customer") as SubjectType, + customerId: customer.id ?? customer.internal_id, + internalCustomerId: customer.internal_id, + ...(entity + ? { + entityId: entity.id ?? entity.internal_id, + internalEntityId: entity.internal_id, + entity, + } + : {}), + customer, + customer_products: row.customer_products, + customer_entitlements: meteredCustomerEntitlements, + customer_prices: row.customer_prices, + flags, + products: row.products as DbProduct[], + entitlements: row.entitlements as EntitlementWithFeature[], + prices: row.prices as DbPrice[], + free_trials: row.free_trials as DbFreeTrial[], + subscriptions: row.subscriptions ?? [], + invoices: row.invoices ?? [], + entity_aggregations: entityAggregations, + }; +}; + +/** Convert raw DB query row to FullSubject via NormalizedFullSubject. */ +export const resultToFullSubject = ({ + row, +}: { + row: SubjectQueryRow; +}): FullSubject => { + const normalized = subjectQueryRowToNormalized({ row }); + return normalizedToFullSubject({ normalized }); +}; diff --git a/server/src/internal/misc/rollouts/fullSubjectRolloutUtils.ts b/server/src/internal/misc/rollouts/fullSubjectRolloutUtils.ts new file mode 100644 index 000000000..a32b440fa --- /dev/null +++ b/server/src/internal/misc/rollouts/fullSubjectRolloutUtils.ts @@ -0,0 +1,20 @@ +import type { AutumnContext, RolloutSnapshot } from "@/honoUtils/HonoEnv.js"; + +export const FULL_SUBJECT_ROLLOUT_ID = "v2-cache"; + +export const isFullSubjectRolloutEnabled = ({ + ctx, +}: { + ctx: AutumnContext; +}): boolean => + ctx.rolloutSnapshot?.rolloutId === FULL_SUBJECT_ROLLOUT_ID && + ctx.rolloutSnapshot.enabled; + +export const getFullSubjectRolloutSnapshot = ({ + ctx, +}: { + ctx: AutumnContext; +}): RolloutSnapshot | undefined => + ctx.rolloutSnapshot?.rolloutId === FULL_SUBJECT_ROLLOUT_ID + ? ctx.rolloutSnapshot + : undefined; diff --git a/server/src/queue/createWorkerContext.ts b/server/src/queue/createWorkerContext.ts index 435e31a97..99ec41034 100644 --- a/server/src/queue/createWorkerContext.ts +++ b/server/src/queue/createWorkerContext.ts @@ -51,6 +51,11 @@ export const createWorkerContext = async ({ }, }); + const rolloutSnapshot = computeRolloutSnapshot({ + orgId: org.id, + customerId, + }); + const ctx: AutumnContext = { org, env, @@ -69,10 +74,7 @@ export const createWorkerContext = async ({ expand: [], skipCache: true, extraLogs: {}, - rolloutSnapshot: computeRolloutSnapshot({ - orgId: org.id, - customerId, - }), + rolloutSnapshot, }; return ctx; diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 2130d3f23..2e075b92a 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -12,8 +12,9 @@ import { queryMiddleware } from "../honoMiddlewares/queryMiddleware.js"; import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js"; import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js"; import { refreshProductsCacheMiddleware } from "../honoMiddlewares/refreshProductsCacheMiddleware.js"; -import { responseFilterMiddleware } from "../honoMiddlewares/responseFilter/responseFilterMiddleware.js"; import { requestBlockMiddleware } from "../honoMiddlewares/requestBlockMiddleware.js"; +import { responseFilterMiddleware } from "../honoMiddlewares/responseFilter/responseFilterMiddleware.js"; +import { rolloutMiddleware } from "../honoMiddlewares/rolloutMiddleware.js"; import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware.js"; import type { HonoEnv } from "../honoUtils/HonoEnv.js"; import { @@ -44,6 +45,7 @@ apiRouter.use("*", responseFilterMiddleware); apiRouter.use("*", secretKeyMiddleware); apiRouter.use("*", requestBlockMiddleware); apiRouter.use("*", orgConfigMiddleware); +apiRouter.use("*", rolloutMiddleware); apiRouter.use("*", apiVersionMiddleware); apiRouter.use("*", refreshCacheMiddleware); apiRouter.use("*", refreshProductsCacheMiddleware); diff --git a/server/src/utils/logging/logAlertEvent.ts b/server/src/utils/logging/logAlertEvent.ts new file mode 100644 index 000000000..75d819cdc --- /dev/null +++ b/server/src/utils/logging/logAlertEvent.ts @@ -0,0 +1,32 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { AlertCategory, AlertSeverity } from "./loggerTypes.js"; + +export const logAlertEvent = ({ + ctx, + severity, + category, + alertKey, + message, + source, + component, + data, +}: { + ctx: AutumnContext; + severity: AlertSeverity; + category: AlertCategory; + alertKey: string; + message: string; + source: string; + component: string; + data: Record; +}) => { + ctx.logger.warn(message, { + type: "alert_event", + alert_key: alertKey, + severity, + category, + source, + component, + data, + }); +}; diff --git a/server/src/utils/logging/loggerTypes.ts b/server/src/utils/logging/loggerTypes.ts index ccfbf6967..6368113c9 100644 --- a/server/src/utils/logging/loggerTypes.ts +++ b/server/src/utils/logging/loggerTypes.ts @@ -43,3 +43,13 @@ export type LogWorkflowContext = { payload: unknown; name: string; // workflow / job name }; + +export type AlertSeverity = "warning" | "error" | "critical"; + +export type AlertCategory = + | "redis" + | "db" + | "worker" + | "cache" + | "billing" + | "system"; diff --git a/server/src/utils/workerUtils/createAutumnContext.ts b/server/src/utils/workerUtils/createAutumnContext.ts index d5c9ae89e..1fa5e645b 100644 --- a/server/src/utils/workerUtils/createAutumnContext.ts +++ b/server/src/utils/workerUtils/createAutumnContext.ts @@ -47,6 +47,8 @@ export const createWorkerAutumnContext = async ({ createdAt: org.created_at || Date.now(), }); + const rolloutSnapshot = computeRolloutSnapshot({ orgId: org.id }); + return { org, env, @@ -64,6 +66,6 @@ export const createWorkerAutumnContext = async ({ timestamp: Date.now(), skipCache: false, extraLogs: {}, - rolloutSnapshot: computeRolloutSnapshot({ orgId: org.id }), + rolloutSnapshot, } satisfies AutumnContext; }; diff --git a/server/tests/integration/db/full-subject-cache/full-subject-cache-rollout.test.ts b/server/tests/integration/db/full-subject-cache/full-subject-cache-rollout.test.ts new file mode 100644 index 000000000..334867be7 --- /dev/null +++ b/server/tests/integration/db/full-subject-cache/full-subject-cache-rollout.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { redisV2 } from "@/external/redis/initRedisV2.js"; +import { + buildFullSubjectKey, + getCachedFullSubject, + setCachedFullSubject, +} from "@/internal/customers/cache/fullSubject/index.js"; +import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js"; +import { getCustomerBucket } from "@/internal/misc/rollouts/rolloutUtils.js"; +import { buildCustomerMeteredScenario } from "../full-subject/utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "../full-subject/utils/withInsertedScenario.js"; + +const findCustomerIdInChangedBucket = ({ + prefix, + min, + max, +}: { + prefix: string; + min: number; + max: number; +}) => { + for (let i = 0; i < 10000; i++) { + const customerId = `${prefix}-${i}`; + const bucket = getCustomerBucket({ customerId }); + if (bucket >= min && bucket < max) return customerId; + } + + throw new Error(`No customer found in range [${min}, ${max})`); +}; + +describe(`${chalk.yellowBright("fullSubject cache rollout staleness")}`, () => { + test("stale subject is evicted when rollout routing changes", async () => { + const customerId = findCustomerIdInChangedBucket({ + prefix: "fullsubject-rollout", + min: 20, + max: 50, + }); + const scenario = buildCustomerMeteredScenario({ + ctx, + name: customerId, + }); + scenario.customer.id = customerId; + scenario.ids.customerId = customerId; + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + expect(normalized).toBeDefined(); + + const result = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(result).toBe("OK"); + + ctx.rolloutSnapshot = { + customerBucket: getCustomerBucket({ customerId }), + rolloutId: "v2-cache", + enabled: true, + percent: 50, + previousPercent: 20, + changedAt: Date.now() + 1000, + }; + + const cached = await getCachedFullSubject({ + ctx, + customerId: scenario.ids.customerId, + source: "integration-test", + }); + + expect(cached).toBeUndefined(); + + const subjectExists = await redisV2.get( + buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: scenario.ids.customerId, + }), + ); + + expect(subjectExists ?? null).toBeNull(); + ctx.rolloutSnapshot = undefined; + }, + }); + }); +}); diff --git a/server/tests/integration/db/full-subject-cache/full-subject-cache-roundtrip.test.ts b/server/tests/integration/db/full-subject-cache/full-subject-cache-roundtrip.test.ts new file mode 100644 index 000000000..7e6d7debe --- /dev/null +++ b/server/tests/integration/db/full-subject-cache/full-subject-cache-roundtrip.test.ts @@ -0,0 +1,350 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { normalizedToFullSubject } from "@autumn/shared"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { redisV2 } from "@/external/redis/initRedisV2.js"; +import type { CachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; +import { + buildFullSubjectBalanceKey, + buildFullSubjectGuardKey, + buildFullSubjectKey, + buildFullSubjectReserveKey, + getCachedFullSubject, + invalidateCachedFullSubject, + setCachedFullSubject, +} from "@/internal/customers/cache/fullSubject/index.js"; +import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js"; +import { fullSubjectToComparableSubject } from "../full-subject/utils/buildComparableFullSubject.js"; +import { + buildCustomerMeteredScenario, + buildCustomerWithInvoicesAndSubscriptionsScenario, + buildEntitySubjectScenario, +} from "../full-subject/utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "../full-subject/utils/withInsertedScenario.js"; + +const cleanupKeys = async ({ + customerId, + entityId, +}: { + customerId: string; + entityId?: string; +}) => { + const subjectKey = buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + entityId, + }); + const subjectRaw = (await redisV2.get(subjectKey)) as string | null; + const keys = [ + subjectKey, + buildFullSubjectReserveKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + entityId, + }), + buildFullSubjectGuardKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + entityId, + }), + ]; + + if (subjectRaw) { + const subject = JSON.parse(subjectRaw) as CachedFullSubject; + for (const featureId of subject.meteredFeatures) { + keys.push( + buildFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + entityId, + featureId, + }), + ); + } + } + + await redisV2.del(...keys); +}; + +afterEach(async () => {}); + +describe(`${chalk.yellowBright("fullSubject cache roundtrip")}`, () => { + test("customer subject round-trips through cache", async () => { + const scenario = buildCustomerWithInvoicesAndSubscriptionsScenario({ + ctx, + name: "fullsubject-cache-roundtrip-customer", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + expect(normalized).toBeDefined(); + + const result = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(result).toBe("OK"); + + const cached = await getCachedFullSubject({ + ctx, + customerId: scenario.ids.customerId, + source: "integration-test", + }); + + expect( + fullSubjectToComparableSubject({ + fullSubject: cached!, + }), + ).toEqual( + fullSubjectToComparableSubject({ + fullSubject: normalizedToFullSubject({ normalized: normalized! }), + }), + ); + + await cleanupKeys({ customerId: scenario.ids.customerId }); + }, + }); + }); + + test("entity subject round-trips through cache", async () => { + const scenario = buildEntitySubjectScenario({ + ctx, + name: "fullsubject-cache-roundtrip-entity", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const entityId = scenario.ids.entityIds[0]!; + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + entityId, + }); + expect(normalized).toBeDefined(); + + const result = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(result).toBe("OK"); + + const cached = await getCachedFullSubject({ + ctx, + customerId: scenario.ids.customerId, + entityId, + source: "integration-test", + }); + + expect( + fullSubjectToComparableSubject({ + fullSubject: cached!, + }), + ).toEqual( + fullSubjectToComparableSubject({ + fullSubject: normalizedToFullSubject({ normalized: normalized! }), + }), + ); + + await cleanupKeys({ + customerId: scenario.ids.customerId, + entityId, + }); + }, + }); + }); + + test("missing balance hash returns cache miss", async () => { + const scenario = buildCustomerMeteredScenario({ + ctx, + name: "fullsubject-cache-missing-balance", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + expect(normalized).toBeDefined(); + const result = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(result).toBe("OK"); + + const subjectKey = buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: scenario.ids.customerId, + }); + const subjectRaw = (await redisV2.get(subjectKey)) as string | null; + const subject = JSON.parse(subjectRaw!) as CachedFullSubject; + + await redisV2.del( + buildFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: scenario.ids.customerId, + featureId: subject.meteredFeatures[0]!, + }), + ); + + const cached = await getCachedFullSubject({ + ctx, + customerId: scenario.ids.customerId, + source: "integration-test", + }); + + expect(cached).toBeUndefined(); + await cleanupKeys({ customerId: scenario.ids.customerId }); + }, + }); + }); + + test("missing top-level subject returns cache miss", async () => { + const scenario = buildCustomerMeteredScenario({ + ctx, + name: "fullsubject-cache-missing-subject", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + expect(normalized).toBeDefined(); + const result = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(result).toBe("OK"); + + await redisV2.del( + buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: scenario.ids.customerId, + }), + ); + + const cached = await getCachedFullSubject({ + ctx, + customerId: scenario.ids.customerId, + source: "integration-test", + }); + + expect(cached).toBeUndefined(); + }, + }); + }); + + test("invalidation removes subject and makes cache unreadable", async () => { + const scenario = buildCustomerMeteredScenario({ + ctx, + name: "fullsubject-cache-invalidation", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + expect(normalized).toBeDefined(); + const result = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(result).toBe("OK"); + + await invalidateCachedFullSubject({ + ctx, + customerId: scenario.ids.customerId, + source: "integration-test", + }); + + const cached = await getCachedFullSubject({ + ctx, + customerId: scenario.ids.customerId, + source: "integration-test", + }); + + expect(cached).toBeUndefined(); + }, + }); + }); + + test("existing subject skips write when overwrite is false", async () => { + const scenario = buildCustomerWithInvoicesAndSubscriptionsScenario({ + ctx, + name: "fullsubject-cache-skip-existing", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + expect(normalized).toBeDefined(); + + const firstResult = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(firstResult).toBe("OK"); + + const subjectKey = buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: scenario.ids.customerId, + }); + const firstSubjectRaw = (await redisV2.get(subjectKey)) as + | string + | null; + expect(firstSubjectRaw).toBeDefined(); + + const secondResult = await setCachedFullSubject({ + ctx, + normalized: normalized!, + fetchTimeMs: Date.now(), + }); + expect(secondResult).toBe("CACHE_EXISTS"); + + const secondSubjectRaw = (await redisV2.get(subjectKey)) as + | string + | null; + expect(secondSubjectRaw).toEqual(firstSubjectRaw); + + await cleanupKeys({ customerId: scenario.ids.customerId }); + }, + }); + }); +}); diff --git a/server/tests/integration/db/full-subject/full-subject-aggregates.test.ts b/server/tests/integration/db/full-subject/full-subject-aggregates.test.ts new file mode 100644 index 000000000..f6d104110 --- /dev/null +++ b/server/tests/integration/db/full-subject/full-subject-aggregates.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { getFullSubject } from "@/internal/customers/repos/getFullSubject/index.js"; +import { fullSubjectToComparableSubject } from "./utils/buildComparableFullSubject.js"; +import { + buildCustomerWithEntityBoundDataScenario, + buildEntitySubjectScenario, + buildLooseEntityEntitlementScenario, +} from "./utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "./utils/withInsertedScenario.js"; + +describe(`${chalk.yellowBright("fullSubject aggregates")}`, () => { + test("customer-scoped: entity-bound products move to aggregated_customer_products", async () => { + const scenario = buildCustomerWithEntityBoundDataScenario({ + ctx, + name: "fullsubject-aggregated-products", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.customer_products).toHaveLength(1); + expect(comparable.customer_products[0]?.id).toBe( + scenario.customerProducts[0]?.id, + ); + + expect(comparable.aggregated_customer_products).toHaveLength(1); + expect(comparable.aggregated_customer_products[0]?.id).toBe( + scenario.customerProducts[1]?.id, + ); + }, + }); + }); + + test("customer-scoped: entity-bound entitlements move to aggregated_customer_entitlements", async () => { + const scenario = buildCustomerWithEntityBoundDataScenario({ + ctx, + name: "fullsubject-aggregated-entitlements", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect( + comparable.customer_products[0]?.customer_entitlements, + ).toHaveLength(1); + expect( + comparable.customer_products[0]?.customer_entitlements[0]?.id, + ).toBe(scenario.customerEntitlements[0]?.id); + + expect(comparable.aggregated_customer_entitlements).toHaveLength(1); + expect( + comparable.aggregated_customer_entitlements[0]?.internal_feature_id, + ).toBe(scenario.customerEntitlements[1]?.internal_feature_id); + expect(comparable.aggregated_customer_entitlements[0]?.balance).toBe( + scenario.customerEntitlements[1]?.balance, + ); + }, + }); + }); + + test("customer-scoped: aggregated feature balances sum allowance_total and per-entity balance fields", async () => { + const scenario = buildEntitySubjectScenario({ + ctx, + name: "fullsubject-aggregated-feature-balance", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.aggregated_customer_entitlements).toHaveLength(1); + const aggregate = comparable.aggregated_customer_entitlements[0]!; + + expect(aggregate.allowance_total).toBe(200); + expect(aggregate.balance).toBe(30); + expect(aggregate.adjustment).toBe(0); + expect(aggregate.additional_balance).toBe(0); + expect(aggregate.entity_count).toBe(2); + expect(JSON.stringify(aggregate.feature.id)).toBe( + JSON.stringify(scenario.customerEntitlements[1]!.feature_id), + ); + + expect(aggregate.entities).toMatchObject({ + [scenario.ids.internalEntityIds[0]!]: { + id: scenario.ids.internalEntityIds[0], + balance: 20, + adjustment: 0, + additional_balance: 0, + }, + [scenario.ids.internalEntityIds[1]!]: { + id: scenario.ids.internalEntityIds[1], + balance: 10, + adjustment: 0, + additional_balance: 0, + }, + }); + }, + }); + }); + + test("customer-scoped: entity-scoped loose entitlements are aggregated and excluded from top-level extras", async () => { + const scenario = buildLooseEntityEntitlementScenario({ + ctx, + name: "fullsubject-aggregated-loose-entities", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.extra_customer_entitlements).toHaveLength(1); + expect(comparable.extra_customer_entitlements[0]?.id).toBe( + scenario.customerEntitlements[0]?.id, + ); + + expect(comparable.aggregated_customer_entitlements).toHaveLength(1); + const aggregate = comparable.aggregated_customer_entitlements[0]!; + expect(aggregate.allowance_total).toBe(200); + expect(aggregate.balance).toBe(30); + expect(aggregate.entity_count).toBe(2); + expect(aggregate.entities).toMatchObject({ + [scenario.ids.internalEntityIds[0]!]: { + id: scenario.ids.internalEntityIds[0], + balance: 20, + adjustment: 0, + additional_balance: 0, + }, + [scenario.ids.internalEntityIds[1]!]: { + id: scenario.ids.internalEntityIds[1], + balance: 10, + adjustment: 0, + additional_balance: 0, + }, + }); + }, + }); + }); +}); diff --git a/server/tests/integration/db/full-subject/full-subject-entity.test.ts b/server/tests/integration/db/full-subject/full-subject-entity.test.ts new file mode 100644 index 000000000..53d48544c --- /dev/null +++ b/server/tests/integration/db/full-subject/full-subject-entity.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { getFullSubject } from "@/internal/customers/repos/getFullSubject/index.js"; +import { fullSubjectToComparableSubject } from "./utils/buildComparableFullSubject.js"; +import { + buildEntitySubjectScenario, + buildEntitySubjectWithSubscriptionsAndInvoicesScenario, + buildLooseEntityEntitlementScenario, +} from "./utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "./utils/withInsertedScenario.js"; + +describe(`${chalk.yellowBright("fullSubject entity semantics")}`, () => { + test("entity subject includes parent and selected entity products only", async () => { + const scenario = buildEntitySubjectScenario({ + ctx, + name: "fullsubject-entity-products-only", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubjectWithCustomer = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + entityId: scenario.ids.entityIds[0], + }); + const fullSubjectEntityOnly = await getFullSubject({ + ctx, + entityId: scenario.ids.entityIds[0], + }); + + const comparableWithCustomer = fullSubjectToComparableSubject({ + fullSubject: fullSubjectWithCustomer!, + }); + const comparableEntityOnly = fullSubjectToComparableSubject({ + fullSubject: fullSubjectEntityOnly!, + }); + + expect( + comparableWithCustomer.customer_products.map((product) => product.id), + ).toEqual( + comparableEntityOnly.customer_products.map((product) => product.id), + ); + + expect( + [...comparableWithCustomer.customer_products] + .map((product) => product.internal_entity_id) + .sort((left, right) => (left ?? "").localeCompare(right ?? "")), + ).toEqual( + [null, scenario.ids.internalEntityIds[0]].sort((left, right) => + (left ?? "").localeCompare(right ?? ""), + ), + ); + }, + }); + }); + + test("entity subject excludes unrelated entity entitlements", async () => { + const scenario = buildEntitySubjectScenario({ + ctx, + name: "fullsubject-entity-entitlement-filter", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + entityId: scenario.ids.entityIds[0], + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + const returnedEntitlementIds = comparable.customer_products.flatMap( + (product) => product.customer_entitlements.map((ent) => ent.id), + ); + + expect(returnedEntitlementIds).toContain( + scenario.customerEntitlements[0]?.id, + ); + expect(returnedEntitlementIds).toContain( + scenario.customerEntitlements[1]?.id, + ); + expect(returnedEntitlementIds).not.toContain( + scenario.customerEntitlements[2]?.id, + ); + }, + }); + }); + + test("entity subject omits invoices but still includes subscriptions from selected products", async () => { + const scenario = buildEntitySubjectWithSubscriptionsAndInvoicesScenario({ + ctx, + name: "fullsubject-entity-invoice-contract", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + entityId: scenario.ids.entityIds[0], + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.invoices).toEqual([]); + expect(comparable.subscriptions).toHaveLength(1); + expect(comparable.subscriptions[0]?.id).toBe( + scenario.subscriptions[0]?.id, + ); + }, + }); + }); + + test("entity subject includes customer-level loose and selected entity loose, but excludes unrelated entity loose", async () => { + const scenario = buildLooseEntityEntitlementScenario({ + ctx, + name: "fullsubject-entity-loose-selection", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + entityId: scenario.ids.entityIds[0], + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + const extraIds = comparable.extra_customer_entitlements.map( + (customerEntitlement) => customerEntitlement.id, + ); + + expect(extraIds).toHaveLength(2); + expect(new Set(extraIds).size).toBe(2); + expect(extraIds).toContain(scenario.customerEntitlements[0]?.id); + expect(extraIds).toContain(scenario.customerEntitlements[1]?.id); + expect(extraIds).not.toContain(scenario.customerEntitlements[2]?.id); + }, + }); + }); +}); diff --git a/server/tests/integration/db/full-subject/full-subject-mixed-shapes.test.ts b/server/tests/integration/db/full-subject/full-subject-mixed-shapes.test.ts new file mode 100644 index 000000000..7bb78b0a7 --- /dev/null +++ b/server/tests/integration/db/full-subject/full-subject-mixed-shapes.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { + getFullSubject, + getFullSubjectNormalized, +} from "@/internal/customers/repos/getFullSubject/index.js"; +import { fullSubjectToComparableSubject } from "./utils/buildComparableFullSubject.js"; +import { + buildBooleanMeteredLooseScenario, + buildBooleanOnlyScenario, + buildNoCustomerProductsScenario, + buildOnlyEntityBoundProductScenario, + buildProductAndLooseSameFeatureScenario, +} from "./utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "./utils/withInsertedScenario.js"; + +describe(`${chalk.yellowBright("fullSubject mixed shapes")}`, () => { + test("product + loose on same feature stays split between product and extra entitlements", async () => { + const scenario = buildProductAndLooseSameFeatureScenario({ + ctx, + name: "fullsubject-product-plus-loose", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.customer_products).toHaveLength(1); + expect( + comparable.customer_products[0]?.customer_entitlements, + ).toHaveLength(1); + expect(comparable.extra_customer_entitlements).toHaveLength(1); + expect(comparable.extra_customer_entitlements[0]?.id).toBe( + scenario.customerEntitlements[1]?.id, + ); + }, + }); + }); + + test("boolean + metered + loose combined hydrates correctly", async () => { + const scenario = buildBooleanMeteredLooseScenario({ + ctx, + name: "fullsubject-boolean-metered-loose", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.customer_products).toHaveLength(1); + expect( + comparable.customer_products[0]?.customer_entitlements, + ).toHaveLength(2); + expect(comparable.extra_customer_entitlements).toHaveLength(1); + }, + }); + }); + + test("no customer products yields a stable empty subject shape", async () => { + const scenario = buildNoCustomerProductsScenario({ + ctx, + name: "fullsubject-no-products", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect(normalized?.customer_products).toEqual([]); + expect(normalized?.customer_entitlements).toEqual([]); + expect(fullSubject?.customer_products).toEqual([]); + expect(fullSubject?.extra_customer_entitlements).toEqual([]); + }, + }); + }); + + test("boolean-only subject hydrates to flags-backed product entitlements", async () => { + const scenario = buildBooleanOnlyScenario({ + ctx, + name: "fullsubject-boolean-only", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect(Object.keys(normalized?.flags ?? {})).toHaveLength(1); + expect(normalized?.customer_entitlements).toEqual([]); + expect( + fullSubject?.customer_products[0]?.customer_entitlements, + ).toHaveLength(1); + }, + }); + }); + + test("only entity-bound products produce empty top-level customer products on customer-scoped subject", async () => { + const scenario = buildOnlyEntityBoundProductScenario({ + ctx, + name: "fullsubject-only-entity-bound", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.customer_products).toEqual([]); + expect(comparable.aggregated_customer_products).toHaveLength(1); + }, + }); + }); +}); diff --git a/server/tests/integration/db/full-subject/full-subject-parity.test.ts b/server/tests/integration/db/full-subject/full-subject-parity.test.ts new file mode 100644 index 000000000..fe5d902a3 --- /dev/null +++ b/server/tests/integration/db/full-subject/full-subject-parity.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from "bun:test"; +import { CustomerExpand, normalizedToFullSubject } from "@autumn/shared"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService.js"; +import { + getFullSubject, + getFullSubjectNormalized, +} from "@/internal/customers/repos/getFullSubject/index.js"; +import { + fullCustomerToComparableSubject, + fullSubjectToComparableSubject, +} from "./utils/buildComparableFullSubject.js"; +import { + buildCustomerLooseEntitlementScenario, + buildCustomerMeteredScenario, + buildCustomerMixedBooleanMeteredScenario, + buildCustomerWithEntityBoundDataScenario, + buildCustomerWithInvoicesAndSubscriptionsScenario, + buildEntitySubjectScenario, + buildRolloverScenario, +} from "./utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "./utils/withInsertedScenario.js"; + +const pickParityFields = ({ + comparable, +}: { + comparable: ReturnType; +}) => ({ + customer_products: comparable.customer_products, + extra_customer_entitlements: comparable.extra_customer_entitlements, + subscriptions: comparable.subscriptions, + invoices: comparable.invoices, +}); + +describe(`${chalk.yellowBright("fullSubject db parity")}`, () => { + test("customer-scoped parity: basic metered product", async () => { + const scenario = buildCustomerMeteredScenario({ + ctx, + name: "fullsubject-basic-metered", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: scenario.ids.customerId, + }); + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + const normalized = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect(fullSubject).toBeDefined(); + expect(normalized).toBeDefined(); + + const hydrated = normalizedToFullSubject({ + normalized: normalized!, + }); + + expect( + pickParityFields({ + comparable: fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }), + }), + ).toEqual( + fullCustomerToComparableSubject({ + fullCustomer, + }), + ); + + expect( + fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }), + ).toEqual( + fullSubjectToComparableSubject({ + fullSubject: hydrated, + }), + ); + }, + }); + }); + + test("customer-scoped parity: mixed boolean and metered", async () => { + const scenario = buildCustomerMixedBooleanMeteredScenario({ + ctx, + name: "fullsubject-mixed-bool-metered", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: scenario.ids.customerId, + }); + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect( + pickParityFields({ + comparable: fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }), + }), + ).toEqual( + fullCustomerToComparableSubject({ + fullCustomer, + }), + ); + }, + }); + }); + + test("customer-scoped parity: loose entitlement", async () => { + const scenario = buildCustomerLooseEntitlementScenario({ + ctx, + name: "fullsubject-loose", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: scenario.ids.customerId, + }); + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect( + pickParityFields({ + comparable: fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }), + }), + ).toEqual( + fullCustomerToComparableSubject({ + fullCustomer, + }), + ); + }, + }); + }); + + test("customer-scoped parity: subscriptions and invoices", async () => { + const scenario = buildCustomerWithInvoicesAndSubscriptionsScenario({ + ctx, + name: "fullsubject-subs-invoices", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: scenario.ids.customerId, + withSubs: true, + expand: [CustomerExpand.Invoices], + }); + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect( + pickParityFields({ + comparable: fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }), + }), + ).toEqual( + fullCustomerToComparableSubject({ + fullCustomer, + }), + ); + }, + }); + }); + + test("customer-scoped: entity-bound data lives in aggregated fields", async () => { + const scenario = buildCustomerWithEntityBoundDataScenario({ + ctx, + name: "fullsubject-aggregated-entity-data", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + const comparable = fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }); + + expect(comparable.customer_products).toHaveLength(1); + expect(comparable.customer_products[0]?.internal_entity_id).toBeNull(); + + expect(comparable.aggregated_customer_products).toHaveLength(1); + expect( + comparable.aggregated_customer_products[0]?.internal_entity_id, + ).toBe(scenario.ids.internalEntityIds[0]); + + expect(comparable.aggregated_customer_entitlements).toHaveLength(1); + expect( + JSON.stringify( + comparable.aggregated_customer_entitlements[0]?.feature_id ?? null, + ), + ).toBe( + JSON.stringify(scenario.customerEntitlements[1]!.feature_id ?? null), + ); + }, + }); + }); + + test("entity-scoped query semantics: parent and selected entity products only", async () => { + const scenario = buildEntitySubjectScenario({ + ctx, + name: "fullsubject-entity-query", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullSubjectWithCustomer = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + entityId: scenario.ids.entityIds[0], + }); + const fullSubjectEntityOnly = await getFullSubject({ + ctx, + entityId: scenario.ids.entityIds[0], + }); + + const comparableWithCustomer = fullSubjectToComparableSubject({ + fullSubject: fullSubjectWithCustomer!, + }); + const comparableEntityOnly = fullSubjectToComparableSubject({ + fullSubject: fullSubjectEntityOnly!, + }); + + expect( + comparableWithCustomer.customer_products.map((product) => product.id), + ).toEqual( + comparableEntityOnly.customer_products.map((product) => product.id), + ); + + expect(comparableWithCustomer.customer_products).toHaveLength(2); + expect( + [...comparableWithCustomer.customer_products] + .map((product) => product.internal_entity_id) + .sort((left, right) => (left ?? "").localeCompare(right ?? "")), + ).toEqual( + [null, scenario.ids.internalEntityIds[0]].sort((left, right) => + (left ?? "").localeCompare(right ?? ""), + ), + ); + + expect( + comparableWithCustomer.customer_products.some( + (product) => + product.internal_entity_id === scenario.ids.internalEntityIds[1], + ), + ).toBe(false); + + expect(comparableWithCustomer.invoices).toEqual([]); + }, + }); + }); + + test("customer-scoped parity: rollovers are preserved with deterministic order", async () => { + const scenario = buildRolloverScenario({ + ctx, + name: "fullsubject-rollovers", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: scenario.ids.customerId, + }); + const fullSubject = await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect( + pickParityFields({ + comparable: fullSubjectToComparableSubject({ + fullSubject: fullSubject!, + }), + }), + ).toEqual( + fullCustomerToComparableSubject({ + fullCustomer, + }), + ); + }, + }); + }); +}); diff --git a/server/tests/integration/db/full-subject/full-subject-query-row.test.ts b/server/tests/integration/db/full-subject/full-subject-query-row.test.ts new file mode 100644 index 000000000..4268d6748 --- /dev/null +++ b/server/tests/integration/db/full-subject/full-subject-query-row.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { fetchSubjectQueryRow } from "./utils/fetchSubjectQueryRow.js"; +import { + buildCustomerWithEntityBoundDataScenario, + buildEntitySubjectScenario, + buildEntitySubjectWithSubscriptionsAndInvoicesScenario, +} from "./utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "./utils/withInsertedScenario.js"; + +describe(`${chalk.yellowBright("fullSubject raw query row")}`, () => { + test("customer-scoped row excludes entity-bound products from top-level arrays", async () => { + const scenario = buildCustomerWithEntityBoundDataScenario({ + ctx, + name: "fullsubject-row-customer-aggregates", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const row = await fetchSubjectQueryRow({ + ctx, + customerId: scenario.ids.customerId, + }); + + expect(row).toBeDefined(); + expect(row?.customer_products).toHaveLength(1); + expect(row?.customer_products[0]?.internal_entity_id).toBeNull(); + expect( + row?.entity_aggregations?.aggregated_customer_products, + ).toHaveLength(1); + expect( + row?.entity_aggregations?.aggregated_customer_products[0] + ?.internal_entity_id, + ).toBe(scenario.ids.internalEntityIds[0]); + }, + }); + }); + + test("entity-scoped row includes parent plus selected entity products", async () => { + const scenario = buildEntitySubjectScenario({ + ctx, + name: "fullsubject-row-entity-products", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const row = await fetchSubjectQueryRow({ + ctx, + customerId: scenario.ids.customerId, + entityId: scenario.ids.entityIds[0], + }); + + expect(row).toBeDefined(); + expect(row?.customer_products).toHaveLength(2); + expect( + row?.customer_products.map((product) => product.internal_entity_id), + ).toEqual([null, scenario.ids.internalEntityIds[0]]); + }, + }); + }); + + test("entity-scoped row omits invoices but still includes subscriptions", async () => { + const scenario = buildEntitySubjectWithSubscriptionsAndInvoicesScenario({ + ctx, + name: "fullsubject-row-entity-invoices", + }); + + await withInsertedScenario({ + ctx, + scenario, + run: async ({ scenario }) => { + const row = await fetchSubjectQueryRow({ + ctx, + entityId: scenario.ids.entityIds[0], + }); + + expect(row).toBeDefined(); + expect(row?.invoices).toBeUndefined(); + expect(row?.subscriptions).toHaveLength(1); + }, + }); + }); +}); diff --git a/server/tests/integration/db/full-subject/utils/buildComparableFullSubject.ts b/server/tests/integration/db/full-subject/utils/buildComparableFullSubject.ts new file mode 100644 index 000000000..8399f3bf8 --- /dev/null +++ b/server/tests/integration/db/full-subject/utils/buildComparableFullSubject.ts @@ -0,0 +1,220 @@ +import type { + FullAggregatedFeatureBalance, + FullCusProduct, + FullCustomer, + FullCustomerEntitlement, + FullCustomerPrice, + FullSubject, + Invoice, + Subscription, +} from "@autumn/shared"; +import { FeatureType } from "@autumn/shared"; + +const sortByString = (items: T[], getValue: (item: T) => string) => + [...items].sort((left, right) => + getValue(left).localeCompare(getValue(right)), + ); + +const comparableRollover = ( + rollover: FullCustomerEntitlement["rollovers"][number], +) => ({ + id: rollover.id, + cus_ent_id: rollover.cus_ent_id, + balance: rollover.balance, + usage: rollover.usage, + expires_at: rollover.expires_at, + entities: rollover.entities, +}); + +const comparableCustomerEntitlement = ( + customerEntitlement: FullCustomerEntitlement, +) => { + const base = { + id: customerEntitlement.id, + customer_product_id: customerEntitlement.customer_product_id, + entitlement_id: customerEntitlement.entitlement_id, + internal_entity_id: customerEntitlement.internal_entity_id, + internal_feature_id: customerEntitlement.internal_feature_id, + feature_id: customerEntitlement.feature_id, + external_id: customerEntitlement.external_id, + expires_at: customerEntitlement.expires_at, + entitlement: { + id: customerEntitlement.entitlement.id, + internal_feature_id: customerEntitlement.entitlement.internal_feature_id, + entity_feature_id: customerEntitlement.entitlement.entity_feature_id, + allowance_type: customerEntitlement.entitlement.allowance_type, + allowance: customerEntitlement.entitlement.allowance, + interval: customerEntitlement.entitlement.interval, + interval_count: customerEntitlement.entitlement.interval_count, + usage_limit: customerEntitlement.entitlement.usage_limit, + feature: { + id: customerEntitlement.entitlement.feature.id, + internal_id: customerEntitlement.entitlement.feature.internal_id, + type: customerEntitlement.entitlement.feature.type, + }, + }, + rollovers: sortByString( + customerEntitlement.rollovers.map(comparableRollover), + (rollover) => rollover.id, + ), + }; + + if (customerEntitlement.entitlement.feature.type === FeatureType.Boolean) { + return base; + } + + return { + ...base, + unlimited: customerEntitlement.unlimited, + balance: customerEntitlement.balance, + adjustment: customerEntitlement.adjustment, + additional_balance: customerEntitlement.additional_balance, + usage_allowed: customerEntitlement.usage_allowed, + next_reset_at: customerEntitlement.next_reset_at, + entities: customerEntitlement.entities, + }; +}; + +const comparableCustomerPrice = (customerPrice: FullCustomerPrice) => ({ + id: customerPrice.id, + customer_product_id: customerPrice.customer_product_id, + price_id: customerPrice.price_id, + price: { + id: customerPrice.price.id, + internal_product_id: customerPrice.price.internal_product_id, + entitlement_id: customerPrice.price.entitlement_id, + billing_type: customerPrice.price.billing_type, + config: customerPrice.price.config, + }, +}); + +const comparableCustomerProduct = (customerProduct: FullCusProduct) => ({ + id: customerProduct.id, + product_id: customerProduct.product_id, + internal_product_id: customerProduct.internal_product_id, + internal_entity_id: customerProduct.internal_entity_id, + status: customerProduct.status, + quantity: customerProduct.quantity, + options: customerProduct.options, + subscription_ids: customerProduct.subscription_ids, + free_trial_id: customerProduct.free_trial_id, + product: { + id: customerProduct.product.id, + internal_id: customerProduct.product.internal_id, + is_add_on: customerProduct.product.is_add_on, + is_default: customerProduct.product.is_default, + group: customerProduct.product.group, + version: customerProduct.product.version, + archived: customerProduct.product.archived, + }, + customer_prices: sortByString( + customerProduct.customer_prices.map(comparableCustomerPrice), + (customerPrice) => customerPrice.id, + ), + customer_entitlements: sortByString( + customerProduct.customer_entitlements.map(comparableCustomerEntitlement), + (customerEntitlement) => customerEntitlement.id, + ), +}); + +const comparableSubscription = (subscription: Subscription) => ({ + id: subscription.id, + stripe_id: subscription.stripe_id, + current_period_start: subscription.current_period_start, + current_period_end: subscription.current_period_end, + usage_features: [...subscription.usage_features].sort(), +}); + +const comparableInvoice = (invoice: Invoice) => ({ + id: invoice.id, + internal_entity_id: invoice.internal_entity_id, + stripe_id: invoice.stripe_id, + status: invoice.status, + total: invoice.total, + currency: invoice.currency, + product_ids: [...invoice.product_ids].sort(), + internal_product_ids: [...invoice.internal_product_ids].sort(), +}); + +const comparableAggregatedEntitlement = ( + customerEntitlement: FullAggregatedFeatureBalance, +) => ({ + api_id: customerEntitlement.api_id, + internal_feature_id: customerEntitlement.internal_feature_id, + feature_id: customerEntitlement.feature_id, + allowance_total: customerEntitlement.allowance_total, + balance: customerEntitlement.balance, + adjustment: customerEntitlement.adjustment, + additional_balance: customerEntitlement.additional_balance, + unlimited: customerEntitlement.unlimited, + usage_allowed: customerEntitlement.usage_allowed, + entity_count: customerEntitlement.entity_count, + entities: customerEntitlement.entities, + feature: { + id: customerEntitlement.feature.id, + type: customerEntitlement.feature.type, + }, +}); + +export const fullCustomerToComparableSubject = ({ + fullCustomer, +}: { + fullCustomer: FullCustomer; +}) => ({ + customer_products: sortByString( + fullCustomer.customer_products.map(comparableCustomerProduct), + (customerProduct) => customerProduct.id, + ), + extra_customer_entitlements: sortByString( + (fullCustomer.extra_customer_entitlements ?? []).map( + comparableCustomerEntitlement, + ), + (customerEntitlement) => customerEntitlement.id, + ), + subscriptions: sortByString( + (fullCustomer.subscriptions ?? []).map(comparableSubscription), + (subscription) => subscription.id, + ), + invoices: sortByString( + (fullCustomer.invoices ?? []).map(comparableInvoice), + (invoice) => invoice.id, + ), +}); + +export const fullSubjectToComparableSubject = ({ + fullSubject, +}: { + fullSubject: FullSubject; +}) => ({ + customer_products: sortByString( + fullSubject.customer_products.map(comparableCustomerProduct), + (customerProduct) => customerProduct.id, + ), + extra_customer_entitlements: sortByString( + (fullSubject.extra_customer_entitlements ?? []).map( + comparableCustomerEntitlement, + ), + (customerEntitlement) => customerEntitlement.id, + ), + subscriptions: sortByString( + (fullSubject.subscriptions ?? []).map(comparableSubscription), + (subscription) => subscription.id, + ), + invoices: sortByString( + (fullSubject.invoices ?? []).map(comparableInvoice), + (invoice) => invoice.id, + ), + aggregated_customer_products: sortByString( + (fullSubject.aggregated_customer_products ?? []).map( + comparableCustomerProduct, + ), + (customerProduct) => customerProduct.id, + ), + aggregated_customer_entitlements: sortByString( + (fullSubject.aggregated_customer_entitlements ?? []).map( + comparableAggregatedEntitlement, + ), + (customerEntitlement) => + `${customerEntitlement.internal_feature_id}:${customerEntitlement.feature_id}`, + ), +}); diff --git a/server/tests/integration/db/full-subject/utils/cleanupFullSubjectScenario.ts b/server/tests/integration/db/full-subject/utils/cleanupFullSubjectScenario.ts new file mode 100644 index 000000000..0e3d5ed84 --- /dev/null +++ b/server/tests/integration/db/full-subject/utils/cleanupFullSubjectScenario.ts @@ -0,0 +1,28 @@ +import { customers, products, subscriptions } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { eq, inArray } from "drizzle-orm"; +import type { FullSubjectScenario } from "./fullSubjectScenarioBuilders.js"; + +export const cleanupFullSubjectScenario = async ({ + ctx, + scenario, +}: { + ctx: TestContext; + scenario: FullSubjectScenario; +}) => { + if (scenario.ids.subscriptionIds.length > 0) { + await ctx.db + .delete(subscriptions) + .where(inArray(subscriptions.id, scenario.ids.subscriptionIds)); + } + + await ctx.db + .delete(customers) + .where(eq(customers.internal_id, scenario.ids.internalCustomerId)); + + if (scenario.ids.productInternalIds.length > 0) { + await ctx.db + .delete(products) + .where(inArray(products.internal_id, scenario.ids.productInternalIds)); + } +}; diff --git a/server/tests/integration/db/full-subject/utils/fetchSubjectQueryRow.ts b/server/tests/integration/db/full-subject/utils/fetchSubjectQueryRow.ts new file mode 100644 index 000000000..4d9492eb6 --- /dev/null +++ b/server/tests/integration/db/full-subject/utils/fetchSubjectQueryRow.ts @@ -0,0 +1,29 @@ +import type { CusProductStatus, SubjectQueryRow } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; +import { getFullSubjectQuery } from "@/internal/customers/repos/getFullSubject/index.js"; + +export const fetchSubjectQueryRow = async ({ + ctx, + customerId, + entityId, + inStatuses = RELEVANT_STATUSES, +}: { + ctx: TestContext; + customerId?: string; + entityId?: string; + inStatuses?: CusProductStatus[]; +}): Promise => { + const result = await ctx.db.execute( + getFullSubjectQuery({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + entityId, + inStatuses, + }), + ); + + if (!result?.length) return null; + return result[0] as unknown as SubjectQueryRow; +}; diff --git a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts new file mode 100644 index 000000000..bcd3559f5 --- /dev/null +++ b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts @@ -0,0 +1,1299 @@ +import { + ApiVersion, + BillingInterval, + BillingType, + BillingVersion, + CollectionMethod, + CusProductStatus, + type DbCustomer, + type DbCustomerEntitlement, + type DbCustomerPrice, + type DbCustomerProduct, + type DbEntitlement, + type DbFeature, + type DbPrice, + type DbProduct, + type DbRollover, + EntInterval, + type Entity, + FeatureType, + type InvoiceRow, + InvoiceStatus, + type SubscriptionRow, +} from "@autumn/shared"; +import { AllowanceType } from "@shared/models/productModels/entModels/entModels.js"; +import { BillWhen } from "@shared/models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; + +type ScenarioIds = { + customerId: string; + internalCustomerId: string; + entityIds: string[]; + internalEntityIds: string[]; + productInternalIds: string[]; + productIds: string[]; + subscriptionIds: string[]; +}; + +export type FullSubjectScenario = { + customer: DbCustomer; + entities: Entity[]; + products: DbProduct[]; + entitlements: DbEntitlement[]; + prices: DbPrice[]; + customerProducts: DbCustomerProduct[]; + customerPrices: DbCustomerPrice[]; + customerEntitlements: DbCustomerEntitlement[]; + rollovers: DbRollover[]; + subscriptions: SubscriptionRow[]; + invoices: InvoiceRow[]; + ids: ScenarioIds; +}; + +const now = Date.now(); + +const buildUniqueKey = ({ name }: { name: string }) => + `${name}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const requireFeature = ({ + ctx, + featureId, +}: { + ctx: TestContext; + featureId: TestFeature; +}): DbFeature => { + const feature = ctx.features.find((candidate) => candidate.id === featureId); + if (!feature) throw new Error(`Feature ${featureId} not found in test org`); + return feature as DbFeature; +}; + +const buildCustomer = ({ + ctx, + key, +}: { + ctx: TestContext; + key: string; +}): DbCustomer => ({ + internal_id: `cus_int_${key}`, + org_id: ctx.org.id, + created_at: now, + name: `Customer ${key}`, + id: `cus_${key}`, + email: `${key}@example.com`, + fingerprint: null, + metadata: {}, + env: ctx.env, + processor: null, + processors: {}, + send_email_receipts: false, + auto_topups: null, + spend_limits: null, + usage_alerts: null, + overage_allowed: null, +}); + +const buildEntity = ({ + ctx, + customer, + key, + index, +}: { + ctx: TestContext; + customer: DbCustomer; + key: string; + index: number; +}): Entity => { + const usersFeature = requireFeature({ ctx, featureId: TestFeature.Users }); + return { + id: `ent_${key}_${index}`, + org_id: ctx.org.id, + created_at: now, + internal_id: `ent_int_${key}_${index}`, + internal_customer_id: customer.internal_id, + env: ctx.env, + name: `Entity ${index}`, + deleted: false, + internal_feature_id: usersFeature.internal_id, + spend_limits: null, + usage_alerts: null, + overage_allowed: null, + feature_id: usersFeature.id, + }; +}; + +const buildProduct = ({ + ctx, + key, + suffix, +}: { + ctx: TestContext; + key: string; + suffix: string; +}): DbProduct => ({ + internal_id: `prod_int_${key}_${suffix}`, + id: `prod_${key}_${suffix}`, + name: `Product ${suffix}`, + description: `Product ${suffix}`, + org_id: ctx.org.id, + created_at: now, + env: ctx.env, + is_add_on: false, + is_default: false, + group: "db-fullsubject", + version: 1, + processor: null, + base_variant_id: null, + archived: false, +}); + +const buildEntitlement = ({ + ctx, + product, + feature, + key, + suffix, + entityFeatureId = null, +}: { + ctx: TestContext; + product: DbProduct; + feature: DbFeature; + key: string; + suffix: string; + entityFeatureId?: string | null; +}): DbEntitlement => ({ + id: `ent_${key}_${suffix}`, + created_at: now, + internal_feature_id: feature.internal_id, + internal_product_id: product.internal_id, + is_custom: false, + allowance_type: + feature.type === FeatureType.Boolean + ? AllowanceType.None + : AllowanceType.Fixed, + allowance: feature.type === FeatureType.Boolean ? null : 100, + interval: feature.type === FeatureType.Boolean ? null : EntInterval.Month, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: entityFeatureId, + org_id: ctx.org.id, + feature_id: feature.id, + usage_limit: null, + rollover: null, +}); + +const buildPrice = ({ + ctx, + product, + entitlement, + key, + suffix, +}: { + ctx: TestContext; + product: DbProduct; + entitlement: DbEntitlement; + key: string; + suffix: string; +}): DbPrice => ({ + id: `price_${key}_${suffix}`, + org_id: ctx.org.id, + internal_product_id: product.internal_id, + config: { + type: "usage", + bill_when: BillWhen.EndOfPeriod, + billing_units: 1, + internal_feature_id: entitlement.internal_feature_id, + feature_id: entitlement.feature_id ?? "", + usage_tiers: [{ to: 1000, amount: 1 }], + interval: BillingInterval.Month, + interval_count: 1, + stripe_meter_id: null, + stripe_price_id: null, + stripe_empty_price_id: null, + stripe_product_id: null, + stripe_placeholder_price_id: null, + stripe_event_name: null, + stripe_prepaid_price_v2_id: null, + should_prorate: false, + }, + created_at: now, + billing_type: BillingType.UsageInArrear, + tier_behavior: null, + is_custom: false, + entitlement_id: entitlement.id, + proration_config: null, +}); + +const buildCustomerProduct = ({ + customer, + product, + key, + suffix, + internalEntityId = null, + entityId = null, + subscriptionStripeIds = [], +}: { + customer: DbCustomer; + product: DbProduct; + key: string; + suffix: string; + internalEntityId?: string | null; + entityId?: string | null; + subscriptionStripeIds?: string[]; +}): DbCustomerProduct => ({ + id: `cp_${key}_${suffix}`, + internal_customer_id: customer.internal_id, + internal_product_id: product.internal_id, + internal_entity_id: internalEntityId, + created_at: now, + status: CusProductStatus.Active, + processor: null, + canceled: false, + canceled_at: null, + ended_at: null, + starts_at: now, + options: [], + product_id: product.id, + free_trial_id: null, + trial_ends_at: null, + collection_method: CollectionMethod.ChargeAutomatically, + subscription_ids: subscriptionStripeIds, + scheduled_ids: null, + quantity: 1, + is_custom: false, + customer_id: customer.id ?? null, + entity_id: entityId, + billing_version: BillingVersion.V2, + api_version: null, + api_semver: ApiVersion.V2_2, + external_id: null, +}); + +const buildCustomerPrice = ({ + customer, + customerProduct, + price, + key, + suffix, +}: { + customer: DbCustomer; + customerProduct: DbCustomerProduct; + price: DbPrice; + key: string; + suffix: string; +}): DbCustomerPrice => ({ + id: `cpr_${key}_${suffix}`, + created_at: now, + price_id: price.id, + options: null, + internal_customer_id: customer.internal_id, + customer_product_id: customerProduct.id, +}); + +const buildCustomerEntitlement = ({ + customer, + entitlement, + key, + suffix, + customerProductId = null, + internalEntityId = null, + balance = 100, + expiresAt = null, +}: { + customer: DbCustomer; + entitlement: DbEntitlement; + key: string; + suffix: string; + customerProductId?: string | null; + internalEntityId?: string | null; + balance?: number; + expiresAt?: number | null; +}): DbCustomerEntitlement => ({ + id: `ce_${key}_${suffix}`, + customer_product_id: customerProductId, + entitlement_id: entitlement.id, + internal_customer_id: customer.internal_id, + internal_entity_id: internalEntityId, + internal_feature_id: entitlement.internal_feature_id, + unlimited: false, + balance, + created_at: now, + next_reset_at: now + 30 * 24 * 60 * 60 * 1000, + usage_allowed: false, + adjustment: 0, + additional_balance: 0, + entities: null, + expires_at: expiresAt, + cache_version: 0, + customer_id: customer.id ?? null, + feature_id: entitlement.feature_id ?? null, + external_id: `bal_${key}_${suffix}`, +}); + +const buildRollover = ({ + customerEntitlement, + key, + suffix, + balance, + expiresAt, +}: { + customerEntitlement: DbCustomerEntitlement; + key: string; + suffix: string; + balance: number; + expiresAt: number; +}): DbRollover => ({ + id: `ro_${key}_${suffix}`, + cus_ent_id: customerEntitlement.id, + balance, + expires_at: expiresAt, + usage: 0, + entities: {}, +}); + +const buildSubscription = ({ + ctx, + key, + suffix, +}: { + ctx: TestContext; + key: string; + suffix: string; +}): SubscriptionRow => ({ + id: `sub_${key}_${suffix}`, + org_id: ctx.org.id, + stripe_id: `stripe_sub_${key}_${suffix}`, + stripe_schedule_id: null, + created_at: now, + metadata: {}, + usage_features: [TestFeature.Messages], + env: ctx.env, + current_period_start: now, + current_period_end: now + 30 * 24 * 60 * 60 * 1000, +}); + +const buildInvoice = ({ + customer, + key, + suffix, + product, + internalEntityId = null, +}: { + customer: DbCustomer; + key: string; + suffix: string; + product: DbProduct; + internalEntityId?: string | null; +}): InvoiceRow => ({ + id: `inv_${key}_${suffix}`, + created_at: now, + product_ids: [product.id], + internal_product_ids: [product.internal_id], + internal_customer_id: customer.internal_id, + internal_entity_id: internalEntityId, + stripe_id: `stripe_inv_${key}_${suffix}`, + status: InvoiceStatus.Paid, + hosted_invoice_url: null, + total: 1000, + currency: "usd", + discounts: [], + items: [], +}); + +const buildBaseCustomerScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}) => { + const key = buildUniqueKey({ name }); + const customer = buildCustomer({ ctx, key }); + const parentProduct = buildProduct({ ctx, key, suffix: "parent" }); + const messagesFeature = requireFeature({ + ctx, + featureId: TestFeature.Messages, + }); + const dashboardFeature = requireFeature({ + ctx, + featureId: TestFeature.Dashboard, + }); + + return { + key, + customer, + parentProduct, + messagesFeature, + dashboardFeature, + }; +}; + +export const buildCustomerMeteredScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const { key, customer, parentProduct, messagesFeature } = + buildBaseCustomerScenario({ ctx, name }); + const messagesEntitlement = buildEntitlement({ + ctx, + product: parentProduct, + feature: messagesFeature, + key, + suffix: "messages", + }); + const messagesPrice = buildPrice({ + ctx, + product: parentProduct, + entitlement: messagesEntitlement, + key, + suffix: "messages", + }); + const customerProduct = buildCustomerProduct({ + customer, + product: parentProduct, + key, + suffix: "parent", + }); + const customerPrice = buildCustomerPrice({ + customer, + customerProduct, + price: messagesPrice, + key, + suffix: "messages", + }); + const customerEntitlement = buildCustomerEntitlement({ + customer, + entitlement: messagesEntitlement, + key, + suffix: "messages", + customerProductId: customerProduct.id, + balance: 87, + }); + + return { + customer, + entities: [], + products: [parentProduct], + entitlements: [messagesEntitlement], + prices: [messagesPrice], + customerProducts: [customerProduct], + customerPrices: [customerPrice], + customerEntitlements: [customerEntitlement], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [], + internalEntityIds: [], + productInternalIds: [parentProduct.internal_id], + productIds: [parentProduct.id], + subscriptionIds: [], + }, + }; +}; + +export const buildCustomerMixedBooleanMeteredScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const { key, customer, parentProduct, messagesFeature, dashboardFeature } = + buildBaseCustomerScenario({ ctx, name }); + const messagesEntitlement = buildEntitlement({ + ctx, + product: parentProduct, + feature: messagesFeature, + key, + suffix: "messages", + }); + const dashboardEntitlement = buildEntitlement({ + ctx, + product: parentProduct, + feature: dashboardFeature, + key, + suffix: "dashboard", + }); + const messagesPrice = buildPrice({ + ctx, + product: parentProduct, + entitlement: messagesEntitlement, + key, + suffix: "messages", + }); + const customerProduct = buildCustomerProduct({ + customer, + product: parentProduct, + key, + suffix: "parent", + }); + const customerPrice = buildCustomerPrice({ + customer, + customerProduct, + price: messagesPrice, + key, + suffix: "messages", + }); + const meteredCe = buildCustomerEntitlement({ + customer, + entitlement: messagesEntitlement, + key, + suffix: "messages", + customerProductId: customerProduct.id, + balance: 42, + }); + const booleanCe = buildCustomerEntitlement({ + customer, + entitlement: dashboardEntitlement, + key, + suffix: "dashboard", + customerProductId: customerProduct.id, + balance: 0, + }); + + return { + customer, + entities: [], + products: [parentProduct], + entitlements: [messagesEntitlement, dashboardEntitlement], + prices: [messagesPrice], + customerProducts: [customerProduct], + customerPrices: [customerPrice], + customerEntitlements: [meteredCe, booleanCe], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [], + internalEntityIds: [], + productInternalIds: [parentProduct.internal_id], + productIds: [parentProduct.id], + subscriptionIds: [], + }, + }; +}; + +export const buildCustomerLooseEntitlementScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const { key, customer, messagesFeature } = buildBaseCustomerScenario({ + ctx, + name, + }); + const looseProduct = buildProduct({ ctx, key, suffix: "loose-holder" }); + const looseEntitlement = buildEntitlement({ + ctx, + product: looseProduct, + feature: messagesFeature, + key, + suffix: "loose", + }); + const looseCe = buildCustomerEntitlement({ + customer, + entitlement: looseEntitlement, + key, + suffix: "loose", + customerProductId: null, + balance: 33, + }); + + return { + customer, + entities: [], + products: [looseProduct], + entitlements: [looseEntitlement], + prices: [], + customerProducts: [], + customerPrices: [], + customerEntitlements: [looseCe], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [], + internalEntityIds: [], + productInternalIds: [looseProduct.internal_id], + productIds: [looseProduct.id], + subscriptionIds: [], + }, + }; +}; + +export const buildCustomerWithInvoicesAndSubscriptionsScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const scenario = buildCustomerMeteredScenario({ ctx, name }); + const subscription = buildSubscription({ + ctx, + key: scenario.ids.internalCustomerId, + suffix: "main", + }); + const customerProduct = { + ...scenario.customerProducts[0], + subscription_ids: [subscription.stripe_id ?? ""], + }; + const invoice = buildInvoice({ + customer: scenario.customer, + key: scenario.ids.internalCustomerId, + suffix: "main", + product: scenario.products[0], + }); + + return { + ...scenario, + customerProducts: [customerProduct], + subscriptions: [subscription], + invoices: [invoice], + ids: { + ...scenario.ids, + subscriptionIds: [subscription.id], + }, + }; +}; + +export const buildCustomerWithEntityBoundDataScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const { key, customer, parentProduct, messagesFeature } = + buildBaseCustomerScenario({ ctx, name }); + const entity = buildEntity({ ctx, customer, key, index: 1 }); + const entityProduct = buildProduct({ ctx, key, suffix: "entity" }); + + const parentEntitlement = buildEntitlement({ + ctx, + product: parentProduct, + feature: messagesFeature, + key, + suffix: "parent", + }); + const entityEntitlement = buildEntitlement({ + ctx, + product: entityProduct, + feature: messagesFeature, + key, + suffix: "entity", + entityFeatureId: entity.feature_id ?? null, + }); + + const parentPrice = buildPrice({ + ctx, + product: parentProduct, + entitlement: parentEntitlement, + key, + suffix: "parent", + }); + const entityPrice = buildPrice({ + ctx, + product: entityProduct, + entitlement: entityEntitlement, + key, + suffix: "entity", + }); + + const parentCp = buildCustomerProduct({ + customer, + product: parentProduct, + key, + suffix: "parent", + }); + const entityCp = buildCustomerProduct({ + customer, + product: entityProduct, + key, + suffix: "entity", + internalEntityId: entity.internal_id, + entityId: entity.id ?? null, + }); + + const parentCpr = buildCustomerPrice({ + customer, + customerProduct: parentCp, + price: parentPrice, + key, + suffix: "parent", + }); + const entityCpr = buildCustomerPrice({ + customer, + customerProduct: entityCp, + price: entityPrice, + key, + suffix: "entity", + }); + + const parentCe = buildCustomerEntitlement({ + customer, + entitlement: parentEntitlement, + key, + suffix: "parent", + customerProductId: parentCp.id, + balance: 50, + }); + const entityCe = buildCustomerEntitlement({ + customer, + entitlement: entityEntitlement, + key, + suffix: "entity", + customerProductId: entityCp.id, + internalEntityId: entity.internal_id, + balance: 25, + }); + + return { + customer, + entities: [entity], + products: [parentProduct, entityProduct], + entitlements: [parentEntitlement, entityEntitlement], + prices: [parentPrice, entityPrice], + customerProducts: [parentCp, entityCp], + customerPrices: [parentCpr, entityCpr], + customerEntitlements: [parentCe, entityCe], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [entity.id ?? ""], + internalEntityIds: [entity.internal_id], + productInternalIds: [ + parentProduct.internal_id, + entityProduct.internal_id, + ], + productIds: [parentProduct.id, entityProduct.id], + subscriptionIds: [], + }, + }; +}; + +export const buildBooleanOnlyScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const { key, customer, parentProduct, dashboardFeature } = + buildBaseCustomerScenario({ ctx, name }); + + const dashboardEntitlement = buildEntitlement({ + ctx, + product: parentProduct, + feature: dashboardFeature, + key, + suffix: "dashboard", + }); + + const customerProduct = buildCustomerProduct({ + customer, + product: parentProduct, + key, + suffix: "parent", + }); + + const booleanCe = buildCustomerEntitlement({ + customer, + entitlement: dashboardEntitlement, + key, + suffix: "dashboard", + customerProductId: customerProduct.id, + balance: 0, + }); + + return { + customer, + entities: [], + products: [parentProduct], + entitlements: [dashboardEntitlement], + prices: [], + customerProducts: [customerProduct], + customerPrices: [], + customerEntitlements: [booleanCe], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [], + internalEntityIds: [], + productInternalIds: [parentProduct.internal_id], + productIds: [parentProduct.id], + subscriptionIds: [], + }, + }; +}; + +export const buildProductAndLooseSameFeatureScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const base = buildCustomerMeteredScenario({ ctx, name }); + const looseProduct = buildProduct({ + ctx, + key: base.ids.internalCustomerId, + suffix: "loose-holder", + }); + const looseEntitlement = buildEntitlement({ + ctx, + product: looseProduct, + feature: requireFeature({ ctx, featureId: TestFeature.Messages }), + key: base.ids.internalCustomerId, + suffix: "loose", + }); + const looseCe = buildCustomerEntitlement({ + customer: base.customer, + entitlement: looseEntitlement, + key: base.ids.internalCustomerId, + suffix: "loose", + customerProductId: null, + balance: 12, + }); + + return { + ...base, + products: [...base.products, looseProduct], + entitlements: [...base.entitlements, looseEntitlement], + customerEntitlements: [...base.customerEntitlements, looseCe], + ids: { + ...base.ids, + productInternalIds: [ + ...base.ids.productInternalIds, + looseProduct.internal_id, + ], + productIds: [...base.ids.productIds, looseProduct.id], + }, + }; +}; + +export const buildBooleanMeteredLooseScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const mixed = buildCustomerMixedBooleanMeteredScenario({ ctx, name }); + const looseProduct = buildProduct({ + ctx, + key: mixed.ids.internalCustomerId, + suffix: "loose-holder", + }); + const looseEntitlement = buildEntitlement({ + ctx, + product: looseProduct, + feature: requireFeature({ ctx, featureId: TestFeature.Messages }), + key: mixed.ids.internalCustomerId, + suffix: "loose", + }); + const looseCe = buildCustomerEntitlement({ + customer: mixed.customer, + entitlement: looseEntitlement, + key: mixed.ids.internalCustomerId, + suffix: "loose", + customerProductId: null, + balance: 7, + }); + + return { + ...mixed, + products: [...mixed.products, looseProduct], + entitlements: [...mixed.entitlements, looseEntitlement], + customerEntitlements: [...mixed.customerEntitlements, looseCe], + ids: { + ...mixed.ids, + productInternalIds: [ + ...mixed.ids.productInternalIds, + looseProduct.internal_id, + ], + productIds: [...mixed.ids.productIds, looseProduct.id], + }, + }; +}; + +export const buildNoCustomerProductsScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const key = buildUniqueKey({ name }); + const customer = buildCustomer({ ctx, key }); + + return { + customer, + entities: [], + products: [], + entitlements: [], + prices: [], + customerProducts: [], + customerPrices: [], + customerEntitlements: [], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [], + internalEntityIds: [], + productInternalIds: [], + productIds: [], + subscriptionIds: [], + }, + }; +}; + +export const buildOnlyEntityBoundProductScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}) => { + const scenario = buildCustomerWithEntityBoundDataScenario({ ctx, name }); + return { + ...scenario, + customerProducts: [scenario.customerProducts[1]!], + customerPrices: [scenario.customerPrices[1]!], + customerEntitlements: [scenario.customerEntitlements[1]!], + products: [scenario.products[1]!], + entitlements: [scenario.entitlements[1]!], + prices: [scenario.prices[1]!], + ids: { + ...scenario.ids, + productInternalIds: [scenario.ids.productInternalIds[1]!], + productIds: [scenario.ids.productIds[1]!], + }, + } satisfies FullSubjectScenario; +}; + +export const buildEntitySubjectScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const { key, customer, parentProduct, messagesFeature } = + buildBaseCustomerScenario({ ctx, name }); + const entityA = buildEntity({ ctx, customer, key, index: 1 }); + const entityB = buildEntity({ ctx, customer, key, index: 2 }); + const entityProductA = buildProduct({ ctx, key, suffix: "entityA" }); + const entityProductB = buildProduct({ ctx, key, suffix: "entityB" }); + + const parentEntitlement = buildEntitlement({ + ctx, + product: parentProduct, + feature: messagesFeature, + key, + suffix: "parent", + }); + const entA = buildEntitlement({ + ctx, + product: entityProductA, + feature: messagesFeature, + key, + suffix: "entityA", + entityFeatureId: entityA.feature_id ?? null, + }); + const entB = buildEntitlement({ + ctx, + product: entityProductB, + feature: messagesFeature, + key, + suffix: "entityB", + entityFeatureId: entityB.feature_id ?? null, + }); + + const priceParent = buildPrice({ + ctx, + product: parentProduct, + entitlement: parentEntitlement, + key, + suffix: "parent", + }); + const priceA = buildPrice({ + ctx, + product: entityProductA, + entitlement: entA, + key, + suffix: "entityA", + }); + const priceB = buildPrice({ + ctx, + product: entityProductB, + entitlement: entB, + key, + suffix: "entityB", + }); + + const cpParent = buildCustomerProduct({ + customer, + product: parentProduct, + key, + suffix: "parent", + }); + const cpA = buildCustomerProduct({ + customer, + product: entityProductA, + key, + suffix: "entityA", + internalEntityId: entityA.internal_id, + entityId: entityA.id ?? null, + }); + const cpB = buildCustomerProduct({ + customer, + product: entityProductB, + key, + suffix: "entityB", + internalEntityId: entityB.internal_id, + entityId: entityB.id ?? null, + }); + + const cprParent = buildCustomerPrice({ + customer, + customerProduct: cpParent, + price: priceParent, + key, + suffix: "parent", + }); + const cprA = buildCustomerPrice({ + customer, + customerProduct: cpA, + price: priceA, + key, + suffix: "entityA", + }); + const cprB = buildCustomerPrice({ + customer, + customerProduct: cpB, + price: priceB, + key, + suffix: "entityB", + }); + + const ceParent = buildCustomerEntitlement({ + customer, + entitlement: parentEntitlement, + key, + suffix: "parent", + customerProductId: cpParent.id, + balance: 50, + }); + const ceA = buildCustomerEntitlement({ + customer, + entitlement: entA, + key, + suffix: "entityA", + customerProductId: cpA.id, + internalEntityId: entityA.internal_id, + balance: 20, + }); + const ceB = buildCustomerEntitlement({ + customer, + entitlement: entB, + key, + suffix: "entityB", + customerProductId: cpB.id, + internalEntityId: entityB.internal_id, + balance: 10, + }); + + return { + customer, + entities: [entityA, entityB], + products: [parentProduct, entityProductA, entityProductB], + entitlements: [parentEntitlement, entA, entB], + prices: [priceParent, priceA, priceB], + customerProducts: [cpParent, cpA, cpB], + customerPrices: [cprParent, cprA, cprB], + customerEntitlements: [ceParent, ceA, ceB], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [entityA.id ?? "", entityB.id ?? ""], + internalEntityIds: [entityA.internal_id, entityB.internal_id], + productInternalIds: [ + parentProduct.internal_id, + entityProductA.internal_id, + entityProductB.internal_id, + ], + productIds: [parentProduct.id, entityProductA.id, entityProductB.id], + subscriptionIds: [], + }, + }; +}; + +export const buildEntitySubjectWithSubscriptionsAndInvoicesScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}) => { + const scenario = buildEntitySubjectScenario({ ctx, name }); + const subscription = buildSubscription({ + ctx, + key: scenario.ids.internalCustomerId, + suffix: "entity", + }); + const customerProducts = [...scenario.customerProducts]; + customerProducts[0] = { + ...customerProducts[0]!, + subscription_ids: [subscription.stripe_id ?? ""], + }; + customerProducts[1] = { + ...customerProducts[1]!, + subscription_ids: [subscription.stripe_id ?? ""], + }; + const invoice = buildInvoice({ + customer: scenario.customer, + key: scenario.ids.internalCustomerId, + suffix: "entity", + product: scenario.products[0]!, + internalEntityId: scenario.ids.internalEntityIds[0]!, + }); + + return { + ...scenario, + customerProducts, + subscriptions: [subscription], + invoices: [invoice], + ids: { + ...scenario.ids, + subscriptionIds: [subscription.id], + }, + } satisfies FullSubjectScenario; +}; + +export const buildLooseEntityEntitlementScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const { key, customer, messagesFeature } = buildBaseCustomerScenario({ + ctx, + name, + }); + const entityA = buildEntity({ ctx, customer, key, index: 1 }); + const entityB = buildEntity({ ctx, customer, key, index: 2 }); + const looseProduct = buildProduct({ ctx, key, suffix: "loose-holder" }); + const looseEntitlement = buildEntitlement({ + ctx, + product: looseProduct, + feature: messagesFeature, + key, + suffix: "loose", + }); + + const customerLooseCe = buildCustomerEntitlement({ + customer, + entitlement: looseEntitlement, + key, + suffix: "customer-loose", + customerProductId: null, + balance: 11, + }); + const entityALooseCe = buildCustomerEntitlement({ + customer, + entitlement: looseEntitlement, + key, + suffix: "entityA-loose", + customerProductId: null, + internalEntityId: entityA.internal_id, + balance: 20, + }); + const entityBLooseCe = buildCustomerEntitlement({ + customer, + entitlement: looseEntitlement, + key, + suffix: "entityB-loose", + customerProductId: null, + internalEntityId: entityB.internal_id, + balance: 10, + }); + + return { + customer, + entities: [entityA, entityB], + products: [looseProduct], + entitlements: [looseEntitlement], + prices: [], + customerProducts: [], + customerPrices: [], + customerEntitlements: [customerLooseCe, entityALooseCe, entityBLooseCe], + rollovers: [], + subscriptions: [], + invoices: [], + ids: { + customerId: customer.id ?? "", + internalCustomerId: customer.internal_id, + entityIds: [entityA.id ?? "", entityB.id ?? ""], + internalEntityIds: [entityA.internal_id, entityB.internal_id], + productInternalIds: [looseProduct.internal_id], + productIds: [looseProduct.id], + subscriptionIds: [], + }, + }; +}; + +export const buildRolloverScenario = ({ + ctx, + name, +}: { + ctx: TestContext; + name: string; +}): FullSubjectScenario => { + const scenario = buildCustomerMeteredScenario({ ctx, name }); + const baseCe = scenario.customerEntitlements[0]; + const rolloverA = buildRollover({ + customerEntitlement: baseCe, + key: scenario.ids.internalCustomerId, + suffix: "a", + balance: 20, + expiresAt: now + 10_000, + }); + const rolloverB = buildRollover({ + customerEntitlement: baseCe, + key: scenario.ids.internalCustomerId, + suffix: "b", + balance: 10, + expiresAt: now + 5_000, + }); + + return { + ...scenario, + rollovers: [rolloverA, rolloverB], + }; +}; diff --git a/server/tests/integration/db/full-subject/utils/insertFullSubjectScenario.ts b/server/tests/integration/db/full-subject/utils/insertFullSubjectScenario.ts new file mode 100644 index 000000000..8040dc527 --- /dev/null +++ b/server/tests/integration/db/full-subject/utils/insertFullSubjectScenario.ts @@ -0,0 +1,71 @@ +import { + customerEntitlements, + customerPrices, + customerProducts, + customers, + entities, + entitlements, + invoices, + prices, + products, + rollovers, + subscriptions, +} from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import type { FullSubjectScenario } from "./fullSubjectScenarioBuilders.js"; + +export const insertFullSubjectScenario = async ({ + ctx, + scenario, +}: { + ctx: TestContext; + scenario: FullSubjectScenario; +}) => { + if (scenario.customer) { + await ctx.db.insert(customers).values(scenario.customer); + } + + if (scenario.entities.length > 0) { + await ctx.db.insert(entities).values(scenario.entities); + } + + if (scenario.products.length > 0) { + await ctx.db.insert(products).values(scenario.products); + } + + if (scenario.entitlements.length > 0) { + await ctx.db.insert(entitlements).values(scenario.entitlements); + } + + if (scenario.prices.length > 0) { + await ctx.db.insert(prices).values(scenario.prices); + } + + if (scenario.subscriptions.length > 0) { + await ctx.db.insert(subscriptions).values(scenario.subscriptions); + } + + if (scenario.customerProducts.length > 0) { + await ctx.db.insert(customerProducts).values(scenario.customerProducts); + } + + if (scenario.customerPrices.length > 0) { + await ctx.db.insert(customerPrices).values(scenario.customerPrices); + } + + if (scenario.customerEntitlements.length > 0) { + await ctx.db + .insert(customerEntitlements) + .values(scenario.customerEntitlements); + } + + if (scenario.rollovers.length > 0) { + await ctx.db.insert(rollovers).values(scenario.rollovers); + } + + if (scenario.invoices.length > 0) { + await ctx.db.insert(invoices).values(scenario.invoices); + } + + return scenario.ids; +}; diff --git a/server/tests/integration/db/full-subject/utils/withInsertedScenario.ts b/server/tests/integration/db/full-subject/utils/withInsertedScenario.ts new file mode 100644 index 000000000..b78076ef4 --- /dev/null +++ b/server/tests/integration/db/full-subject/utils/withInsertedScenario.ts @@ -0,0 +1,22 @@ +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { cleanupFullSubjectScenario } from "./cleanupFullSubjectScenario.js"; +import type { FullSubjectScenario } from "./fullSubjectScenarioBuilders.js"; +import { insertFullSubjectScenario } from "./insertFullSubjectScenario.js"; + +export const withInsertedScenario = async ({ + ctx, + scenario, + run, +}: { + ctx: TestContext; + scenario: FullSubjectScenario; + run: (params: { scenario: FullSubjectScenario }) => Promise; +}) => { + await insertFullSubjectScenario({ ctx, scenario }); + + try { + await run({ scenario }); + } finally { + await cleanupFullSubjectScenario({ ctx, scenario }); + } +}; diff --git a/server/tests/unit/full-subject-cache/full-subject-aggregate-balance.test.ts b/server/tests/unit/full-subject-cache/full-subject-aggregate-balance.test.ts new file mode 100644 index 000000000..258f04c82 --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-aggregate-balance.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { + AppEnv, + FeatureType, + type FullAggregatedFeatureBalance, +} from "@autumn/shared"; +import { mergeAggregatedBalanceIntoApiBalanceV2 } from "@/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.js"; + +describe("fullSubject aggregate balance", () => { + test("uses allowance_total when merging aggregated granted balance", () => { + const aggregatedFeatureBalance: FullAggregatedFeatureBalance = { + api_id: "cus_ent_agg_messages", + internal_feature_id: "feat_int_messages", + internal_customer_id: "cus_int_1", + feature_id: "messages", + allowance_total: 250, + balance: 180, + adjustment: 10, + additional_balance: 0, + unlimited: false, + usage_allowed: false, + entity_count: 2, + entities: { + ent1: { + id: "ent1", + balance: 100, + adjustment: 5, + additional_balance: 0, + }, + ent2: { + id: "ent2", + balance: 80, + adjustment: 5, + additional_balance: 0, + }, + }, + feature: { + internal_id: "feat_int_messages", + org_id: "org_1", + created_at: Date.now(), + env: AppEnv.Sandbox, + id: "messages", + name: "Messages", + type: FeatureType.Metered, + config: null, + display: null, + archived: false, + event_names: [], + }, + }; + + const merged = mergeAggregatedBalanceIntoApiBalanceV2({ + apiBalance: { + object: "balance", + feature_id: "messages", + feature: undefined, + granted: 50, + remaining: 20, + usage: 30, + unlimited: false, + overage_allowed: false, + max_purchase: null, + next_reset_at: null, + breakdown: [], + rollovers: undefined, + }, + aggregatedFeatureBalance, + }); + + expect(merged.granted).toBe(310); + expect(merged.remaining).toBe(200); + expect(merged.usage).toBe(110); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-cache-builders.test.ts b/server/tests/unit/full-subject-cache/full-subject-cache-builders.test.ts new file mode 100644 index 000000000..e3566df82 --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-cache-builders.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + buildFullSubjectBalanceKey, + buildFullSubjectGuardKey, + buildFullSubjectKey, + buildFullSubjectReserveKey, +} from "@/internal/customers/cache/fullSubject/index.js"; + +describe("fullSubject cache key builders", () => { + test("builds customer-scoped keys", () => { + expect( + buildFullSubjectKey({ + orgId: "org", + env: "test", + customerId: "cus", + }), + ).toBe("{cus}:org:test:full_subject"); + + expect( + buildFullSubjectBalanceKey({ + orgId: "org", + env: "test", + customerId: "cus", + featureId: "feat", + }), + ).toBe("{cus}:org:test:full_subject:balances:feat"); + + expect( + buildFullSubjectReserveKey({ + orgId: "org", + env: "test", + customerId: "cus", + }), + ).toBe("{cus}:org:test:full_subject:reserve"); + + expect( + buildFullSubjectGuardKey({ + orgId: "org", + env: "test", + customerId: "cus", + }), + ).toBe("{cus}:org:test:full_subject:guard"); + }); + + test("builds entity-scoped keys", () => { + expect( + buildFullSubjectKey({ + orgId: "org", + env: "test", + customerId: "cus", + entityId: "ent", + }), + ).toBe("{cus}:org:test:entity:ent:full_subject"); + + expect( + buildFullSubjectBalanceKey({ + orgId: "org", + env: "test", + customerId: "cus", + entityId: "ent", + featureId: "feat", + }), + ).toBe("{cus}:org:test:entity:ent:full_subject:balances:feat"); + + expect( + buildFullSubjectReserveKey({ + orgId: "org", + env: "test", + customerId: "cus", + entityId: "ent", + }), + ).toBe("{cus}:org:test:entity:ent:full_subject:reserve"); + + expect( + buildFullSubjectGuardKey({ + orgId: "org", + env: "test", + customerId: "cus", + entityId: "ent", + }), + ).toBe("{cus}:org:test:entity:ent:full_subject:guard"); + }); +}); diff --git a/server/tests/unit/full-subject-cache/full-subject-cache-model.test.ts b/server/tests/unit/full-subject-cache/full-subject-cache-model.test.ts new file mode 100644 index 000000000..51656b05d --- /dev/null +++ b/server/tests/unit/full-subject-cache/full-subject-cache-model.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { + AppEnv, + type NormalizedFullSubject, + SubjectType, +} from "@autumn/shared"; +import { + cachedFullSubjectToNormalized, + normalizedToCachedFullSubject, +} from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; + +const buildNormalized = (): NormalizedFullSubject => + ({ + subjectType: SubjectType.Customer, + customerId: "cus_1", + internalCustomerId: "cus_int_1", + entityId: undefined, + internalEntityId: undefined, + customer: { + id: "cus_1", + internal_id: "cus_int_1", + org_id: "org_1", + env: AppEnv.Live, + created_at: 1, + name: "Test", + email: "test@example.com", + fingerprint: null, + processor: null, + processors: {}, + metadata: { team: "growth" }, + send_email_receipts: true, + auto_topups: null, + spend_limits: null, + usage_alerts: null, + overage_allowed: null, + }, + entity: undefined, + customer_products: [], + customer_entitlements: [ + { + id: "cus_ent_1", + internal_customer_id: "cus_int_1", + internal_entity_id: null, + internal_feature_id: "feat_int_1", + customer_id: "cus_1", + feature_id: "feat_1", + customer_product_id: "cp_1", + entitlement_id: "ent_1", + created_at: 1, + unlimited: false, + balance: 10, + additional_balance: 0, + usage_allowed: true, + next_reset_at: null, + adjustment: 0, + expires_at: null, + cache_version: 0, + entities: null, + external_id: null, + entitlement: { + id: "ent_1", + internal_product_id: "prod_int_1", + internal_feature_id: "feat_int_1", + allowance_type: "fixed", + allowance: 10, + interval: "month", + interval_count: 1, + usage_limit: null, + carry_from_previous: false, + created_at: 1, + entity_feature_id: null, + is_custom: false, + org_id: "org_1", + feature_id: "feat_1", + rollover: null, + feature: { + id: "feat_1", + internal_id: "feat_int_1", + org_id: "org_1", + env: AppEnv.Live, + name: "Feature 1", + type: "metered", + config: null, + display: null, + created_at: 1, + archived: false, + event_names: [], + }, + }, + rollovers: [], + customerPrice: null, + customerProductOptions: [], + customerProductQuantity: 1, + }, + ], + customer_prices: [], + flags: [], + products: [], + entitlements: [], + prices: [], + free_trials: [], + subscriptions: [], + invoices: [], + entity_aggregations: undefined, + }) as unknown as NormalizedFullSubject; + +describe("fullSubject cache model", () => { + test("stores non-balance data in the top-level subject", () => { + const normalized = buildNormalized(); + const cached = normalizedToCachedFullSubject({ normalized }); + + expect(cached.customer_products).toEqual(normalized.customer_products); + expect(cached.meteredFeatures).toEqual(["feat_1"]); + expect(cached._cachedAt).toBeTypeOf("number"); + }); + + test("reconstructs normalized data from cached subject and balances", () => { + const normalized = buildNormalized(); + const cached = normalizedToCachedFullSubject({ normalized }); + const reconstructed = cachedFullSubjectToNormalized({ + cached, + customerEntitlements: normalized.customer_entitlements, + }); + + expect(reconstructed.customer).toEqual(normalized.customer); + expect(reconstructed.customer_products).toEqual( + normalized.customer_products, + ); + expect(reconstructed.customer_entitlements).toEqual( + normalized.customer_entitlements, + ); + expect(reconstructed.customer_prices).toEqual([]); + }); +}); diff --git a/server/tests/unit/logging/log-alert-event.test.ts b/server/tests/unit/logging/log-alert-event.test.ts new file mode 100644 index 000000000..797b841a1 --- /dev/null +++ b/server/tests/unit/logging/log-alert-event.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { logAlertEvent } from "@/utils/logging/logAlertEvent.js"; + +describe("logAlertEvent", () => { + test("emits the shared alert_event schema through ctx.logger", () => { + const calls: unknown[][] = []; + const ctx = { + logger: { + warn: (...args: unknown[]) => { + calls.push(args); + }, + }, + } as AutumnContext; + + logAlertEvent({ + ctx, + severity: "warning", + category: "redis", + alertKey: "redis_full_subject_payload_large", + message: "payload too large", + source: "updateCachedCustomerData", + component: "full_subject_cache", + data: { + payload_bytes: 123, + threshold_bytes: 100, + }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + "payload too large", + { + type: "alert_event", + alert_key: "redis_full_subject_payload_large", + severity: "warning", + category: "redis", + source: "updateCachedCustomerData", + component: "full_subject_cache", + data: { + payload_bytes: 123, + threshold_bytes: 100, + }, + }, + ]); + }); +}); diff --git a/server/tests/unit/middleware/resolveCustomerId.test.ts b/server/tests/unit/middleware/resolveCustomerId.test.ts new file mode 100644 index 000000000..42dd7bafb --- /dev/null +++ b/server/tests/unit/middleware/resolveCustomerId.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import chalk from "chalk"; +import { resolveCustomerId } from "@/honoMiddlewares/utils/resolveCustomerId.js"; + +describe(chalk.yellowBright("resolveCustomerId"), () => { + describe(chalk.cyan("URL path parsing"), () => { + test("extracts customer_id from /v1/customers/:customer_id", () => { + expect( + resolveCustomerId({ + method: "GET", + path: "/v1/customers/cus_123", + }), + ).toBe("cus_123"); + }); + + test("extracts customer_id from nested entity routes", () => { + expect( + resolveCustomerId({ + method: "GET", + path: "/v1/customers/cus_123/entities/ent_1", + }), + ).toBe("cus_123"); + }); + + test("extracts customer_id from /v1/customers/:customer_id/entities/:entity_id/balances", () => { + expect( + resolveCustomerId({ + method: "POST", + path: "/v1/customers/cus_abc/entities/ent_1/balances", + }), + ).toBe("cus_abc"); + }); + + test("extracts customer_id from /v1/customers/:customer_id/billing_portal", () => { + expect( + resolveCustomerId({ + method: "POST", + path: "/v1/customers/cus_abc/billing_portal", + }), + ).toBe("cus_abc"); + }); + + test("ignores non-v1 paths", () => { + expect( + resolveCustomerId({ + method: "GET", + path: "/health/customers/cus_123", + }), + ).toBeUndefined(); + }); + + test("URL takes priority over body customer_id", () => { + expect( + resolveCustomerId({ + method: "POST", + path: "/v1/customers/cus_url", + body: { customer_id: "cus_body" }, + }), + ).toBe("cus_url"); + }); + }); + + describe(chalk.cyan("body parsing"), () => { + test("extracts customer_id from RPC body", () => { + expect( + resolveCustomerId({ + method: "POST", + path: "/v1/billing.attach", + body: { customer_id: "cus_rpc" }, + }), + ).toBe("cus_rpc"); + }); + + test("extracts customer_id from check route", () => { + expect( + resolveCustomerId({ + method: "POST", + path: "/v1/check", + body: { customer_id: "cus_check", feature_id: "feat_1" }, + }), + ).toBe("cus_check"); + }); + + test("extracts id from POST /v1/customers", () => { + expect( + resolveCustomerId({ + method: "POST", + path: "/v1/customers", + body: { id: "cus_new", name: "Test" }, + }), + ).toBe("cus_new"); + }); + + test("does not use body.id for customers.get_or_create", () => { + expect( + resolveCustomerId({ + method: "POST", + path: "/v1/customers.get_or_create", + body: { id: "wrong", customer_id: "cus_right" }, + }), + ).toBe("cus_right"); + }); + }); + + describe(chalk.cyan("query param parsing"), () => { + test("extracts customer_id from query", () => { + expect( + resolveCustomerId({ + method: "GET", + path: "/v1/balances/list", + query: { customer_id: "cus_query" }, + }), + ).toBe("cus_query"); + }); + }); + + describe(chalk.cyan("no customer_id"), () => { + test("returns undefined for org routes", () => { + expect( + resolveCustomerId({ + method: "GET", + path: "/v1/organization", + }), + ).toBeUndefined(); + }); + }); +}); diff --git a/shared/api/customers/flags/utils/getApiFlag.ts b/shared/api/customers/flags/utils/getApiFlag.ts index c41ac1db9..1d23cf0a0 100644 --- a/shared/api/customers/flags/utils/getApiFlag.ts +++ b/shared/api/customers/flags/utils/getApiFlag.ts @@ -36,12 +36,13 @@ export const getApiFlag = ({ : undefined; const primaryCustomerEntitlement = cusEnts[0]; + const apiId = + primaryCustomerEntitlement.external_id ?? primaryCustomerEntitlement.id; return { data: { object: "flag", - id: - primaryCustomerEntitlement.external_id ?? primaryCustomerEntitlement.id, + id: apiId, plan_id: cusEntsToPlanId({ cusEnts }), expires_at: primaryCustomerEntitlement.expires_at, feature_id: feature.id, diff --git a/shared/index.ts b/shared/index.ts index 07d77c102..90c904a73 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -63,7 +63,7 @@ export * from "./models/cusModels/entityModels/entityExpand"; export * from "./models/cusModels/entityModels/entityModels"; export * from "./models/cusModels/entityModels/entityTable"; export * from "./models/cusModels/fullCusModel"; -export * from "./models/cusModels/fullSubjectModel"; +export * from "./models/cusModels/fullSubject"; export * from "./models/cusModels/invoiceModels/invoiceLineItemModels"; export * from "./models/cusModels/invoiceModels/invoiceLineItemTable"; export * from "./models/cusModels/invoiceModels/invoiceModels"; @@ -220,3 +220,4 @@ export * from "./utils/productV3Utils/productItemUtils/productV3ItemUtils"; export * from "./utils/rewardUtils/rewardFilterUtils"; export * from "./utils/rewardUtils/rewardMigrationUtils"; export * from "./utils/scopeDefinitions"; +export * from "./utils/fullSubjectUtils"; diff --git a/shared/models/cusModels/fullSubject/fullSubjectModel.ts b/shared/models/cusModels/fullSubject/fullSubjectModel.ts new file mode 100644 index 000000000..e69963fe0 --- /dev/null +++ b/shared/models/cusModels/fullSubject/fullSubjectModel.ts @@ -0,0 +1,39 @@ +import { z } from "zod/v4"; +import { FullAggregatedFeatureBalanceSchema } from "../../cusProductModels/cusEntModels/aggregatedCusEnt.js"; +import { FullCustomerEntitlementSchema } from "../../cusProductModels/cusEntModels/cusEntModels.js"; +import { FullCusProductSchema } from "../../cusProductModels/cusProductModels.js"; +import { SubscriptionSchema } from "../../subModels/subModels.js"; +import { CustomerSchema } from "../cusModels.js"; +import { EntitySchema } from "../entityModels/entityModels.js"; +import { InvoiceSchema } from "../invoiceModels/invoiceModels.js"; + +export const SubjectType = { + Customer: "customer", + Entity: "entity", +} as const; +export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType]; + +export const FullSubjectSchema = z.object({ + subjectType: z.enum(["customer", "entity"]), + + customerId: z.string(), + internalCustomerId: z.string(), + entityId: z.string().optional(), + internalEntityId: z.string().optional(), + + customer: CustomerSchema, + entity: EntitySchema.optional(), + + customer_products: z.array(FullCusProductSchema), + extra_customer_entitlements: z.array(FullCustomerEntitlementSchema), + + subscriptions: z.array(SubscriptionSchema).optional(), + invoices: z.array(InvoiceSchema), + + aggregated_customer_products: z.array(FullCusProductSchema).optional(), + aggregated_customer_entitlements: z + .array(FullAggregatedFeatureBalanceSchema) + .optional(), +}); + +export type FullSubject = z.infer; diff --git a/shared/models/cusModels/fullSubject/index.ts b/shared/models/cusModels/fullSubject/index.ts new file mode 100644 index 000000000..b1574abae --- /dev/null +++ b/shared/models/cusModels/fullSubject/index.ts @@ -0,0 +1,3 @@ +export * from "./fullSubjectModel.js"; +export * from "./normalizedFullSubjectModel.js"; +export * from "./subjectQueryRow.js"; diff --git a/shared/models/cusModels/fullSubject/normalizedFullSubjectModel.ts b/shared/models/cusModels/fullSubject/normalizedFullSubjectModel.ts new file mode 100644 index 000000000..1f8f8842b --- /dev/null +++ b/shared/models/cusModels/fullSubject/normalizedFullSubjectModel.ts @@ -0,0 +1,102 @@ +import type { AggregatedFeatureBalance } from "../../cusProductModels/cusEntModels/aggregatedCusEnt.js"; +import type { EntityBalance } from "../../cusProductModels/cusEntModels/cusEntModels.js"; +import type { DbRollover } from "../../cusProductModels/cusEntModels/rolloverModels/rolloverTable.js"; +import type { FullCustomerPrice } from "../../cusProductModels/cusPriceModels/cusPriceModels.js"; +import type { DbCustomerPrice } from "../../cusProductModels/cusPriceModels/cusPriceTable.js"; +import type { FeatureOptions } from "../../cusProductModels/cusProductModels.js"; +import type { DbCustomerProduct } from "../../cusProductModels/cusProductTable.js"; +import type { EntitlementWithFeature } from "../../productModels/entModels/entModels.js"; +import type { DbFreeTrial } from "../../productModels/freeTrialModels/freeTrialTable.js"; +import type { DbPrice } from "../../productModels/priceModels/priceTable.js"; +import type { DbProduct } from "../../productModels/productTable.js"; +import type { Subscription } from "../../subModels/subModels.js"; +import type { Customer } from "../cusModels.js"; +import type { Entity } from "../entityModels/entityModels.js"; +import type { Invoice } from "../invoiceModels/invoiceModels.js"; +import type { SubjectType } from "./fullSubjectModel.js"; + +export type SubjectFlag = { + featureId: string; + internalFeatureId: string; + entitlementId: string; + customerEntitlementId: string; + customerProductId: string | null; + internalCustomerId: string; + internalEntityId: string | null; + expiresAt: number | null; + externalId: string | null; +}; + +export type SubjectBalance = { + id: string; + customer_product_id: string | null; + entitlement_id: string; + internal_customer_id: string; + internal_entity_id: string | null; + internal_feature_id: string; + feature_id: string; + unlimited: boolean | null; + balance: number; + adjustment: number | null; + additional_balance: number; + usage_allowed: boolean | null; + next_reset_at: number | null; + expires_at: number | null; + external_id: string | null; + entities: Record | null; + cache_version: number | null; + created_at: number; + customer_id?: string | null; + + entitlement: EntitlementWithFeature; + rollovers: DbRollover[]; + customerPrice: FullCustomerPrice | null; + customerProductOptions: FeatureOptions | null; + customerProductQuantity: number; +}; + +export type EntityAggregations = { + aggregated_customer_products: DbCustomerProduct[]; + aggregated_customer_entitlements: AggregatedFeatureBalance[]; +}; + +/** + * Normalized (flat-array) representation of a customer or entity subject. + * + * - `customer_entitlements` contains metered balances only (product + extra + * combined, distinguished by `customer_product_id` being null for extras). + * - Boolean CEs are collapsed into `flags`. + * - Each `SubjectBalance` carries the pre-resolved context needed by the + * deduction path, so each Redis hash field can be self-contained. + * - Catalog arrays (`products`, `entitlements`, `prices`, `free_trials`) are + * deduplicated reference data shared across the subject. + * + * This is the shape stored in Redis (split across a subject STRING + per-feature + * balance HASHes) and the shape the DB query naturally returns. + */ +export type NormalizedFullSubject = { + subjectType: SubjectType; + customerId: string; + internalCustomerId: string; + entityId?: string; + internalEntityId?: string; + + customer: Customer; + entity?: Entity; + + customer_products: DbCustomerProduct[]; + customer_entitlements: SubjectBalance[]; + customer_prices: DbCustomerPrice[]; + + flags: Record; + + products: DbProduct[]; + entitlements: EntitlementWithFeature[]; + prices: DbPrice[]; + free_trials: DbFreeTrial[]; + + subscriptions: Subscription[]; + invoices: Invoice[]; + + entity_aggregations?: EntityAggregations; +}; diff --git a/shared/models/cusModels/fullSubject/subjectQueryRow.ts b/shared/models/cusModels/fullSubject/subjectQueryRow.ts new file mode 100644 index 000000000..3cb0e2c13 --- /dev/null +++ b/shared/models/cusModels/fullSubject/subjectQueryRow.ts @@ -0,0 +1,39 @@ +import type { AggregatedFeatureBalance } from "../../cusProductModels/cusEntModels/aggregatedCusEnt.js"; +import type { DbCustomerEntitlement } from "../../cusProductModels/cusEntModels/cusEntTable.js"; +import type { DbRollover } from "../../cusProductModels/cusEntModels/rolloverModels/rolloverTable.js"; +import type { DbCustomerPrice } from "../../cusProductModels/cusPriceModels/cusPriceTable.js"; +import type { DbCustomerProduct } from "../../cusProductModels/cusProductTable.js"; +import type { DbFeature } from "../../featureModels/featureTable.js"; +import type { DbEntitlement } from "../../productModels/entModels/entTable.js"; +import type { DbFreeTrial } from "../../productModels/freeTrialModels/freeTrialTable.js"; +import type { DbPrice } from "../../productModels/priceModels/priceTable.js"; +import type { DbProduct } from "../../productModels/productTable.js"; +import type { Subscription } from "../../subModels/subModels.js"; +import type { DbCustomer } from "../cusTable.js"; +import type { Entity } from "../entityModels/entityModels.js"; +import type { Invoice } from "../invoiceModels/invoiceModels.js"; + +type EntitlementWithFeatureRow = DbEntitlement & { + feature: DbFeature; +}; + +/** Raw row shape returned by the getFullSubjectQuery SQL query. */ +export type SubjectQueryRow = { + customer: DbCustomer; + customer_products: DbCustomerProduct[]; + customer_entitlements: DbCustomerEntitlement[]; + customer_prices: DbCustomerPrice[]; + extra_customer_entitlements: DbCustomerEntitlement[]; + rollovers: DbRollover[]; + products: DbProduct[]; + entitlements: EntitlementWithFeatureRow[]; + prices: DbPrice[]; + free_trials: DbFreeTrial[]; + entity_aggregations?: { + aggregated_customer_products: DbCustomerProduct[]; + aggregated_customer_entitlements: AggregatedFeatureBalance[]; + }; + subscriptions: Subscription[]; + invoices?: Invoice[]; + entity?: Entity; +}; diff --git a/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts b/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts index 8f8bf409d..7ba36ce73 100644 --- a/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts +++ b/shared/models/cusProductModels/cusEntModels/aggregatedCusEnt.ts @@ -1,11 +1,13 @@ import { z } from "zod/v4"; -import { EntitlementWithFeatureSchema } from "../../productModels/entModels/entModels.js"; +import { FeatureSchema } from "../../featureModels/featureModels.js"; import { EntityBalanceSchema } from "./cusEntModels.js"; -export const AggregatedCustomerEntitlementSchema = z.object({ +export const AggregatedFeatureBalanceSchema = z.object({ + api_id: z.string(), internal_feature_id: z.string(), internal_customer_id: z.string(), feature_id: z.string(), + allowance_total: z.number(), balance: z.number(), adjustment: z.number(), additional_balance: z.number(), @@ -15,15 +17,15 @@ export const AggregatedCustomerEntitlementSchema = z.object({ entities: z.record(z.string(), EntityBalanceSchema).nullish(), }); -export const FullAggregatedCustomerEntitlementSchema = - AggregatedCustomerEntitlementSchema.extend({ - entitlement: EntitlementWithFeatureSchema, +export const FullAggregatedFeatureBalanceSchema = + AggregatedFeatureBalanceSchema.extend({ + feature: FeatureSchema, }); -export type AggregatedCustomerEntitlement = z.infer< - typeof AggregatedCustomerEntitlementSchema +export type AggregatedFeatureBalance = z.infer< + typeof AggregatedFeatureBalanceSchema >; -export type FullAggregatedCustomerEntitlement = z.infer< - typeof FullAggregatedCustomerEntitlementSchema +export type FullAggregatedFeatureBalance = z.infer< + typeof FullAggregatedFeatureBalanceSchema >; diff --git a/shared/utils/cusEntUtils/classifyCusEnt/cusEntsHaveUnlimited.ts b/shared/utils/cusEntUtils/classifyCusEnt/cusEntsHaveUnlimited.ts new file mode 100644 index 000000000..02a68b0e3 --- /dev/null +++ b/shared/utils/cusEntUtils/classifyCusEnt/cusEntsHaveUnlimited.ts @@ -0,0 +1,18 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { AllowanceType } from "../../../models/productModels/entModels/entModels.js"; + +export const cusEntsHaveUnlimited = ({ + cusEnts, + internalFeatureId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + internalFeatureId: string; +}) => { + return cusEnts.some( + (customerEntitlement) => + customerEntitlement.internal_feature_id === internalFeatureId && + (customerEntitlement.entitlement.allowance_type === + AllowanceType.Unlimited || + customerEntitlement.unlimited), + ); +}; diff --git a/shared/utils/cusEntUtils/classifyCusEnt/cusEntsHaveUsageAllowed.ts b/shared/utils/cusEntUtils/classifyCusEnt/cusEntsHaveUsageAllowed.ts new file mode 100644 index 000000000..045e8156e --- /dev/null +++ b/shared/utils/cusEntUtils/classifyCusEnt/cusEntsHaveUsageAllowed.ts @@ -0,0 +1,21 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { nullish } from "../../utils.js"; + +export const cusEntsHaveUsageAllowed = ({ + cusEnts, + internalFeatureId, + includeUsageLimit = true, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + internalFeatureId: string; + includeUsageLimit?: boolean; +}) => { + return cusEnts.some( + (customerEntitlement) => + customerEntitlement.internal_feature_id === internalFeatureId && + customerEntitlement.usage_allowed && + (includeUsageLimit + ? nullish(customerEntitlement.entitlement.usage_limit) + : true), + ); +}; diff --git a/shared/utils/cusEntUtils/index.ts b/shared/utils/cusEntUtils/index.ts index 6278f2a6b..0d61db2bd 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -23,6 +23,8 @@ export * from "./balanceUtils/rollovers/cusEntsToRolloverBalance"; export * from "./balanceUtils/rollovers/cusEntsToRolloverGranted"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; +export * from "./classifyCusEnt/cusEntsHaveUnlimited"; +export * from "./classifyCusEnt/cusEntsHaveUsageAllowed"; // Classify utils export * from "./classifyCusEntUtils"; // Convert utils barrel diff --git a/shared/utils/fullSubjectUtils/aggregatedUtils/index.ts b/shared/utils/fullSubjectUtils/aggregatedUtils/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/shared/utils/fullSubjectUtils/aggregatedUtils/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/shared/utils/fullSubjectUtils/fullCustomerToFullSubject.ts b/shared/utils/fullSubjectUtils/fullCustomerToFullSubject.ts new file mode 100644 index 000000000..fefb16631 --- /dev/null +++ b/shared/utils/fullSubjectUtils/fullCustomerToFullSubject.ts @@ -0,0 +1,33 @@ +import { + type FullCustomer, + type FullSubject, + SubjectType, +} from "../../index.js"; + +export const fullCustomerToFullSubject = ({ + fullCustomer, +}: { + fullCustomer: FullCustomer; +}): FullSubject => { + const customerId = fullCustomer.id || fullCustomer.internal_id; + const entityId = fullCustomer.entity?.id ?? undefined; + const internalEntityId = fullCustomer.entity?.internal_id ?? undefined; + + return { + subjectType: fullCustomer.entity + ? SubjectType.Entity + : SubjectType.Customer, + customerId, + internalCustomerId: fullCustomer.internal_id, + entityId, + internalEntityId, + customer: fullCustomer, + entity: fullCustomer.entity, + customer_products: fullCustomer.customer_products, + extra_customer_entitlements: fullCustomer.extra_customer_entitlements ?? [], + subscriptions: fullCustomer.subscriptions, + invoices: fullCustomer.invoices ?? [], + aggregated_customer_products: undefined, + aggregated_customer_entitlements: undefined, + }; +}; diff --git a/shared/utils/fullSubjectUtils/fullSubjectToApiCustomerProducts.ts b/shared/utils/fullSubjectUtils/fullSubjectToApiCustomerProducts.ts new file mode 100644 index 000000000..cd0aaa60c --- /dev/null +++ b/shared/utils/fullSubjectUtils/fullSubjectToApiCustomerProducts.ts @@ -0,0 +1,17 @@ +import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubjectModel.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; + +export const fullSubjectToApiCustomerProducts = ({ + fullSubject, +}: { + fullSubject: FullSubject; +}): FullCusProduct[] => { + if (fullSubject.subjectType === "entity") { + return fullSubject.customer_products; + } + + return [ + ...fullSubject.customer_products, + ...(fullSubject.aggregated_customer_products ?? []), + ]; +}; diff --git a/shared/utils/fullSubjectUtils/fullSubjectToCustomerEntitlements.ts b/shared/utils/fullSubjectUtils/fullSubjectToCustomerEntitlements.ts new file mode 100644 index 000000000..f09652913 --- /dev/null +++ b/shared/utils/fullSubjectUtils/fullSubjectToCustomerEntitlements.ts @@ -0,0 +1,105 @@ +import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubjectModel.js"; +import type { CustomerEntitlementFilters } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; +import { cusEntMatchesEntity } from "../cusEntUtils/filterCusEntUtils.js"; +import { sortCusEntsForDeduction } from "../cusEntUtils/sortCusEntsForDeduction.js"; +import { notNullish } from "../utils.js"; + +export const fullSubjectToCustomerEntitlements = ({ + fullSubject, + inStatuses = [CusProductStatus.Active, CusProductStatus.PastDue], + reverseOrder = false, + featureId, + featureIds, + customerEntitlementFilters, +}: { + fullSubject: FullSubject; + inStatuses?: CusProductStatus[]; + reverseOrder?: boolean; + featureId?: string; + featureIds?: string[]; + customerEntitlementFilters?: CustomerEntitlementFilters; +}) => { + let customerEntitlements: FullCusEntWithFullCusProduct[] = []; + + for (const customerProduct of fullSubject.customer_products) { + if (!inStatuses.includes(customerProduct.status)) continue; + + customerEntitlements.push( + ...customerProduct.customer_entitlements.map((customerEntitlement) => ({ + ...customerEntitlement, + customer_product: customerProduct, + })), + ); + } + + for (const customerEntitlement of fullSubject.extra_customer_entitlements) { + customerEntitlements.push({ + ...customerEntitlement, + customer_product: null, + }); + } + + if (featureId) { + customerEntitlements = customerEntitlements.filter( + (customerEntitlement) => + customerEntitlement.entitlement.feature.id === featureId, + ); + } + + if (featureIds) { + customerEntitlements = customerEntitlements.filter((customerEntitlement) => + featureIds.includes(customerEntitlement.entitlement.feature.id), + ); + } + + if (fullSubject.entity) { + customerEntitlements = customerEntitlements.filter((customerEntitlement) => + cusEntMatchesEntity({ + cusEnt: customerEntitlement, + entity: fullSubject.entity, + }), + ); + } + + const now = Date.now(); + customerEntitlements = customerEntitlements.filter( + (customerEntitlement) => + !customerEntitlement.expires_at || customerEntitlement.expires_at > now, + ); + + sortCusEntsForDeduction({ + cusEnts: customerEntitlements, + reverseOrder, + entityId: fullSubject.entity?.id ?? undefined, + customerEntitlementFilters, + }); + + if ( + customerEntitlementFilters?.cusEntIds && + customerEntitlementFilters.cusEntIds.length > 0 + ) { + customerEntitlements = customerEntitlements.filter((customerEntitlement) => + customerEntitlementFilters.cusEntIds?.includes(customerEntitlement.id), + ); + } + + if (notNullish(customerEntitlementFilters?.interval)) { + customerEntitlements = customerEntitlements.filter( + (customerEntitlement) => + customerEntitlement.entitlement.interval === + customerEntitlementFilters.interval, + ); + } + + if (notNullish(customerEntitlementFilters?.balanceId)) { + customerEntitlements = customerEntitlements.filter( + (customerEntitlement) => + (customerEntitlement.external_id ?? customerEntitlement.id) === + customerEntitlementFilters.balanceId, + ); + } + + return customerEntitlements; +}; diff --git a/shared/utils/fullSubjectUtils/index.ts b/shared/utils/fullSubjectUtils/index.ts new file mode 100644 index 000000000..dd0c7405b --- /dev/null +++ b/shared/utils/fullSubjectUtils/index.ts @@ -0,0 +1,6 @@ +export * from "./aggregatedUtils/index.js"; +export { fullCustomerToFullSubject } from "./fullCustomerToFullSubject.js"; +export { fullSubjectToApiCustomerProducts } from "./fullSubjectToApiCustomerProducts.js"; +export { fullSubjectToCustomerEntitlements } from "./fullSubjectToCustomerEntitlements.js"; +export { logFullSubject } from "./logFullSubject.js"; +export { normalizedToFullSubject } from "./normalizedToFullSubject.js"; diff --git a/shared/utils/fullSubjectUtils/logFullSubject.ts b/shared/utils/fullSubjectUtils/logFullSubject.ts new file mode 100644 index 000000000..74da8246d --- /dev/null +++ b/shared/utils/fullSubjectUtils/logFullSubject.ts @@ -0,0 +1,70 @@ +import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubjectModel.js"; + +/** Logs a compact summary of a FullSubject without flooding the terminal. */ +export const logFullSubject = ({ + fullSubject, +}: { + fullSubject: FullSubject; +}) => { + const summarizeProduct = (cp: FullSubject["customer_products"][number]) => ({ + id: cp.id, + product_id: cp.product_id, + status: cp.status, + internal_entity_id: cp.internal_entity_id ?? null, + customer_entitlements: cp.customer_entitlements.map((ce) => ({ + id: ce.id, + feature_id: ce.entitlement?.feature?.id, + feature_type: ce.entitlement?.feature?.type, + balance: ce.balance, + unlimited: ce.unlimited, + usage_allowed: ce.usage_allowed, + rollovers: ce.rollovers?.length ?? 0, + })), + customer_prices: cp.customer_prices.length, + }); + + const summarizeCusEnt = ( + ce: FullSubject["extra_customer_entitlements"][number], + ) => ({ + id: ce.id, + feature_id: ce.entitlement?.feature?.id, + feature_type: ce.entitlement?.feature?.type, + balance: ce.balance, + unlimited: ce.unlimited, + usage_allowed: ce.usage_allowed, + rollovers: ce.rollovers?.length ?? 0, + }); + + const summary = { + subjectType: fullSubject.subjectType, + customerId: fullSubject.customerId, + entityId: fullSubject.entityId ?? null, + customer: { + id: fullSubject.customer.id, + internal_id: fullSubject.customer.internal_id, + name: fullSubject.customer.name, + email: fullSubject.customer.email, + }, + customer_products: fullSubject.customer_products.map(summarizeProduct), + extra_customer_entitlements: + fullSubject.extra_customer_entitlements.map(summarizeCusEnt), + subscriptions: fullSubject.subscriptions?.length ?? 0, + invoices: fullSubject.invoices?.length ?? 0, + aggregated_customer_products: + fullSubject.aggregated_customer_products?.map((cp) => ({ + id: cp.id, + product_id: cp.product_id, + status: cp.status, + })) ?? "N/A", + aggregated_customer_entitlements: + fullSubject.aggregated_customer_entitlements?.map((ae) => ({ + api_id: ae.api_id, + feature_id: ae.feature_id, + balance: ae.balance, + unlimited: ae.unlimited, + entity_count: ae.entity_count, + })) ?? "N/A", + }; + + console.log(JSON.stringify(summary, null, 2)); +}; diff --git a/shared/utils/fullSubjectUtils/normalizedToFullSubject.ts b/shared/utils/fullSubjectUtils/normalizedToFullSubject.ts new file mode 100644 index 000000000..705e8e11a --- /dev/null +++ b/shared/utils/fullSubjectUtils/normalizedToFullSubject.ts @@ -0,0 +1,306 @@ +import type { + FullSubject, + SubjectType, +} from "../../models/cusModels/fullSubject/fullSubjectModel.js"; +import type { + NormalizedFullSubject, + SubjectBalance, + SubjectFlag, +} from "../../models/cusModels/fullSubject/normalizedFullSubjectModel.js"; +import type { FullAggregatedFeatureBalance } from "../../models/cusProductModels/cusEntModels/aggregatedCusEnt.js"; +import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { Replaceable } from "../../models/cusProductModels/cusEntModels/replaceableTable.js"; +import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; + +const getRolloverSortValue = ({ + rollover, +}: { + rollover: SubjectBalance["rollovers"][number]; +}) => rollover.expires_at ?? Number.POSITIVE_INFINITY; + +const subjectBalanceToFullCustomerEntitlement = ({ + subjectBalance, +}: { + subjectBalance: SubjectBalance; +}): FullCustomerEntitlement => { + return { + id: subjectBalance.id, + internal_customer_id: subjectBalance.internal_customer_id, + internal_entity_id: subjectBalance.internal_entity_id, + internal_feature_id: subjectBalance.internal_feature_id, + feature_id: subjectBalance.feature_id, + customer_product_id: subjectBalance.customer_product_id, + entitlement_id: subjectBalance.entitlement_id, + created_at: subjectBalance.created_at, + unlimited: subjectBalance.unlimited, + balance: subjectBalance.balance, + additional_balance: subjectBalance.additional_balance, + usage_allowed: subjectBalance.usage_allowed, + next_reset_at: subjectBalance.next_reset_at, + adjustment: subjectBalance.adjustment, + expires_at: subjectBalance.expires_at, + cache_version: subjectBalance.cache_version ?? 0, + entities: subjectBalance.entities, + external_id: subjectBalance.external_id, + customer_id: subjectBalance.customer_id, + entitlement: subjectBalance.entitlement, + replaceables: [] as Replaceable[], + rollovers: [...subjectBalance.rollovers].sort( + (left, right) => + getRolloverSortValue({ rollover: left }) - + getRolloverSortValue({ rollover: right }), + ), + } as FullCustomerEntitlement; +}; + +const subjectFlagToFullCustomerEntitlement = ({ + subjectFlag, + fullEntitlement, +}: { + subjectFlag: SubjectFlag; + fullEntitlement: FullCustomerEntitlement["entitlement"]; +}): FullCustomerEntitlement => { + return { + id: subjectFlag.customerEntitlementId, + internal_customer_id: subjectFlag.internalCustomerId, + internal_entity_id: subjectFlag.internalEntityId, + internal_feature_id: subjectFlag.internalFeatureId, + feature_id: subjectFlag.featureId, + customer_product_id: subjectFlag.customerProductId, + entitlement_id: subjectFlag.entitlementId, + created_at: 0, + unlimited: null, + balance: 0, + additional_balance: 0, + usage_allowed: null, + next_reset_at: null, + adjustment: 0, + expires_at: subjectFlag.expiresAt, + cache_version: 0, + entities: null, + external_id: subjectFlag.externalId, + entitlement: fullEntitlement, + replaceables: [] as Replaceable[], + rollovers: [], + } as FullCustomerEntitlement; +}; + +/** + * Convert a NormalizedFullSubject (flat arrays) into a FullSubject (nested). + * Reconstructs boolean CEs from flags + catalog, nests metered CEs into products, + * and separates extras by null customer_product_id. + */ +export const normalizedToFullSubject = ({ + normalized, +}: { + normalized: NormalizedFullSubject; +}): FullSubject => { + const entitlementsById = new Map( + normalized.entitlements.map( + (entitlement) => [entitlement.id, entitlement] as const, + ), + ); + const productsByInternalId = new Map( + normalized.products.map( + (product) => [product.internal_id, product] as const, + ), + ); + const pricesById = new Map( + normalized.prices.map((price) => [price.id, price] as const), + ); + const freeTrialsById = new Map( + normalized.free_trials.map( + (freeTrial) => [freeTrial.id, freeTrial] as const, + ), + ); + + const customerPricesByCustomerProductId = new Map< + string, + FullCustomerPrice[] + >(); + for (const customerPrice of normalized.customer_prices) { + if (!customerPrice.customer_product_id) continue; + + const price = customerPrice.price_id + ? pricesById.get(customerPrice.price_id) + : undefined; + if (!price) continue; + + const fullCustomerPrice = { ...customerPrice, price } as FullCustomerPrice; + const existing = + customerPricesByCustomerProductId.get( + customerPrice.customer_product_id, + ) ?? []; + existing.push(fullCustomerPrice); + customerPricesByCustomerProductId.set( + customerPrice.customer_product_id, + existing, + ); + } + + for (const customerEntitlement of normalized.customer_entitlements) { + if (!customerEntitlement.customer_product_id) continue; + if (!customerEntitlement.customerPrice) continue; + + const customerProductId = customerEntitlement.customer_product_id; + const existing = + customerPricesByCustomerProductId.get(customerProductId) ?? []; + if ( + existing.some( + (customerPrice) => + customerPrice.id === customerEntitlement.customerPrice?.id, + ) + ) { + continue; + } + + existing.push(customerEntitlement.customerPrice); + customerPricesByCustomerProductId.set(customerProductId, existing); + } + + const meteredCesByCustomerProductId = new Map< + string, + FullCustomerEntitlement[] + >(); + const extraMeteredCes: FullCustomerEntitlement[] = []; + + for (const customerEntitlement of normalized.customer_entitlements) { + const fullCustomerEntitlement = subjectBalanceToFullCustomerEntitlement({ + subjectBalance: customerEntitlement, + }); + + if (!customerEntitlement.customer_product_id) { + extraMeteredCes.push(fullCustomerEntitlement); + } else { + const existing = + meteredCesByCustomerProductId.get( + customerEntitlement.customer_product_id, + ) ?? []; + existing.push(fullCustomerEntitlement); + meteredCesByCustomerProductId.set( + customerEntitlement.customer_product_id, + existing, + ); + } + } + + const booleanCesByCustomerProductId = new Map< + string, + FullCustomerEntitlement[] + >(); + const extraBooleanCes: FullCustomerEntitlement[] = []; + + for (const flag of Object.values(normalized.flags)) { + const entitlement = entitlementsById.get(flag.entitlementId); + if (!entitlement) continue; + + const fullCustomerEntitlement = subjectFlagToFullCustomerEntitlement({ + subjectFlag: flag, + fullEntitlement: entitlement, + }); + + if (!flag.customerProductId) { + extraBooleanCes.push(fullCustomerEntitlement); + } else { + const existing = + booleanCesByCustomerProductId.get(flag.customerProductId) ?? []; + existing.push(fullCustomerEntitlement); + booleanCesByCustomerProductId.set(flag.customerProductId, existing); + } + } + + const customerProducts: FullCusProduct[] = []; + for (const customerProduct of normalized.customer_products) { + const product = productsByInternalId.get( + customerProduct.internal_product_id, + ); + if (!product) continue; + + const meteredCes = + meteredCesByCustomerProductId.get(customerProduct.id) ?? []; + const booleanCes = + booleanCesByCustomerProductId.get(customerProduct.id) ?? []; + + customerProducts.push({ + ...customerProduct, + product, + free_trial: customerProduct.free_trial_id + ? (freeTrialsById.get(customerProduct.free_trial_id) ?? null) + : null, + customer_prices: + customerPricesByCustomerProductId.get(customerProduct.id) ?? [], + customer_entitlements: [...meteredCes, ...booleanCes], + } as FullCusProduct); + } + + const extraCustomerEntitlements = [...extraMeteredCes, ...extraBooleanCes]; + + let aggregatedCustomerProducts: FullCusProduct[] | undefined; + let aggregatedCustomerEntitlements: + | FullAggregatedFeatureBalance[] + | undefined; + + if (normalized.entity_aggregations) { + const entityAgg = normalized.entity_aggregations; + + aggregatedCustomerProducts = []; + for (const entityCusProduct of entityAgg.aggregated_customer_products) { + const product = productsByInternalId.get( + entityCusProduct.internal_product_id, + ); + if (!product) continue; + + aggregatedCustomerProducts.push({ + ...entityCusProduct, + product, + free_trial: entityCusProduct.free_trial_id + ? (freeTrialsById.get(entityCusProduct.free_trial_id) ?? null) + : null, + customer_prices: [], + customer_entitlements: [], + } as FullCusProduct); + } + + aggregatedCustomerEntitlements = ( + entityAgg.aggregated_customer_entitlements ?? [] + ) + .map((aggregatedCusEnt) => { + const feature = normalized.entitlements.find( + (entitlement) => + entitlement.internal_feature_id === + aggregatedCusEnt.internal_feature_id, + )?.feature; + if (!feature) return null; + return { + ...aggregatedCusEnt, + feature, + } as FullAggregatedFeatureBalance; + }) + .filter((e): e is FullAggregatedFeatureBalance => e !== null); + } + + return { + subjectType: normalized.subjectType as SubjectType, + customerId: normalized.customerId, + internalCustomerId: normalized.internalCustomerId, + ...(normalized.entity + ? { + entityId: normalized.entityId, + internalEntityId: normalized.internalEntityId, + entity: normalized.entity, + } + : {}), + customer: normalized.customer, + customer_products: customerProducts, + extra_customer_entitlements: extraCustomerEntitlements, + subscriptions: normalized.subscriptions ?? [], + invoices: normalized.invoices ?? [], + ...(aggregatedCustomerProducts + ? { aggregated_customer_products: aggregatedCustomerProducts } + : {}), + ...(aggregatedCustomerEntitlements + ? { aggregated_customer_entitlements: aggregatedCustomerEntitlements } + : {}), + } as FullSubject; +}; diff --git a/vite/src/App.tsx b/vite/src/App.tsx index 922af6abb..ff0f7c8fd 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -9,8 +9,8 @@ import { OnboardingLayout } from "./app/OnboardingLayout"; import { useSession } from "./lib/auth-client"; import { identifyUser } from "./utils/posthogTracking"; import { AdminView } from "./views/admin/AdminView"; -import { ImpersonateRedirect } from "./views/admin/ImpersonateRedirect"; import { EdgeConfigView } from "./views/admin/edge-config/EdgeConfigView"; +import { ImpersonateRedirect } from "./views/admin/ImpersonateRedirect"; import { OAuthClientsView } from "./views/admin/oauth/OAuthClientsView"; import { AcceptInvitation } from "./views/auth/AcceptInvitation"; import { Consent } from "./views/auth/Consent"; @@ -72,8 +72,14 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> } /> + } + /> } diff --git a/vite/src/views/admin/AdminView.tsx b/vite/src/views/admin/AdminView.tsx index 03de97a83..9c1e7ade7 100644 --- a/vite/src/views/admin/AdminView.tsx +++ b/vite/src/views/admin/AdminView.tsx @@ -1,8 +1,10 @@ +import { AppEnv } from "@autumn/shared"; import { Globe, Sliders } from "@phosphor-icons/react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; +import { useEnv } from "@/utils/envUtils"; import { AdminOrgTable } from "@/views/admin/AdminOrgTable"; import { AdminUserTable } from "@/views/admin/AdminUserTable"; import { DefaultView } from "../DefaultView"; @@ -12,7 +14,9 @@ import { useAdmin } from "./hooks/useAdmin"; export const AdminView = () => { const navigate = useNavigate(); + const env = useEnv(); const { isAdmin, isPending } = useAdmin(); + const adminBasePath = env === AppEnv.Sandbox ? "/sandbox/admin" : "/admin"; if (isPending) { return ( @@ -27,7 +31,7 @@ export const AdminView = () => { } const handleStopImpersonating = async () => { - const { data, error } = await authClient.admin.stopImpersonating(); + const { error } = await authClient.admin.stopImpersonating(); if (error) { toast.error("Something went wrong"); @@ -42,7 +46,7 @@ export const AdminView = () => {
- } - variant="ghost" - size="sm" - onClick={() => onDelete({ rolloutId, orgId })} - /> - - prev: {orgEntry.previousPercent}% | changed:{" "} - {formatTimestamp(orgEntry.changedAt)} - +
+
+

{orgId}

+

+ prev: {formatPercent(orgEntry.previousPercent)} | changed:{" "} + {formatTimestamp(orgEntry.changedAt)} +

+
+ +
+ setEditPercent(Number(event.target.value))} + className="h-9" + /> + % +
+ +
+ + } + variant="secondary" + size="sm" + onClick={() => onDelete({ rolloutId, orgId })} + /> +
); }; @@ -109,85 +138,133 @@ const RolloutCard = ({ onUpdateGlobal: ({ rolloutId, percent, - }: { rolloutId: string; percent: number }) => void; + }: { + rolloutId: string; + percent: number; + }) => void; onUpdateOrg: ({ rolloutId, orgId, percent, - }: { rolloutId: string; orgId: string; percent: number }) => void; + }: { + rolloutId: string; + orgId: string; + percent: number; + }) => void; onDeleteOrg: ({ rolloutId, orgId, - }: { rolloutId: string; orgId: string }) => void; + }: { + rolloutId: string; + orgId: string; + }) => void; onAddOrg: ({ rolloutId }: { rolloutId: string }) => void; }) => { const [expanded, setExpanded] = useState(true); const [globalPercent, setGlobalPercent] = useState(entry.percent); const orgEntries = Object.entries(entry.orgs); + const globalPercentInputId = `rollout-global-percent-${rolloutId}`; return ( -
-
- -

{rolloutId}

-
- Global: - setGlobalPercent(Number(e.target.value))} - className="w-16 px-2 py-1 text-xs border rounded bg-background" - /> - % - -
-
- - {expanded && ( -
-
- prev: {entry.previousPercent}% | changed:{" "} - {formatTimestamp(entry.changedAt)} -
- -
-

Org Overrides

+ + +
+
+
+ {rolloutId} +
+ + Global {formatPercent(entry.percent)} + + + {orgEntries.length} org override + {orgEntries.length === 1 ? "" : "s"} + + + prev: {formatPercent(entry.previousPercent)} | changed:{" "} + {formatTimestamp(entry.changedAt)} + +
+
+
+ +
+
+ + + setGlobalPercent(Number(event.target.value)) + } + className="h-9" + /> +
+ +
+
+
+ + {expanded && ( + +
+
+

+ Org Overrides +

+

+ Override the rollout percentage for specific organizations. +

+
+ +
{orgEntries.length === 0 && ( -

- No org overrides. Global percent applies to all. -

+
+

+ No org overrides yet. +

+

+ Global rollout percentage applies to every org until you add an + override. +

+
)}
@@ -202,16 +279,20 @@ const RolloutCard = ({ /> ))}
-
+ )} -
+ ); }; export const EdgeConfigView = () => { const navigate = useNavigate(); + const env = useEnv(); const { isAdmin, isPending } = useAdmin(); const axiosInstance = useAxiosInstance(); + const adminBasePath = env === AppEnv.Sandbox ? "/sandbox/admin" : "/admin"; + const [addOrgRolloutId, setAddOrgRolloutId] = useState(); + const [createRolloutOpen, setCreateRolloutOpen] = useState(false); const { data, isLoading, refetch } = useQuery({ queryKey: ["admin-rollouts"], @@ -225,7 +306,10 @@ export const EdgeConfigView = () => { mutationFn: async ({ rolloutId, percent, - }: { rolloutId: string; percent: number }) => { + }: { + rolloutId: string; + percent: number; + }) => { await axiosInstance.put(`/admin/rollouts/${rolloutId}`, { percent }); }, onSuccess: () => { @@ -241,11 +325,14 @@ export const EdgeConfigView = () => { rolloutId, orgId, percent, - }: { rolloutId: string; orgId: string; percent: number }) => { - await axiosInstance.put( - `/admin/rollouts/${rolloutId}/orgs/${orgId}`, - { percent }, - ); + }: { + rolloutId: string; + orgId: string; + percent: number; + }) => { + await axiosInstance.put(`/admin/rollouts/${rolloutId}/orgs/${orgId}`, { + percent, + }); }, onSuccess: () => { toast.success("Org override updated"); @@ -259,10 +346,11 @@ export const EdgeConfigView = () => { mutationFn: async ({ rolloutId, orgId, - }: { rolloutId: string; orgId: string }) => { - await axiosInstance.delete( - `/admin/rollouts/${rolloutId}/orgs/${orgId}`, - ); + }: { + rolloutId: string; + orgId: string; + }) => { + await axiosInstance.delete(`/admin/rollouts/${rolloutId}/orgs/${orgId}`); }, onSuccess: () => { toast.success("Org override removed"); @@ -272,24 +360,13 @@ export const EdgeConfigView = () => { toast.error(getBackendErr(error, "Failed to remove org override")), }); - const handleAddOrg = ({ rolloutId }: { rolloutId: string }) => { - const orgId = prompt("Enter org ID:"); - if (!orgId?.trim()) return; - - const percentStr = prompt("Enter rollout percentage (0-100):", "0"); - const percent = Number(percentStr); - if (Number.isNaN(percent) || percent < 0 || percent > 100) { - toast.error("Invalid percentage"); - return; - } - - updateOrgMutation.mutate({ rolloutId, orgId: orgId.trim(), percent }); - }; - const handleDeleteOrg = ({ rolloutId, orgId, - }: { rolloutId: string; orgId: string }) => { + }: { + rolloutId: string; + orgId: string; + }) => { if (!confirm(`Remove org override for ${orgId}?`)) return; deleteOrgMutation.mutate({ rolloutId, orgId }); }; @@ -308,35 +385,93 @@ export const EdgeConfigView = () => { const rolloutEntries = Object.entries(rollouts); return ( -
-
- } - variant="ghost" - size="sm" - onClick={() => navigate("/admin")} - /> -

Rollout Edge Config

-
- } - variant="ghost" - size="sm" - onClick={() => refetch()} - /> - {data && ( - - {data.configHealthy ? "Healthy" : "Unhealthy"} | Last sync:{" "} - {data.lastSuccessAt ?? "never"} - - )} +
+ { + if (!open) { + setAddOrgRolloutId(undefined); + } + }} + rolloutId={addOrgRolloutId} + onSubmit={({ rolloutId, orgId, percent }) => + updateOrgMutation.mutate({ rolloutId, orgId, percent }) + } + isSaving={updateOrgMutation.isPending} + /> + + updateGlobalMutation.mutate( + { rolloutId, percent }, + { + onSuccess: () => { + setCreateRolloutOpen(false); + }, + }, + ) + } + isSaving={updateGlobalMutation.isPending} + /> + +
+
+ } + variant="secondary" + size="sm" + onClick={() => navigate(adminBasePath)} + /> +
+

+ Rollout Edge Config +

+

+ Manage global rollout percentages and per-org overrides. +

+
+
+
+ {data && ( + + {data.configHealthy ? "Healthy" : "Unhealthy"} | Last sync:{" "} + {data.lastSuccessAt ?? "never"} + + )} + } + variant="secondary" + size="sm" + onClick={() => refetch()} + > + Refresh + + +
{rolloutEntries.length === 0 && ( -

- No rollouts configured. Add a rollout entry to the S3 config to get - started. -

+ + +
+

+ No rollouts configured +

+

+ Create your first rollout entry, then add organization-specific + overrides where needed. +

+
+ +
+
)}
@@ -352,7 +487,7 @@ export const EdgeConfigView = () => { updateOrgMutation.mutate({ rolloutId, orgId, percent }) } onDeleteOrg={handleDeleteOrg} - onAddOrg={handleAddOrg} + onAddOrg={({ rolloutId }) => setAddOrgRolloutId(rolloutId)} /> ))}
diff --git a/vite/src/views/admin/edge-config/RolloutCreateDialog.tsx b/vite/src/views/admin/edge-config/RolloutCreateDialog.tsx new file mode 100644 index 000000000..8eacde34e --- /dev/null +++ b/vite/src/views/admin/edge-config/RolloutCreateDialog.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { Input } from "@/components/v2/inputs/Input"; + +export const RolloutCreateDialog = ({ + open, + onOpenChange, + onSubmit, + isSaving, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: ({ + rolloutId, + percent, + }: { + rolloutId: string; + percent: number; + }) => void; + isSaving: boolean; +}) => { + const [rolloutId, setRolloutId] = useState(""); + const [percentInput, setPercentInput] = useState("0"); + + useEffect(() => { + if (open) return; + setRolloutId(""); + setPercentInput("0"); + }, [open]); + + const percent = Number(percentInput); + const isPercentValid = + !Number.isNaN(percent) && percent >= 0 && percent <= 100; + const trimmedRolloutId = rolloutId.trim(); + const canSubmit = trimmedRolloutId.length > 0 && isPercentValid; + + const handleSubmit = () => { + if (!canSubmit) return; + onSubmit({ + rolloutId: trimmedRolloutId, + percent, + }); + }; + + return ( + + + + Create Rollout + + Add a new rollout entry and set its default global percentage. + + + +
+
+ + setRolloutId(event.target.value)} + /> +
+ +
+ + setPercentInput(event.target.value)} + /> + {percentInput.length > 0 && !isPercentValid && ( +

+ Enter a percentage between 0 and 100. +

+ )} +
+
+ + + + + +
+
+ ); +}; diff --git a/vite/src/views/admin/edge-config/RolloutOrgDialog.tsx b/vite/src/views/admin/edge-config/RolloutOrgDialog.tsx new file mode 100644 index 000000000..44e055e88 --- /dev/null +++ b/vite/src/views/admin/edge-config/RolloutOrgDialog.tsx @@ -0,0 +1,214 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { Input } from "@/components/v2/inputs/Input"; +import { useDebounce } from "@/hooks/useDebounce"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +type OrgSearchResult = { + id: string; + name: string; + slug: string; + createdAt: string; +}; + +type OrgSearchResponse = { + rows: OrgSearchResult[]; + hasNextPage: boolean; +}; + +export const RolloutOrgDialog = ({ + open, + onOpenChange, + rolloutId, + onSubmit, + isSaving, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + rolloutId?: string; + onSubmit: ({ + rolloutId, + orgId, + percent, + }: { + rolloutId: string; + orgId: string; + percent: number; + }) => void; + isSaving: boolean; +}) => { + const axiosInstance = useAxiosInstance(); + const [search, setSearch] = useState(""); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [percentInput, setPercentInput] = useState("0"); + const debouncedSearch = useDebounce({ + value: search.trim(), + delayMs: 250, + }); + + useEffect(() => { + if (open) return; + setSearch(""); + setSelectedOrgId(""); + setPercentInput("0"); + }, [open]); + + const { data, isLoading } = useQuery({ + queryKey: ["admin-rollout-org-search", debouncedSearch], + queryFn: async () => { + const params = new URLSearchParams(); + if (debouncedSearch) { + params.append("search", debouncedSearch); + } + + const { data } = await axiosInstance.get( + `/admin/orgs${params.toString() ? `?${params.toString()}` : ""}`, + ); + return data; + }, + enabled: open && debouncedSearch.length > 0, + }); + + const rows = useMemo(() => data?.rows ?? [], [data?.rows]); + const selectedOrg = rows.find((row) => row.id === selectedOrgId); + const percent = Number(percentInput); + const isPercentValid = + !Number.isNaN(percent) && percent >= 0 && percent <= 100; + + const handleSubmit = () => { + if (!rolloutId || !selectedOrgId || !isPercentValid) return; + onSubmit({ + rolloutId, + orgId: selectedOrgId, + percent, + }); + onOpenChange(false); + }; + + return ( + + + + Add Org Override + + Search for an organization and set the rollout percentage override. + + + +
+
+ + setSearch(event.target.value)} + /> +
+ {debouncedSearch.length === 0 ? ( +
+ Start typing to search organizations. +
+ ) : isLoading ? ( +
+ Searching organizations... +
+ ) : rows.length === 0 ? ( +
+ No organizations found. +
+ ) : ( +
+ {rows.map((org) => { + const isSelected = selectedOrgId === org.id; + + return ( + + ); + })} +
+ )} +
+
+ +
+ + setPercentInput(event.target.value)} + /> + {selectedOrg && ( +

+ Override will apply to{" "} + {selectedOrg.id}. +

+ )} + {percentInput.length > 0 && !isPercentValid && ( +

+ Enter a percentage between 0 and 100. +

+ )} +
+
+ + + + + +
+
+ ); +}; diff --git a/vite/src/views/admin/oauth/OAuthClientsView.tsx b/vite/src/views/admin/oauth/OAuthClientsView.tsx index c1a6b51d1..ef344b182 100644 --- a/vite/src/views/admin/oauth/OAuthClientsView.tsx +++ b/vite/src/views/admin/oauth/OAuthClientsView.tsx @@ -1,3 +1,4 @@ +import { AppEnv } from "@autumn/shared"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, @@ -17,6 +18,7 @@ import { IconButton } from "@/components/v2/buttons/IconButton"; import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useEnv } from "@/utils/envUtils"; import { getBackendErr } from "@/utils/genUtils"; import { DefaultView } from "../../DefaultView"; import LoadingScreen from "../../general/LoadingScreen"; @@ -42,10 +44,12 @@ interface OAuthClient { export const OAuthClientsView = () => { const navigate = useNavigate(); + const env = useEnv(); const { isAdmin, isPending } = useAdmin(); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); const [editingClient, setEditingClient] = useState(null); + const adminBasePath = env === AppEnv.Sandbox ? "/sandbox/admin" : "/admin"; const axiosInstance = useAxiosInstance(); @@ -132,7 +136,7 @@ export const OAuthClientsView = () => { variant="secondary" size="sm" icon={} - onClick={() => navigate("/admin")} + onClick={() => navigate(adminBasePath)} />

diff --git a/vite/src/views/main-sidebar/components/AdminDropdownItems.tsx b/vite/src/views/main-sidebar/components/AdminDropdownItems.tsx index fb1fe3638..02f391f86 100644 --- a/vite/src/views/main-sidebar/components/AdminDropdownItems.tsx +++ b/vite/src/views/main-sidebar/components/AdminDropdownItems.tsx @@ -1,3 +1,4 @@ +import { AppEnv } from "@autumn/shared"; import { LogOut, Shield } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -6,15 +7,18 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { authClient, useSession } from "@/lib/auth-client"; +import { useEnv } from "@/utils/envUtils"; import { getBackendErr, notNullish } from "@/utils/genUtils"; import { AdminOnly } from "@/views/admin/components/AdminOnly"; export const AdminDropdownItems = () => { const { data, isPending } = useSession(); + const env = useEnv(); const [stopImpersonatingLoading, setStopImpersonatingLoading] = useState(false); const isImpersonating = notNullish(data?.session?.impersonatedBy); + const adminPath = env === AppEnv.Sandbox ? "/sandbox/admin" : "/admin"; if (isPending) return null; return ( @@ -42,7 +46,7 @@ export const AdminDropdownItems = () => { )} { - window.location.href = "/admin"; + window.location.href = adminPath; }} >