refactor: removed transaction in track, added rate limiting

This commit is contained in:
John Yeo
2025-11-19 12:13:52 +00:00
50 changed files with 1335 additions and 743 deletions

158
bun.lock
View File

@@ -63,10 +63,13 @@
"@opentelemetry/sdk-trace-node": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.0.42",
"@sentry/bun": "catalog:",
"@supabase/supabase-js": "^2.46.2",
"@types/qs": "^6.14.0",
"@types/semver": "^7.7.1",
"@typescript/native-preview": "^7.0.0-dev.20251114.1",
"@upstash/ratelimit": "^2.0.7",
"@upstash/redis": "^1.35.6",
"@vercel/sdk": "^1.17.0",
"ai": "^4.3.10",
"autumn-js": "^0.1.8",
@@ -197,6 +200,8 @@
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/react": "^10.25.0",
"@sentry/vite-plugin": "^4.6.0",
"@squircle/core": "^1.0.6",
"@squircle/paint-polyfill": "^1.0.6",
"@squircle/tailwindcss": "^1.0.6",
@@ -272,6 +277,7 @@
},
},
"catalog": {
"@sentry/bun": "10.25.0",
"drizzle-kit": "^0.31.1",
"drizzle-orm": "0.43.1",
"stripe": "19.3.0-beta.1",
@@ -345,6 +351,10 @@
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.32.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg=="],
"@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="],
"@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.3.1", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.8.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw=="],
"@autumn/scripts": ["@autumn/scripts@workspace:scripts"],
"@autumn/server": ["@autumn/server@workspace:server"],
@@ -973,6 +983,8 @@
"@posthog/core": ["@posthog/core@1.5.2", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ=="],
"@prisma/instrumentation": ["@prisma/instrumentation@6.15.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-6TXaH6OmDkMOQvOxwLZ8XS51hU2v4A3vmE2pSijCIiGRJYyNeMcL6nMHQMyYdZRD8wl7LF3Wzc+AMPMV/9Oo7A=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@@ -1177,6 +1189,52 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.25.0", "", { "dependencies": { "@sentry/core": "10.25.0" } }, "sha512-wzg1ITZxrRtQouHPCgpt3tl1GiNAWFVy2RYK2KstFEhpYBAOUn9BAdP7KU9UyHBFKqbAvV4oGtAT8H2/Y4+leA=="],
"@sentry-internal/feedback": ["@sentry-internal/feedback@10.25.0", "", { "dependencies": { "@sentry/core": "10.25.0" } }, "sha512-qlbT4tOd+WRyKpLdsbi26rkynGBoVabnY8/9rFnTxZ0WIUG5EFhJFqEeRLMyv+uk0uRFF3H0I9+u+qP/BKxIcQ=="],
"@sentry-internal/replay": ["@sentry-internal/replay@10.25.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.25.0", "@sentry/core": "10.25.0" } }, "sha512-V/kKQn9T46HBTiP0bIThmpVr94K4vXwYM3/EHVpGSq4P9RynX06cgps8GLHq94+A0kX/DbK9igEMZmIuzS1q3A=="],
"@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.25.0", "", { "dependencies": { "@sentry-internal/replay": "10.25.0", "@sentry/core": "10.25.0" } }, "sha512-zuj5jVNswZ/aA1nPPbU+VIFkQG0695lbyIfS1Skq+5o2FdRIS3MGnBXw1abI9h4pft8GLQWcKiBxISM7UpSz6w=="],
"@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.0", "", {}, "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ=="],
"@sentry/browser": ["@sentry/browser@10.25.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.25.0", "@sentry-internal/feedback": "10.25.0", "@sentry-internal/replay": "10.25.0", "@sentry-internal/replay-canvas": "10.25.0", "@sentry/core": "10.25.0" } }, "sha512-UgSVT3RTM3vsK914TPuHVJQsjq5ooXVmjMtsWP3Ep+6f7N+1UVX4ZXsyyj5lDOcWdc79FgproD+MrEf9Cj6uBg=="],
"@sentry/bun": ["@sentry/bun@10.25.0", "", { "dependencies": { "@sentry/core": "10.25.0", "@sentry/node": "10.25.0" } }, "sha512-mcd5wGCI4Y30LrN3ihfDIuEi7DwGAIrEjLz2zN+Ek4equIeAC8kJrNvXl4rBYwoODSkth2K+9tqUPHoX6D4rKw=="],
"@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.6.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.6.0", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g=="],
"@sentry/cli": ["@sentry/cli@2.58.2", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.2", "@sentry/cli-linux-arm": "2.58.2", "@sentry/cli-linux-arm64": "2.58.2", "@sentry/cli-linux-i686": "2.58.2", "@sentry/cli-linux-x64": "2.58.2", "@sentry/cli-win32-arm64": "2.58.2", "@sentry/cli-win32-i686": "2.58.2", "@sentry/cli-win32-x64": "2.58.2" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-U4u62V4vaTWF+o40Mih8aOpQKqKUbZQt9A3LorIJwaE3tO3XFLRI70eWtW2se1Qmy0RZ74zB14nYcFNFl2t4Rw=="],
"@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.2", "", { "os": "darwin" }, "sha512-MArsb3zLhA2/cbd4rTm09SmTpnEuZCoZOpuZYkrpDw1qzBVJmRFA1W1hGAQ9puzBIk/ubY3EUhhzuU3zN2uD6w=="],
"@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-HU9lTCzcHqCz/7Mt5n+cv+nFuJdc1hGD2h35Uo92GgxX3/IujNvOUfF+nMX9j6BXH6hUt73R5c0Ycq9+a3Parg=="],
"@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-ay3OeObnbbPrt45cjeUyQjsx5ain1laj1tRszWj37NkKu55NZSp4QCg1gGBZ0gBGhckI9nInEsmKtix00alw2g=="],
"@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-CN9p0nfDFsAT1tTGBbzOUGkIllwS3hygOUyTK7LIm9z+UHw5uNgNVqdM/3Vg+02ymjkjISNB3/+mqEM5osGXdA=="],
"@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.2", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-oX/LLfvWaJO50oBVOn4ZvG2SDWPq0MN8SV9eg5tt2nviq+Ryltfr7Rtoo+HfV+eyOlx1/ZXhq9Wm7OT3cQuz+A=="],
"@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-+cl3x2HPVMpoSVGVM1IDWlAEREZrrVQj4xBb0TRKII7g3hUxRsAIcsrr7+tSkie++0FuH4go/b5fGAv51OEF3w=="],
"@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-omFVr0FhzJ8oTJSg1Kf+gjLgzpYklY0XPfLxZ5iiMiYUKwF5uo1RJRdkUOiEAv0IqpUKnmKcmVCLaDxsWclB7Q=="],
"@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.2", "", { "os": "win32", "cpu": "x64" }, "sha512-2NAFs9UxVbRztQbgJSP5i8TB9eJQ7xraciwj/93djrSMHSEbJ0vC47TME0iifgvhlHMs5vqETOKJtfbbpQAQFA=="],
"@sentry/core": ["@sentry/core@10.25.0", "", {}, "sha512-mGi4BYIPwZjWdOXHrPoXz1AW4/cQbFoiuW/m+OOATmtSoGTDnWYwP+qZU7VLlL+v8ZEzxfPi2C1NPfJtPj7QWA=="],
"@sentry/node": ["@sentry/node@10.25.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-amqplib": "0.51.0", "@opentelemetry/instrumentation-connect": "0.48.0", "@opentelemetry/instrumentation-dataloader": "0.22.0", "@opentelemetry/instrumentation-express": "0.53.0", "@opentelemetry/instrumentation-fs": "0.24.0", "@opentelemetry/instrumentation-generic-pool": "0.48.0", "@opentelemetry/instrumentation-graphql": "0.52.0", "@opentelemetry/instrumentation-hapi": "0.51.0", "@opentelemetry/instrumentation-http": "0.204.0", "@opentelemetry/instrumentation-ioredis": "0.52.0", "@opentelemetry/instrumentation-kafkajs": "0.14.0", "@opentelemetry/instrumentation-knex": "0.49.0", "@opentelemetry/instrumentation-koa": "0.52.0", "@opentelemetry/instrumentation-lru-memoizer": "0.49.0", "@opentelemetry/instrumentation-mongodb": "0.57.0", "@opentelemetry/instrumentation-mongoose": "0.51.0", "@opentelemetry/instrumentation-mysql": "0.50.0", "@opentelemetry/instrumentation-mysql2": "0.51.0", "@opentelemetry/instrumentation-pg": "0.57.0", "@opentelemetry/instrumentation-redis": "0.53.0", "@opentelemetry/instrumentation-tedious": "0.23.0", "@opentelemetry/instrumentation-undici": "0.15.0", "@opentelemetry/resources": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.15.0", "@sentry/core": "10.25.0", "@sentry/node-core": "10.25.0", "@sentry/opentelemetry": "10.25.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-++mugiYF8X7CLtpymGN3N4J40SvQVIsVa6K7pURhooT4eX1QXYOBJSaOqvOXk5GN4qed5wETHNBkZuXSO0RARQ=="],
"@sentry/node-core": ["@sentry/node-core@10.25.0", "", { "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", "@sentry/core": "10.25.0", "@sentry/opentelemetry": "10.25.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0" } }, "sha512-Hk0s7r9pkotZ1yfUc9+XX0ALDQ/bjaYsWF23O2q8Yfc4m8NcQio54ztAmdI+Yf+YiHLpt0x9Hlgwpl3AaRvwIA=="],
"@sentry/opentelemetry": ["@sentry/opentelemetry@10.25.0", "", { "dependencies": { "@sentry/core": "10.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0" } }, "sha512-AWCRzUIzvI+0RHXTmGvVx+MUtyyjwmC6F6d6XCnWhBKWGO52I+ucz1X8INIZxCrK05dpviFpeLZy+pzfgw892g=="],
"@sentry/react": ["@sentry/react@10.25.0", "", { "dependencies": { "@sentry/browser": "10.25.0", "@sentry/core": "10.25.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-LBQHgyPAFzuy99mEJF8ZF2AOxxJiGAmtu10eQhglhFgfJsU7JJVsee0h+vTSmvHMDtFrIwhZi3i1X5snZ/kzoA=="],
"@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="],
@@ -1475,6 +1533,8 @@
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
"@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="],
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
@@ -1527,6 +1587,12 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="],
"@upstash/ratelimit": ["@upstash/ratelimit@2.0.7", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-qNQW4uBPKVk8c4wFGj2S/vfKKQxXx1taSJoSGBN36FeiVBBKHQgsjPbKUijZ9Xu5FyVK+pfiXWKIsQGyoje8Fw=="],
"@upstash/redis": ["@upstash/redis@1.35.6", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-aSEIGJgJ7XUfTYvhQcQbq835re7e/BXjs8Janq6Pvr6LlmTZnyqwT97RziZLO/8AVUL037RLXqqiQC6kCt+5pA=="],
"@vercel/sdk": ["@vercel/sdk@1.17.1", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"], "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-wh4Y+nZAsLM0SoaLkYeHn1trIJklG5uwqTbl2keDQEJFuzcE3fG8RwyiuS+ZWW/BdpfiIwN2xAhSiSlbafTzvw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -2051,6 +2117,8 @@
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -2343,7 +2411,7 @@
"luxon": ["luxon@3.5.0", "", {}, "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
"make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
@@ -2737,6 +2805,8 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
@@ -2921,6 +2991,8 @@
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -2965,6 +3037,10 @@
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -3013,7 +3089,7 @@
"zod-openapi": ["zod-openapi@5.4.3", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-6kJ/gJdvHZtuxjYHoMtkl2PixCwRuZ/s79dVkEr7arHvZGXfx7Cvh53X3HfJ5h9FzGelXOXlnyjwfX0sKEPByw=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="],
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
@@ -3519,6 +3595,8 @@
"@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -3537,10 +3615,64 @@
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@rollup/plugin-replace/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"@sentry/node/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.204.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.204.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g=="],
"@sentry/node/@opentelemetry/instrumentation-amqplib": ["@opentelemetry/instrumentation-amqplib@0.51.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XGmjYwjVRktD4agFnWBWQXo9SiYHKBxR6Ag3MLXwtLE4R99N3a08kGKM5SC1qOFKIELcQDGFEFT9ydXMH00Luw=="],
"@sentry/node/@opentelemetry/instrumentation-connect": ["@opentelemetry/instrumentation-connect@0.48.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/connect": "3.4.38" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-OMjc3SFL4pC16PeK+tDhwP7MRvDPalYCGSvGqUhX5rASkI2H0RuxZHOWElYeXkV0WP+70Gw6JHWac/2Zqwmhdw=="],
"@sentry/node/@opentelemetry/instrumentation-dataloader": ["@opentelemetry/instrumentation-dataloader@0.22.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-bXnTcwtngQsI1CvodFkTemrrRSQjAjZxqHVc+CJZTDnidT0T6wt3jkKhnsjU/Kkkc0lacr6VdRpCu2CUWa0OKw=="],
"@sentry/node/@opentelemetry/instrumentation-express": ["@opentelemetry/instrumentation-express@0.53.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-r/PBafQmFYRjuxLYEHJ3ze1iBnP2GDA1nXOSS6E02KnYNZAVjj6WcDA1MSthtdAUUK0XnotHvvWM8/qz7DMO5A=="],
"@sentry/node/@opentelemetry/instrumentation-fs": ["@opentelemetry/instrumentation-fs@0.24.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-HjIxJ6CBRD770KNVaTdMXIv29Sjz4C1kPCCK5x1Ujpc6SNnLGPqUVyJYZ3LUhhnHAqdbrl83ogVWjCgeT4Q0yw=="],
"@sentry/node/@opentelemetry/instrumentation-generic-pool": ["@opentelemetry/instrumentation-generic-pool@0.48.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TLv/On8pufynNR+pUbpkyvuESVASZZKMlqCm4bBImTpXKTpqXaJJ3o/MUDeMlM91rpen+PEv2SeyOKcHCSlgag=="],
"@sentry/node/@opentelemetry/instrumentation-graphql": ["@opentelemetry/instrumentation-graphql@0.52.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-3fEJ8jOOMwopvldY16KuzHbRhPk8wSsOTSF0v2psmOCGewh6ad+ZbkTx/xyUK9rUdUMWAxRVU0tFpj4Wx1vkPA=="],
"@sentry/node/@opentelemetry/instrumentation-hapi": ["@opentelemetry/instrumentation-hapi@0.51.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-qyf27DaFNL1Qhbo/da+04MSCw982B02FhuOS5/UF+PMhM61CcOiu7fPuXj8TvbqyReQuJFljXE6UirlvoT/62g=="],
"@sentry/node/@opentelemetry/instrumentation-http": ["@opentelemetry/instrumentation-http@0.204.0", "", { "dependencies": { "@opentelemetry/core": "2.1.0", "@opentelemetry/instrumentation": "0.204.0", "@opentelemetry/semantic-conventions": "^1.29.0", "forwarded-parse": "2.1.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-1afJYyGRA4OmHTv0FfNTrTAzoEjPQUYgd+8ih/lX0LlZBnGio/O80vxA0lN3knsJPS7FiDrsDrWq25K7oAzbkw=="],
"@sentry/node/@opentelemetry/instrumentation-ioredis": ["@opentelemetry/instrumentation-ioredis@0.52.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/redis-common": "^0.38.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-rUvlyZwI90HRQPYicxpDGhT8setMrlHKokCtBtZgYxQWRF5RBbG4q0pGtbZvd7kyseuHbFpA3I/5z7M8b/5ywg=="],
"@sentry/node/@opentelemetry/instrumentation-kafkajs": ["@opentelemetry/instrumentation-kafkajs@0.14.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.30.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kbB5yXS47dTIdO/lfbbXlzhvHFturbux4EpP0+6H78Lk0Bn4QXiZQW7rmZY1xBCY16mNcCb8Yt0mhz85hTnSVA=="],
"@sentry/node/@opentelemetry/instrumentation-knex": ["@opentelemetry/instrumentation-knex@0.49.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.33.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-NKsRRT27fbIYL4Ix+BjjP8h4YveyKc+2gD6DMZbr5R5rUeDqfC8+DTfIt3c3ex3BIc5Vvek4rqHnN7q34ZetLQ=="],
"@sentry/node/@opentelemetry/instrumentation-koa": ["@opentelemetry/instrumentation-koa@0.52.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-JJSBYLDx/mNSy8Ibi/uQixu2rH0bZODJa8/cz04hEhRaiZQoeJ5UrOhO/mS87IdgVsHrnBOsZ6vDu09znupyuA=="],
"@sentry/node/@opentelemetry/instrumentation-lru-memoizer": ["@opentelemetry/instrumentation-lru-memoizer@0.49.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ctXu+O/1HSadAxtjoEg2w307Z5iPyLOMM8IRNwjaKrIpNAthYGSOanChbk1kqY6zU5CrpkPHGdAT6jk8dXiMqw=="],
"@sentry/node/@opentelemetry/instrumentation-mongodb": ["@opentelemetry/instrumentation-mongodb@0.57.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KD6Rg0KSHWDkik+qjIOWoksi1xqSpix8TSPfquIK1DTmd9OTFb5PHmMkzJe16TAPVEuElUW8gvgP59cacFcrMQ=="],
"@sentry/node/@opentelemetry/instrumentation-mongoose": ["@opentelemetry/instrumentation-mongoose@0.51.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gwWaAlhhV2By7XcbyU3DOLMvzsgeaymwP/jktDC+/uPkCmgB61zurwqOQdeiRq9KAf22Y2dtE5ZLXxytJRbEVA=="],
"@sentry/node/@opentelemetry/instrumentation-mysql": ["@opentelemetry/instrumentation-mysql@0.50.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/mysql": "2.15.27" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-duKAvMRI3vq6u9JwzIipY9zHfikN20bX05sL7GjDeLKr2qV0LQ4ADtKST7KStdGcQ+MTN5wghWbbVdLgNcB3rA=="],
"@sentry/node/@opentelemetry/instrumentation-mysql2": ["@opentelemetry/instrumentation-mysql2@0.51.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.41.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zT2Wg22Xn43RyfU3NOUmnFtb5zlDI0fKcijCj9AcK9zuLZ4ModgtLXOyBJSSfO+hsOCZSC1v/Fxwj+nZJFdzLQ=="],
"@sentry/node/@opentelemetry/instrumentation-pg": ["@opentelemetry/instrumentation-pg@0.57.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/sql-common": "^0.41.0", "@types/pg": "8.15.5", "@types/pg-pool": "2.0.6" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-dWLGE+r5lBgm2A8SaaSYDE3OKJ/kwwy5WLyGyzor8PLhUL9VnJRiY6qhp4njwhnljiLtzeffRtG2Mf/YyWLeTw=="],
"@sentry/node/@opentelemetry/instrumentation-redis": ["@opentelemetry/instrumentation-redis@0.53.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/redis-common": "^0.38.0", "@opentelemetry/semantic-conventions": "^1.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-WUHV8fr+8yo5RmzyU7D5BIE1zwiaNQcTyZPwtxlfr7px6NYYx7IIpSihJK7WA60npWynfxxK1T67RAVF0Gdfjg=="],
"@sentry/node/@opentelemetry/instrumentation-tedious": ["@opentelemetry/instrumentation-tedious@0.23.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@types/tedious": "^4.0.14" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-3TMTk/9VtlRonVTaU4tCzbg4YqW+Iq/l5VnN2e5whP6JgEg/PKfrGbqQ+CxQWNLfLaQYIUgEZqAn5gk/inh1uQ=="],
"@sentry/node/@opentelemetry/instrumentation-undici": ["@opentelemetry/instrumentation-undici@0.15.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.204.0" }, "peerDependencies": { "@opentelemetry/api": "^1.7.0" } }, "sha512-sNFGA/iCDlVkNjzTzPRcudmI11vT/WAfAguRdZY9IspCw02N4WSC72zTuQhSMheh2a1gdeM9my1imnKRvEEvEg=="],
"@smithy/eventstream-codec/@smithy/types": ["@smithy/types@1.2.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA=="],
"@smithy/eventstream-codec/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@1.1.0", "", { "dependencies": { "tslib": "^2.5.0" } }, "sha512-7UtIE9eH0u41zpB60Jzr0oNCQ3hMJUabMcKRUVjmyHTXiWDE4vjSqN6qlih7rCNeKGbioS7f/y2Jgym4QZcKFg=="],
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
@@ -3697,6 +3829,8 @@
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -4043,6 +4177,20 @@
"@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw=="],
"@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
"@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
"@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"@sentry/node/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.204.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw=="],
"@sentry/node/@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.1.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ=="],
"@sentry/node/@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.15.5", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -4171,6 +4319,10 @@
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"unplugin/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@@ -4373,6 +4525,8 @@
"tsc-alias/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@aws-sdk/client-cognito-identity/@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=="],

View File

@@ -11,7 +11,8 @@
"catalog": {
"stripe": "19.3.0-beta.1",
"drizzle-orm": "0.43.1",
"drizzle-kit": "^0.31.1"
"drizzle-kit": "^0.31.1",
"@sentry/bun": "10.25.0"
}
},
"type": "module",
@@ -20,15 +21,12 @@
"vite:build": "bun -F @autumn/vite build:bun",
"d": "ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts",
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts",
"setup": "node scripts/setup/setup.js",
"setup:test": "bun scripts/setup/setup-test.ts",
"tests": "infisical run --env=dev -- bun scripts/test.ts",
"migrate-functions": "infisical run --env=dev -- bun scripts/migrations/migrate-functions.ts",
"migrate-functions:prod": "infisical run --env=prod -- bun scripts/migrations/migrate-functions.ts",
"validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts",
"setupci": "node scripts/setup/setupci.js",
"replicate": "bun scripts/db/replicate.ts",
"db:push": " bun -F @autumn/shared db:push",

View File

@@ -0,0 +1,123 @@
import AutumnError, { AutumnInt } from "../src/external/autumn/autumnCli";
export const main = async () => {
console.log("🚀 Starting rate limit test...\n");
// Initialize client
const autumn = new AutumnInt();
const numRequests = 200;
const customerId1 = "test1";
const customerId2 = "test2";
console.log(`Testing with ${numRequests} concurrent requests to /track endpoint`);
console.log(`Using base URL: ${autumn.baseUrl}\n`);
// Create all track requests
const cusId1Promises = [];
const cusId2Promises = [];
for (let i = 0; i < numRequests; i++) {
cusId1Promises.push(
autumn.customers.create({
id: customerId1,
}),
);
cusId2Promises.push(
autumn.customers.create({
id: customerId2,
}),
);
}
// Execute all requests concurrently for both customers
const startTime = Date.now();
const [cusId1Results, cusId2Results] = await Promise.all([
Promise.allSettled(cusId1Promises),
Promise.allSettled(cusId2Promises),
]);
const duration = Date.now() - startTime;
// Helper function to analyze results
const analyzeResults = (results: PromiseSettledResult<unknown>[]) => {
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const rateLimited = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
const otherErrors = results.filter(
(r) =>
r.status === "rejected" &&
!(
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded"
),
).length;
return { succeeded, rateLimited, otherErrors };
};
const cus1Stats = analyzeResults(cusId1Results);
const cus2Stats = analyzeResults(cusId2Results);
const totalRequests = numRequests * 2;
// Display results
console.log("📊 Results:");
console.log("═".repeat(60));
console.log(`Total requests: ${totalRequests} (${numRequests} per customer)`);
console.log(`⏱️ Duration: ${duration}ms`);
console.log(`📈 Throughput: ${Math.round(totalRequests / (duration / 1000))} req/s`);
console.log("═".repeat(60));
console.log(`\n👤 Customer 1 (${customerId1}):`);
console.log("─".repeat(60));
console.log(` Total: ${numRequests}`);
console.log(` ✅ Succeeded: ${cus1Stats.succeeded}`);
console.log(` ⛔ Rate limited: ${cus1Stats.rateLimited}`);
console.log(` ❌ Other errors: ${cus1Stats.otherErrors}`);
console.log(`\n👤 Customer 2 (${customerId2}):`);
console.log("─".repeat(60));
console.log(` Total: ${numRequests}`);
console.log(` ✅ Succeeded: ${cus2Stats.succeeded}`);
console.log(` ⛔ Rate limited: ${cus2Stats.rateLimited}`);
console.log(` ❌ Other errors: ${cus2Stats.otherErrors}`);
console.log("\n📈 Combined Stats:");
console.log("─".repeat(60));
console.log(
` ✅ Total succeeded: ${cus1Stats.succeeded + cus2Stats.succeeded}`,
);
console.log(
` ⛔ Total rate limited: ${cus1Stats.rateLimited + cus2Stats.rateLimited}`,
);
console.log(
` ❌ Total errors: ${cus1Stats.otherErrors + cus2Stats.otherErrors}`,
);
// Show sample errors if any
const totalErrors = cus1Stats.otherErrors + cus2Stats.otherErrors;
if (totalErrors > 0) {
console.log("\n⚠ Sample of other errors:");
const errorSamples = [...cusId1Results, ...cusId2Results]
.filter(
(r) =>
r.status === "rejected" &&
!(
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded"
),
)
.slice(0, 3);
for (const sample of errorSamples) {
if (sample.status === "rejected") {
console.log(` - ${sample.reason}`);
}
}
}
console.log("\n✨ Test complete!");
};
await main();

View File

@@ -12,12 +12,10 @@
"c": "ENV_FILE=.env infisical run --env=dev -- bun cron",
"dev": "cross-env NODE_ENV=development bunx nodemon",
"workers:dev": "cross-env NODE_ENV=development bunx nodemon --exec bun src/workers.ts --signal SIGTERM --delay 500ms",
"start": "bun src/index.ts",
"workers": "bun src/workers.ts",
"cron": "bun src/cron.ts",
"check": "bun src/check.ts",
"t": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroupsV3.ts",
"parallel-tests": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroupsV3.ts",
"parallel-tests:v1": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts",
@@ -57,10 +55,13 @@
"@opentelemetry/sdk-trace-node": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.0.42",
"@sentry/bun": "catalog:",
"@supabase/supabase-js": "^2.46.2",
"@types/qs": "^6.14.0",
"@types/semver": "^7.7.1",
"@typescript/native-preview": "^7.0.0-dev.20251114.1",
"@upstash/ratelimit": "^2.0.7",
"@upstash/redis": "^1.35.6",
"@vercel/sdk": "^1.17.0",
"ai": "^4.3.10",
"autumn-js": "^0.1.8",

View File

@@ -325,8 +325,9 @@ end
-- cusFeature: Balance object to deduct from
-- amount: Amount to handle (positive for deduction, negative for refund)
-- adjustGrantedBalance: If true, decrement granted_balance instead of incrementing usage
-- overageBehavior: "cap" or "reject" - affects allocatedFeatureBypass logic
-- Returns: { remaining: number, deltas: [{key, field, delta}], stateChanges: [{type, index, field, newValue/delta}] }
local function deductFromOverage(cusFeature, amount, adjustGrantedBalance)
local function deductFromOverage(cusFeature, amount, adjustGrantedBalance, overageBehavior)
local remaining = amount
local deltas = {}
local stateChanges = {}
@@ -350,8 +351,11 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance)
end
-- Check if overage is allowed
-- Continuous use features automatically allow overage
local allowOverage = cusFeature.overage_allowed or (cusFeature.feature and cusFeature.feature.type == "metered" and cusFeature.feature.consumable == false)
-- Continuous use features automatically allow overage (unless overageBehavior is "reject")
local allocatedFeatureBypass = cusFeature.feature and cusFeature.feature.type == "metered" and cusFeature.feature.consumable == false and overageBehavior ~= "reject"
local allowOverage = cusFeature.overage_allowed or allocatedFeatureBypass
if not allowOverage then
return {
@@ -553,8 +557,9 @@ end
-- cusFeature: Balance object to deduct from
-- amount: Amount to deduct (can be negative for refunds)
-- adjustGrantedBalance: If true, decrement granted_balance instead of incrementing usage
-- overageBehavior: "cap" or "reject" - passed to deductFromOverage
-- Returns: { remaining: number, deltas: [{key, field, delta}], stateChanges: [{type, index, field, newValue/delta}] }
local function deductFromMainBalance(cusFeature, amount, adjustGrantedBalance)
local function deductFromMainBalance(cusFeature, amount, adjustGrantedBalance, overageBehavior)
local allDeltas = {}
local allStateChanges = {}
local remaining = amount
@@ -574,7 +579,7 @@ local function deductFromMainBalance(cusFeature, amount, adjustGrantedBalance)
-- Pass 2: Deduct from overage (increments purchased_balance up to max_purchase)
if remaining > 0 then
local overageResult = deductFromOverage(cusFeature, remaining, adjustGrantedBalance)
local overageResult = deductFromOverage(cusFeature, remaining, adjustGrantedBalance, overageBehavior)
remaining = overageResult.remaining
for _, delta in ipairs(overageResult.deltas) do
@@ -587,7 +592,7 @@ local function deductFromMainBalance(cusFeature, amount, adjustGrantedBalance)
-- NEGATIVE AMOUNT (REFUND): overage → current_balance
else
-- Pass 1: Refund from overage (decrements purchased_balance down to 0)
local overageResult = deductFromOverage(cusFeature, remaining, adjustGrantedBalance)
local overageResult = deductFromOverage(cusFeature, remaining, adjustGrantedBalance, overageBehavior)
remaining = overageResult.remaining
for _, delta in ipairs(overageResult.deltas) do
@@ -626,7 +631,7 @@ end
-- Deduct from a single customer feature (handles rollovers + main balance)
-- Returns: { remaining: number, deltas: array, stateChanges: array }
local function deductFromCusFeature(cusFeature, amount)
local function deductFromCusFeature(cusFeature, amount, overageBehavior)
local allDeltas = {}
local allStateChanges = {}
@@ -644,7 +649,7 @@ local function deductFromCusFeature(cusFeature, amount)
-- Step 2: Deduct remaining from main balance
if remaining ~= 0 then
local mainResult = deductFromMainBalance(cusFeature, remaining, adjustGrantedBalance)
local mainResult = deductFromMainBalance(cusFeature, remaining, adjustGrantedBalance, overageBehavior)
remaining = mainResult.remaining
-- Collect main balance deltas and state changes
@@ -668,7 +673,7 @@ end
-- If targetEntityId is provided, only deduct from that entity (entity-level tracking)
-- If targetEntityId is nil, deduct from ALL entities (customer-level tracking)
-- Returns: { remaining: number, deltas: array, customerStateChanges: array, entityStateChanges: { [entityId] = array } }
local function deductFromFeatureWithEntities(customerFeature, entityFeaturesMap, amount, targetEntityId)
local function deductFromFeatureWithEntities(customerFeature, entityFeaturesMap, amount, targetEntityId, overageBehavior)
local allDeltas = {}
local customerStateChanges = {}
local entityStateChanges = {}
@@ -701,7 +706,7 @@ local function deductFromFeatureWithEntities(customerFeature, entityFeaturesMap,
if entityFeatures then
local entityFeature = entityFeatures[customerFeature.id]
if entityFeature and remaining ~= 0 then
local entityMainResult = deductFromMainBalance(entityFeature, remaining, adjustGrantedBalance)
local entityMainResult = deductFromMainBalance(entityFeature, remaining, adjustGrantedBalance, overageBehavior)
remaining = entityMainResult.remaining
for _, delta in ipairs(entityMainResult.deltas) do
table.insert(allDeltas, delta)
@@ -729,7 +734,7 @@ local function deductFromFeatureWithEntities(customerFeature, entityFeaturesMap,
-- Step 4: Deduct from customer main balance
if remaining ~= 0 then
local customerMainResult = deductFromMainBalance(customerFeature, remaining, adjustGrantedBalance)
local customerMainResult = deductFromMainBalance(customerFeature, remaining, adjustGrantedBalance, overageBehavior)
remaining = customerMainResult.remaining
for _, delta in ipairs(customerMainResult.deltas) do
table.insert(allDeltas, delta)
@@ -753,7 +758,7 @@ local function deductFromFeatureWithEntities(customerFeature, entityFeaturesMap,
-- Step 2: Deduct from customer main balance
if remaining ~= 0 then
local customerMainResult = deductFromMainBalance(customerFeature, remaining, adjustGrantedBalance)
local customerMainResult = deductFromMainBalance(customerFeature, remaining, adjustGrantedBalance, overageBehavior)
remaining = customerMainResult.remaining
for _, delta in ipairs(customerMainResult.deltas) do
table.insert(allDeltas, delta)
@@ -802,7 +807,7 @@ local function deductFromFeatureWithEntities(customerFeature, entityFeaturesMap,
local entityFeatures = entityFeaturesMap[entityId]
local entityFeature = entityFeatures[customerFeature.id]
if entityFeature and remaining ~= 0 then
local entityMainResult = deductFromMainBalance(entityFeature, remaining, adjustGrantedBalance)
local entityMainResult = deductFromMainBalance(entityFeature, remaining, adjustGrantedBalance, overageBehavior)
remaining = entityMainResult.remaining
for _, delta in ipairs(entityMainResult.deltas) do
table.insert(allDeltas, delta)
@@ -901,6 +906,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
local syncMode = request.syncMode or false
local targetBalance = request.targetBalance
-- Collect all deltas and state changes for this request
local requestDeltas = {}
local requestStateChanges = {}
@@ -938,7 +944,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
if cusFeature then
-- Customer has this feature - deduct from customer + entities
if not cusFeature.unlimited then
local result = deductFromFeatureWithEntities(cusFeature, entityFeatureStates, amount, entityId)
local result = deductFromFeatureWithEntities(cusFeature, entityFeatureStates, amount, entityId, overageBehavior)
-- Collect deltas
for _, delta in ipairs(result.deltas) do
@@ -977,7 +983,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
if entityFeatures and entityFeatures[featureId] then
local entityFeature = entityFeatures[featureId]
if not entityFeature.unlimited then
local result = deductFromCusFeature(entityFeature, amount)
local result = deductFromCusFeature(entityFeature, amount, overageBehavior)
-- Collect deltas
for _, delta in ipairs(result.deltas) do
@@ -1011,7 +1017,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
local entityFeature = entityFeatures[featureId]
if entityFeature and remainingAmount ~= 0 then
if not entityFeature.unlimited then
local result = deductFromCusFeature(entityFeature, remainingAmount)
local result = deductFromCusFeature(entityFeature, remainingAmount, overageBehavior)
-- Collect deltas
for _, delta in ipairs(result.deltas) do
@@ -1050,7 +1056,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
local creditAmount = remainingAmount * creditItem.credit_cost
if not otherCusFeature.unlimited then
local result = deductFromFeatureWithEntities(otherCusFeature, entityFeatureStates, creditAmount, entityId)
local result = deductFromFeatureWithEntities(otherCusFeature, entityFeatureStates, creditAmount, entityId, overageBehavior)
-- Collect deltas
for _, delta in ipairs(result.deltas) do

View File

@@ -91,6 +91,32 @@ export class AutumnInt {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.headers,
});
if (response.status !== 200) {
// Handle rate limit errors
if (response.status === 429) {
throw new AutumnError({
message: `request failed, rate limit exceeded`,
code: "rate_limit_exceeded",
});
}
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: `request failed, error: ${error}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message,
code: error.code,
});
}
return response.json();
}
@@ -582,8 +608,16 @@ export class AutumnInt {
},
};
track = async (params: TrackParams) => {
const data = await this.post(`/track`, params);
track = async (
params: TrackParams,
{ skipCache = false }: { skipCache?: boolean } = {},
) => {
const queryParams = new URLSearchParams();
if (skipCache) {
queryParams.append("skip_cache", "true");
}
const data = await this.post(`/track?${queryParams.toString()}`, params);
return data;
};

View File

@@ -0,0 +1,48 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import {
CHECK_RATE_LIMIT,
GENERAL_RATE_LIMIT,
TRACK_RATE_LIMIT,
} from "./rateLimitConstants";
let upstash: Redis | undefined;
let generalRateLimiter: Ratelimit | undefined;
let trackRateLimiter: Ratelimit | undefined;
let checkRateLimiter: Ratelimit | undefined;
if (
process.env.UPSTASH_REDIS_REST_URL &&
process.env.UPSTASH_REDIS_REST_TOKEN
) {
upstash = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL ?? "",
token: process.env.UPSTASH_REDIS_REST_TOKEN ?? "",
});
// 1. General rate limiter
generalRateLimiter = new Ratelimit({
redis: upstash,
limiter: Ratelimit.slidingWindow(GENERAL_RATE_LIMIT, "1s"),
});
// 2. Track rate limiter
trackRateLimiter = new Ratelimit({
redis: upstash,
limiter: Ratelimit.slidingWindow(TRACK_RATE_LIMIT, "1s"),
});
// 3. Check / get customer rate limiter
checkRateLimiter = new Ratelimit({
redis: upstash,
limiter: Ratelimit.slidingWindow(CHECK_RATE_LIMIT, "1s"),
});
}
export enum RateLimitType {
General = "general",
Track = "track",
Check = "check",
}
export { upstash, generalRateLimiter, trackRateLimiter, checkRateLimiter };

View File

@@ -0,0 +1,7 @@
export const GENERAL_RATE_LIMIT = 1000; // per org
export const TRACK_RATE_LIMIT = 10000; // per customer ID
export const CHECK_RATE_LIMIT = 10000; // per customer ID
// const TRACK_RATE_LIMIT = 10;
// const CHECK_RATE_LIMIT = 10;
// const GENERAL_RATE_LIMIT = 10;

View File

@@ -0,0 +1,124 @@
import type { Context } from "hono";
import {
parseCustomerIdFromBody,
parseCustomerIdFromUrl,
} from "../../honoMiddlewares/analyticsMiddleware";
import { matchRoute } from "../../honoMiddlewares/middlewareUtils";
import type { HonoEnv } from "../../honoUtils/HonoEnv";
import {
checkRateLimiter,
generalRateLimiter,
RateLimitType,
trackRateLimiter,
} from "./initUpstash";
export const getRateLimitType = (c: Context<HonoEnv>) => {
const method = c.req.method;
const path = c.req.path;
// Exact match patterns for track endpoints
const trackPatterns = [
{
method: "POST",
url: "/v1/events",
},
{
method: "POST",
url: "/v1/track",
},
];
// Patterns for check endpoints (including dynamic customer_id)
const checkPatterns = [
{
method: "POST",
url: "/v1/check",
},
{
method: "POST",
url: "/v1/entitled",
},
];
const getCustomerPatterns = [
{
method: "GET",
url: "/v1/customers/:customer_id",
},
{
method: "POST",
url: "/v1/customers",
},
];
if (
trackPatterns.some((pattern) => matchRoute({ url: path, method, pattern }))
) {
return RateLimitType.Track;
}
if (
checkPatterns.some((pattern) =>
matchRoute({ url: path, method, pattern }),
) ||
getCustomerPatterns.some((pattern) =>
matchRoute({ url: path, method, pattern }),
)
) {
return RateLimitType.Check;
}
return RateLimitType.General;
};
export const getRateLimiter = ({
rateLimitType,
}: {
rateLimitType: RateLimitType;
}) => {
switch (rateLimitType) {
case RateLimitType.Track:
return trackRateLimiter;
case RateLimitType.Check:
return checkRateLimiter;
case RateLimitType.General:
return generalRateLimiter;
}
};
export const getRateLimitKey = async ({
c,
rateLimitType,
}: {
c: Context<HonoEnv>;
rateLimitType: RateLimitType;
}) => {
const ctx = c.get("ctx");
const orgId = ctx.org?.id;
const env = ctx.env;
// 1. If rate limit type is general
switch (rateLimitType) {
case RateLimitType.Track: {
const res = await parseCustomerIdFromBody(c);
const customerId = res?.customerId;
return `track:${orgId}:${env}:${customerId}`;
}
case RateLimitType.Check: {
const res = await parseCustomerIdFromBody(c);
const urlCustomerId = parseCustomerIdFromUrl({ url: c.req.path });
const customerId = res?.customerId || urlCustomerId;
const sendEvent = res?.sendEvent;
if (customerId && sendEvent) {
return `track:${orgId}:${env}:${customerId}`;
}
return `check:${orgId}:${env}:${customerId}`;
}
case RateLimitType.General:
return `general:${orgId}:${env}`;
}
};

View File

@@ -1,7 +1,8 @@
import * as Sentry from "@sentry/bun";
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
const parseCustomerIdFromUrl = ({
export const parseCustomerIdFromUrl = ({
url,
}: {
url: string;
@@ -21,6 +22,33 @@ const parseCustomerIdFromUrl = ({
return undefined;
};
export const parseCustomerIdFromBody = async (
c: Context<HonoEnv>,
): Promise<
{ customerId: string | undefined; sendEvent: boolean | undefined } | undefined
> => {
const method = c.req.method;
if (method === "POST" || method === "PUT" || method === "PATCH") {
try {
// Clone the request to read body without consuming it
const body = await c.req.json();
const isCreateCustomerPath =
c.req.path.startsWith("/v1/customers") && method === "POST";
return {
customerId: isCreateCustomerPath ? body?.id : body?.customer_id,
sendEvent: body?.send_event,
};
} catch (_error) {
// Body might not be JSON, that's okay
return undefined;
}
}
return undefined;
};
/**
* Logs response details asynchronously without blocking
*/
@@ -109,6 +137,11 @@ export const analyticsMiddleware = async (c: Context<HonoEnv>, next: Next) => {
ctx.logger.info(`${method} ${c.req.path} (${ctx.org?.slug})`);
Sentry.setUser({
...reqContext,
body: undefined,
});
// Execute the request
await next();

View File

@@ -1,4 +1,5 @@
import { ErrCode, RecaseError as SharedRecaseError } from "@autumn/shared";
import * as Sentry from "@sentry/bun";
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import Stripe from "stripe";
@@ -7,7 +8,6 @@ import { formatZodError } from "@/errors/formatZodError.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import RecaseError from "@/utils/errorUtils.js";
import { handleErrorSkip } from "./errorSkipMiddleware.js";
/**
* Hono error handler middleware
* Handles different error types and responds appropriately with proper logging
@@ -19,6 +19,7 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
// If no context/logger available, fallback to console
if (!logger) {
console.error("Error occurred before context was set:", err);
Sentry.captureException(err);
return c.json(
{
message: "Internal server error",
@@ -28,10 +29,13 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
);
}
// Check for error skip cases first (warn-level errors)
// Check for error skip cases first (warn-level errors that don't need Sentry)
const skipResponse = handleErrorSkip(err, c);
if (skipResponse) return skipResponse;
// If we got here, it's an error worth tracking - capture to Sentry
Sentry.captureException(err);
// 1. Handle RecaseError (our custom errors)
if (err instanceof RecaseError || err instanceof SharedRecaseError) {
logger.warn(
@@ -75,39 +79,24 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
}
// 3. Handle Zod validation errors
// Note: User input validation errors are already handled by errorSkipMiddleware
// If we get here, it's an internal Zod error (bug in our code)
if (err instanceof ZodError) {
const formattedError = formatZodError(err);
// 1. If it's validation error
if (c.get("validated")) {
logger.error(
`INTERNAL ZOD ERROR (${ctx.org?.slug || "unknown"}): ${formattedError}`,
);
logger.error(err);
logger.error(
`INTERNAL ZOD ERROR (${ctx.org?.slug || "unknown"}): ${formattedError}`,
);
logger.error(err);
return c.json(
{
message: formattedError,
code: ErrCode.InvalidInputs,
env: ctx.env,
},
500,
);
} else {
logger.warn(
`ZOD ERROR (${ctx.org?.slug || "unknown"}): ${formattedError}`,
);
return c.json(
{
message: formattedError,
code: ErrCode.InvalidInputs,
env: ctx.env,
},
400,
);
}
return c.json(
{
message: formattedError,
code: ErrCode.InvalidInputs,
env: ctx.env,
},
500,
);
}
// 4. Handle unknown errors

View File

@@ -1,9 +1,4 @@
import {
CusErrorCode,
ErrCode,
ProductErrorCode,
RecaseError as SharedRecaseError,
} from "@autumn/shared";
import { ErrCode, RecaseError as SharedRecaseError } from "@autumn/shared";
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import Stripe from "stripe";
@@ -34,13 +29,13 @@ const ROUTE_ERROR_SKIP_MAP = [
},
] as const;
/** Global error codes that should be logged as warnings instead of errors (all routes) */
const GLOBAL_WARN_ERROR_CODES: string[] = [
ProductErrorCode.ProductNotFound,
CusErrorCode.CustomerNotFound,
ErrCode.CustomerNotFound,
ErrCode.EntityNotFound,
];
// /** Global error codes that should be logged as warnings instead of errors (all routes) */
// const GLOBAL_WARN_ERROR_CODES: string[] = [
// ProductErrorCode.ProductNotFound,
// CusErrorCode.CustomerNotFound,
// ErrCode.CustomerNotFound,
// ErrCode.EntityNotFound,
// ];
/** Advanced route-specific error handling rules (for complex matching logic) */
const ROUTE_SPECIFIC_RULES: Array<{
@@ -135,6 +130,13 @@ const STRIPE_RULES = [
/** Zod-specific error handling rules */
const ZOD_RULES = [
{
name: "Zod user input validation error",
match: (err: Error, c: Context<HonoEnv>) =>
err instanceof ZodError && !c.get("validated"),
statusCode: 400,
format: (err: ZodError) => formatZodError(err),
},
{
name: "Zod error on /attach",
match: (err: Error, c: Context<HonoEnv>) =>
@@ -182,8 +184,19 @@ export const handleErrorSkip = (err: Error, c: Context<HonoEnv>) => {
const ctx = c.get("ctx");
const logger = ctx?.logger;
if (!logger) {
return null; // Let main error handler deal with this
if (!logger) return null; // Let main error handler deal with this
if (err instanceof SharedRecaseError) {
logger.warn(
`${err.message}, org: ${ctx.org?.slug || "unknown"}, path: ${c.req.path}`,
);
return createErrorResponse({
c,
ctx,
message: err.message,
code: err.code,
statusCode: (err.statusCode || 400) as ContentfulStatusCode,
});
}
// 1. Check route-based error code skipping (simplest case)
@@ -213,22 +226,22 @@ export const handleErrorSkip = (err: Error, c: Context<HonoEnv>) => {
}
}
// 2. Check global warn-level error codes
if (
(err instanceof RecaseError || err instanceof SharedRecaseError) &&
GLOBAL_WARN_ERROR_CODES.includes(err.code)
) {
logger.warn(
`${err.message}, org: ${ctx.org?.slug || "unknown"}, path: ${c.req.path}`,
);
return createErrorResponse({
c,
ctx,
message: err.message,
code: err.code,
statusCode: 404,
});
}
// // 2. Check global warn-level error codes
// if (
// (err instanceof RecaseError || err instanceof SharedRecaseError) &&
// GLOBAL_WARN_ERROR_CODES.includes(err.code)
// ) {
// logger.warn(
// `${err.message}, org: ${ctx.org?.slug || "unknown"}, path: ${c.req.path}`,
// );
// return createErrorResponse({
// c,
// ctx,
// message: err.message,
// code: err.code,
// statusCode: 404,
// });
// }
// 3. Check advanced route-specific rules
for (const rule of ROUTE_SPECIFIC_RULES) {

View File

@@ -1,92 +1,50 @@
import type { Context } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import type { HonoEnv } from "../honoUtils/HonoEnv.js";
import type { Context, Next } from "hono";
import { StatusCodes } from "http-status-codes";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { upstash } from "../external/upstash/initUpstash";
import {
getRateLimiter,
getRateLimitKey,
getRateLimitType,
} from "../external/upstash/rateLimitUtils";
/**
* General rate limiter for all API routes: 50k requests/second per organization
* Analytics middleware for Hono
* Enriches logger context and logs responses
*/
export const generalRateLimiter = rateLimiter({
windowMs: 1000, // 1 second
limit: 100_000,
standardHeaders: "draft-6",
keyGenerator: (c: Context<HonoEnv>) => {
const ctx = c.var.ctx;
if (!ctx?.org?.id) {
return "anonymous";
}
return `org:${ctx.org.id}:${ctx.env}`;
},
handler: (c: Context<HonoEnv>) => {
return c.json(
{
message: "Too many requests. Please try again later.",
code: "rate_limit_exceeded",
},
429,
);
},
});
export const rateLimitMiddleware = async (c: Context<HonoEnv>, next: Next) => {
if (!upstash) {
await next();
return;
}
/**
* Factory function to create customer-based rate limiters
* Key format: customer_id:org_id:env
*/
const createCustomerRateLimiter = ({ limit }: { limit: number }) => {
return rateLimiter({
windowMs: 1000, // 1 second
limit,
const ctx = c.get("ctx");
try {
// 1. Check method.
const rateLimitType = getRateLimitType(c);
standardHeaders: "draft-6",
keyGenerator: async (c: Context<HonoEnv>) => {
const ctx = c.var.ctx;
// 2. Get rate limit key
const rateLimitKey = await getRateLimitKey({ c, rateLimitType });
const rateLimiter = getRateLimiter({ rateLimitType });
// Try to get customer_id from request body
let customerId: string | undefined;
// 3. Check rate limit
const result = await rateLimiter?.limit(rateLimitKey);
try {
const bodyObj = await c.req.json();
customerId = bodyObj?.customer_id;
} catch {
// If we can't parse the body, fall back to org-level limiting
}
if (!customerId || !ctx?.org?.id || !ctx?.env) {
// Fall back to org-level limiting if customer info not available
return ctx?.org?.id
? `org:${ctx.org.id}:${ctx.env || "unknown"}`
: "anonymous";
}
return `customer:${customerId}:${ctx.org.id}:${ctx.env}`;
},
handler: (c: Context<HonoEnv>) => {
return c.json(
{
message: "Too many requests. Please try again later.",
code: "rate_limit_exceeded",
},
429,
if (!result?.success) {
ctx.logger.warn(
`${rateLimitType} - Rate limit exceeded: ${rateLimitKey}`,
);
},
// store: new RedisStore({
// client: new Redis({
// url: process.env.UPSTASH_URL!,
// token: process.env.UPSTASH_TOKEN!,
// }),
// }),
});
return c.json(
{ error: "Rate limit exceeded" },
StatusCodes.TOO_MANY_REQUESTS,
);
}
} catch (error) {
ctx.logger.error(
`Error checking rate limit, error: ${error}. Bypassing for now`,
);
}
// 4. Execute the request
await next();
};
/**
* Rate limiter for /track and /events endpoints: 1k requests/second per customer
*/
export const customerTrackRateLimiter = createCustomerRateLimiter({
limit: 10_000,
});
/**
* Rate limiter for /check and /entitled endpoints: 100k requests/second per customer
*/
export const customerCheckRateLimiter = createCustomerRateLimiter({
limit: 100_000,
});

View File

@@ -3,6 +3,7 @@
// Skip OpenTelemetry instrumentation in development for faster startup
if (process.env.NODE_ENV !== "development") {
await import("./instrumentation.js");
await import("./sentry.js");
}
import cluster from "node:cluster";

View File

@@ -3,30 +3,13 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { vercelWebhookRouter } from "./external/vercel/vercelWebhookRouter.js";
import { handleConnectWebhook } from "./external/webhooks/connectWebhookRouter.js";
import { analyticsMiddleware } from "./honoMiddlewares/analyticsMiddleware.js";
import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js";
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js";
import { queryMiddleware } from "./honoMiddlewares/queryMiddleware.js";
import { refreshCacheMiddleware } from "./honoMiddlewares/refreshCacheMiddleware.js";
import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { handleHealthCheck } from "./honoUtils/handleHealthCheck.js";
import { balancesRouter } from "./internal/balances/balancesRouter.js";
import { billingRouter } from "./internal/billing/billingRouter.js";
import { cusRouter } from "./internal/customers/cusRouter.js";
import { entityRouter } from "./internal/entities/entityRouter.js";
import { featureRouter } from "./internal/features/featureRouter.js";
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
import { honoOrgRouter } from "./internal/orgs/orgRouter.js";
import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.js";
import {
honoProductBetaRouter,
honoProductRouter,
migrationRouter,
} from "./internal/products/productRouter.js";
import { apiRouter } from "./routers/apiRouter.js";
import { internalRouter } from "./routers/internalRouter.js";
import { auth } from "./utils/auth.js";
@@ -102,29 +85,7 @@ export const createHonoApp = () => {
app.route("/webhooks/vercel", vercelWebhookRouter);
// API Middleware
app.use("/v1/*", secretKeyMiddleware);
app.use("/v1/*", orgConfigMiddleware);
app.use("/v1/*", apiVersionMiddleware);
app.use("/v1/*", analyticsMiddleware);
app.use("/v1/*", refreshCacheMiddleware);
app.use("/v1/*", queryMiddleware());
app.route("v1", billingRouter);
app.route("v1", balancesRouter);
app.route("v1", migrationRouter);
app.route("v1", entityRouter);
app.route("v1/customers", cusRouter);
app.route("v1/products_beta", honoProductBetaRouter);
app.route("v1/products", honoProductRouter);
app.route("v1/plans", honoProductRouter);
app.route("v1/features", featureRouter);
app.route("v1", balancesRouter);
app.route("v1/platform", platformBetaRouter);
app.route("v1/platform/beta", platformBetaRouter);
app.route("v1/organization", honoOrgRouter);
app.route("/v1", apiRouter);
app.route("", internalRouter);
app.onError(errorMiddleware);

View File

@@ -1,9 +1,9 @@
import "dotenv/config";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
// Initialize OTLP trace exporter with the endpoint URL and headers

View File

@@ -3,14 +3,12 @@ import {
type ApiEntityV1,
type CheckParams,
type CustomerLegacyData,
ErrCode,
type Feature,
FeatureNotFoundError,
InternalError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { getOrCreateApiCustomer } from "../../../customers/cusUtils/getOrCreateApiCustomer.js";
import { getCachedApiEntity } from "../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js";
import type { CheckData } from "../checkTypes/CheckData.js";
@@ -88,11 +86,7 @@ export const getCheckData = async ({
});
if (!feature) {
throw new RecaseError({
message: `feature with id ${feature_id} not found`,
code: ErrCode.FeatureNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
throw new FeatureNotFoundError({ featureId: feature_id });
}
let apiEntity: ApiCustomer | ApiEntityV1 | undefined;

View File

@@ -1,70 +0,0 @@
import type {
Entity,
Feature,
FullCusEntWithFullCusProduct,
} from "@autumn/shared";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { performDeductionOnCusEnt } from "../../../trigger/updateBalanceTask.js";
import { CusEntService } from "../../customers/cusProducts/cusEnts/CusEntitlementService.js";
import { filterCusEnts } from "../../customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js";
export const deductFromAdditionalBalance = async ({
cusEnts,
entity,
feature,
toDeduct,
ctx,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
entity?: Entity;
feature: Feature;
toDeduct: number;
ctx: AutumnContext;
}) => {
const { db, features } = ctx;
const filteredCusEnts = filterCusEnts({
cusEnts,
feature,
entity,
features,
}) as FullCusEntWithFullCusProduct[];
// console.log("Deducting from additional balance");
for (let i = 0; i < filteredCusEnts.length; i++) {
const cusEnt = filteredCusEnts[i];
const {
newBalance,
newEntities,
toDeduct: newToDeduct,
} = performDeductionOnCusEnt({
cusEnt,
toDeduct,
entityId: entity?.id,
allowNegativeBalance: false,
field: "additional_balance",
});
// Update cus ent in place
filteredCusEnts[i] = {
...cusEnt,
additional_balance: newBalance,
entities: newEntities,
};
await CusEntService.update({
db,
id: cusEnt.id,
updates: {
additional_balance: newBalance,
entities: newEntities,
},
});
toDeduct = newToDeduct;
if (toDeduct === 0) break;
}
return toDeduct;
};

View File

@@ -1,109 +0,0 @@
import type {
Entity,
Feature,
FullCusEntWithFullCusProduct,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { performDeductionOnCusEnt } from "../../../trigger/updateBalanceTask.js";
import { CusEntService } from "../../customers/cusProducts/cusEnts/CusEntitlementService.js";
import { filterCusEnts } from "../../customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js";
export const deductFromAdditionalGrantedBalance = async ({
cusEnts,
entity,
feature,
toDeduct,
ctx,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
entity?: Entity;
feature: Feature;
toDeduct: number;
ctx: AutumnContext;
}) => {
const { db, features } = ctx;
const filteredCusEnts = filterCusEnts({
cusEnts,
feature,
entity,
features,
}) as FullCusEntWithFullCusProduct[];
const printLogs = false;
for (let i = 0; i < filteredCusEnts.length; i++) {
const cusEnt = filteredCusEnts[i];
if (printLogs) {
console.log("BEFORE DEDUCTION:", {
additional_granted_balance: cusEnt.additional_granted_balance,
additional_balance: cusEnt.additional_balance,
toDeduct,
});
}
const {
newBalance,
newEntities,
toDeduct: newToDeduct,
} = performDeductionOnCusEnt({
cusEnt,
toDeduct,
entityId: entity?.id,
allowNegativeBalance: true,
setZeroAdjustment: false,
blockUsageLimit: true,
field: "additional_granted_balance",
});
const toDeduct2 = new Decimal(toDeduct).minus(newToDeduct).toNumber();
// Use updated cusEnt with new additional_granted_balance
const updatedCusEnt = {
...cusEnt,
additional_granted_balance: newBalance,
entities: newEntities,
};
const { newBalance: newBalance2, newEntities: newEntities2 } =
performDeductionOnCusEnt({
cusEnt: updatedCusEnt,
toDeduct: toDeduct2,
entityId: entity?.id,
allowNegativeBalance: true,
setZeroAdjustment: false,
blockUsageLimit: true,
field: "additional_balance",
});
// Update cus ent in place
filteredCusEnts[i] = {
...updatedCusEnt,
additional_balance: newBalance2,
entities: newEntities2,
};
if (printLogs) {
console.log("FINAL UPDATE:", {
additional_granted_balance: newBalance,
additional_balance: newBalance2,
});
}
await CusEntService.update({
db,
id: cusEnt.id,
updates: {
additional_balance: newBalance2,
additional_granted_balance: newBalance,
entities: newEntities2,
},
});
toDeduct = newToDeduct;
if (toDeduct === 0) break;
}
return toDeduct;
};

View File

@@ -1,7 +1,7 @@
-- Function: Handle additional_balance fields (deduct or add)
-- Handles both customer-level and entity-level additional_balance
-- Supports both positive remaining_amount (deduct) and negative remaining_amount (add)
-- Also updates additional_granted_balance when alter_granted_balance is true
-- Also updates adjustment when alter_granted_balance is true
-- Returns deducted/added amount and updated additional_balance values
DROP FUNCTION IF EXISTS deduct_from_additional_balance(jsonb);
@@ -9,7 +9,7 @@ CREATE FUNCTION deduct_from_additional_balance(params jsonb)
RETURNS TABLE (
additional_deducted numeric,
new_additional_balance numeric,
new_additional_granted_balance numeric,
new_adjustment numeric,
new_entities jsonb
)
LANGUAGE plpgsql
@@ -17,7 +17,7 @@ AS $$
DECLARE
-- Extract parameters from JSONB
current_additional_balance numeric := COALESCE((params->>'current_additional_balance')::numeric, 0);
current_additional_granted_balance numeric := COALESCE((params->>'current_additional_granted_balance')::numeric, 0);
current_adjustment numeric := COALESCE((params->>'current_adjustment')::numeric, 0);
current_entities jsonb := COALESCE(params->'current_entities', '{}'::jsonb);
remaining_amount numeric := (params->>'remaining_amount')::numeric;
credit_cost numeric := (params->>'credit_cost')::numeric;
@@ -28,27 +28,25 @@ DECLARE
total_additional_deducted numeric := 0;
result_additional_balance numeric;
result_additional_granted_balance numeric;
result_adjustment numeric;
result_entities jsonb;
-- Variables for entity deduction
remaining numeric;
entity_key text;
entity_additional_balance numeric;
entity_additional_granted_balance numeric;
additional_deductible numeric;
entity_additional_deducted numeric;
new_entity_additional_balance numeric;
new_entity_additional_granted_balance numeric;
BEGIN
-- Initialize return values
result_additional_balance := current_additional_balance;
result_additional_granted_balance := current_additional_granted_balance;
result_adjustment := current_adjustment;
result_entities := current_entities;
-- Skip if flag is set or remaining_amount is zero
IF skip_additional_balance OR remaining_amount = 0 THEN
RETURN QUERY SELECT 0::numeric, result_additional_balance, result_additional_granted_balance, result_entities;
RETURN QUERY SELECT 0::numeric, result_additional_balance, result_adjustment, result_entities;
RETURN;
END IF;
@@ -70,9 +68,9 @@ BEGIN
IF additional_deductible > 0 THEN
-- Deduct from customer-level additional_balance
result_additional_balance := result_additional_balance - additional_deductible;
-- If alter_granted_balance is true, also deduct from additional_granted_balance
-- If alter_granted_balance is true, also deduct from adjustment
IF alter_granted_balance THEN
result_additional_granted_balance := result_additional_granted_balance - additional_deductible;
result_adjustment := result_adjustment - additional_deductible;
END IF;
-- Convert back to feature amount for tracking
total_additional_deducted := additional_deductible / credit_cost;
@@ -81,9 +79,9 @@ BEGIN
ELSE
-- Adding: subtract negative amount (which adds)
result_additional_balance := result_additional_balance - additional_deductible;
-- If alter_granted_balance is true, also add to additional_granted_balance
-- If alter_granted_balance is true, also add to adjustment
IF alter_granted_balance THEN
result_additional_granted_balance := result_additional_granted_balance - additional_deductible;
result_adjustment := result_adjustment - additional_deductible;
END IF;
-- Convert back to feature amount for tracking (will be negative)
total_additional_deducted := additional_deductible / credit_cost;
@@ -103,7 +101,6 @@ BEGIN
-- Specific Entity Mode: Handle one entity's additional_balance
-- ========================================================================
entity_additional_balance := COALESCE((result_entities->target_entity_id->>'additional_balance')::numeric, 0);
entity_additional_granted_balance := COALESCE((result_entities->target_entity_id->>'additional_granted_balance')::numeric, 0);
-- Convert feature amount to credit amount
additional_deductible := remaining_amount * credit_cost;
@@ -120,13 +117,12 @@ BEGIN
ARRAY[target_entity_id, 'additional_balance'],
to_jsonb(new_entity_additional_balance)
);
-- If alter_granted_balance is true, also deduct from entity-level additional_granted_balance
-- If alter_granted_balance is true, update adjustment field
IF alter_granted_balance THEN
new_entity_additional_granted_balance := entity_additional_granted_balance - additional_deductible;
result_entities := jsonb_set(
result_entities,
ARRAY[target_entity_id, 'additional_granted_balance'],
to_jsonb(new_entity_additional_granted_balance)
ARRAY[target_entity_id, 'adjustment'],
to_jsonb(COALESCE((result_entities->target_entity_id->>'adjustment')::numeric, 0) - additional_deductible)
);
END IF;
-- Convert back to feature amount and add to total
@@ -142,13 +138,12 @@ BEGIN
ARRAY[target_entity_id, 'additional_balance'],
to_jsonb(new_entity_additional_balance)
);
-- If alter_granted_balance is true, also add to entity-level additional_granted_balance
-- If alter_granted_balance is true, update adjustment field
IF alter_granted_balance THEN
new_entity_additional_granted_balance := entity_additional_granted_balance - additional_deductible;
result_entities := jsonb_set(
result_entities,
ARRAY[target_entity_id, 'additional_granted_balance'],
to_jsonb(new_entity_additional_granted_balance)
ARRAY[target_entity_id, 'adjustment'],
to_jsonb(COALESCE((result_entities->target_entity_id->>'adjustment')::numeric, 0) - additional_deductible)
);
END IF;
-- Convert back to feature amount (will be negative) and add to total
@@ -168,7 +163,6 @@ BEGIN
EXIT WHEN remaining = 0;
entity_additional_balance := COALESCE((result_entities->entity_key->>'additional_balance')::numeric, 0);
entity_additional_granted_balance := COALESCE((result_entities->entity_key->>'additional_granted_balance')::numeric, 0);
IF remaining > 0 THEN
-- Deducting: limit to available balance
@@ -182,13 +176,12 @@ BEGIN
ARRAY[entity_key, 'additional_balance'],
to_jsonb(new_entity_additional_balance)
);
-- If alter_granted_balance is true, also deduct from entity-level additional_granted_balance
-- If alter_granted_balance is true, update adjustment field
IF alter_granted_balance THEN
new_entity_additional_granted_balance := entity_additional_granted_balance - additional_deductible;
result_entities := jsonb_set(
result_entities,
ARRAY[entity_key, 'additional_granted_balance'],
to_jsonb(new_entity_additional_granted_balance)
ARRAY[entity_key, 'adjustment'],
to_jsonb(COALESCE((result_entities->entity_key->>'adjustment')::numeric, 0) - additional_deductible)
);
END IF;
-- Track deduction and reduce remaining
@@ -204,13 +197,12 @@ BEGIN
ARRAY[entity_key, 'additional_balance'],
to_jsonb(new_entity_additional_balance)
);
-- If alter_granted_balance is true, also add to entity-level additional_granted_balance
-- If alter_granted_balance is true, update adjustment field
IF alter_granted_balance THEN
new_entity_additional_granted_balance := entity_additional_granted_balance - remaining;
result_entities := jsonb_set(
result_entities,
ARRAY[entity_key, 'additional_granted_balance'],
to_jsonb(new_entity_additional_granted_balance)
ARRAY[entity_key, 'adjustment'],
to_jsonb(COALESCE((result_entities->entity_key->>'adjustment')::numeric, 0) - remaining)
);
END IF;
-- Track addition and reduce remaining (will become 0 after first entity)
@@ -223,7 +215,7 @@ BEGIN
END IF;
-- Return results
RETURN QUERY SELECT total_additional_deducted, result_additional_balance, result_additional_granted_balance, result_entities;
RETURN QUERY SELECT total_additional_deducted, result_additional_balance, result_adjustment, result_entities;
END;
$$;

View File

@@ -1,6 +1,5 @@
-- Helper function: Perform deduction calculation for a single entitlement
-- This handles both entity-scoped and regular balance deductions
-- Also updates additional_granted_balance when alter_granted_balance is true
DROP FUNCTION IF EXISTS deduct_from_main_balance(jsonb);
CREATE FUNCTION deduct_from_main_balance(params jsonb)
@@ -8,8 +7,7 @@ RETURNS TABLE (
deducted numeric,
new_balance numeric,
new_entities jsonb,
new_additional_granted_balance numeric,
new_adjustment numeric -- LEGACY: can be removed when no longer needed
new_adjustment numeric
)
LANGUAGE plpgsql
AS $$
@@ -17,7 +15,7 @@ DECLARE
-- Extract parameters from JSONB
current_balance numeric := (params->>'current_balance')::numeric;
current_entities jsonb := COALESCE(params->'current_entities', '{}'::jsonb);
current_additional_granted_balance numeric := COALESCE((params->>'current_additional_granted_balance')::numeric, 0);
current_adjustment numeric := COALESCE((params->>'current_adjustment')::numeric, 0);
amount_to_deduct numeric := (params->>'amount_to_deduct')::numeric;
credit_cost numeric := (params->>'credit_cost')::numeric;
allow_negative boolean := COALESCE((params->>'allow_negative')::boolean, false);
@@ -32,21 +30,18 @@ DECLARE
deducted_amount numeric := 0;
result_balance numeric;
result_entities jsonb;
result_additional_granted_balance numeric;
result_adjustment numeric := 0; -- LEGACY: can be removed when no longer needed
result_adjustment numeric;
-- Variables for entity deduction
remaining numeric;
entity_key text;
entity_balance numeric;
entity_additional_granted_balance numeric;
deduct_amount numeric;
new_balance numeric;
new_entity_additional_granted_balance numeric;
BEGIN
-- Initialize return values
result_additional_granted_balance := current_additional_granted_balance;
result_adjustment := current_adjustment;
-- ============================================================================
-- CASE 1: ENTITY-SCOPED - ALL ENTITIES (no specific entity_id)
@@ -62,7 +57,6 @@ BEGIN
EXIT WHEN remaining = 0;
entity_balance := COALESCE((result_entities->entity_key->>'balance')::numeric, 0);
entity_additional_granted_balance := COALESCE((result_entities->entity_key->>'additional_granted_balance')::numeric, 0);
-- Calculate deduction respecting allow_negative and min_balance
-- Handle negative amounts (adding credits) differently
@@ -87,16 +81,8 @@ BEGIN
to_jsonb(new_balance)
);
-- If alter_granted_balance is true, also deduct from entity-level additional_granted_balance
-- If alter_granted_balance is true, update adjustment field to track the change
IF alter_granted_balance THEN
new_entity_additional_granted_balance := entity_additional_granted_balance - deduct_amount;
result_entities := jsonb_set(
result_entities,
ARRAY[entity_key, 'additional_granted_balance'],
to_jsonb(new_entity_additional_granted_balance)
);
-- LEGACY: Update adjustment field to track the change (can be removed when no longer needed)
result_entities := jsonb_set(
result_entities,
ARRAY[entity_key, 'adjustment'],
@@ -116,7 +102,6 @@ BEGIN
-- ============================================================================
ELSIF has_entity_scope AND target_entity_id IS NOT NULL THEN
entity_balance := COALESCE((current_entities->target_entity_id->>'balance')::numeric, 0);
entity_additional_granted_balance := COALESCE((current_entities->target_entity_id->>'additional_granted_balance')::numeric, 0);
-- Calculate deduction respecting allow_negative and min_balance
-- Handle negative amounts (adding credits) differently
@@ -141,16 +126,8 @@ BEGIN
to_jsonb(new_balance)
);
-- If alter_granted_balance is true, also deduct from entity-level additional_granted_balance
-- If alter_granted_balance is true, update adjustment field to track the change
IF alter_granted_balance THEN
new_entity_additional_granted_balance := entity_additional_granted_balance - deducted_amount;
result_entities := jsonb_set(
result_entities,
ARRAY[target_entity_id, 'additional_granted_balance'],
to_jsonb(new_entity_additional_granted_balance)
);
-- LEGACY: Update adjustment field to track the change (can be removed when no longer needed)
result_entities := jsonb_set(
result_entities,
ARRAY[target_entity_id, 'adjustment'],
@@ -187,16 +164,14 @@ BEGIN
result_balance := current_balance - deducted_amount;
result_entities := current_entities; -- Entities unchanged for non-entity-scoped
-- If alter_granted_balance is true, also deduct from customer-level additional_granted_balance
-- If alter_granted_balance is true, update adjustment field to track the change
IF alter_granted_balance THEN
result_additional_granted_balance := result_additional_granted_balance - deducted_amount;
-- LEGACY: Update adjustment field to track the change (can be removed when no longer needed)
result_adjustment := result_adjustment - deducted_amount;
END IF;
END IF;
-- Return results
RETURN QUERY SELECT deducted_amount, result_balance, result_entities, result_additional_granted_balance, result_adjustment;
RETURN QUERY SELECT deducted_amount, result_balance, result_entities, result_adjustment;
END;
$$;

View File

@@ -25,6 +25,8 @@ DECLARE
END;
skip_additional_balance boolean := COALESCE((params->>'skip_additional_balance')::boolean, false);
alter_granted_balance boolean := COALESCE((params->>'alter_granted_balance')::boolean, false);
overage_behaviour text := NULLIF(params->>'overage_behaviour', '');
feature_id text := NULLIF(params->>'feature_id', '');
remaining_amount numeric;
rollover_deducted numeric := 0;
@@ -39,7 +41,7 @@ DECLARE
-- Current state from DB
current_balance numeric;
current_additional_balance numeric;
current_additional_granted_balance numeric;
current_adjustment numeric;
current_entities jsonb;
-- Balance update (alter_granted) variables
@@ -49,13 +51,12 @@ DECLARE
-- Additional balance deduction
additional_deducted numeric;
new_additional_balance numeric;
new_additional_granted_balance numeric;
-- Results from deduction helper
deducted numeric;
new_balance numeric;
new_entities jsonb;
new_adjustment numeric; -- LEGACY: can be removed when no longer needed
new_adjustment numeric;
-- Tracking
updates_json jsonb := '{}'::jsonb;
result_json jsonb;
@@ -127,21 +128,21 @@ BEGIN
SELECT
ce.balance,
COALESCE(ce.additional_balance, 0),
COALESCE(ce.additional_granted_balance, 0),
COALESCE(ce.adjustment, 0),
COALESCE(ce.entities, '{}'::jsonb)
INTO
current_balance,
current_additional_balance,
current_additional_granted_balance,
current_adjustment,
current_entities
FROM customer_entitlements ce
WHERE ce.id = ent_id;
-- STEP 2: Deduct from additional_balance (customer-level and entity-level)
SELECT * INTO additional_deducted, new_additional_balance, new_additional_granted_balance, current_entities
SELECT * INTO additional_deducted, new_additional_balance, new_adjustment, current_entities
FROM deduct_from_additional_balance(jsonb_build_object(
'current_additional_balance', current_additional_balance,
'current_additional_granted_balance', current_additional_granted_balance,
'current_adjustment', current_adjustment,
'current_entities', current_entities,
'remaining_amount', remaining_amount,
'credit_cost', credit_cost,
@@ -155,11 +156,11 @@ BEGIN
remaining_amount := remaining_amount - additional_deducted;
-- STEP 3: Perform deduction from main balance (Pass 1: allow_negative = false)
SELECT * INTO deducted, new_balance, new_entities, new_additional_granted_balance, new_adjustment
SELECT * INTO deducted, new_balance, new_entities, new_adjustment
FROM deduct_from_main_balance(jsonb_build_object(
'current_balance', current_balance,
'current_entities', current_entities,
'current_additional_granted_balance', new_additional_granted_balance,
'current_adjustment', new_adjustment,
'amount_to_deduct', remaining_amount,
'credit_cost', credit_cost,
'allow_negative', false,
@@ -176,9 +177,8 @@ BEGIN
SET
balance = new_balance,
additional_balance = new_additional_balance,
additional_granted_balance = new_additional_granted_balance,
entities = new_entities,
adjustment = COALESCE(adjustment, 0) + new_adjustment -- LEGACY: can be removed when no longer needed
adjustment = new_adjustment
WHERE ce.id = ent_id;
ELSE
-- Don't update entities for non-entity-scoped entitlements (keep NULL)
@@ -186,8 +186,7 @@ BEGIN
SET
balance = new_balance,
additional_balance = new_additional_balance,
additional_granted_balance = new_additional_granted_balance,
adjustment = COALESCE(adjustment, 0) + new_adjustment -- LEGACY: can be removed when no longer needed
adjustment = new_adjustment
WHERE ce.id = ent_id;
END IF;
@@ -198,7 +197,7 @@ BEGIN
jsonb_build_object(
'balance', new_balance,
'additional_balance', new_additional_balance,
'additional_granted_balance', new_additional_granted_balance,
'adjustment', new_adjustment,
'entities', new_entities,
'deducted', deducted + additional_deducted,
'additional_deducted', additional_deducted
@@ -233,12 +232,12 @@ BEGIN
SELECT
ce.balance,
COALESCE(ce.additional_balance, 0),
COALESCE(ce.additional_granted_balance, 0),
COALESCE(ce.adjustment, 0),
COALESCE(ce.entities, '{}'::jsonb)
INTO
current_balance,
current_additional_balance,
current_additional_granted_balance,
current_adjustment,
current_entities
FROM customer_entitlements ce
WHERE ce.id = ent_id;
@@ -247,11 +246,11 @@ BEGIN
new_additional_balance := current_additional_balance;
-- Perform deduction (Pass 2: allow_negative = true)
SELECT * INTO deducted, new_balance, new_entities, new_additional_granted_balance, new_adjustment
SELECT * INTO deducted, new_balance, new_entities, new_adjustment
FROM deduct_from_main_balance(jsonb_build_object(
'current_balance', current_balance,
'current_entities', current_entities,
'current_additional_granted_balance', current_additional_granted_balance,
'current_adjustment', current_adjustment,
'amount_to_deduct', remaining_amount,
'credit_cost', credit_cost,
'allow_negative', true,
@@ -268,9 +267,8 @@ BEGIN
SET
balance = new_balance,
additional_balance = new_additional_balance,
additional_granted_balance = new_additional_granted_balance,
entities = new_entities,
adjustment = COALESCE(adjustment, 0) + new_adjustment -- LEGACY: can be removed when no longer needed
adjustment = new_adjustment
WHERE ce.id = ent_id;
ELSE
-- Don't update entities for non-entity-scoped entitlements (keep NULL)
@@ -278,8 +276,7 @@ BEGIN
SET
balance = new_balance,
additional_balance = new_additional_balance,
additional_granted_balance = new_additional_granted_balance,
adjustment = COALESCE(adjustment, 0) + new_adjustment -- LEGACY: can be removed when no longer needed
adjustment = new_adjustment
WHERE ce.id = ent_id;
END IF;
@@ -293,7 +290,7 @@ BEGIN
jsonb_build_object(
'balance', new_balance,
'additional_balance', new_additional_balance,
'additional_granted_balance', new_additional_granted_balance,
'adjustment', new_adjustment,
'entities', new_entities,
'deducted', (updates_json->ent_id->>'deducted')::numeric + deducted,
'additional_deducted', COALESCE((updates_json->ent_id->>'additional_deducted')::numeric, 0)
@@ -307,7 +304,7 @@ BEGIN
jsonb_build_object(
'balance', new_balance,
'additional_balance', new_additional_balance,
'additional_granted_balance', new_additional_granted_balance,
'adjustment', new_adjustment,
'entities', new_entities,
'deducted', deducted,
'additional_deducted', 0
@@ -320,6 +317,14 @@ BEGIN
END LOOP;
END IF;
-- Check overage behaviour
IF remaining_amount > 0 AND overage_behaviour = 'reject' THEN
RAISE EXCEPTION 'INSUFFICIENT_BALANCE|featureId:%|value:%|remaining:%',
feature_id,
COALESCE(amount_to_deduct::text, '0'),
remaining_amount;
END IF;
-- Build final result
result_json := jsonb_build_object(
'updates', updates_json,

View File

@@ -1,10 +1,39 @@
import type { ApiBalance, TrackParams, TrackResponseV2 } from "@autumn/shared";
import { InsufficientBalanceError } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { getApiCustomerBase } from "../../../customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
import { getOrCreateCustomer } from "../../../customers/cusUtils/getOrCreateCustomer.js";
import type { FeatureDeduction } from "./getFeatureDeductions.js";
import { runDeductionTx } from "./runDeductionTx.js";
const catchInsufficientBalanceError = ({
error,
body,
}: {
error: any;
body: TrackParams;
}) => {
// Check if it's an insufficient balance error from PostgreSQL
if (error.message?.includes("INSUFFICIENT_BALANCE")) {
// Parse the error message: INSUFFICIENT_BALANCE|featureId:{id}|value:{amount}|remaining:{remaining}
const parts = error.message.split("|");
const featureIdMatch = parts[1]?.match(/featureId:(.*)/);
const valueMatch = parts[2]?.match(/value:(.*)/);
const featureId = featureIdMatch?.[1] || body.feature_id;
const value = valueMatch?.[1]
? Number.parseFloat(valueMatch[1])
: (body.value ?? 1);
throw new InsufficientBalanceError({
value,
featureId,
});
}
throw error;
};
/**
* Execute PostgreSQL-based tracking with full transaction support
*/
@@ -37,23 +66,32 @@ export const executePostgresTracking = async ({
withEntities: true,
});
const { fullCus: updatedFullCus, actualDeductions } = await runDeductionTx({
ctx,
customerId: body.customer_id,
entityId: body.entity_id,
deductions: featureDeductions,
overageBehaviour: body.overage_behavior,
eventInfo: {
event_name: body.feature_id || body.event_name || "",
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
refreshCache: true,
fullCus,
skipAdditionalBalance: true,
});
let updatedFullCus;
let actualDeductions;
try {
const result = await runDeductionTx({
ctx,
customerId: body.customer_id,
entityId: body.entity_id,
deductions: featureDeductions,
overageBehaviour: body.overage_behavior,
eventInfo: {
event_name: body.feature_id || body.event_name || "",
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
refreshCache: true,
fullCus,
skipAdditionalBalance: true,
});
updatedFullCus = result.fullCus;
actualDeductions = result.actualDeductions;
} catch (error: any) {
catchInsufficientBalanceError({ error, body });
}
if (updatedFullCus) {
const { apiCustomer } = await getApiCustomerBase({
@@ -62,7 +100,7 @@ export const executePostgresTracking = async ({
});
const balancesRes: Record<string, ApiBalance> = {};
for (const featureId of Object.keys(actualDeductions)) {
for (const featureId of Object.keys(actualDeductions ?? {})) {
balancesRes[featureId] = apiCustomer.balances[featureId];
}

View File

@@ -0,0 +1,82 @@
import {
cusProductsToCusPrices,
type FullCusEntWithFullCusProduct,
type FullCustomer,
type PgDeductionUpdate,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { adjustAllowance } from "../../../../trigger/adjustAllowance";
import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService";
import { getTotalNegativeBalance } from "../../../customers/cusProducts/cusEnts/cusEntUtils";
export const handlePaidAllocatedCusEnt = async ({
ctx,
cusEnt,
fullCus,
updates,
}: {
ctx: AutumnContext;
cusEnt: FullCusEntWithFullCusProduct;
fullCus: FullCustomer;
updates: Record<string, PgDeductionUpdate>;
}) => {
const { db, env, org } = ctx;
const update = updates[cusEnt.id];
const cusPrices = cusProductsToCusPrices({
cusProducts: fullCus.customer_products,
});
// Calculate original negative balance
const originalGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: cusEnt.balance!,
entities: cusEnt.entities!,
});
// Calculate new negative balance from updates
const newGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: update.balance,
entities: update.entities,
});
const { newReplaceables, deletedReplaceables } = await adjustAllowance({
db,
env,
org,
cusPrices: cusPrices as any,
customer: fullCus,
affectedFeature: cusEnt.entitlement.feature,
cusEnt: cusEnt as any,
originalBalance: originalGrpBalance,
newBalance: newGrpBalance,
logger: ctx.logger,
});
// Adjust balance based on replaceables
let reUpdatedBalance = update.balance;
if (newReplaceables && newReplaceables.length > 0) {
reUpdatedBalance = reUpdatedBalance - newReplaceables.length;
} else if (deletedReplaceables && deletedReplaceables.length > 0) {
reUpdatedBalance = reUpdatedBalance + deletedReplaceables.length;
}
if (reUpdatedBalance !== update.balance) {
await CusEntService.update({
db,
id: cusEnt.id,
updates: {
balance: reUpdatedBalance,
},
});
// Update the updates object with the new balance
const cusEntId = cusEnt.id;
updates[cusEntId].balance = reUpdatedBalance;
updates[cusEntId].newReplaceables = newReplaceables ?? undefined;
updates[cusEntId].deletedReplaceables = deletedReplaceables ?? undefined;
}
};

View File

@@ -0,0 +1,65 @@
import {
cusProductsToCusEnts,
type FullCustomer,
type PgDeductionUpdate,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService";
export const rollbackDeduction = async ({
ctx,
oldFullCus,
updates,
}: {
ctx: AutumnContext;
oldFullCus: FullCustomer;
updates: Record<string, PgDeductionUpdate>;
}) => {
const { db, logger } = ctx;
logger.warn(
`[ROLLBACK] Starting rollback for ${Object.keys(updates).length} entitlements`,
);
const cusEnts = cusProductsToCusEnts({
cusProducts: oldFullCus.customer_products,
});
// For each updated entitlement, restore to original state from oldFullCus
for (const cusEntId of Object.keys(updates)) {
try {
// Find the original cusEnt from oldFullCus
const originalCusEnt = cusEnts.find((ce) => ce.id === cusEntId);
if (!originalCusEnt) {
logger.error(
`[ROLLBACK] Could not find original cusEnt ${cusEntId} in oldFullCus`,
);
continue;
}
// Restore the entitlement to original values
await CusEntService.update({
db,
id: cusEntId,
updates: {
balance: originalCusEnt.balance,
additional_balance: originalCusEnt.additional_balance,
adjustment: originalCusEnt.adjustment,
entities: originalCusEnt.entities,
},
});
logger.info(
`[ROLLBACK] Successfully restored cusEnt ${cusEntId} to original state`,
);
} catch (error) {
// Best effort - log error but continue with other rollbacks
logger.error(
`[ROLLBACK] Failed to rollback cusEnt ${cusEntId}: ${error}`,
);
}
}
logger.warn("[ROLLBACK] Rollback completed");
};

View File

@@ -7,12 +7,10 @@ import {
CusProductStatus,
cusEntToCusPrice,
cusProductsToCusEnts,
cusProductsToCusPrices,
FeatureUsageType,
type FullCustomer,
getMaxOverage,
getRelevantFeatures,
InsufficientBalanceError,
InternalError,
notNullish,
nullish,
@@ -21,22 +19,17 @@ import {
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { sql } from "drizzle-orm";
import type { DrizzleCli } from "../../../../db/initDrizzle.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { adjustAllowance } from "../../../../trigger/adjustAllowance.js";
import { EventService } from "../../../api/events/EventService.js";
import { CusService } from "../../../customers/CusService.js";
import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
getTotalNegativeBalance,
getUnlimitedAndUsageAllowed,
} from "../../../customers/cusProducts/cusEnts/cusEntUtils.js";
import { getUnlimitedAndUsageAllowed } from "../../../customers/cusProducts/cusEnts/cusEntUtils.js";
import { deleteCachedApiCustomer } from "../../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
import { getCreditCost } from "../../../features/creditSystemUtils.js";
import { isPaidContinuousUse } from "../../../features/featureUtils.js";
import { deductFromCache } from "../redisTrackUtils/deductFromCache.js";
import { constructEvent, type EventInfo } from "./eventUtils.js";
import type { FeatureDeduction } from "./getFeatureDeductions.js";
import { handlePaidAllocatedCusEnt } from "./handlePaidAllocatedCusEnt.js";
import { rollbackDeduction } from "./rollbackDeduction.js";
export type DeductionTxParams = {
ctx: AutumnContext;
@@ -90,16 +83,6 @@ export const deductFromCusEnts = async ({
const printLogs = false;
// if (printLogs) {
// console.log(
// `Deductions: `,
// deductions.map((d) => ({
// feature_id: d.feature.id,
// deduction: d.deduction,
// })),
// );
// }
const isPaidAllocated = deductions.some((d) =>
isPaidContinuousUse({
feature: d.feature,
@@ -169,7 +152,9 @@ export const deductFromCusEnts = async ({
customer_entitlement_id: ce.id,
credit_cost: creditCost,
entity_feature_id: ce.entitlement.entity_feature_id,
usage_allowed: ce.usage_allowed || isFreeAllocated,
usage_allowed:
ce.usage_allowed ||
(isFreeAllocated && overageBehaviour !== "reject"),
min_balance: notNullish(maxOverage) ? -maxOverage : undefined,
add_to_adjustment: addToAdjustment,
};
@@ -202,6 +187,8 @@ export const deductFromCusEnts = async ({
cus_ent_ids: cusEntIds.length > 0 ? cusEntIds : null,
skip_additional_balance: skipAdditionalBalance,
alter_granted_balance: alterGrantedBalance,
overage_behaviour: overageBehaviour ?? "cap",
feature_id: feature.id,
})}::jsonb
)`,
);
@@ -225,13 +212,6 @@ export const deductFromCusEnts = async ({
const { updates, remaining: featureRemaining } = resultJson;
if (featureRemaining > 0 && overageBehaviour === "reject") {
throw new InsufficientBalanceError({
value: toDeduct ?? 1,
featureId: feature.id,
});
}
// Track the maximum remaining amount across all deductions
remainingAmounts[feature.id] = featureRemaining;
@@ -274,73 +254,34 @@ export const deductFromCusEnts = async ({
}
// Bill on Stripe for each updated entitlement
const cusPrices = cusProductsToCusPrices({
cusProducts: fullCus.customer_products,
});
for (const cusEntId of Object.keys(updates)) {
const update = updates[cusEntId];
const cusEnt = cusEnts.find((ce) => ce.id === cusEntId);
try {
for (const cusEntId of Object.keys(updates)) {
const update = updates[cusEntId];
const cusEnt = cusEnts.find((ce) => ce.id === cusEntId);
if (!cusEnt) continue;
if (!cusEnt) continue;
// Calculate original negative balance
const originalGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: cusEnt.balance!,
entities: cusEnt.entities!,
});
// Calculate new negative balance from updates
const newGrpBalance = getTotalNegativeBalance({
cusEnt,
balance: update.balance,
entities: update.entities,
});
const { newReplaceables, deletedReplaceables } = await adjustAllowance({
db,
env,
org,
cusPrices: cusPrices as any,
customer: fullCus,
affectedFeature: feature,
cusEnt: cusEnt as any,
originalBalance: originalGrpBalance,
newBalance: newGrpBalance,
logger: ctx.logger,
});
// Adjust balance based on replaceables
let reUpdatedBalance = update.balance;
if (newReplaceables && newReplaceables.length > 0) {
reUpdatedBalance = reUpdatedBalance - newReplaceables.length;
} else if (deletedReplaceables && deletedReplaceables.length > 0) {
reUpdatedBalance = reUpdatedBalance + deletedReplaceables.length;
}
if (reUpdatedBalance !== update.balance) {
await CusEntService.update({
db,
id: cusEntId,
updates: {
balance: reUpdatedBalance,
},
await handlePaidAllocatedCusEnt({
ctx,
cusEnt,
fullCus,
updates,
});
// Update the updates object with the new balance
updates[cusEntId].balance = reUpdatedBalance;
updates[cusEntId].newReplaceables = newReplaceables ?? undefined;
updates[cusEntId].deletedReplaceables =
deletedReplaceables ?? undefined;
updateCusEntInFullCus({
fullCus,
cusEntId,
update,
});
}
updateCusEntInFullCus({
fullCus,
cusEntId,
update,
} catch (error) {
await rollbackDeduction({
ctx,
oldFullCus,
updates,
});
throw error;
}
}
@@ -372,78 +313,62 @@ export const runDeductionTx = async (
let event: Event | undefined;
let actualDeductions: Record<string, number> = {};
const result = await db.transaction(
async (tx) => {
// Set statement timeout to cancel query at database level if it takes too long
// This will automatically rollback the transaction and free the connection
await tx.execute(sql`SET LOCAL statement_timeout = '1000ms'`);
const result = await deductFromCusEnts(params);
fullCus = result.fullCus;
actualDeductions = result.actualDeductions;
// Pass tx as the db connection
const txParams = {
...params,
ctx: {
...ctx,
db: tx as unknown as typeof db,
},
};
if (!fullCus) {
return {
fullCus,
event,
actualDeductions,
};
}
const result = await deductFromCusEnts(txParams);
fullCus = result.fullCus;
actualDeductions = result.actualDeductions;
if (params.eventInfo) {
const newEvent = constructEvent({
ctx,
eventInfo: params.eventInfo,
internalCustomerId: fullCus.internal_id,
internalEntityId: fullCus.entity?.internal_id,
customerId: fullCus.id ?? "",
entityId: fullCus.entity?.id,
});
if (!fullCus) return;
if (params.eventInfo) {
const newEvent = constructEvent({
ctx: txParams.ctx,
eventInfo: params.eventInfo,
internalCustomerId: fullCus.internal_id,
internalEntityId: fullCus.entity?.internal_id,
customerId: fullCus.id ?? "",
entityId: fullCus.entity?.id,
});
event = await EventService.insert({
db: tx as unknown as DrizzleCli,
event: newEvent,
});
}
return result;
},
{
isolationLevel: "read committed",
},
);
event = await EventService.insert({
db,
event: newEvent,
});
}
if (params?.refreshCache && fullCus) {
// 1. If paid allocated, delete cache
if (result?.isPaidAllocated) {
await deleteCachedApiCustomer({
customerId: fullCus.id ?? "",
orgId: ctx.org.id,
env: ctx.env,
});
} else {
for (const [featureId, deductedAmount] of Object.entries(
actualDeductions,
)) {
if (deductedAmount !== 0) {
// Only deduct if something was actually deducted
// console.log(
// `Deducting from Redis cache: ${featureId}, ${deductedAmount}`,
// );
await deductFromCache({
ctx,
customerId: fullCus.id ?? "",
featureId,
amount: deductedAmount,
entityId: params.entityId,
});
}
}
// Log summary comparison
}
await deleteCachedApiCustomer({
customerId: fullCus.id ?? "",
orgId: ctx.org.id,
env: ctx.env,
});
// // 1. If paid allocated, delete cache
// if (result?.isPaidAllocated) {
// await deleteCachedApiCustomer({
// customerId: fullCus.id ?? "",
// orgId: ctx.org.id,
// env: ctx.env,
// });
// } else {
// for (const [featureId, deductedAmount] of Object.entries(
// actualDeductions,
// )) {
// if (deductedAmount !== 0) {
// await deductFromCache({
// ctx,
// customerId: fullCus.id ?? "",
// featureId,
// amount: deductedAmount,
// entityId: params.entityId,
// });
// }
// }
// }
}
return {

View File

@@ -41,11 +41,7 @@ export const initCusEntEntities = ({
for (const entity of entities) {
if (!entitlementLinkedToEntity({ entitlement, entity })) continue;
if (
existingCusEnt &&
existingCusEnt.entities &&
existingCusEnt.entities[entity.id]
) {
if (existingCusEnt?.entities?.[entity.id]) {
continue;
}
@@ -58,7 +54,6 @@ export const initCusEntEntities = ({
balance: resetBalance || 0,
adjustment: 0,
additional_balance: 0,
additional_granted_balance: 0,
};
}

View File

@@ -20,15 +20,13 @@ export const getResetBalancesUpdate = ({
for (const entityId in newEntities) {
newEntities[entityId].balance = newBalance;
newEntities[entityId].adjustment = 0;
newEntities[entityId].additional_balance = 0;
newEntities[entityId].additional_granted_balance = 0;
}
update = { entities: newEntities };
} else {
update = {
balance: newBalance,
additional_balance: 0,
additional_granted_balance: 0,
adjustment: 0,
};
}

View File

@@ -167,13 +167,6 @@ export const getApiBalance = ({
};
}
const totalAdjustment = sumValues(
cusEnts.map((cusEnt) => {
const { adjustment } = getCusEntBalance({ cusEnt, entityId });
return adjustment;
}),
);
const totalUnused = sumValues(
cusEnts.map((cusEnt) => {
const { unused } = getCusEntBalance({ cusEnt, entityId });
@@ -197,18 +190,18 @@ export const getApiBalance = ({
),
);
const totalAdditionalGrantedBalance = sumValues(
const totalAdjustment = sumValues(
cusEnts.map((cusEnt) => {
const { additional_granted_balance } = getCusEntBalance({
const { adjustment } = getCusEntBalance({
cusEnt,
entityId,
});
return additional_granted_balance;
return adjustment;
}),
);
const grantedBalance = new Decimal(totalGrantedBalanceWithRollovers)
.add(totalAdditionalGrantedBalance)
.add(totalAdjustment)
.toNumber();
// 2. Purchased balance
@@ -285,7 +278,6 @@ export const getApiBalance = ({
data: apiBalance,
legacyData: {
prepaid_quantity: totalPrepaidQuantity,
total_adjustment: totalAdjustment,
},
};
};

View File

@@ -1,3 +1,5 @@
import * as Sentry from "@sentry/bun";
const handleResFinish = (req: any, res: any) => {
const skipUrls = ["/v1/customers/all/search"];
@@ -51,6 +53,11 @@ export const analyticsMiddleware = async (req: any, res: any, next: any) => {
user_id: req.userId || null,
};
Sentry.setUser({
...reqContext,
body: undefined,
});
if (req.span) {
req.span.setAttributes({
org_id: req.org?.id,

View File

@@ -1,8 +1,13 @@
await import("../sentry.js");
import { AuthType } from "@autumn/shared";
import {
DeleteMessageCommand,
type Message,
ReceiveMessageCommand,
} from "@aws-sdk/client-sqs";
import * as Sentry from "@sentry/bun";
import type { Logger } from "pino";
import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js";
import { logger } from "@/external/logtail/logtailUtils.js";
@@ -94,6 +99,17 @@ const processMessage = async ({
logger: workerLogger,
});
if (ctx) {
Sentry.setUser({
org_id: ctx.org?.id,
org_slug: ctx.org?.slug,
env: ctx.env,
authType: AuthType.Worker,
jobName: job.name,
messageId: message.MessageId,
});
}
if (actionHandlers.includes(job.name as JobName)) {
// Note: action handlers need BullMQ queue for nested jobs
// This will need to be refactored when migrating action handlers to SQS
@@ -139,16 +155,14 @@ const processMessage = async ({
});
}
} catch (error: any) {
workerLogger.error(
`Failed to process SQS job: ${job.name}, error: ${error}`,
{
jobName: job.name,
error: {
message: error.message,
stack: error.stack,
},
Sentry.captureException(error);
workerLogger.error(`Failed to process SQS job: ${job.name}`, {
jobName: job.name,
error: {
message: error.message,
stack: error.stack,
},
);
});
// Don't delete the message on error - it will become visible again for retry
throw error;
}

View File

@@ -0,0 +1,47 @@
import { Hono } from "hono";
import { analyticsMiddleware } from "../honoMiddlewares/analyticsMiddleware.js";
import { apiVersionMiddleware } from "../honoMiddlewares/apiVersionMiddleware.js";
import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware.js";
import { queryMiddleware } from "../honoMiddlewares/queryMiddleware.js";
import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js";
import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js";
import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware.js";
import type { HonoEnv } from "../honoUtils/HonoEnv.js";
import { balancesRouter } from "../internal/balances/balancesRouter.js";
import { billingRouter } from "../internal/billing/billingRouter.js";
import { cusRouter } from "../internal/customers/cusRouter.js";
import { entityRouter } from "../internal/entities/entityRouter.js";
import { featureRouter } from "../internal/features/featureRouter.js";
import { honoOrgRouter } from "../internal/orgs/orgRouter.js";
import { platformBetaRouter } from "../internal/platform/platformBeta/platformBetaRouter.js";
import {
honoProductBetaRouter,
honoProductRouter,
migrationRouter,
} from "../internal/products/productRouter.js";
export const apiRouter = new Hono<HonoEnv>();
apiRouter.use("*", secretKeyMiddleware);
apiRouter.use("*", orgConfigMiddleware);
apiRouter.use("*", apiVersionMiddleware);
apiRouter.use("*", analyticsMiddleware);
apiRouter.use("*", rateLimitMiddleware);
apiRouter.use("*", refreshCacheMiddleware);
apiRouter.use("*", queryMiddleware());
apiRouter.route("", billingRouter);
apiRouter.route("", balancesRouter);
apiRouter.route("", migrationRouter);
apiRouter.route("", entityRouter);
apiRouter.route("/customers", cusRouter);
apiRouter.route("/products_beta", honoProductBetaRouter);
apiRouter.route("/products", honoProductRouter);
apiRouter.route("/plans", honoProductRouter);
apiRouter.route("/features", featureRouter);
apiRouter.route("", balancesRouter);
apiRouter.route("/platform", platformBetaRouter);
apiRouter.route("/platform/beta", platformBetaRouter);
apiRouter.route("/organization", honoOrgRouter);

8
server/src/sentry.ts Normal file
View File

@@ -0,0 +1,8 @@
import * as Sentry from "@sentry/bun";
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
sendDefaultPii: true,
});
}

View File

@@ -207,7 +207,7 @@ export const performDeductionOnCusEnt = ({
addAdjustment?: boolean;
setZeroAdjustment?: boolean;
blockUsageLimit?: boolean;
field?: "balance" | "additional_balance" | "additional_granted_balance";
field?: "balance" | "additional_balance";
}): {
newBalance: number;
newEntities: Record<string, EntityBalance> | undefined;

View File

@@ -1,8 +1,6 @@
import {
AllowanceType,
ApiVersionClass,
type AppEnv,
AuthType,
CusProductStatus,
type Customer,
cusEntToIncludedUsage,
@@ -14,7 +12,6 @@ import {
FeatureUsageType,
type FullCusEntWithFullCusProduct,
type FullCustomerEntitlement,
LATEST_VERSION,
type Organization,
sumValues,
} from "@autumn/shared";
@@ -28,8 +25,6 @@ import { deductFromApiCusRollovers } from "@/internal/customers/cusProducts/cusE
import { getCusEntsInFeatures } from "@/internal/customers/cusUtils/cusUtils.js";
import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { deductFromAdditionalBalance } from "../internal/balances/deductUtils/deductFromAdditionalBalance.js";
import { generateId } from "../utils/genUtils.js";
import {
deductAllowanceFromCusEnt,
@@ -456,30 +451,6 @@ export const updateUsage = async ({
if (toDeduct === 0) return;
// 2. Deduct from additional balance
toDeduct = await deductFromAdditionalBalance({
toDeduct,
cusEnts,
feature,
entity: customer.entity,
ctx: {
db,
features,
org,
env,
logger,
id: generateId("test"),
isPublic: false,
authType: AuthType.SecretKey,
apiVersion: new ApiVersionClass(LATEST_VERSION),
timestamp: Date.now(),
expand: [],
skipCache: true,
},
});
if (toDeduct === 0) return;
// 3. Deduct from allowance
for (const cusEnt of cusEnts) {
toDeduct = await deductAllowanceFromCusEnt({

View File

@@ -1,4 +1,5 @@
import { ErrCode } from "@autumn/shared";
import * as Sentry from "@sentry/bun";
import chalk from "chalk";
import { StatusCodes } from "http-status-codes";
import Stripe from "stripe";
@@ -59,6 +60,7 @@ export const handleRequestError = ({
action: string;
}) => {
try {
Sentry.captureException(error);
const logger = req.logger;
if (error instanceof RecaseError) {
logger.warn(

View File

@@ -10,6 +10,7 @@ console.warn = (...args: any[]) => {
import "dotenv/config";
import cluster from "node:cluster";
import { initInfisical } from "./external/infisical/initInfisical.js";
// Number of worker processes (defaults to CPU cores)

View File

@@ -24,11 +24,20 @@ const pro = constructProduct({
featureId: TestFeature.Users,
includedUsage: 5,
}),
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 300,
}),
constructArrearProratedItem({
featureId: TestFeature.Workflows,
includedUsage: 0,
pricePerUnit: 10,
}),
// constructFeatureItem({
// featureId: TestFeature.Words,
// includedUsage: 100,
// entityFeatureId: TestFeature.Users,
// }) as LimitedItem,
],
});
@@ -58,6 +67,19 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => {
customer_id: customerId,
product_id: pro.id,
});
await autumn.entities.create(customerId, [
{
id: "1",
name: "Entity 1",
feature_id: TestFeature.Users,
},
{
id: "2",
name: "Entity 2",
feature_id: TestFeature.Users,
},
]);
});
return;

View File

@@ -0,0 +1,87 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, ErrCode } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectAutumnError } from "../../../utils/expectUtils/expectErrUtils";
const testCase = "track-allocated6";
const customerId = testCase;
const pro = constructProduct({
type: "free",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 5,
}),
],
});
describe(`${chalk.yellowBright(`${testCase}: Tracking allocated feature with overage behavior reject`)}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
// Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
});
test("should throw insufficient balance error when overage behavior is reject", async () => {
await expectAutumnError({
errCode: ErrCode.InsufficientBalance,
func: async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 6,
overage_behavior: "reject",
});
},
});
const curBalance = await autumnV1.customers.get(customerId);
expect(curBalance.features[TestFeature.Users].balance).toBe(5);
});
test("should throw insufficient balance error when overage behavior is reject and skip_cache is true", async () => {
await expectAutumnError({
errCode: ErrCode.InsufficientBalance,
func: async () => {
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Users,
value: 6,
overage_behavior: "reject",
},
{
skipCache: true,
},
);
},
});
const curBalance = await autumnV1.customers.get(customerId);
expect(curBalance.features[TestFeature.Users].balance).toBe(5);
});
});

View File

@@ -0,0 +1,84 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { attachFailedPaymentMethod } from "../../../../src/external/stripe/stripeCusUtils";
import { CusService } from "../../../../src/internal/customers/CusService";
import { expectAutumnError } from "../../../utils/expectUtils/expectErrUtils";
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 50,
includedUsage: 1,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
export const pro = constructProduct({
items: [userItem],
type: "pro",
});
const testCase = "track-paid-allocated6";
describe(`${chalk.yellowBright(`${testCase}: Testing track usage for cont use, bill immediately but payment fails`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
beforeAll(async () => {
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
customerId,
});
await initCustomerV3({
ctx,
customerId,
customerData: {},
attachPm: "success",
withTestClock: true,
});
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
const cus = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
await attachFailedPaymentMethod({
stripeCli: ctx.stripeCli,
customer: cus!,
});
});
test("should create track +3 usage and hit an error", async () => {
await expectAutumnError({
func: async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 3,
});
},
});
const customer = await autumn.customers.get(customerId);
expect(customer.features[TestFeature.Users].balance).toBe(1);
});
});

View File

@@ -2,7 +2,6 @@ import { z } from "zod/v4";
export const CusFeatureLegacyDataSchema = z.object({
prepaid_quantity: z.number(),
total_adjustment: z.number(),
});
export type CusFeatureLegacyData = z.infer<typeof CusFeatureLegacyDataSchema>;

View File

@@ -13,7 +13,6 @@ export const EntityBalanceSchema = z.object({
adjustment: z.number(),
additional_balance: z.number().optional(),
additional_granted_balance: z.number().optional(),
});
export const CustomerEntitlementSchema = z.object({
@@ -33,7 +32,6 @@ export const CustomerEntitlementSchema = z.object({
balance: z.number().nullish().default(0),
additional_balance: z.number().default(0),
additional_granted_balance: z.number().default(0),
usage_allowed: z.boolean().nullable(),
next_reset_at: z.number().nullable(),

View File

@@ -33,9 +33,6 @@ export const customerEntitlements = pgTable(
// New field, free_balance: how much balance can be deducted
additional_balance: numeric({ mode: "number" }).notNull().default(0),
additional_granted_balance: numeric({ mode: "number" })
.notNull()
.default(0),
// Need to work on free balance...
entities: jsonb("entities").$type<Record<string, EntityBalance>>(),

View File

@@ -12,7 +12,6 @@ export const getSummedEntityBalances = ({
return {
balance: 0,
additional_balance: 0,
additional_granted_balance: 0,
adjustment: 0,
unused: 0,
count: 0,
@@ -24,16 +23,13 @@ export const getSummedEntityBalances = ({
(acc, curr) => acc + (curr.additional_balance ?? 0),
0,
),
additional_granted_balance: Object.values(cusEnt.entities).reduce(
(acc, curr) => acc + (curr.additional_granted_balance ?? 0),
0,
),
balance: Object.values(cusEnt.entities).reduce(
(acc, curr) => acc + curr.balance,
0,
),
adjustment: Object.values(cusEnt.entities).reduce(
(acc, curr) => acc + curr.adjustment,
(acc, curr) => acc + (curr.adjustment ?? 0),
0,
),
unused: 0,
@@ -50,7 +46,6 @@ export const getCusEntBalance = ({
}): {
balance: number;
additional_balance: number;
additional_granted_balance: number;
adjustment: number;
unused: number;
count: number;
@@ -70,7 +65,6 @@ export const getCusEntBalance = ({
return {
balance: 0,
additional_balance: 0,
additional_granted_balance: 0,
adjustment: 0,
unused: 0,
count: 1,
@@ -81,8 +75,6 @@ export const getCusEntBalance = ({
balance: entityBalance || 0,
additional_balance:
cusEnt.entities?.[entityId]?.additional_balance || 0,
additional_granted_balance:
cusEnt.entities?.[entityId]?.additional_granted_balance || 0,
adjustment,
unused: 0,
count: 1,
@@ -93,7 +85,6 @@ export const getCusEntBalance = ({
return {
balance: cusEnt.balance || 0,
additional_balance: cusEnt.additional_balance || 0,
additional_granted_balance: cusEnt.additional_granted_balance || 0,
adjustment: cusEnt.adjustment || 0,
unused: cusEnt.replaceables?.length || 0,
count: 1,

3
vite/.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Sentry Config File
.env.sentry-build-plugin

View File

@@ -40,6 +40,8 @@
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/react": "^10.25.0",
"@sentry/vite-plugin": "^4.6.0",
"@squircle/core": "^1.0.6",
"@squircle/paint-polyfill": "^1.0.6",
"@squircle/tailwindcss": "^1.0.6",

View File

@@ -1,4 +1,5 @@
import { AppEnv } from "@autumn/shared";
import * as Sentry from "@sentry/react";
import { init } from "@squircle/core";
import * as React from "react";
import { useEffect } from "react";
@@ -38,6 +39,12 @@ export default function App() {
email: data.user.email,
name: data.user.name,
});
Sentry.setUser({
email: data.user.email ?? "unknown_email",
name: data.user.name ?? "unknown_name",
id: data.user.id ?? "unknown_user",
orgId: data.session.activeOrganizationId ?? "unknown_org",
});
}
}, [data]);
return (

View File

@@ -7,12 +7,18 @@ import "./styles/form/states.css";
import "./styles/typography.css";
import "./styles/custom.css";
import * as Sentry from "@sentry/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PostHogProvider } from "posthog-js/react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
sendDefaultPii: true,
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {

View File

@@ -97,6 +97,7 @@ function UpdateCusEntitlement({
feature_id: feature.id,
current_balance: balanceInt,
customer_entitlement_id: cusEnt.id,
entity_id: entityId ?? undefined,
// usage: 0,
});

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
@@ -8,9 +9,14 @@ import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
react(),
tailwindcss(),
tsconfigPaths(), // Automatically reads paths from tsconfig.json
tailwindcss(), // Automatically reads paths from tsconfig.json
tsconfigPaths(),
sentryVitePlugin({
org: process.env.VITE_SENTRY_ORG,
project: process.env.VITE_SENTRY_PROJECT,
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@@ -31,6 +37,7 @@ export default defineConfig({
"@radix/tooltip": "@radix-ui/react-tooltip",
},
},
optimizeDeps: {
// Exclude workspace dependencies from pre-bundling to avoid cache issues
exclude: [
@@ -43,8 +50,10 @@ export default defineConfig({
"drizzle-orm",
],
},
// Clear cache on config change
cacheDir: "node_modules/.vite",
server: {
host: "0.0.0.0", // Required for Docker
port: process.env.VITE_PORT
@@ -70,4 +79,8 @@ export default defineConfig({
allow: [".."],
},
},
build: {
sourcemap: true,
},
});