diff --git a/AGENTS.md b/AGENTS.md index 3fc38f0ae..e3ec09e55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,9 @@ - When writing DB queries, for the `customers`, `products` and `features` tables (and others possibly not mentioned here), the primary key when updating is `internal_id`, not `id` - When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas. +# Testing +- When writing tests, ALWAYS consult the corresponding guide in `server/tests/_guides/` to understand the proper patterns and structure. For example, when writing `/check` endpoint tests, read `server/tests/_guides/check-endpoint-tests.md` first. + # Linting and Codebase rules - You can access the biome linter by running `npx biome check `. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `npx biome check --write ` @@ -13,6 +16,10 @@ - This codebase uses Bun as its preferred package manager and Node runtime. +- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";` + +- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` + - Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier. - When creating "hooks" folders, don't nest them under "components" diff --git a/CLAUDE.md b/CLAUDE.md index 9c9928326..db956f7b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,9 @@ - JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary - When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas. +# Testing +- When writing tests, ALWAYS consult the corresponding guide in `server/tests/_guides/` to understand the proper patterns and structure. For example, when writing `/check` endpoint tests, read `server/tests/_guides/check-endpoint-tests.md` first. + # Linting and Codebase rules - You can access the biome linter by running `bunx biome check `. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write ` @@ -20,9 +23,12 @@ - Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. +- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why ` which will tell you why a certain package was installed and by who. - Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better - Do not run "npx tsc" - run "tsc" instead as it is installed globally. + +- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` ## Error Handling in API Routes - NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes - ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc. diff --git a/bun.lock b/bun.lock index c00443a87..ed0e97eec 100644 --- a/bun.lock +++ b/bun.lock @@ -5,17 +5,17 @@ "name": "autumn", "dependencies": { "@wooorm/starry-night": "^3.8.0", - "ag-charts-react": "^12.0.1", - "chalk": "^5.3.0", - "drizzle-kit": "^0.31.1", + "ag-charts-react": "^12.3.0", + "chalk": "^5.6.2", + "drizzle-kit": "^0.31.5", "tailwind-scrollbar-hide": "^4.0.0", }, "devDependencies": { - "@biomejs/biome": "^2.2.4", - "@types/node": "^24.0.3", - "concurrently": "^9.1.2", - "dotenv": "^16.5.0", - "inquirer": "^12.6.3", + "@biomejs/biome": "^2.2.7", + "@types/node": "^24.9.1", + "concurrently": "^9.2.1", + "dotenv": "^16.6.1", + "inquirer": "^12.10.0", }, }, "scripts": { @@ -26,6 +26,8 @@ "chalk": "^5.3.0", "dotenv": "^16.5.0", "inquirer": "^12.6.3", + "ora": "^9.0.0", + "p-limit": "^7.2.0", }, "devDependencies": { "@types/inquirer": "^9.0.7", @@ -394,23 +396,23 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="], - "@biomejs/biome": ["@biomejs/biome@2.2.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.6", "@biomejs/cli-darwin-x64": "2.2.6", "@biomejs/cli-linux-arm64": "2.2.6", "@biomejs/cli-linux-arm64-musl": "2.2.6", "@biomejs/cli-linux-x64": "2.2.6", "@biomejs/cli-linux-x64-musl": "2.2.6", "@biomejs/cli-win32-arm64": "2.2.6", "@biomejs/cli-win32-x64": "2.2.6" }, "bin": { "biome": "bin/biome" } }, "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw=="], + "@biomejs/biome": ["@biomejs/biome@2.2.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.7", "@biomejs/cli-darwin-x64": "2.2.7", "@biomejs/cli-linux-arm64": "2.2.7", "@biomejs/cli-linux-arm64-musl": "2.2.7", "@biomejs/cli-linux-x64": "2.2.7", "@biomejs/cli-linux-x64-musl": "2.2.7", "@biomejs/cli-win32-arm64": "2.2.7", "@biomejs/cli-win32-x64": "2.2.7" }, "bin": { "biome": "bin/biome" } }, "sha512-1a8j0UP1vXVUf3UzMZEJ/zS2VgAG6wU6Cuh/I764sUGI+MCnJs/9WaojHYBDCxCMLTgU60/WqnYof85emXmSBA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xBUUsebnO2/Qj1v7eZmKUy2ZcFkZ4/jLUkxN02Qup1RPoRaiW9AKXHrqS3L7iX6PzofHY2xuZ+Pb9kAcpoe0qA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-vsY4NhmxqgfLJufr9XUnC+yGUPJiXAc1mz6FcjaAmuIuLwfghN4uQO7hnW2AneGyoi2mNe9Jbvf6Qtq4AjzrFg=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nUdco104rjV9dULi1VssQ5R/kX2jE/Z2sDjyqS+siV9sTQda0DwmEUixFNRCWvZJRRiZUWhgiDFJ4n7RowO8Mg=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-FrTwvKO/7t5HbVTvhlMOTOVQLAcR7r4O4iFQhEpZXUtBfosHqrX/JJlX7daPawoe14MDcCu9CDg0zLVpTuDvuQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tPTcGAIEOOZrj2tQ7fdraWlaxNKApBw6l4In8wQQV1IyxnAexqi0hykHzKEX8hKKctf5gxGBfNCzyIvqpj4CFQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-MnsysF5s/iLC5wnYvuMseOy+m8Pd4bWG1uwlVyy2AUbfjAVUgtbYbboc5wMXljFrDY7e6rLjLTR4S2xqDpGlQg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-h5D1jhwA2b7cFXerYiJfXHSzzAMFFoEDL5Mc2BgiaEw0iaSgSso/3Nc6FbOR55aTQISql+IpB4PS7JoV26Gdbw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-URqAJi0kONyBKG4V9NVafHLDtm6IHmF4qPYi/b6x7MD6jxpWeJiTCO6R5+xDlWckX2T/OGv6Yq3nkz6s0M8Ykw=="], "@browserbasehq/sdk": ["@browserbasehq/sdk@2.6.0", "", { "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-83iXP5D7xMm8Wyn66TUaUrgoByCmAJuoMoZQI3sGg3JAiMlTfnCIMqyVBoNSaItaPIkaCnrsj6LiusmXV2X9YA=="], @@ -1302,7 +1304,7 @@ "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], - "@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -1422,7 +1424,7 @@ "ag-charts-locale": ["ag-charts-locale@12.2.0", "", {}, "sha512-xwMTzoNi/SAV6EwApRuBXOc3K+Thi/ijsKIkIhaGPmc3VPkCVCWW/TcAontiZ3dXUgxsL3jXBNyarH7f+XzWkQ=="], - "ag-charts-react": ["ag-charts-react@12.2.0", "", { "dependencies": { "ag-charts-community": "12.2.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-uc6XE2tLbQ5LZugCdQyyrw5+I2EUDQMqOwMS3j0NzRJAXfj0DrZEg9zTkgwDrn24t28al7rdeWtVespXP+EmpA=="], + "ag-charts-react": ["ag-charts-react@12.3.0", "", { "dependencies": { "ag-charts-community": "12.3.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-SyrGBKn6YgaoXh/ZZuSpRNX5DjgIqu9LGRjo0N24KFz++WPBb6iYBPnIMqm2R0qZ38D15gyh7hc3w614w/Vbog=="], "ag-charts-types": ["ag-charts-types@12.2.0", "", {}, "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ=="], @@ -1584,7 +1586,7 @@ "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cli-spinners": ["cli-spinners@3.3.0", "", {}, "sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -2096,7 +2098,7 @@ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], @@ -2216,7 +2218,7 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], @@ -2370,9 +2372,9 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + "ora": ["ora@9.0.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.2.2", "string-width": "^8.1.0", "strip-ansi": "^7.1.2" } }, "sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A=="], - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -2708,7 +2710,7 @@ "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2824,7 +2826,7 @@ "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], - "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unfetch": ["unfetch@4.1.0", "", {}, "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg=="], @@ -2922,7 +2924,7 @@ "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -2962,10 +2964,6 @@ "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@autumn/server/@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], - - "@autumn/shared/@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], - "@autumn/vite/@types/node": ["@types/node@22.18.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -3026,6 +3024,8 @@ "@hyperdx/node-opentelemetry/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="], + "@hyperdx/node-opentelemetry/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "@hyperdx/node-opentelemetry/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], @@ -3364,12 +3364,50 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/body-parser/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/bunyan/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/chai-http/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/connect/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/cors/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/memcached/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/mysql/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/node-fetch/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/oracledb/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/pg/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/send/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/serve-static/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "@types/serve-static/@types/send": ["@types/send@0.17.5", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w=="], + "@types/superagent/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/tedious/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/through/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/ws/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/yauzl/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "ag-charts-react/ag-charts-community": ["ag-charts-community@12.3.0", "", { "dependencies": { "ag-charts-core": "12.3.0", "ag-charts-locale": "12.3.0", "ag-charts-types": "12.3.0" } }, "sha512-D+2Dfp7fZHLcvlH7oPUiA0hVeBD1tmnBJQNIo61wN9feJsKQtV16Hx0xiYxV0g7xJ+iUp+0vi2a6bXx4uKsGbQ=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "autumn-js/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], @@ -3386,6 +3424,10 @@ "body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], + "bun-types/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -3400,6 +3442,8 @@ "css-select/domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="], + "engine.io/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -3416,8 +3460,6 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -3426,10 +3468,10 @@ "html-minifier-terser/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "mocha/log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "nodemon/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=="], @@ -3440,11 +3482,7 @@ "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "ora/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - - "ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], - - "ora/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=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], @@ -3456,6 +3494,8 @@ "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "protobufjs/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], @@ -3464,7 +3504,7 @@ "react-email/glob": ["glob@11.0.3", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA=="], - "react-email/log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "react-email/ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -3474,6 +3514,8 @@ "recaseai/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "recaseai/ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + "renderkid/htmlparser2": ["htmlparser2@6.1.0", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.0.0", "domutils": "^2.5.2", "entities": "^2.0.0" } }, "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A=="], "renderkid/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3492,8 +3534,6 @@ "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -3514,16 +3554,18 @@ "winston-transport/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@autumn/server/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@autumn/shared/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -3706,6 +3748,10 @@ "@hyperdx/node-opentelemetry/ora/is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + "@hyperdx/node-opentelemetry/ora/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + + "@hyperdx/node-opentelemetry/ora/log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "@hyperdx/node-opentelemetry/ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -3788,6 +3834,8 @@ "@opentelemetry/instrumentation-pg/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.202.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fTBjMqKCfotFWfLzaKyhjLvyEyq5vDKTTFfBmx21btv3gvy8Lq6N5Dh2OzqeuN4DjtpSvNT1uNVfg08eD2Rfxw=="], + "@opentelemetry/instrumentation-pg/@types/pg/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "@opentelemetry/instrumentation-redis-4/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.202.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fTBjMqKCfotFWfLzaKyhjLvyEyq5vDKTTFfBmx21btv3gvy8Lq6N5Dh2OzqeuN4DjtpSvNT1uNVfg08eD2Rfxw=="], "@opentelemetry/instrumentation-redis/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.202.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fTBjMqKCfotFWfLzaKyhjLvyEyq5vDKTTFfBmx21btv3gvy8Lq6N5Dh2OzqeuN4DjtpSvNT1uNVfg08eD2Rfxw=="], @@ -3822,10 +3870,54 @@ "@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], + "@types/body-parser/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/bunyan/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/chai-http/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/connect/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/cors/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/memcached/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/mysql/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/node-fetch/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/oracledb/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/pg/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/send/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/serve-static/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/superagent/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/tedious/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/through/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/ws/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@types/yauzl/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "ag-charts-react/ag-charts-community/ag-charts-core": ["ag-charts-core@12.3.0", "", { "dependencies": { "ag-charts-types": "12.3.0" } }, "sha512-13WfzbGxknSEp4Q8YriOPGcP+3w4fl81VaUuSJMITJK6o1BPIKZFUBNkfT8Ep5ZH93cfSCteY+Z2acBvdhIamA=="], + + "ag-charts-react/ag-charts-community/ag-charts-locale": ["ag-charts-locale@12.3.0", "", {}, "sha512-4ifdXX8Q6sjBAf85Jc2RKcl4A1lrk60jPrZwi9ACY+luFkKfxCujga66okP1JFERN31SigJRTraoXW91HFikIg=="], + + "ag-charts-react/ag-charts-community/ag-charts-types": ["ag-charts-types@12.3.0", "", {}, "sha512-wvlhAgQNkXrmSSJJqeEMP4a8WJviAgphuMtNt7073sBOI0LVdcgAV7kyyJOYjbYB8S1SqKyPP0jdbwROOmQBCQ=="], + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "bun-types/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -3884,6 +3976,8 @@ "css-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], + "engine.io/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "eslint/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -3894,7 +3988,9 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "mocha/log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "mocha/log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -3908,24 +4004,34 @@ "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], - - "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "prebuild-install/tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "protobufjs/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "react-email/glob/jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], "react-email/glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], "react-email/glob/path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="], - "react-email/log-symbols/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "react-email/ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "react-email/ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "react-email/ora/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=="], "recaseai/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "recaseai/ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "recaseai/ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "recaseai/ora/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=="], + "renderkid/htmlparser2/domhandler": ["domhandler@4.3.1", "", { "dependencies": { "domelementtype": "^2.2.0" } }, "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ=="], "renderkid/htmlparser2/domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="], @@ -3938,8 +4044,6 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "tsc-alias/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "tsc-alias/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -3950,6 +4054,8 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@hyperdx/node-opentelemetry/@opentelemetry/auto-instrumentations-node/@opentelemetry/instrumentation-aws-lambda/@types/aws-lambda": ["@types/aws-lambda@8.10.147", "", {}, "sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew=="], "@hyperdx/node-opentelemetry/@opentelemetry/auto-instrumentations-node/@opentelemetry/instrumentation-aws-sdk/@opentelemetry/propagation-utils": ["@opentelemetry/propagation-utils@0.30.16", "", { "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-ZVQ3Z/PQ+2GQlrBfbMMMT0U7MzvYZLCPP800+ooyaBqm4hMvuQHfP028gB9/db0mwkmyEAMad9houukUVxhwcw=="], @@ -4018,20 +4124,50 @@ "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], + "@opentelemetry/instrumentation-pg/@types/pg/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@sentry/node/@opentelemetry/instrumentation-mysql/@types/mysql/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@sentry/node/@opentelemetry/instrumentation-pg/@types/pg/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "mocha/log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "nodemon/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "prebuild-install/tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "react-email/glob/path-scurry/lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], + "react-email/ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "react-email/ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "recaseai/ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "recaseai/ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "renderkid/htmlparser2/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], "tsc-alias/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=="], + + "@hyperdx/node-opentelemetry/@opentelemetry/auto-instrumentations-node/@opentelemetry/instrumentation-mysql/@types/mysql/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@hyperdx/node-opentelemetry/@opentelemetry/auto-instrumentations-node/@opentelemetry/instrumentation-pg/@types/pg/@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + "@hyperdx/node-opentelemetry/ora/cli-cursor/restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "@hyperdx/node-opentelemetry/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@sentry/node/@opentelemetry/instrumentation-mysql/@types/mysql/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@sentry/node/@opentelemetry/instrumentation-pg/@types/pg/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@hyperdx/node-opentelemetry/@opentelemetry/auto-instrumentations-node/@opentelemetry/instrumentation-mysql/@types/mysql/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "@hyperdx/node-opentelemetry/@opentelemetry/auto-instrumentations-node/@opentelemetry/instrumentation-pg/@types/pg/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], } } diff --git a/package.json b/package.json index 788570b42..597ec1255 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "dev:simple": "concurrently \"cd shared && bun dev:watch\" \"cd server && bun dev\" \"cd server && bun workers:dev\" \"cd vite && bun dev\"", "vite:build": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun", "vite:start": "bun -F @autumn/vite start:bun", - "shared": "bun -F @autumn/shared build", "server": "bun -F @autumn/shared build && bun -F @autumn/server start", "workers": "bun -F @autumn/shared build && bun -F @autumn/server workers", @@ -22,34 +21,30 @@ "server:cron": "pnpm -F server cron:start", "server:check": "NODE_ENV=production pnpm -F server check", "setup": "node scripts/setup.js", - "setup:test": "bun scripts/setup-test.ts", "tests": "bun scripts/test.ts", - "setupci": "node scripts/setupci.js", "db:push": " bun -F @autumn/shared db:push", "db:generate": "bun -F @autumn/shared db:generate", "db:migrate": " bun -F @autumn/shared db:migrate", - "docker:up": "docker compose -f docker-compose.dev.yml up --build", "docker:up:unix": "docker compose -f docker-compose.unix.yml up --build", "docker:up:ci": "docker compose -f docker-compose.ci.yml up --build", - "vite:build:bun": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun", "vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun" }, "dependencies": { "@wooorm/starry-night": "^3.8.0", - "ag-charts-react": "^12.0.1", - "chalk": "^5.3.0", - "drizzle-kit": "^0.31.1", + "ag-charts-react": "^12.3.0", + "chalk": "^5.6.2", + "drizzle-kit": "^0.31.5", "tailwind-scrollbar-hide": "^4.0.0" }, "devDependencies": { - "@biomejs/biome": "^2.2.4", - "@types/node": "^24.0.3", - "concurrently": "^9.1.2", - "dotenv": "^16.5.0", - "inquirer": "^12.6.3" + "@biomejs/biome": "^2.2.7", + "@types/node": "^24.9.1", + "concurrently": "^9.2.1", + "dotenv": "^16.6.1", + "inquirer": "^12.10.0" } } diff --git a/scripts/package.json b/scripts/package.json index 2da9851be..cfe6dd785 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -11,7 +11,9 @@ "@autumn/shared": "workspace:*", "chalk": "^5.3.0", "dotenv": "^16.5.0", - "inquirer": "^12.6.3" + "inquirer": "^12.6.3", + "ora": "^9.0.0", + "p-limit": "^7.2.0" }, "devDependencies": { "@types/inquirer": "^9.0.7", diff --git a/scripts/test.ts b/scripts/test.ts index be06646c3..3b83dbfbd 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -43,7 +43,12 @@ function fuzzyMatchScore({ const pathLower = filePath.toLowerCase(); const fileName = pathLower.split("/").pop() || ""; - // Check if search matches exactly in filename (highest priority) + // Check if path ends with the exact search pattern (highest priority for path-based searches) + if (pathLower.endsWith(`${searchLower}.ts`)) { + return 10000; + } + + // Check if search matches exactly in filename if (fileName === `${searchLower}.ts`) { return 1000; } @@ -80,6 +85,30 @@ function fuzzyMatchScore({ return score; } +/** + * Detects whether a test file uses Bun or Mocha framework + */ +function detectTestFramework({ + filePath, +}: { + filePath: string; +}): "bun" | "mocha" { + try { + const { readFileSync } = require("node:fs"); + const content = readFileSync(filePath, "utf-8"); + // Check first 20 lines for bun:test import + const lines = content.split("\n").slice(0, 20); + const hasBunTest = lines.some( + (line) => + line.includes('from "bun:test"') || line.includes("from 'bun:test'"), + ); + return hasBunTest ? "bun" : "mocha"; + } catch (_error) { + // Default to mocha if we can't read the file + return "mocha"; + } +} + /** * Runs shell test scripts from server/shell/ directory or individual test files */ @@ -92,9 +121,12 @@ async function runTest() { chalk.red("โŒ Please provide a shell script or test file name"), ); console.log( - chalk.cyan("\nUsage: bun tests [args...]"), + chalk.cyan("\nUsage: bun tests [args...]"), ); console.log(chalk.gray("Examples:")); + console.log( + chalk.gray(" bun tests setup # run test setup script"), + ); console.log(chalk.gray(" bun tests g1")); console.log(chalk.gray(" bun tests g1 setup")); console.log( @@ -106,7 +138,40 @@ async function runTest() { process.exit(1); } - const serverDir = resolve(process.cwd(), "server"); + // Detect if we're already in the server directory (e.g., when run via server/run.sh) + const cwd = process.cwd(); + const serverDir = + cwd.endsWith("/server") || cwd.endsWith("\\server") + ? cwd + : resolve(cwd, "server"); + + // Handle special "setup" command + if (scriptName === "setup") { + const setupScript = resolve(serverDir, "tests", "setupMain.ts"); + console.log(chalk.cyan("๐Ÿ—๏ธ Running test setup...\n")); + + const child = spawn("bun", [setupScript], { + cwd: serverDir, + stdio: "inherit", + env: { ...process.env, NODE_ENV: "production" }, + }); + + child.on("exit", (code) => { + if (code === 0) { + console.log(chalk.green("\nโœ… Setup completed successfully")); + } else { + console.log(chalk.red(`\nโŒ Setup failed with code ${code}`)); + process.exit(code || 1); + } + }); + + child.on("error", (error) => { + console.log(chalk.red(`\nโŒ Error running setup: ${error.message}`)); + process.exit(1); + }); + return; + } + const shellScript = resolve(serverDir, "shell", `${scriptName}.sh`); // First try to find a shell script @@ -117,45 +182,25 @@ async function runTest() { chalk.cyan(`๐Ÿงช Running shell script: ${scriptName}.sh${argsDisplay}\n`), ); - // Create a new process group by spawning with detached: true const child = spawn("bash", [shellScript, ...additionalArgs], { cwd: serverDir, stdio: "inherit", env: { ...process.env, NODE_ENV: "production" }, - detached: true, }); - // Store the process group ID - const pgid = child.pid; - - // Forward termination signals to entire process group - const killProcessGroup = () => { - if (pgid) { - try { - // Kill the entire process group with SIGKILL (force kill) - process.kill(-pgid, "SIGKILL"); - } catch (_err) { - // Process group might already be dead - } - } - }; - + // Forward termination signals to child process process.on("SIGINT", () => { - console.log(chalk.yellow("\nโš ๏ธ Received SIGINT, killing all test processes...\n")); - killProcessGroup(); + console.log(chalk.yellow("\nโš ๏ธ Received SIGINT, stopping tests...\n")); + child.kill("SIGINT"); process.exit(130); }); process.on("SIGTERM", () => { - console.log(chalk.yellow("\nโš ๏ธ Received SIGTERM, killing all test processes...\n")); - killProcessGroup(); + console.log(chalk.yellow("\nโš ๏ธ Received SIGTERM, stopping tests...\n")); + child.kill("SIGTERM"); process.exit(143); }); - process.on("exit", () => { - killProcessGroup(); - }); - child.on("exit", (code) => { if (code === 0) { console.log( @@ -216,15 +261,29 @@ async function runTest() { const testFile = bestMatch; console.log(chalk.green(`โœ“ Found: ${testFile.relative}\n`)); - console.log(chalk.cyan(`๐Ÿงช Running test file...\n`)); - // Run the test file with mocha - const child = spawn("bunx", ["mocha", "--timeout", "0", testFile.path], { - cwd: serverDir, - stdio: "inherit", - env: { ...process.env, NODE_ENV: "production" }, - detached: true, - }); + // Detect test framework + const framework = detectTestFramework({ filePath: testFile.path }); + const frameworkLabel = framework === "bun" ? "Bun" : "Mocha"; + console.log(chalk.cyan(`๐Ÿงช Running test file with ${frameworkLabel}...\n`)); + + // Run the test file with the appropriate framework + const child = + framework === "bun" + ? spawn("bun", ["test", "--timeout", "0", testFile.relative], { + cwd: serverDir, + stdio: "inherit", + env: { ...process.env, NODE_ENV: "production" }, + }) + : spawn( + "npx", + ["mocha", "--bail", "--timeout", "10000000", testFile.relative], + { + cwd: serverDir, + stdio: "inherit", + env: { ...process.env, NODE_ENV: "production" }, + }, + ); // Store the process group ID const pgid = child.pid; @@ -242,13 +301,17 @@ async function runTest() { }; process.on("SIGINT", () => { - console.log(chalk.yellow("\nโš ๏ธ Received SIGINT, killing test process...\n")); + console.log( + chalk.yellow("\nโš ๏ธ Received SIGINT, killing test process...\n"), + ); killProcessGroup(); process.exit(130); }); process.on("SIGTERM", () => { - console.log(chalk.yellow("\nโš ๏ธ Received SIGTERM, killing test process...\n")); + console.log( + chalk.yellow("\nโš ๏ธ Received SIGTERM, killing test process...\n"), + ); killProcessGroup(); process.exit(143); }); diff --git a/scripts/testGroups/README.md b/scripts/testGroups/README.md new file mode 100644 index 000000000..27b415c6c --- /dev/null +++ b/scripts/testGroups/README.md @@ -0,0 +1,133 @@ +# Test Groups + +Organized test suites for the Autumn test framework. + +## Usage + +From the project root: + +```bash +# Run a specific test group +./scripts/testGroups/g1.sh + +# Run with setup +./scripts/testGroups/g1.sh setup +``` + +From the server directory (legacy): + +```bash +./run.sh /path/to/test/group/script.sh +``` + +## Test Groups + +### G1: Upgrade & Downgrade Tests โœ… (Migrated to Bun) +**Status:** Fully migrated to Bun + TypeScript runner +**Tests:** +- `server/tests/check/basic/*.test.ts` +- `server/tests/attach/basic/*.test.ts` +- `server/tests/attach/upgrade/*.test.ts` +- `server/tests/attach/downgrade/*.test.ts` +- `server/tests/attach/free/*.test.ts` +- `server/tests/attach/addOn/*.test.ts` +- `server/tests/attach/entities/*.test.ts` +- `server/tests/attach/checkout/*.test.ts` + +**Features:** +- Live spinner showing current test +- Beautiful success/failure indicators +- Concise error reports +- **Compact mode** for large test suites (reduces screen overflow) + +### G2: Migrations, Versions & Others โณ (Mocha) +**Status:** Uses Mocha (pending migration) +**Tests:** +- Migrations +- Version updates +- Prepaid features +- Interval upgrades + +### G3: Continuous Use Tests โณ (Mocha) +**Status:** Uses Mocha (pending migration) +**Tests:** +- Entity management +- Usage tracking +- Updates +- Roles + +### G4: Merged & Core Tests โณ (Mocha) +**Status:** Uses Mocha (pending migration) +**Tests:** +- Merged subscriptions +- Core cancellation +- Multi-attach scenarios + +### G5: Advanced Features โณ (Mocha) +**Status:** Uses Mocha (pending migration) +**Tests:** +- Multi-feature +- Coupons +- Referrals +- Rollovers +- Usage limits + +### G6: Alex Integration Tests โณ (Mocha) +**Status:** Uses Mocha (pending migration) +**Tests:** +- End-to-end scenarios +- Product switching +- Topups + +## Migration Progress + +- โœ… Test runner built (TypeScript + Bun) +- โœ… G1 tests migrated to Bun +- โณ G2-G6 pending migration + +## Compact Mode ๐Ÿ“ฆ + +When running many tests (dozens of test files), the standard output can overflow your terminal screen. Use compact mode to see only: +- Recently completed tests (last 3 with tick marks) +- Failed tests summary (updated in real-time) +- Currently running tests (up to 6) with their active test case +- A single line with test stats (progress, passed, failed) +- Full error details at the end + +### Usage + +**In shell scripts (recommended):** +```bash +# Use BUN_PARALLEL_COMPACT instead of BUN_PARALLEL +BUN_PARALLEL_COMPACT \ + 'server/tests/check/basic' \ + 'server/tests/attach/basic' +``` + +**Direct command line:** +```bash +# Add --compact flag +bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --compact + +# Combine with other options +bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --compact --max=10 +``` + +### When to Use Compact Mode + +- โœ… Running 20+ test files (like in g1.sh) +- โœ… CI/CD pipelines with limited scrollback +- โœ… When you only care about failures +- โŒ Debugging specific tests (use full mode to see progress) +- โŒ Running < 10 test files + +## Adding New Test Groups + +1. Create a new script in `scripts/testGroups/` +2. Use the TypeScript runner for new tests: + ```bash + bun scripts/testScripts/runTests.ts server/tests/your/tests + ``` +3. For large test suites, use `BUN_PARALLEL_COMPACT` instead of `BUN_PARALLEL` +4. Update this README with the test group details + diff --git a/scripts/testGroups/config.sh b/scripts/testGroups/config.sh new file mode 100755 index 000000000..a6141c57d --- /dev/null +++ b/scripts/testGroups/config.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Shared configuration for test groups + +# Get project root directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SERVER_DIR="$PROJECT_ROOT/server" + +# Find bun executable (check common locations) +if command -v bun &> /dev/null; then + BUN_CMD="bun" +elif [ -f "$HOME/.bun/bin/bun" ]; then + BUN_CMD="$HOME/.bun/bin/bun" +elif [ -f "/usr/local/bin/bun" ]; then + BUN_CMD="/usr/local/bin/bun" +else + echo "Error: bun not found. Please install bun or add it to PATH." + exit 1 +fi + +# Test runner function +BUN_PARALLEL() { + cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTests.ts "$@" +} + +# Test runner with compact mode (recommended for many tests) +BUN_PARALLEL_COMPACT() { + cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTests.ts "$@" --compact +} + +# Setup function +BUN_SETUP() { + cd "$SERVER_DIR" && $BUN_CMD tests/setupMain.ts +} + +# Mocha function (for tests not yet migrated) +MOCHA_CMD() { + cd "$SERVER_DIR" && npx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts "$@" +} + diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh new file mode 100755 index 000000000..02b2ce875 --- /dev/null +++ b/scripts/testGroups/g1.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Test Group 1: Upgrade & Downgrade Tests +# Description: Tests for product upgrades and downgrades + +# Source shared configuration +source "$(dirname "$0")/config.sh" + +# Setup if requested +if [[ "$1" == *"setup"* ]]; then + echo "Running test setup..." + BUN_SETUP +fi + +# Run tests using TypeScript runner with compact mode +# Adjust --max to control concurrency (default: 6) +BUN_PARALLEL_COMPACT \ + 'server/tests/check/basic' \ + 'server/tests/attach/basic' \ + 'server/tests/attach/upgrade' \ + 'server/tests/attach/downgrade' \ + 'server/tests/attach/free' \ + 'server/tests/attach/addOn' \ + 'server/tests/attach/entities' \ + 'server/tests/attach/checkout' \ + --max=6 \ + + + diff --git a/scripts/testGroups/g2.sh b/scripts/testGroups/g2.sh new file mode 100755 index 000000000..437d60e6a --- /dev/null +++ b/scripts/testGroups/g2.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Test Group 2: Migrations, Versions & Others +# Description: Tests for migrations, version updates, and miscellaneous features + +# Source shared configuration +source "$(dirname "$0")/config.sh" + +# Setup if requested +if [[ "$1" == *"setup"* ]]; then + echo "Running test setup..." + BUN_SETUP +fi + +# These tests still use Mocha - will be migrated later + +MOCHA_CMD \ +'tests/attach/migrations/*.ts' \ +'tests/attach/newVersion/*.ts' \ +'tests/attach/upgradeOld/*.ts' \ +'tests/attach/others/*.ts' \ +'tests/attach/updateEnts/*.ts' \ +'tests/advanced/check/*.ts' + +MOCHA_CMD 'tests/attach/prepaid/*.ts' \ +'tests/interval/upgrade/*.ts' \ +'tests/interval/multiSub/*.ts' + diff --git a/scripts/testGroups/g3.sh b/scripts/testGroups/g3.sh new file mode 100755 index 000000000..1b8545c7f --- /dev/null +++ b/scripts/testGroups/g3.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Test Group 3: Continuous Use Tests +# Description: Tests for continuous usage tracking, entities, and roles + +# Source shared configuration +source "$(dirname "$0")/config.sh" + +# Setup if requested +if [[ "$1" == *"setup"* ]]; then + echo "Running test setup..." + BUN_SETUP +fi + +# These tests still use Mocha - will be migrated later + +MOCHA_CMD 'tests/contUse/entities/*.ts' +MOCHA_CMD 'tests/contUse/update/*.ts' +MOCHA_CMD 'tests/contUse/track/*.ts' +MOCHA_CMD 'tests/contUse/roles/*.ts' + diff --git a/scripts/testGroups/g4.sh b/scripts/testGroups/g4.sh new file mode 100755 index 000000000..0c74e1173 --- /dev/null +++ b/scripts/testGroups/g4.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Test Group 4: Merged & Core Tests +# Description: Tests for merged subscriptions and core functionality + +# Source shared configuration +source "$(dirname "$0")/config.sh" + +# Setup if requested +if [[ "$1" == *"setup"* ]]; then + echo "Running test setup..." + BUN_SETUP +fi + +# These tests still use Mocha - will be migrated later + +MOCHA_CMD 'tests/merged/group/*.ts' + +MOCHA_CMD 'tests/merged/add/*.ts' \ +'tests/merged/downgrade/*.ts' \ +'tests/merged/prepaid/*.ts' \ +'tests/merged/separate/*.ts' \ +'tests/merged/upgrade/*.ts' \ +'tests/merged/trial/*.ts' + +MOCHA_CMD 'tests/merged/addOn/*.ts' \ +'tests/core/cancel/*.ts' \ +'tests/core/multiAttach/*.ts' \ +'tests/core/multiAttach/multiInvoice/*.ts' \ +'tests/core/multiAttach/multiUpgrade/*.ts' + diff --git a/scripts/testGroups/g5.sh b/scripts/testGroups/g5.sh new file mode 100755 index 000000000..405f6d9cd --- /dev/null +++ b/scripts/testGroups/g5.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Test Group 5: Advanced Features +# Description: Tests for advanced features like coupons, referrals, usage limits + +# Source shared configuration +source "$(dirname "$0")/config.sh" + +# Setup if requested +if [[ "$1" == *"setup"* ]]; then + echo "Running test setup..." + BUN_SETUP +fi + +# These tests still use Mocha - will be migrated later + +MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ + 'tests/advanced/coupons/*.ts' \ + 'tests/attach/updateQuantity/*.ts' \ + 'tests/advanced/referrals/*.ts' \ + 'tests/advanced/referrals/paid/*.ts' \ + 'tests/advanced/rollovers/*.ts' \ + 'tests/advanced/customInterval/*.ts' + +MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ + 'tests/advanced/usageLimit/*.ts' + +MOCHA_CMD 'tests/advanced/usage/*.ts' + diff --git a/scripts/testGroups/g6.sh b/scripts/testGroups/g6.sh new file mode 100755 index 000000000..7ab049ba7 --- /dev/null +++ b/scripts/testGroups/g6.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Test Group 6: Alex Tests +# Description: Integration tests for Alex scenarios + +# Source shared configuration +source "$(dirname "$0")/config.sh" + +# Setup if requested +if [[ "$1" == *"setup"* ]]; then + echo "Running test setup..." + BUN_SETUP +fi + +# These tests still use Mocha - will be migrated later +cd "$SERVER_DIR" + +npx mocha --parallel --timeout 10000000 \ + 'tests/alex/01_free.ts' 'tests/alex/02_pro.ts' 'tests/alex/03_premium.ts' \ + 'tests/alex/04_topups.ts' 'tests/alex/05_cancel.ts' 'tests/alex/06_switch.ts' \ + --ignore 'tests/alex/00_setup.ts' + diff --git a/scripts/testScripts/runTests.ts b/scripts/testScripts/runTests.ts new file mode 100755 index 000000000..e85a1771a --- /dev/null +++ b/scripts/testScripts/runTests.ts @@ -0,0 +1,672 @@ +#!/usr/bin/env bun + +import { spawn } from "bun"; +import chalk from "chalk"; +import { readdir } from "fs/promises"; +import pLimit from "p-limit"; +import { basename, resolve } from "path"; + +interface TestResult { + file: string; + status: "pending" | "running" | "passed" | "failed"; + output: string; + duration: number; + error?: string; + lastTestName?: string; +} + +class TestRunner { + private results: Map = new Map(); + private testFiles: string[] = []; + private maxParallel: number = 6; + private spinnerFrames = ["โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "]; + private spinnerIndex = 0; + private renderInterval?: Timer; + private startLine = 0; + private compactMode: boolean = false; + private lastRenderedLines = 0; + + constructor(maxParallel?: number, compactMode?: boolean) { + if (maxParallel) this.maxParallel = maxParallel; + if (compactMode) this.compactMode = compactMode; + } + + async collectTestFiles(directories: string[]): Promise { + const testFiles: string[] = []; + + for (const dir of directories) { + const resolvedDir = resolve(process.cwd(), dir); + try { + const files = await readdir(resolvedDir); + for (const file of files) { + if (file.endsWith(".test.ts")) { + testFiles.push(resolve(resolvedDir, file)); + } + } + } catch (error) { + console.error(chalk.red(`Error reading directory ${dir}:`), error); + } + } + + return testFiles; + } + + private extractLastTest(output: string): string | null { + const lines = output.split("\n"); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + + const testMatch = line.match(/^[โœ“โœ—]\s+(.+?)(?:\s+\[\d+\.\d+m?s\])?$/); + if (testMatch) { + return testMatch[1]; + } + + const bunTestMatch = line.match(/test\s+"([^"]+)"/); + if (bunTestMatch) { + return bunTestMatch[1]; + } + } + + return null; + } + + private truncateTestName(name: string, maxLength: number = 50): string { + if (name.length <= maxLength) return name; + return name.substring(0, maxLength - 3) + "..."; + } + + private hideCursor() { + process.stdout.write("\x1B[?25l"); + } + + private showCursor() { + process.stdout.write("\x1B[?25h"); + } + + private moveCursor(line: number, col: number = 0) { + process.stdout.write(`\x1B[${line};${col}H`); + } + + private clearLine() { + process.stdout.write("\x1B[2K"); + } + + private getSpacesNeeded(): number { + if (!this.compactMode) { + return this.testFiles.length + 3; + } + + // Compact mode: dynamically calculate based on content + // Base: 10 lines for headers, stats, spacing + // + 3 lines for recently completed + // + failed tests * 4 (name + 2 error lines + spacing) + // + running tests + const failedCount = Array.from(this.results.values()).filter( + (r) => r.status === "failed", + ).length; + const runningCount = Array.from(this.results.values()).filter( + (r) => r.status === "running", + ).length; + + return Math.min( + 10 + 3 + failedCount * 4 + Math.min(runningCount, 6), + 30, // Cap at 30 lines + ); + } + + private render() { + this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length; + const spinner = this.spinnerFrames[this.spinnerIndex]; + + if (this.compactMode) { + // Compact mode: show completed, failed, running tests, then stats + let lineNum = this.startLine; + + const completed = Array.from(this.results.values()).filter( + (r) => r.status === "passed" || r.status === "failed", + ).length; + const passed = Array.from(this.results.values()).filter( + (r) => r.status === "passed", + ).length; + const failed = Array.from(this.results.values()).filter( + (r) => r.status === "failed", + ).length; + + // Show recently completed tests (last 3) + const passedTests = Array.from(this.results.entries()) + .filter(([_, result]) => result.status === "passed") + .slice(-3); // Get last 3 completed + + if (passedTests.length > 0) { + this.moveCursor(lineNum, 0); + this.clearLine(); + process.stdout.write( + chalk.green.bold(`Recently Completed (${passed} total):\n`), + ); + lineNum++; + + for (const [file] of passedTests) { + this.moveCursor(lineNum, 0); + this.clearLine(); + const testName = basename(file); + process.stdout.write( + ` ${chalk.green("โœ“")} ${chalk.dim(testName)}\n`, + ); + lineNum++; + } + + // Add blank line + this.moveCursor(lineNum, 0); + this.clearLine(); + process.stdout.write("\n"); + lineNum++; + } + + // Show failed tests + const failedTests = Array.from(this.results.entries()).filter( + ([_, result]) => result.status === "failed", + ); + + if (failedTests.length > 0) { + this.moveCursor(lineNum, 0); + this.clearLine(); + process.stdout.write( + chalk.red.bold(`Failed (${failedTests.length}):\n`), + ); + lineNum++; + + for (const [file, result] of failedTests) { + this.moveCursor(lineNum, 0); + this.clearLine(); + const testName = basename(file); + process.stdout.write(` ${chalk.red("โœ—")} ${testName}\n`); + lineNum++; + + // Show first 2 lines of error + if (result.error) { + const errorLines = result.error.split("\n").filter((l) => l.trim()); + const displayLines = errorLines.slice(0, 2); + for (const line of displayLines) { + this.moveCursor(lineNum, 0); + this.clearLine(); + const truncated = + line.length > 80 ? line.substring(0, 77) + "..." : line; + process.stdout.write(` ${chalk.dim(truncated)}\n`); + lineNum++; + } + } + } + + // Add blank line after failed tests + this.moveCursor(lineNum, 0); + this.clearLine(); + process.stdout.write("\n"); + lineNum++; + } + + // Show currently running tests + const runningTests = Array.from(this.results.entries()).filter( + ([_, result]) => result.status === "running", + ); + + if (runningTests.length > 0) { + this.moveCursor(lineNum, 0); + this.clearLine(); + process.stdout.write( + chalk.cyan.bold(`Running (${runningTests.length}):\n`), + ); + lineNum++; + + for (const [file, result] of runningTests) { + this.moveCursor(lineNum, 0); + this.clearLine(); + const testName = basename(file); + let displayText = ` ${chalk.cyan(spinner)} ${testName}`; + if (result.lastTestName) { + const truncated = this.truncateTestName(result.lastTestName, 40); + displayText += chalk.dim(` โ€บ ${truncated}`); + } + process.stdout.write(`${displayText}\n`); + lineNum++; + } + + // Add blank line after running tests + this.moveCursor(lineNum, 0); + this.clearLine(); + process.stdout.write("\n"); + lineNum++; + } + + // Show stats line + this.moveCursor(lineNum, 0); + this.clearLine(); + process.stdout.write( + `${chalk.cyan(spinner)} Progress: ${chalk.bold(`${completed}/${this.testFiles.length}`)} | ` + + `${chalk.green(`โœ“ ${passed}`)} | ` + + `${failed > 0 ? chalk.red(`โœ— ${failed}`) : chalk.dim(`โœ— ${failed}`)}\n`, + ); + lineNum++; + + // Clear any remaining lines from previous renders + const maxLines = this.getSpacesNeeded(); + while (lineNum < maxLines) { + this.moveCursor(lineNum, 0); + this.clearLine(); + lineNum++; + } + + // Track how many lines we actually used + this.lastRenderedLines = lineNum - this.startLine; + } else { + // Full mode: show all tests + let lineNum = this.startLine; + + for (const file of this.testFiles) { + const result = this.results.get(file); + if (!result) continue; + + this.moveCursor(lineNum, 0); + this.clearLine(); + + const testName = basename(file); + let statusIcon: string; + let displayText: string; + + switch (result.status) { + case "pending": + statusIcon = chalk.dim("โ‹ฏ"); + displayText = chalk.dim(testName); + break; + case "running": + statusIcon = chalk.cyan(spinner); + displayText = testName; + if (result.lastTestName) { + const truncated = this.truncateTestName(result.lastTestName); + displayText += chalk.dim(` โ€บ ${truncated}`); + } + break; + case "passed": + statusIcon = chalk.green("โœ“"); + displayText = chalk.dim(testName); + break; + case "failed": + statusIcon = chalk.red("โœ—"); + displayText = testName; + break; + } + + process.stdout.write(`${statusIcon} ${displayText}\n`); + lineNum++; + } + + // Summary line + const completed = Array.from(this.results.values()).filter( + (r) => r.status === "passed" || r.status === "failed", + ).length; + const failed = Array.from(this.results.values()).filter( + (r) => r.status === "failed", + ).length; + const running = Array.from(this.results.values()).filter( + (r) => r.status === "running", + ).length; + + this.moveCursor(lineNum + 1, 0); + this.clearLine(); + if (running > 0) { + process.stdout.write( + chalk.dim( + `Running: ${running} | Completed: ${completed}/${this.testFiles.length} | Failed: ${failed}`, + ), + ); + } + + // Track how many lines we actually used + this.lastRenderedLines = lineNum + 2 - this.startLine; + } + } + + async runTest(file: string): Promise { + const startTime = performance.now(); + + // Initialize as running + const result: TestResult = { + file, + status: "running", + output: "", + duration: 0, + }; + this.results.set(file, result); + + try { + const proc = spawn(["bun", "test", "--timeout", "0", file], { + stdout: "pipe", + stderr: "pipe", + }); + + let output = ""; + const decoder = new TextDecoder(); + + if (proc.stdout) { + for await (const chunk of proc.stdout) { + const text = decoder.decode(chunk); + output += text; + + // Update last test name + const lastTest = this.extractLastTest(output); + if (lastTest) { + result.lastTestName = lastTest; + result.output = output; + this.results.set(file, result); + } + } + } + + if (proc.stderr) { + for await (const chunk of proc.stderr) { + output += decoder.decode(chunk); + } + } + + await proc.exited; + const duration = performance.now() - startTime; + + if (proc.exitCode === 0) { + this.results.set(file, { + ...result, + status: "passed", + output, + duration, + }); + } else { + this.results.set(file, { + ...result, + status: "failed", + output, + duration, + error: this.extractError(output), + }); + } + } catch (error) { + const duration = performance.now() - startTime; + this.results.set(file, { + ...result, + status: "failed", + output: "", + duration, + error: String(error), + }); + } + } + + private extractError(output: string): string { + const lines = output.split("\n"); + const errorLines: string[] = []; + let inError = false; + let capturedLines = 0; + + for (const line of lines) { + if ( + line.includes("error:") || + line.includes("Error:") || + line.includes("Expected:") || + line.includes("Received:") || + line.includes("AssertionError") + ) { + inError = true; + } + + if (inError) { + errorLines.push(line); + capturedLines++; + + if (capturedLines > 20) break; + } + + if (line.match(/^[\s]*โœ—/)) { + errorLines.push(line); + } + } + + return errorLines.length > 0 ? errorLines.join("\n").trim() : output; + } + + private cleanup() { + if (this.renderInterval) { + clearInterval(this.renderInterval); + } + this.showCursor(); + } + + private handleInterrupt() { + this.cleanup(); + + // Move cursor past all output (use actual rendered lines in compact mode) + const linesToMove = this.compactMode + ? this.lastRenderedLines + : this.getSpacesNeeded(); + process.stdout.write(`\x1B[${linesToMove}B`); + console.log("\n"); + + console.log(chalk.yellow.bold("\nโš  Tests interrupted by user (Ctrl+C)\n")); + + // Print summary of what we have so far + const failedTests = Array.from(this.results.values()).filter( + (t) => t.status === "failed", + ); + const completedTests = Array.from(this.results.values()).filter( + (t) => t.status === "passed" || t.status === "failed", + ); + + console.log( + chalk.dim( + `Completed: ${completedTests.length}/${this.testFiles.length} tests before interruption`, + ), + ); + + if (failedTests.length > 0) { + console.log( + chalk.red.bold( + `\n${"โ•".repeat(70)}\n FAILED TESTS (${failedTests.length})\n${"โ•".repeat(70)}\n`, + ), + ); + + for (const test of failedTests) { + const testName = basename(test.file); + console.log(chalk.red.bold(`\nโœ— ${testName}`)); + console.log(chalk.dim("โ”€".repeat(70))); + + if (test.error) { + const errorLines = test.error.split("\n"); + for (const line of errorLines) { + if (line.trim()) { + if (line.includes("Expected:") || line.includes("Received:")) { + console.log(chalk.yellow(line)); + } else if (line.includes("โœ—")) { + console.log(chalk.red(line)); + } else { + console.log(chalk.dim(line)); + } + } + } + } + } + + console.log( + chalk.red.bold( + `\n${"โ•".repeat(70)}\n ${failedTests.length} test file(s) failed\n${"โ•".repeat(70)}\n`, + ), + ); + } + + process.exit(130); // Standard exit code for SIGINT + } + + async run(directories: string[]): Promise { + this.testFiles = await this.collectTestFiles(directories); + + if (this.testFiles.length === 0) { + console.log(chalk.yellow("No test files found in specified directories")); + return; + } + + console.log( + chalk.bold(`\nRunning ${this.testFiles.length} test file(s)...\n`), + ); + + // Initialize all tests as pending + for (const file of this.testFiles) { + this.results.set(file, { + file, + status: "pending", + output: "", + duration: 0, + }); + } + + // Setup SIGINT handler + const sigintHandler = () => this.handleInterrupt(); + process.on("SIGINT", sigintHandler); + + // Hide cursor and create space for all tests + this.hideCursor(); + this.startLine = 1; // Start from line 1 + + // Create space - less space needed in compact mode + const spacesNeeded = this.getSpacesNeeded(); + this.lastRenderedLines = spacesNeeded; // Initialize to full space + for (let i = 0; i < spacesNeeded; i++) { + console.log(); + } + + // Move cursor back up to start rendering + process.stdout.write(`\x1B[${spacesNeeded}A`); + + // Start rendering loop + this.renderInterval = setInterval(() => this.render(), 100); + + // Run tests with concurrency limit + const limit = pLimit(this.maxParallel); + const promises = this.testFiles.map((file) => + limit(() => this.runTest(file)), + ); + + await Promise.all(promises); + + // Remove SIGINT handler + process.off("SIGINT", sigintHandler); + + // Final render + this.cleanup(); + this.render(); + + // Move cursor past all output (use actual rendered lines in compact mode) + const linesToMove = this.compactMode + ? this.lastRenderedLines + : this.getSpacesNeeded(); + process.stdout.write(`\x1B[${linesToMove}B`); + console.log("\n"); + + // Print summary + this.printSummary(); + } + + private printSummary() { + const failedTests = Array.from(this.results.values()).filter( + (t) => t.status === "failed", + ); + + if (failedTests.length === 0) { + console.log( + chalk.green.bold(`โœ“ All ${this.testFiles.length} test file(s) passed!`), + ); + process.exit(0); + } + + console.log( + chalk.red.bold( + `\n${"โ•".repeat(70)}\n FAILED TESTS (${failedTests.length}/${this.testFiles.length})\n${"โ•".repeat(70)}\n`, + ), + ); + + for (const test of failedTests) { + const testName = basename(test.file); + console.log(chalk.red.bold(`\nโœ— ${testName}`)); + console.log(chalk.dim("โ”€".repeat(70))); + + if (test.error) { + const errorLines = test.error.split("\n"); + for (const line of errorLines) { + if (line.trim()) { + if (line.includes("Expected:") || line.includes("Received:")) { + console.log(chalk.yellow(line)); + } else if (line.includes("โœ—")) { + console.log(chalk.red(line)); + } else { + console.log(chalk.dim(line)); + } + } + } + } + } + + console.log( + chalk.red.bold( + `\n${"โ•".repeat(70)}\n ${failedTests.length} test file(s) failed\n${"โ•".repeat(70)}\n`, + ), + ); + process.exit(1); + } +} + +// Parse CLI arguments +const args = process.argv.slice(2); +const directories: string[] = []; +let maxParallel = 6; +let compactMode = false; + +for (const arg of args) { + if (arg.startsWith("--max=")) { + maxParallel = Number.parseInt(arg.split("=")[1], 10); + } else if (arg === "--compact") { + compactMode = true; + } else if (arg.startsWith("-")) { + console.error(chalk.red(`Unknown option: ${arg}`)); + console.log( + "Usage: bun scripts/testScripts/runTests.ts [dir2] [...] [--max=N] [--compact]", + ); + process.exit(1); + } else { + directories.push(arg); + } +} + +if (directories.length === 0) { + console.error(chalk.red("Error: No test directories specified")); + console.log( + "Usage: bun scripts/testScripts/runTests.ts [dir2] [...] [--max=N] [--compact]", + ); + console.log("\nOptions:"); + console.log(" --max=N Set maximum parallel test files (default: 6)"); + console.log( + " --compact Use compact mode (only show summary and failures)", + ); + console.log("\nExamples:"); + console.log( + " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade", + ); + console.log( + " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade server/tests/attach/downgrade", + ); + console.log( + " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --max=10", + ); + console.log( + " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --compact", + ); + process.exit(1); +} + +// Run tests +const runner = new TestRunner(maxParallel, compactMode); +await runner.run(directories); diff --git a/server/run.sh b/server/run.sh index 891ab4890..5303839ce 100755 --- a/server/run.sh +++ b/server/run.sh @@ -14,11 +14,8 @@ elif [[ "$filename" == *"/tests/"* ]]; then path_after_tests=$(echo "$filename" | sed 's/.*\/tests\///') # Remove .ts extension if present path_after_tests="${path_after_tests%.ts}" - if [ -n "$2" ]; then - ./test.sh custom "$path_after_tests" "$2" - else - ./test.sh custom "$path_after_tests" - fi + # Use scripts/test.ts which auto-detects framework + bun ../scripts/test.ts "$path_after_tests" elif [[ "$filename" == *".sh"* ]]; then "$filename" diff --git a/server/shell/config.sh b/server/shell/config.sh index a6f94394e..92eff5806 100755 --- a/server/shell/config.sh +++ b/server/shell/config.sh @@ -1,4 +1,33 @@ #!/bin/bash -MOCHA_SETUP="bunx mocha --timeout 10000000 tests/00_setup.ts" -MOCHA_CMD="bunx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts" \ No newline at end of file +# Get project root directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVER_DIR="$SCRIPT_DIR/.." +PROJECT_ROOT="$SERVER_DIR/.." + +# Find bun executable +if command -v bun &> /dev/null; then + BUN_CMD="bun" +elif [ -f "$HOME/.bun/bin/bun" ]; then + BUN_CMD="$HOME/.bun/bin/bun" +elif [ -f "/usr/local/bin/bun" ]; then + BUN_CMD="/usr/local/bin/bun" +else + echo "Error: bun not found. Please install bun or add it to PATH." + exit 1 +fi + +# Setup function +BUN_SETUP="$BUN_CMD tests/setupMain.ts" + +# Test runner functions (using new TypeScript runner) +BUN_PARALLEL() { + cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTests.ts "$@" +} + +BUN_PARALLEL_COMPACT() { + cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTests.ts "$@" --compact +} + +# Mocha command (for tests not yet migrated) +MOCHA_CMD="npx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts" \ No newline at end of file diff --git a/server/shell/g1.sh b/server/shell/g1.sh index 6d0569571..77638166c 100755 --- a/server/shell/g1.sh +++ b/server/shell/g1.sh @@ -1,23 +1,26 @@ #!/bin/bash +# Test Group 1: Upgrade & Downgrade Tests +# Description: Tests for product upgrades and downgrades + # Source shared configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/config.sh" -# If contains setup then run $MOCHA_SETUP +# Setup if requested if [[ "$1" == *"setup"* ]]; then - MOCHA_PARALLEL=true $MOCHA_SETUP + echo "Running test setup..." + $BUN_SETUP fi -$MOCHA_CMD \ -'tests/attach/basic/*.ts' \ -'tests/attach/upgrade/*.ts' \ -'tests/attach/downgrade/*.ts' - -$MOCHA_CMD \ -'tests/attach/checkout/*.ts' \ -'tests/attach/entities/*.ts' \ -'tests/attach/free/*.ts'\ -'tests/attach/addOn/*.ts' \ - - +# Run tests using TypeScript runner with compact mode +# Adjust --max to control concurrency (default: 6) +$BUN_PARALLEL_COMPACT \ + 'tests/check/basic' \ + 'tests/attach/basic' \ + 'tests/attach/upgrade' \ + 'tests/attach/downgrade' \ + 'tests/attach/free' \ + 'tests/attach/addOn' \ + 'tests/attach/entities' \ + 'tests/attach/checkout' \ No newline at end of file diff --git a/server/shell/g5.sh b/server/shell/g5.sh index f083cf29f..11b1c5c1f 100755 --- a/server/shell/g5.sh +++ b/server/shell/g5.sh @@ -8,16 +8,16 @@ if [[ "$1" == *"setup"* ]]; then MOCHA_PARALLEL=true $MOCHA_SETUP fi -$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ - 'tests/advanced/coupons/*.ts' \ - 'tests/attach/updateQuantity/*.ts' \ - 'tests/advanced/referrals/*.ts' \ - 'tests/advanced/referrals/paid/*.ts' \ - 'tests/advanced/rollovers/*.ts' \ - 'tests/advanced/customInterval/*.ts' +# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ +# 'tests/advanced/coupons/*.ts' \ +# 'tests/attach/updateQuantity/*.ts' \ +# 'tests/advanced/referrals/*.ts' \ +# 'tests/advanced/referrals/paid/*.ts' \ +# 'tests/advanced/rollovers/*.ts' \ +# 'tests/advanced/customInterval/*.ts' -$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ - 'tests/advanced/usageLimit/*.ts' +# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \ +# 'tests/advanced/usageLimit/*.ts' $MOCHA_CMD 'tests/advanced/usage/*.ts' diff --git a/server/shell/run-parallel.sh b/server/shell/run-parallel.sh new file mode 100755 index 000000000..ad50bbf14 --- /dev/null +++ b/server/shell/run-parallel.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash + +# Run Bun test files in parallel with proper error reporting +# Usage: ./run-parallel.sh [test_directory2] [...] [--max=N] + +if [ $# -eq 0 ]; then + echo "Error: No test directories specified" + echo "Usage: ./run-parallel.sh [test_directory2] [...] [--max=N]" + exit 1 +fi + +# Parse arguments +TEST_DIRS=() +MAX_PARALLEL=6 + +for arg in "$@"; do + if [[ "$arg" == --max=* ]]; then + MAX_PARALLEL="${arg#*=}" + else + if [ ! -d "$arg" ]; then + echo "Error: Directory '$arg' not found" + exit 1 + fi + TEST_DIRS+=("$arg") + fi +done + +if [ ${#TEST_DIRS[@]} -eq 0 ]; then + echo "Error: No valid test directories specified" + exit 1 +fi + +TEMP_DIR=$(mktemp -d) +FAILED_DIR="$TEMP_DIR/failed" +STATUS_DIR="$TEMP_DIR/status" +mkdir -p "$FAILED_DIR" "$STATUS_DIR" + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' # No Color + +# Spinner frames +SPINNER_FRAMES=('โ ‹' 'โ ™' 'โ น' 'โ ธ' 'โ ผ' 'โ ด' 'โ ฆ' 'โ ง' 'โ ‡' 'โ ') +SPINNER_FRAME=0 +NUM_SPINNER_FRAMES=${#SPINNER_FRAMES[@]} + +# Collect all test files first +TEST_FILES=() +for TEST_DIR in "${TEST_DIRS[@]}"; do + for test_file in "$TEST_DIR"/*.test.ts; do + if [ -f "$test_file" ]; then + TEST_FILES+=("$test_file") + # Create status file + echo "pending" > "$STATUS_DIR/$(basename "$test_file").status" + fi + done +done + +# Check if any tests were found +if [ ${#TEST_FILES[@]} -eq 0 ]; then + echo "No test files found in specified directories" + rm -rf "$TEMP_DIR" + exit 0 +fi + +# Helper function to get status +get_status() { + local test_file="$1" + local test_name=$(basename "$test_file") + local status_file="$STATUS_DIR/${test_name}.status" + if [ -f "$status_file" ]; then + cat "$status_file" + else + echo "pending" + fi +} + +# Helper function to set status +set_status() { + local test_file="$1" + local status="$2" + local test_name=$(basename "$test_file") + echo "$status" > "$STATUS_DIR/${test_name}.status" +} + +# Cleanup function +cleanup() { + # Stop spinner + if [ ! -z "$SPINNER_PID" ]; then + kill $SPINNER_PID 2>/dev/null || true + wait $SPINNER_PID 2>/dev/null || true + fi + + # Kill all descendant processes + pkill -P $$ 2>/dev/null || true + pkill -f "bun test" 2>/dev/null || true + jobs -p | while read pid; do kill -9 $pid 2>/dev/null || true; done + + # Show cursor again + tput cnorm 2>/dev/null || true + + # Clean up temp directory + rm -rf "$TEMP_DIR" + exit 130 +} + +# Set up signal handlers +trap cleanup SIGINT SIGTERM EXIT + +# Function to render the test list +render_tests() { + local line_num=1 + + # Save cursor position + tput sc 2>/dev/null || true + + for test_file in "${TEST_FILES[@]}"; do + local test_name=$(basename "$test_file") + local status=$(get_status "$test_file") + local display_name="${test_name}" + + # Move to the line + tput cup $((line_num - 1)) 0 2>/dev/null || true + + # Clear line + tput el 2>/dev/null || true + + case "$status" in + "pending") + echo -ne "${DIM}โ‹ฏ${NC} ${DIM}${display_name}${NC}" + ;; + "running") + local frame_idx=$((SPINNER_FRAME % NUM_SPINNER_FRAMES)) + local spinner_char="${SPINNER_FRAMES[$frame_idx]}" + echo -ne "${CYAN}${spinner_char}${NC} ${display_name}" + ;; + "passed") + echo -ne "${GREEN}โœ“${NC} ${DIM}${display_name}${NC}" + ;; + "failed") + echo -ne "${RED}โœ—${NC} ${display_name}" + ;; + esac + + ((line_num++)) + done + + # Restore cursor position + tput rc 2>/dev/null || true +} + +# Function to update spinner animation +animate_spinner() { + while true; do + SPINNER_FRAME=$((SPINNER_FRAME + 1)) + render_tests + sleep 0.1 + done +} + +# Function to run a test +run_test() { + local test_file=$1 + local test_name=$(basename "$test_file") + local output_file="$TEMP_DIR/$test_name.log" + + # Mark as running + set_status "$test_file" "running" + + # Run the test + if script -q /dev/null bash -c "FORCE_COLOR=3 bun test --timeout 0 '$test_file' 2>&1" > "$output_file"; then + set_status "$test_file" "passed" + return 0 + else + set_status "$test_file" "failed" + echo "$test_file|$output_file" > "$FAILED_DIR/$test_name.failed" + return 1 + fi +} + +# Hide cursor +tput civis 2>/dev/null || true + +# Initial render - create space for all tests +echo "" +for test_file in "${TEST_FILES[@]}"; do + echo "" +done + +# Move cursor back up +tput cuu ${#TEST_FILES[@]} 2>/dev/null || true + +# Start spinner animation in background +animate_spinner & +SPINNER_PID=$! + +# Run tests in parallel +count=0 +for test_file in "${TEST_FILES[@]}"; do + # Wait if we've hit max parallel + while [ $(jobs -r | wc -l) -ge $((MAX_PARALLEL + 1)) ]; do + sleep 0.1 + done + + run_test "$test_file" & + ((count++)) +done + +# Wait for all tests to complete (exclude spinner process) +for job in $(jobs -p); do + if [ "$job" != "$SPINNER_PID" ]; then + wait $job 2>/dev/null || true + fi +done + +# Stop spinner +if [ ! -z "$SPINNER_PID" ]; then + kill $SPINNER_PID 2>/dev/null || true + wait $SPINNER_PID 2>/dev/null || true +fi + +# Final render +render_tests + +# Move cursor below test list +echo "" +echo "" + +# Show cursor again +tput cnorm 2>/dev/null || true + +# Report failures +FAILED_COUNT=$(ls "$FAILED_DIR"/*.failed 2>/dev/null | wc -l) +if [ $FAILED_COUNT -gt 0 ]; then + echo -e "${RED}${BOLD}========================================" + echo -e "FAILED TESTS ($FAILED_COUNT/${count}):" + echo -e "========================================${NC}" + echo "" + + # Show detailed errors + for failure_file in "$FAILED_DIR"/*.failed; do + IFS='|' read -r test_file output_file < "$failure_file" + echo -e "${RED}${BOLD}โœ— $(basename $test_file)${NC}" + echo -e "${DIM}โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€${NC}" + cat "$output_file" + echo "" + done + rm -rf "$TEMP_DIR" + + # Remove trap before exit to prevent double cleanup + trap - SIGINT SIGTERM EXIT + exit 1 +else + echo -e "${GREEN}${BOLD}โœ“ All tests passed!${NC} ${CYAN}($count tests)${NC}" + rm -rf "$TEMP_DIR" + + # Remove trap before exit to prevent double cleanup + trap - SIGINT SIGTERM EXIT + exit 0 +fi diff --git a/server/src/db/cteUtils/strategies/relationGraph.ts b/server/src/db/cteUtils/strategies/relationGraph.ts index 5166ccf67..f1cca1de5 100644 --- a/server/src/db/cteUtils/strategies/relationGraph.ts +++ b/server/src/db/cteUtils/strategies/relationGraph.ts @@ -1,4 +1,4 @@ -import { type SQL, sql } from "drizzle-orm"; +import { type SQL } from "drizzle-orm"; import type { PgTable } from "drizzle-orm/pg-core"; import type { CTEConfig } from "../buildCte.js"; import { CTEBuilder } from "../buildCte.js"; @@ -47,6 +47,7 @@ export function parseJoinCondition({ // Extract column names from SQL // Expected format: "target_table.child_key = parent_table.parent_key" try { + // @ts-expect-error const sqlString = joinCondition.getSQL().sql; // Try to parse pattern: table.column = table.column diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 6b06f20cc..41f7629a3 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -463,6 +463,13 @@ export class AutumnInt { }, }; + organization = { + resetDefaultAccount: async () => { + const data = await this.post(`/organization/reset_default_account`, {}); + return data; + }, + }; + track = async (params: TrackParams & { timestamp?: number }) => { const data = await this.post(`/track`, params); return data; diff --git a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts index c3caae123..3e8659df1 100644 --- a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts +++ b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts @@ -260,15 +260,18 @@ export const createStripeInArrearPrice = async ({ }; } + const recurringData = billingIntervalToStripe({ + interval: price.config.interval!, + intervalCount: price.config.interval_count, + }); + const stripePrice = await stripeCli.prices.create({ ...productData, ...priceAmountData, currency: org.default_currency || "usd", recurring: { - ...billingIntervalToStripe({ - interval: price.config.interval, - intervalCount: price.config.interval_count, - }), + interval: recurringData.interval, + interval_count: recurringData.interval_count, meter: meter.id, usage_type: "metered", }, diff --git a/server/src/external/stripe/stripePriceUtils.ts b/server/src/external/stripe/stripePriceUtils.ts index dc3002574..01d2d52c7 100644 --- a/server/src/external/stripe/stripePriceUtils.ts +++ b/server/src/external/stripe/stripePriceUtils.ts @@ -136,3 +136,21 @@ export const getPlaceholderItem = ({ quantity: 0, }; }; + +export const createEmptySubItem = ({ + recurring, + stripeProductId, +}: { + recurring: Stripe.PriceCreateParams.Recurring; + stripeProductId: string; +}) => { + return { + price_data: { + product: stripeProductId, + unit_amount: 1, + currency: "usd", + recurring, + }, + quantity: 0, + } satisfies Stripe.SubscriptionCreateParams.Item; +}; diff --git a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts index a8f958542..1978eeda2 100644 --- a/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/convertSubUtils.ts @@ -1,4 +1,4 @@ -import Stripe from "stripe"; +import type Stripe from "stripe"; export const getLatestPeriodEnd = ({ sub, @@ -34,7 +34,7 @@ export const getEarliestPeriodStart = ({ }: { sub: Stripe.Subscription; }) => { - if (sub.items.data.length == 0) { + if (sub.items.data.length === 0) { return Date.now(); } @@ -43,7 +43,7 @@ export const getEarliestPeriodStart = ({ }, sub.items.data[0].current_period_start); }; export const getLatestPeriodStart = ({ sub }: { sub: Stripe.Subscription }) => { - if (sub.items.data.length == 0) { + if (sub.items.data.length === 0) { return Date.now(); } @@ -53,7 +53,7 @@ export const getLatestPeriodStart = ({ sub }: { sub: Stripe.Subscription }) => { }; export const subToPeriodStartEnd = ({ sub }: { sub?: Stripe.Subscription }) => { - if (!sub || sub.items.data.length == 0) { + if (!sub || sub.items.data.length === 0) { return { start: Date.now(), end: Date.now(), diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index ab27e56dc..cdd63c104 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -1,6 +1,5 @@ import { ApiVersion, - AppEnv, type Customer, EntInterval, type FullCusProduct, @@ -21,7 +20,6 @@ import { submitUsageToStripe } from "../../stripeMeterUtils.js"; import { getInvoiceItemForUsage } from "../../stripePriceUtils.js"; import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js"; import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js"; -import { getAllFullCustomers } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js"; export const handleUsagePrices = async ({ db, @@ -136,14 +134,6 @@ export const handleUsagePrices = async ({ return; } - const allFullCustomers = await getAllFullCustomers({ - db, - orgId: org.id, - env: AppEnv.Live, - }); - - console.log(`All full customers: ${allFullCustomers.length}`); - const ent = relatedCusEnt.entitlement; const resetBalancesUpdate = getResetBalancesUpdate({ diff --git a/server/src/external/webhooks/connectWebhookRouter.ts b/server/src/external/webhooks/connectWebhookRouter.ts index 2d2b61e56..26f40bc21 100644 --- a/server/src/external/webhooks/connectWebhookRouter.ts +++ b/server/src/external/webhooks/connectWebhookRouter.ts @@ -14,6 +14,7 @@ import { import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; +import { handleWebhookErrorSkip } from "../../utils/routerUtils/webhookErrorSkip.js"; import { handleStripeWebhookEvent } from "../stripe/handleStripeWebhookEvent.js"; export const connectWebhookRouter: Router = express.Router(); @@ -117,7 +118,10 @@ export const handleConnectWebhook = async (c: Context) => { }); return c.json({ message: "Webhook received" }, 200); } catch (error) { - logger.error(`Stripe webhook, error: ${error}`, { error }); + const shouldSkip = handleWebhookErrorSkip({ error, logger }); + if (!shouldSkip) { + logger.error(`Stripe webhook, error: ${error}`, { error }); + } return c.json({ message: "Webhook received, internal server error" }, 200); } }; diff --git a/server/src/honoMiddlewares/errorSkipMiddleware.ts b/server/src/honoMiddlewares/errorSkipMiddleware.ts index 22890ec70..121bf19b6 100644 --- a/server/src/honoMiddlewares/errorSkipMiddleware.ts +++ b/server/src/honoMiddlewares/errorSkipMiddleware.ts @@ -113,6 +113,14 @@ const STRIPE_RULES = [ statusCode: 400, code: ErrCode.InvalidRequest, }, + { + name: "Not a valid URL error", + match: (err: Error) => + err instanceof Stripe.errors.StripeError && + err.message.includes("Not a valid URL"), + statusCode: 400, + code: ErrCode.InvalidRequest, + }, ] as const; /** Zod-specific error handling rules */ @@ -124,6 +132,13 @@ const ZOD_RULES = [ statusCode: 400, format: (err: ZodError) => formatZodError(err), }, + { + name: "Zod error on /checkout (email validation)", + match: (err: Error, c: Context) => + err instanceof ZodError && c.req.url.includes("/checkout"), + statusCode: 400, + format: (err: ZodError) => formatZodError(err), + }, ] as const; const createErrorResponse = ({ diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 507f3ab60..ceb584fc7 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -18,7 +18,6 @@ import { cusRouter } from "./internal/customers/cusRouter.js"; import { internalCusRouter } from "./internal/customers/internalCusRouter.js"; import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js"; import { honoOrgRouter } from "./internal/orgs/orgRouter.js"; -import { honoPlatformRouter } from "./internal/platform/honoPlatformRouter.js"; import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.js"; import { internalProductRouter } from "./internal/products/internalProductRouter.js"; import { honoProductRouter } from "./internal/products/productRouter.js"; @@ -96,7 +95,7 @@ export const createHonoApp = () => { app.post("/v1/check", ...handleCheck); app.route("v1/customers", cusRouter); app.route("v1/products", honoProductRouter); - app.route("v1/platform", honoPlatformRouter); + app.route("v1/platform", platformBetaRouter); app.route("v1/platform/beta", platformBetaRouter); app.route("v1/organization", honoOrgRouter); diff --git a/server/src/internal/analytics/analyticsRouter.ts b/server/src/internal/analytics/analyticsRouter.ts index a52a19757..581053cef 100644 --- a/server/src/internal/analytics/analyticsRouter.ts +++ b/server/src/internal/analytics/analyticsRouter.ts @@ -1,12 +1,12 @@ -import { AnalyticsService } from "@/internal/analytics/AnalyticsService.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { ErrCode, FullCustomer } from "@autumn/shared"; +import { ErrCode, type FullCustomer } from "@autumn/shared"; import { format } from "date-fns"; import { Router } from "express"; import { StatusCodes } from "http-status-codes"; import z from "zod"; +import { AnalyticsService } from "@/internal/analytics/AnalyticsService.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; const analyticsRouter = Router(); @@ -60,7 +60,7 @@ analyticsRouter.post("", (req, res) => if (Array.isArray(feature_id)) featureIds = feature_id; else featureIds = [feature_id]; - let events = await AnalyticsService.getTimeseriesEvents({ + const events = await AnalyticsService.getTimeseriesEvents({ req, params: { interval: range, @@ -83,7 +83,7 @@ analyticsRouter.post("", (req, res) => event["period"] = parseInt(format(new Date(event["period"]), "T")); }); - let usageList = events.data.filter( + const usageList = events.data.filter( (event: any) => event.period <= Date.now(), ); diff --git a/server/src/internal/analytics/internalAnalyticsRouter.ts b/server/src/internal/analytics/internalAnalyticsRouter.ts index 7e921074b..f49e018ae 100644 --- a/server/src/internal/analytics/internalAnalyticsRouter.ts +++ b/server/src/internal/analytics/internalAnalyticsRouter.ts @@ -188,8 +188,6 @@ analyticsRouter.post("/events", async (req: any, res: any) => aggregateAll, }); - console.log("events", events); - res.status(200).json({ customer, events, diff --git a/server/src/internal/api/check/handleCheck.ts b/server/src/internal/api/check/handleCheck.ts index f97693ef6..b1ca74c9f 100644 --- a/server/src/internal/api/check/handleCheck.ts +++ b/server/src/internal/api/check/handleCheck.ts @@ -1,4 +1,10 @@ -import { type CheckParams, CheckParamsSchema } from "@autumn/shared"; +import { + AffectedResource, + applyResponseVersionChanges, + type CheckParams, + CheckParamsSchema, + type CheckResult, +} from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { getCheckData } from "./checkUtils/getCheckData.js"; import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js"; @@ -82,7 +88,19 @@ export const handleCheck = createRoute({ requiredBalance, }); - return c.json(v2Response); + console.log("API Version:", ctx.apiVersion.value); + // console.log("V2 Response:", v2Response); + + // Apply version transformations based on API version + const transformedResponse = applyResponseVersionChanges({ + input: v2Response, + targetVersion: ctx.apiVersion, + resource: AffectedResource.Check, + }); + + // console.log("Transformed Response:", transformedResponse); + + return c.json(transformedResponse); // const { allowed, balance } = v2Response; // const featureToUse = allFeatures.find( @@ -151,7 +169,6 @@ export const handleCheck = createRoute({ // }); // } - return c.json({ success: true }); // const body = c.req.valid("json"); // const res = await handleCheck(body); // return c.json(res); diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index d6372d5cb..4f14a7a28 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -8,11 +8,11 @@ import { ErrCode, type FeatureOptions, type FullCusProduct, + productsAreSame, } from "@autumn/shared"; import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; -import { productsAreSame } from "@/internal/products/productUtils/compareProductUtils.js"; import { isFreeProduct, isProductUpgrade, diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts index 42e323a00..1a678a4e5 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts @@ -1,37 +1,34 @@ -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; - import { - AttachReplaceable, + type AttachReplaceable, BillingType, - FullCusProduct, - FullCustomerEntitlement, - FullEntitlement, + type FullCusProduct, + type FullCustomerEntitlement, + type FullEntitlement, getFeatureInvoiceDescription, - intervalsSame, - PreviewLineItem, - Price, + type PreviewLineItem, + type Price, } from "@autumn/shared"; -import Stripe from "stripe"; -import { attachParamsToProduct } from "../convertAttachParams.js"; -import { - getBillingType, - getPriceEntitlement, - getPriceForOverage, -} from "@/internal/products/prices/priceUtils.js"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { findCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import { getExistingUsageFromCusProducts, getRelatedCusPrice, } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { subToAutumnInterval } from "@/external/stripe/utils.js"; -import { Decimal } from "decimal.js"; -import { getCurContUseItems } from "@/internal/invoices/previewItemUtils/getCurContUseItems.js"; -import { intervalsAreSame } from "../getAttachConfig.js"; import { newPriceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js"; +import { getCurContUseItems } from "@/internal/invoices/previewItemUtils/getCurContUseItems.js"; import { getDefaultPriceStr } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; -import { priceToContUseItem } from "./priceToContUseItem.js"; import { shouldProrate } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js"; +import { + getBillingType, + getPriceEntitlement, + getPriceForOverage, +} from "@/internal/products/prices/priceUtils.js"; +import { attachParamsToProduct } from "../convertAttachParams.js"; +import { intervalsAreSame } from "../getAttachConfig.js"; +import { priceToContUseItem } from "./priceToContUseItem.js"; export const getContUseNewItems = async ({ price, @@ -56,14 +53,14 @@ export const getContUseNewItems = async ({ internalEntityId: attachParams.internalEntityId, }); - let description = newPriceToInvoiceDescription({ + const description = newPriceToInvoiceDescription({ org, price, product: newProduct, quantity: usage, }); - if (usage == 0) { + if (usage === 0) { return { price_id: price.id, price: getDefaultPriceStr({ org, price, ent, features }), @@ -80,8 +77,8 @@ export const getContUseNewItems = async ({ prevCusEnt && !shouldProrate(price.proration_config?.on_decrease) ) { - let isDowngrade = ent.allowance! > prevCusEnt.entitlement.allowance!; - let prevBalance = prevCusEnt.balance!; + const isDowngrade = ent.allowance! > prevCusEnt.entitlement.allowance!; + const prevBalance = prevCusEnt.balance!; if (isDowngrade && prevBalance < 0) { overage = new Decimal(prevBalance).abs().toNumber(); @@ -89,8 +86,8 @@ export const getContUseNewItems = async ({ } } - let amount = getPriceForOverage(price, overage); - let description = getFeatureInvoiceDescription({ + const amount = getPriceForOverage(price, overage); + const description = getFeatureInvoiceDescription({ feature: ent.feature, usage: usage, prodName: newProduct.name, @@ -130,28 +127,33 @@ export const getContUseInvoiceItems = async ({ }) : []; - let newEnts = product.entitlements; - let oldItems: PreviewLineItem[] = []; - let newItems: PreviewLineItem[] = []; - let replaceables: AttachReplaceable[] = []; + const newEnts = product.entitlements; + const oldItems: PreviewLineItem[] = []; + const newItems: PreviewLineItem[] = []; + const replaceables: AttachReplaceable[] = []; for (const price of product.prices) { - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); if (billingType !== BillingType.InArrearProrated) { continue; } - let ent = getPriceEntitlement(price, newEnts); - let prevCusEnt = findCusEnt({ + const ent = getPriceEntitlement(price, newEnts); + + const prevCusEnt = findCusEnt({ cusEnts, feature: ent.feature, }); - let prevCusPrice = prevCusEnt + const prevCusPrice = prevCusEnt ? getRelatedCusPrice(prevCusEnt, cusPrices)! : undefined; - if (!prevCusEnt || !sub) { + const curItem = curItems.find( + (item) => item.price_id === prevCusPrice?.price.id, + ); + + if (!prevCusEnt || !sub || !curItem) { const newItem = await getContUseNewItems({ price, ent, @@ -159,24 +161,20 @@ export const getContUseInvoiceItems = async ({ prevCusEnt, }); - const prevItem = curItems.find( - (item) => item.price_id === prevCusPrice?.price.id, - ); + // const prevItem = curItems.find( + // (item) => item.price_id === prevCusPrice?.price.id, + // ); newItems.push(newItem); - if (prevItem) { - oldItems.push(prevItem); + if (curItem) { + oldItems.push(curItem); } continue; } - const curItem = curItems.find( - (item) => item.price_id === prevCusPrice?.price.id, - ); - - let { + const { oldItem, newItems: newItems_, replaceables: replaceables_, diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts index ca01f99d7..510008c02 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts @@ -132,8 +132,8 @@ export const priceToContUseItem = async ({ // Clean up items // 1. If old item and new item same, remove both - const oldAmount = res.oldItem?.amount ?? 0; - const newAmount = res.newItem?.amount ?? 0; + const oldAmount = res.oldItem?.amount!; + const newAmount = res.newItem?.amount!; if (new Decimal(oldAmount).add(newAmount).eq(0)) { return { diff --git a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts index 8212075c0..be06b5707 100644 --- a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts +++ b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts @@ -68,7 +68,7 @@ export const mapOptionsList = ({ if (isOneOff(prices) || isFreeProduct(prices)) return newOptionsList; const curOptionsList = curCusProduct?.options || []; - console.log("Current options list:", curOptionsList); + for (const option of curOptionsList) { const inNewOptions = newOptionsList.find( (newOption) => newOption.feature_id === option.feature_id, @@ -84,7 +84,5 @@ export const mapOptionsList = ({ } } - console.log(`New options list:`, newOptionsList); - return newOptionsList; }; diff --git a/server/src/internal/features/creditSystemUtils.ts b/server/src/internal/features/creditSystemUtils.ts index aa8f6e00a..7fdbbc8d8 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -1,6 +1,8 @@ -import { CreditSchemaItem, FeatureType } from "@autumn/shared"; - -import { Feature } from "@autumn/shared"; +import { + type CreditSchemaItem, + type Feature, + FeatureType, +} from "@autumn/shared"; import { Decimal } from "decimal.js"; export const creditSystemContainsFeature = ({ @@ -30,8 +32,8 @@ export const getCreditSystemsFromFeature = ({ }) => { return features.filter( (f) => - f.type == FeatureType.CreditSystem && - f.id != featureId && + f.type === FeatureType.CreditSystem && + f.id !== featureId && creditSystemContainsFeature({ creditSystem: f, meteredFeatureId: featureId, @@ -52,8 +54,8 @@ export const featureToCreditSystem = ({ for (const schemaItem of schema) { if (schemaItem.metered_feature_id === featureId) { - let creditAmount = schemaItem.credit_amount; - let featureAmount = schemaItem.feature_amount; + const creditAmount = schemaItem.credit_amount; + const featureAmount = schemaItem.feature_amount; return new Decimal(creditAmount) .div(featureAmount) @@ -64,3 +66,26 @@ export const featureToCreditSystem = ({ return amount; }; + +export const getCreditCost = ({ + featureId, + creditSystem, + amount, +}: { + featureId: string; + creditSystem: Feature; + amount: number; +}) => { + const schema: CreditSchemaItem[] = creditSystem.config.schema; + + for (const schemaItem of schema) { + if (schemaItem.metered_feature_id === featureId) { + return new Decimal(schemaItem.credit_amount) + .div(schemaItem.feature_amount) + .mul(amount) + .toNumber(); + } + } + + return 1; +}; diff --git a/server/src/internal/features/handlers/handleDeleteFeature.ts b/server/src/internal/features/handlers/handleDeleteFeature.ts index e70171dee..7e76555c7 100644 --- a/server/src/internal/features/handlers/handleDeleteFeature.ts +++ b/server/src/internal/features/handlers/handleDeleteFeature.ts @@ -62,6 +62,6 @@ export const handleDeleteFeature = async (req: any, res: any) => env: req.env, }); - res.status(200).json({ message: "Feature deleted" }); + res.status(200).json({ success: true }); }, }); diff --git a/server/src/internal/features/utils/constructFeatureUtils.ts b/server/src/internal/features/utils/constructFeatureUtils.ts index cc4a4c4a3..5794eb555 100644 --- a/server/src/internal/features/utils/constructFeatureUtils.ts +++ b/server/src/internal/features/utils/constructFeatureUtils.ts @@ -1,9 +1,11 @@ -import { AggregateType } from "@autumn/shared"; - -import { AppEnv, FeatureType, FeatureUsageType } from "@autumn/shared"; - +import { + AggregateType, + type AppEnv, + type Feature, + FeatureType, + FeatureUsageType, +} from "@autumn/shared"; import { generateId, keyToTitle } from "@/utils/genUtils.js"; -import { Feature } from "@autumn/shared"; export const constructFeature = ({ id, @@ -22,7 +24,7 @@ export const constructFeature = ({ config: any; display: any; }) => { - let newFeature: Feature = { + const newFeature: Feature = { internal_id: generateId("fe"), id, name, @@ -33,6 +35,7 @@ export const constructFeature = ({ config, display, archived: false, + event_names: [], }; return newFeature; @@ -49,7 +52,7 @@ export const constructBooleanFeature = ({ env: AppEnv; name?: string; }) => { - let newFeature: Feature = { + const newFeature: Feature = { internal_id: generateId("fe"), org_id: orgId, env, @@ -60,6 +63,7 @@ export const constructBooleanFeature = ({ type: FeatureType.Boolean, config: null, archived: false, + event_names: [], }; return newFeature; @@ -78,7 +82,7 @@ export const constructMeteredFeature = ({ env: AppEnv; usageType: FeatureUsageType; }) => { - let newFeature: Feature = { + const newFeature: Feature = { internal_id: generateId("fe"), org_id: orgId, env, @@ -102,6 +106,7 @@ export const constructMeteredFeature = ({ usage_type: usageType, }, archived: false, + event_names: [], }; return newFeature; @@ -132,7 +137,7 @@ export const constructCreditSystem = ({ usage_type: FeatureUsageType.Single, }; - let newFeature: Feature = { + const newFeature: Feature = { internal_id: generateId("fe"), org_id: orgId, env, @@ -143,6 +148,7 @@ export const constructCreditSystem = ({ type: FeatureType.CreditSystem, config, archived: false, + event_names: [], }; return newFeature; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts index 4fb168344..9b813d4b0 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts @@ -1,28 +1,24 @@ -import Stripe from "stripe"; - -import { attachParamToCusProducts } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; -import { cusProductToPrices } from "@autumn/shared"; -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; - -import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import { - AttachBranch, - AttachConfig, + type AttachBranch, + type AttachConfig, BillingType, - PreviewLineItem, + cusProductToPrices, + type PreviewLineItem, } from "@autumn/shared"; - -import { formatAmount } from "@/utils/formatUtils.js"; +import type Stripe from "stripe"; +import { priceToUnusedPreviewItem } from "@/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.js"; +import { attachParamToCusProducts } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; +import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; -import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js"; - import { isArrearPrice, isContUsePrice, } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; -import { priceToUnusedPreviewItem } from "@/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.js"; +import { getBillingType } from "@/internal/products/prices/priceUtils.js"; +import { formatAmount } from "@/utils/formatUtils.js"; export const getItemsForCurProduct = async ({ sub, @@ -71,7 +67,7 @@ export const getItemsForCurProduct = async ({ // console.log("items: ", items); - let { oldItems } = await getContUseInvoiceItems({ + const { oldItems } = await getContUseInvoiceItems({ sub, attachParams, logger, @@ -81,9 +77,9 @@ export const getItemsForCurProduct = async ({ items = [...items, ...oldItems]; for (const price of curPrices) { - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); - if (billingType == BillingType.UsageInArrear) { + if (billingType === BillingType.UsageInArrear) { const { amount, description } = getCusPriceUsage({ price, cusProduct: curCusProduct, diff --git a/server/src/internal/orgs/OrgService.ts b/server/src/internal/orgs/OrgService.ts index 61491f055..66369cd60 100644 --- a/server/src/internal/orgs/OrgService.ts +++ b/server/src/internal/orgs/OrgService.ts @@ -133,7 +133,7 @@ export class OrgService { return { ...result, config: OrgConfigSchema.parse(result.config || {}), - }; + } as Organization; } static async getWithKeys({ diff --git a/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts b/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts new file mode 100644 index 000000000..2ca3c9475 --- /dev/null +++ b/server/src/internal/orgs/handlers/handleResetDefaultAccount.ts @@ -0,0 +1,88 @@ +import { AppEnv, ErrCode } from "@autumn/shared"; +import { initMasterStripe } from "@/external/connect/initStripeCli.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { OrgService } from "../OrgService.js"; +import { createConnectAccount } from "../orgUtils/createConnectAccount.js"; + +export const handleResetDefaultAccount = createRoute({ + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, logger, env } = ctx; + + // Validation: Only allow for test org + if (org.id !== process.env.TESTS_ORG_ID) { + throw new RecaseError({ + message: "This endpoint can only be used for test organizations", + code: ErrCode.InvalidRequest, + statusCode: 403, + }); + } + + // Only works for sandbox environment + if (env !== AppEnv.Sandbox) { + throw new RecaseError({ + message: "This endpoint only works for sandbox environment", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const currentDefaultAccountId = org.test_stripe_connect?.default_account_id; + + // Delete the current default account if it exists + if (currentDefaultAccountId) { + try { + const masterStripe = initMasterStripe({ env: AppEnv.Sandbox }); + + // Try to close the account + try { + await masterStripe.accounts.del(currentDefaultAccountId); + logger.info(`Closed default account: ${currentDefaultAccountId}`); + } catch (error) { + logger.warn( + `Failed to close default account ${currentDefaultAccountId}, it may not exist anymore`, + { error }, + ); + } + } catch (error) { + logger.error("Error deleting default account", { error }); + // Continue anyway to create a new account + } + } + + // Create a new default account + // We need a dummy user object for creating the account + const dummyUser = { + id: "test-user", + email: org.slug ? `${org.slug}@test.com` : "test@test.com", + name: org.name || "Test User", + }; + + const newAccount = await createConnectAccount({ + org: org, + user: dummyUser as any, + }); + + // Update the organization with the new default account ID + await OrgService.update({ + db, + orgId: org.id, + updates: { + test_stripe_connect: { + ...org.test_stripe_connect, + default_account_id: newAccount.id, + }, + }, + }); + + logger.info( + `Reset default account for org ${org.id}, new account: ${newAccount.id}`, + ); + + return c.json({ + message: "Default account reset successfully", + new_account_id: newAccount.id, + }); + }, +}); diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index 41fa31a30..122b76eab 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -9,6 +9,7 @@ import { handleRemoveMember, } from "./handlers/handleGetOrgMembers.js"; import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js"; +import { handleResetDefaultAccount } from "./handlers/handleResetDefaultAccount.js"; import { handleUpdateOrg } from "./handlers/handleUpdateOrg.js"; import { handleConnectStripe } from "./handlers/stripeHandlers/handleConnectStripe.js"; import { handleDeleteStripe } from "./handlers/stripeHandlers/handleDeleteStripe.js"; @@ -39,3 +40,4 @@ honoOrgRouter.get("/stripe", ...handleGetStripeAccount); honoOrgRouter.delete("/stripe", ...handleDeleteStripe); honoOrgRouter.post("/stripe", ...handleConnectStripe); honoOrgRouter.get("/stripe/oauth_url", ...handleGetOAuthUrl); +honoOrgRouter.post("/reset_default_account", ...handleResetDefaultAccount); diff --git a/server/src/internal/platform/honoPlatformRouter.ts b/server/src/internal/platform/honoPlatformRouter.ts index ff8c85497..e69de29bb 100644 --- a/server/src/internal/platform/honoPlatformRouter.ts +++ b/server/src/internal/platform/honoPlatformRouter.ts @@ -1,10 +0,0 @@ -import { Hono } from "hono"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; - -/** - * Hono router for platform API endpoints - */ -export const honoPlatformRouter = new Hono(); - -// GET /platform/users - List users created by master org -// honoPlatformRouter.get("/users", ...listPlatformUsers); diff --git a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts index 0ef4a5fe8..d5d1ba8e3 100644 --- a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts +++ b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts @@ -3,6 +3,7 @@ import { member, type Organization, organizations, + RecaseError, user as userTable, } from "@autumn/shared"; import { generateId } from "better-auth"; @@ -82,19 +83,25 @@ export const handleCreatePlatformOrg = createRoute({ ) .limit(1); - const orgExists = OrgService.getBySlug({ + const orgExists = await OrgService.getBySlug({ db, slug: orgSlug, }); - let org: Organization; + if (orgExists && existingMembership.length === 0) { + throw new RecaseError({ + message: `Organization with slug '${orgSlug}' already exists but ${user_email} is not a member`, + }); + } + + let org: Organization & { master?: Organization | null }; if (existingMembership.length === 0) { // Create new organization const orgId = generateId(); console.log(`Creating new organization: ${orgId} (${orgSlug})`); - [org] = await db + const [insertedOrg] = await db .insert(organizations) .values({ id: orgId, @@ -107,6 +114,8 @@ export const handleCreatePlatformOrg = createRoute({ }) .returning(); + org = { ...insertedOrg, master: masterOrg }; + // Create membership await db.insert(member).values({ id: generateId(), @@ -121,7 +130,7 @@ export const handleCreatePlatformOrg = createRoute({ logger.info(`Created new organization: ${org.id} (${orgSlug})`); } else { - org = existingMembership[0].organizations; + org = { ...existingMembership[0].organizations, master: masterOrg }; logger.info(`Found existing organization: ${org.id} (${orgSlug})`); } diff --git a/server/src/internal/platform/platformBeta/platformBetaRouter.ts b/server/src/internal/platform/platformBeta/platformBetaRouter.ts index d2cf54de0..79a23c095 100644 --- a/server/src/internal/platform/platformBeta/platformBetaRouter.ts +++ b/server/src/internal/platform/platformBeta/platformBetaRouter.ts @@ -78,6 +78,10 @@ platformBetaRouter.post( "/organization/stripe", ...handleUpdateOrganizationStripe, ); +platformBetaRouter.post( + "/organizations/stripe", + ...handleUpdateOrganizationStripe, +); platformBetaRouter.get("/users", ...listPlatformUsers); diff --git a/server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts b/server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts index 86864d3e9..4a8e95219 100644 --- a/server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts +++ b/server/src/internal/platform/platformBeta/utils/validatePlatformOrg.ts @@ -45,5 +45,8 @@ export const validatePlatformOrg = async ({ }); } - return org; + return { + ...org, + master: masterOrg, + } satisfies Organization; }; diff --git a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts index 24123f8bf..b0a50406a 100644 --- a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts +++ b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts @@ -19,7 +19,7 @@ const conformProductToSchema = ( name: product.name, is_add_on: product.is_add_on, is_default: product.is_default, - group: product.group ?? undefined, + group: product.group ?? "", archived: product.archived ?? undefined, items: product.items, free_trial: product.free_trial diff --git a/server/src/internal/products/handlers/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeleteProduct.ts index 5b4047009..048a0f1d3 100644 --- a/server/src/internal/products/handlers/handleDeleteProduct.ts +++ b/server/src/internal/products/handlers/handleDeleteProduct.ts @@ -63,7 +63,7 @@ export const handleDeleteProduct = (req: any, res: any) => }); } - res.status(200).json({ message: "Product deleted" }); + res.status(200).json({ success: true }); return; }, }); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index d6c5b051a..def568fd7 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -4,6 +4,7 @@ import { notNullish, ProductNotFoundError, type ProductV2, + productsAreSame, RecaseError, UpdateProductQuerySchema, UpdateProductSchema, @@ -20,7 +21,6 @@ import { } from "../../free-trials/freeTrialUtils.js"; import { ProductService } from "../../ProductService.js"; import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js"; -import { productsAreSame } from "../../productUtils/compareProductUtils.js"; import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js"; import { initProductInStripe } from "../../productUtils.js"; import { disableCurrentDefault } from "../handleCreateProduct.js"; diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct_old.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct_old.ts deleted file mode 100644 index 973eef702..000000000 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct_old.ts +++ /dev/null @@ -1,213 +0,0 @@ -// import { -// ErrCode, -// ProductNotFoundError, -// UpdateProductSchema, -// } from "@autumn/shared"; -// import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -// import { FeatureService } from "@/internal/features/FeatureService.js"; -// import { OrgService } from "@/internal/orgs/OrgService.js"; -// import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js"; -// import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; -// import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; -// import { JobName } from "@/queue/JobName.js"; -// import { addTaskToQueue } from "@/queue/queueUtils.js"; -// import RecaseError from "@/utils/errorUtils.js"; -// import { notNullish } from "@/utils/genUtils.js"; -// import { routeHandler } from "@/utils/routerUtils.js"; -// import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js"; -// import { ProductService } from "../../ProductService.js"; -// import { productsAreSame } from "../../productUtils/compareProductUtils.js"; -// import { initProductInStripe } from "../../productUtils.js"; -// import { mapToProductItems } from "../../productV2Utils.js"; -// import { -// disableCurrentDefault, -// handleCreateProduct, -// } from "../handleCreateProduct_old.js"; -// import { handleUpdateProductDetails } from "./updateProductDetails.js"; - -// export const handleUpdateProductV2 = async (req: any, res: any) => -// routeHandler({ -// req, -// res, -// action: "Update product", -// handler: async () => { -// const { productId } = req.params; -// const { version, upsert, disable_version } = req.query; -// const { orgId, env, logger, db } = req; - -// const [features, org, fullProduct, rewardPrograms, _defaultProds] = -// await Promise.all([ -// FeatureService.getFromReq(req), -// OrgService.getFromReq(req), -// ProductService.getFull({ -// db, -// idOrInternalId: productId, -// orgId, -// env, -// version: version ? parseInt(version) : undefined, -// allowNotFound: upsert === "true", -// }), -// RewardProgramService.getByProductId({ -// db, -// productIds: [productId], -// orgId, -// env, -// }), -// ProductService.listDefault({ -// db, -// orgId, -// env, -// }), -// ]); - -// if (!fullProduct) { -// if (upsert === "true") { -// await handleCreateProduct(req, res); -// return; -// } - -// throw new ProductNotFoundError({ productId: productId }); -// } - -// const cusProductsCurVersion = -// await CusProductService.getByInternalProductId({ -// db, -// internalProductId: fullProduct.internal_id, -// }); - -// const cusProductExists = cusProductsCurVersion.length > 0; - -// await disableCurrentDefault({ -// req, -// newProduct: { -// ...fullProduct, -// ...req.body, -// }, -// items: -// req.body.items || -// mapToProductItems({ -// prices: fullProduct.prices, -// entitlements: fullProduct.entitlements, -// features, -// }), -// freeTrial: req.body.free_trial || fullProduct.free_trial || null, -// }); - -// await handleUpdateProductDetails({ -// db, -// curProduct: fullProduct, -// newProduct: UpdateProductSchema.parse(req.body), -// newFreeTrial: req.body.free_trial, -// items: req.body.items, -// org, -// rewardPrograms, -// logger, -// }); - -// const itemsExist = notNullish(req.body.items); -// if (cusProductExists && itemsExist) { -// if (disable_version === "true") { -// throw new RecaseError({ -// message: "Cannot auto save product as there are existing customers", -// code: ErrCode.InvalidRequest, -// statusCode: 400, -// }); -// } - -// const { itemsSame, freeTrialsSame } = productsAreSame({ -// newProductV2: req.body, -// curProductV1: fullProduct, -// features, -// }); -// const productSame = itemsSame && freeTrialsSame; - -// if (!productSame) { -// // await handleVersionProductV2({ -// // req, -// // res, -// // latestProduct: fullProduct, -// // org, -// // env, -// // items: req.body.items, -// // freeTrial: req.body.free_trial, -// // }); -// return; -// } -// res.status(200).send(fullProduct); -// return; -// } - -// const { items, free_trial } = req.body; - -// if (free_trial !== undefined) { -// await validateOneOffTrial({ -// prices: fullProduct.prices, -// freeTrial: free_trial, -// }); -// } - -// await handleNewProductItems({ -// db, -// curPrices: fullProduct.prices, -// curEnts: fullProduct.entitlements, -// newItems: items, -// features, -// product: fullProduct, -// logger, -// isCustom: false, -// }); - -// // New full product -// const newFullProduct = await ProductService.getFull({ -// db, -// idOrInternalId: fullProduct.id, -// orgId, -// env, -// }); - -// if (free_trial !== undefined) { -// await validateOneOffTrial({ -// prices: newFullProduct.prices, -// freeTrial: free_trial, -// }); - -// await handleNewFreeTrial({ -// db, -// curFreeTrial: fullProduct.free_trial, -// newFreeTrial: free_trial, -// internalProductId: fullProduct.internal_id, -// isCustom: false, -// product: fullProduct, -// }); -// } - -// // New full product -// await initProductInStripe({ -// db, -// product: newFullProduct, -// org, -// env, -// logger, -// }); - -// logger.info("Adding task to queue to detect base variant"); -// await addTaskToQueue({ -// jobName: JobName.DetectBaseVariant, -// payload: { -// curProduct: newFullProduct, -// }, -// }); - -// await addTaskToQueue({ -// jobName: JobName.RewardMigration, -// payload: { -// oldPrices: fullProduct.prices, -// productId: fullProduct.id, -// orgId: org.id, -// env, -// }, -// }); -// res.status(200).send({ message: "Product updated" }); -// return; -// }, -// }); diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 993c1f2f8..3898f90bc 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -50,8 +50,8 @@ export const handleVersionProductV2 = async ({ productData: CreateProductV2ParamsSchema.parse({ ...latestProduct, ...newProductV2, - version: newVersion, }), + version: newVersion, orgId: org.id, env: latestProduct.env as AppEnv, processor: latestProduct.processor || undefined, diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index 2512f0ba2..5c6ad0afd 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -11,6 +11,8 @@ import { FeatureUsageType, type FixedPriceConfig, Infinite, + itemToBillingInterval, + itemToEntInterval, OnDecrease, OnIncrease, type Price, @@ -20,10 +22,6 @@ import { UsageModel, type UsagePriceConfig, } from "@autumn/shared"; -import { - itemToBillingInterval, - itemToEntInterval, -} from "@shared/utils/productV2Utils/productItemUtils/itemIntervalUtils.js"; import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -182,7 +180,7 @@ export const toFeatureAndPrice = ({ }) => { const resetUsage = getResetUsage({ item, - feature: features.find((f) => f.id == item.feature_id), + feature: features.find((f) => f.id === item.feature_id), }); let ent: Entitlement = { @@ -251,9 +249,9 @@ export const toFeatureAndPrice = ({ let onDecrease = item.config?.on_decrease || OnDecrease.Prorate; // console.log("Item config:", item.config); - if (shouldProrate(onDecrease) || onDecrease == OnDecrease.Prorate) { + if (shouldProrate(onDecrease) || onDecrease === OnDecrease.Prorate) { onDecrease = - onIncrease == OnIncrease.ProrateImmediately + onIncrease === OnIncrease.ProrateImmediately ? OnDecrease.ProrateImmediately : OnDecrease.ProrateNextCycle; } @@ -279,9 +277,9 @@ export const toFeatureAndPrice = ({ const billingType = getBillingType(price.config!); if ( - (billingType == BillingType.UsageInArrear || - billingType == BillingType.InArrearProrated) && - price.config!.interval == BillingInterval.OneOff + (billingType === BillingType.UsageInArrear || + billingType === BillingType.InArrearProrated) && + price.config!.interval === BillingInterval.OneOff ) { throw new RecaseError({ message: `Usage prices cannot be one-off if not set to prepaid (feature: ${item.feature_id})`, @@ -366,7 +364,7 @@ export const itemToPriceAndEnt = ({ code: ErrCode.InvalidRequest, }); } - const isBoolean = feature?.type == FeatureType.Boolean; + const isBoolean = feature?.type === FeatureType.Boolean; const { ent } = toFeature({ item, diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 160ed917f..a53df0017 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -5,6 +5,7 @@ import { type Feature, FeatureType, Infinite, + itemToEntInterval, OnIncrease, type ProductItem, ProductItemInterval, @@ -13,7 +14,6 @@ import { RolloverDuration, UsageModel, } from "@autumn/shared"; -import { itemToEntInterval } from "@shared/utils/productV2Utils/productItemUtils/itemIntervalUtils.js"; import { StatusCodes } from "http-status-codes"; import RecaseError from "@/utils/errorUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index ccbb18e84..fab06f033 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -1,5 +1,4 @@ -import { ProductNotFoundError } from "@autumn/shared"; - +import { ProductNotFoundError, productsAreSame } from "@autumn/shared"; import { Router } from "express"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js"; @@ -14,7 +13,6 @@ import { handleDeleteProduct } from "./handlers/handleDeleteProduct.js"; import { handleGetProduct } from "./handlers/handleGetProduct.js"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; import { handleListProductsBeta } from "./handlers/handleListProductsBeta.js"; -import { productsAreSame } from "./productUtils/compareProductUtils.js"; export const productBetaRouter: Router = Router(); productBetaRouter.get("", handleListProductsBeta); @@ -156,4 +154,4 @@ export const honoProductRouter = new Hono(); // POST /products - Create a product honoProductRouter.post("", ...createProduct); -honoProductRouter.post("/:productId", ...handleUpdateProductV2); +honoProductRouter.post("/:productId", ...handleUpdateProductV2); // will be deprecated diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 1f816bca6..e9f9655cf 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -11,6 +11,7 @@ import { type FixedPriceConfig, type FullProduct, intervalsSame, + nullish, type Organization, type Price, PriceSchema, @@ -72,11 +73,13 @@ export const getProductVersionCounts = (products: FullProduct[]) => { // Construct product export const constructProduct = ({ productData, + version = 1, orgId, env, processor, }: { productData: CreateProductV2Params; + version?: number; orgId: string; env: AppEnv; processor?: { @@ -89,7 +92,7 @@ export const constructProduct = ({ name: productData.name, is_add_on: productData.is_add_on, is_default: productData.is_default, - version: productData.version || 1, + version: version, group: productData.group || "", env, @@ -141,7 +144,9 @@ export const isProductUpgrade = ({ let totalPrice = new Decimal(0); for (const price of prices) { if ("usage_tiers" in price.config!) { - totalPrice = totalPrice.plus(price.config!.usage_tiers[0].amount); + const tiers = price.config!.usage_tiers; + if (nullish(tiers) || tiers.length === 0) continue; + totalPrice = totalPrice.plus(tiers[0].amount); } else { totalPrice = totalPrice.plus(price.config!.amount); } @@ -176,10 +181,9 @@ export const isFreeProduct = (prices: Price[]) => { let totalPrice = 0; for (const price of prices) { if ("usage_tiers" in price.config!) { - totalPrice += price.config!.usage_tiers.reduce( - (acc, tier) => acc + tier.amount, - 0, - ); + const tiers = price.config!.usage_tiers; + if (nullish(tiers) || tiers.length === 0) continue; + totalPrice += tiers.reduce((acc, tier) => acc + tier.amount, 0); } else { totalPrice += price.config!.amount; } diff --git a/server/src/internal/products/productUtils/compareProductUtils.ts b/server/src/internal/products/productUtils/compareProductUtils.ts deleted file mode 100644 index 407187337..000000000 --- a/server/src/internal/products/productUtils/compareProductUtils.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { - ErrCode, - type Feature, - type FullProduct, - OnDecrease, - OnIncrease, - type ProductItem, - type ProductV2, -} from "@autumn/shared"; -import { - findSimilarItem, - itemsAreSame, -} from "@shared/utils/productV2Utils/compareProductUtils.ts/compareItemUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { freeTrialsAreSame } from "../free-trials/freeTrialUtils.js"; -import { - isFeaturePriceItem, - isPriceItem, -} from "../product-items/productItemUtils/getItemType.js"; -import { getResetUsage } from "../product-items/productItemUtils/itemToPriceAndEnt.js"; -import { itemToPriceOrTiers } from "../product-items/productItemUtils.js"; -import { mapToProductItems } from "../productV2Utils.js"; - -const sanitizeItems = ({ - items, - features, -}: { - items: ProductItem[]; - features: Feature[]; -}) => { - return items.map((item) => { - const priceData = itemToPriceOrTiers({ item }); - const newItem = { - ...item, - reset_usage_when_enabled: getResetUsage({ - item, - feature: features.find((f) => f.id === item.feature_id), - }), - ...priceData, - }; - - if (!newItem.config) { - newItem.config = { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }; - } - - return newItem; - }); -}; -export const productsAreSame = ({ - newProductV1, - newProductV2, - curProductV1, - curProductV2, - features, -}: { - newProductV1?: FullProduct; - newProductV2?: ProductV2; - curProductV1?: FullProduct; - curProductV2?: ProductV2; - features: Feature[]; -}) => { - if (!newProductV1 && !newProductV2) { - throw new RecaseError({ - message: "productsAreSame error: product1 not provided", - code: ErrCode.InvalidRequest, - }); - } - - if (!curProductV1 && !curProductV2) { - throw new RecaseError({ - message: "productsAreSame error: product2 not provided", - code: ErrCode.InvalidRequest, - }); - } - - let items1 = - newProductV2?.items || - mapToProductItems({ - prices: newProductV1?.prices || [], - entitlements: newProductV1?.entitlements || [], - features, - }); - - let items2 = - curProductV2?.items || - mapToProductItems({ - prices: curProductV1?.prices || [], - entitlements: curProductV1?.entitlements || [], - features, - }); - - items1 = sanitizeItems({ items: items1, features }); - items2 = sanitizeItems({ items: items2, features }); - - let itemsSame = true; - let pricesChanged = false; - const newItems: ProductItem[] = []; - const removedItems: ProductItem[] = []; - - if (items1.length !== items2.length) { - itemsSame = false; - } - - // Check if any feature's usage limits have changed - items1.some((item1) => { - const matchingItem2 = items2?.find( - (item2) => item2.feature_id === item1.feature_id, - ); - if (!matchingItem2) return false; - - const feature = features.find((f) => f.id === item1.feature_id); - if (!feature) return false; - - return false; - }); - - if (items1.length !== items2.length) { - itemsSame = false; - } - - for (const item of items1) { - const similarItem = findSimilarItem({ - item, - items: items2, - }); - - if (!similarItem) { - if (isFeaturePriceItem(item) || isPriceItem(item)) { - pricesChanged = true; - } - - itemsSame = false; - newItems.push(item); - - continue; - } - - const { same, pricesChanged: pricesChanged_ } = itemsAreSame({ - item1: item, - item2: similarItem!, - features, - logDifferences: false, - }); - - if (!same) { - itemsSame = false; - newItems.push(item); - } - - if (pricesChanged_) { - pricesChanged = true; - } - } - - for (const item of items2) { - const similarItem = findSimilarItem({ - item, - items: items1, - }); - - if (!similarItem) { - itemsSame = false; - if (isFeaturePriceItem(item) || isPriceItem(item)) { - pricesChanged = true; - } - - removedItems.push(item); - } - } - - // Compare free trial - const freeTrial1 = curProductV1?.free_trial || curProductV2?.free_trial; - const freeTrial2 = newProductV1?.free_trial || newProductV2?.free_trial; - - const freeTrialsSame = freeTrialsAreSame({ - ft1: freeTrial1, - ft2: freeTrial2, - }); - - if (!freeTrialsSame) { - console.log("Free trials different"); - console.log("Free trial 1:", freeTrial1); - console.log("Free trial 2:", freeTrial2); - console.log("--------------------------------"); - } - - // Compare name - return { - itemsSame, - freeTrialsSame, - onlyEntsChanged: !pricesChanged, - newItems, - removedItems, - }; -}; diff --git a/server/src/utils/importUtils/addProductFromSubs.ts b/server/src/utils/importUtils/addProductFromSubs.ts index c3f571f94..4d93186d6 100644 --- a/server/src/utils/importUtils/addProductFromSubs.ts +++ b/server/src/utils/importUtils/addProductFromSubs.ts @@ -88,6 +88,8 @@ export const addProductFromSubs = async ({ sub, }); + const disableFreeTrial = true; + const newCusProduct = await createFullCusProduct({ db, attachParams: { @@ -108,10 +110,12 @@ export const addProductFromSubs = async ({ entityId: entity?.id, isCustom: isCustom, }, + + disableFreeTrial, logger, trialEndsAt: trialEndsAt || undefined, subscriptionIds: sub ? [sub.id] : [], - anchorToUnix: anchorToUnix || end, + anchorToUnix: anchorToUnix || end * 1000, subscriptionStatus: sub?.status ? (stripeToAutumnSubStatus(sub?.status) as CusProductStatus) diff --git a/server/src/utils/routerUtils/expressErrorSkip.ts b/server/src/utils/routerUtils/expressErrorSkip.ts index 1ef435ae5..27eb60c63 100644 --- a/server/src/utils/routerUtils/expressErrorSkip.ts +++ b/server/src/utils/routerUtils/expressErrorSkip.ts @@ -145,6 +145,15 @@ export const handleExpressErrorSkip = ({ code: ErrCode.InvalidRequest, }); } + + // Not a valid URL error + if (error.message.includes("Not a valid URL")) { + req.logger.warn(`Not a valid URL error, org: ${req.org?.slug}`); + return res.status(400).json({ + message: error.message, + code: ErrCode.InvalidRequest, + }); + } } // No skip case matched diff --git a/server/src/utils/routerUtils/webhookErrorSkip.ts b/server/src/utils/routerUtils/webhookErrorSkip.ts new file mode 100644 index 000000000..232a1590a --- /dev/null +++ b/server/src/utils/routerUtils/webhookErrorSkip.ts @@ -0,0 +1,31 @@ +import type { Logger } from "../../external/logtail/logtailUtils.js"; + +/** + * Checks if a webhook error should be skipped (not logged). + * Returns true if error should be skipped, false otherwise. + */ +export const handleWebhookErrorSkip = ({ + error, + logger, +}: { + error: any; + logger?: Logger; +}): boolean => { + const errorMessage = String(error); + + // Skip "live mode key used" errors + if ( + errorMessage.includes("but a live mode key was used to make this request.") + ) { + return true; + } + + // Skip "Not a valid URL" errors + if (errorMessage.includes("Not a valid URL")) { + logger?.warn("Webhook error: Not a valid URL"); + return true; + } + + // Don't skip this error + return false; +}; diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index 66436a76f..bf22f79f5 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -1,11 +1,11 @@ import { - LimitedItem, + type LimitedItem, OnDecrease, OnIncrease, - ProductItem, - ProductItemConfig, + type ProductItem, + type ProductItemConfig, ProductItemInterval, - RolloverConfig, + type RolloverConfig, UsageModel, } from "@autumn/shared"; @@ -16,6 +16,7 @@ export const constructFeatureItem = ({ intervalCount = 1, entityFeatureId, isBoolean = false, + unlimited = false, rolloverConfig, }: { featureId: string; @@ -25,6 +26,7 @@ export const constructFeatureItem = ({ entityFeatureId?: string; isBoolean?: boolean; rolloverConfig?: RolloverConfig; + unlimited?: boolean; }) => { if (isBoolean) { return { @@ -32,7 +34,14 @@ export const constructFeatureItem = ({ entity_feature_id: entityFeatureId, }; } - let item: LimitedItem = { + + if (unlimited) { + return { + feature_id: featureId, + included_usage: "inf", + } as ProductItem; + } + const item: LimitedItem = { feature_id: featureId, included_usage: includedUsage, entity_feature_id: entityFeatureId, @@ -75,7 +84,7 @@ export const constructPrepaidItem = ({ usageLimit?: number; intervalCount?: number; }) => { - let item: ProductItem = { + const item: ProductItem = { feature_id: featureId, usage_model: UsageModel.Prepaid, @@ -118,7 +127,7 @@ export const constructArrearItem = ({ usageLimit?: number; intervalCount?: number; }) => { - let item: ProductItem = { + const item: ProductItem = { feature_id: featureId, usage_model: UsageModel.PayPerUse, included_usage: includedUsage, @@ -153,7 +162,7 @@ export const constructArrearProratedItem = ({ usageLimit?: number; rolloverConfig?: RolloverConfig; }) => { - let item: ProductItem = { + const item: ProductItem = { feature_id: featureId, usage_model: UsageModel.PayPerUse, included_usage: includedUsage, diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 7dddbc4de..6333dce2e 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -74,6 +74,7 @@ export const constructRawProduct = ({ version: 1, group: "", created_at: Date.now(), + env: AppEnv.Sandbox, }; }; @@ -107,11 +108,11 @@ export const constructProduct = ({ forcePaidDefault?: boolean; }) => { let price = 0; - if (type == "pro") { + if (type === "pro") { price = 20; - } else if (type == "premium") { + } else if (type === "premium") { price = 50; - } else if (type == "growth") { + } else if (type === "growth") { price = 100; } @@ -129,7 +130,7 @@ export const constructProduct = ({ ); } - if (type == "one_off") { + if (type === "one_off") { items.push( constructPriceItem({ price: 10, @@ -152,8 +153,9 @@ export const constructProduct = ({ ? `${keyToTitle(type)} (${interval})` : keyToTitle(type), items, + env: AppEnv.Sandbox, is_add_on: isAddOn, - is_default: (type == "free" && isDefault) || forcePaidDefault, + is_default: (type === "free" && isDefault) || forcePaidDefault, version: 1, group: group || "", free_trial: diff --git a/server/src/utils/scriptUtils/initCustomer.ts b/server/src/utils/scriptUtils/initCustomer.ts index 32adf595c..3791fb18a 100644 --- a/server/src/utils/scriptUtils/initCustomer.ts +++ b/server/src/utils/scriptUtils/initCustomer.ts @@ -230,7 +230,6 @@ export const initCustomerV2 = async ({ name, email, fingerprint: customerData?.fingerprint || fingerprint_, - // @ts-expect-error stripe_id: stripeCus.id, }); diff --git a/server/src/utils/scriptUtils/logUtils/logSubItems.ts b/server/src/utils/scriptUtils/logUtils/logSubItems.ts index dbbb1cc9f..a44c3330d 100644 --- a/server/src/utils/scriptUtils/logUtils/logSubItems.ts +++ b/server/src/utils/scriptUtils/logUtils/logSubItems.ts @@ -5,9 +5,13 @@ import { subItemToAutumnInterval } from "@/external/stripe/utils.js"; export const logSubItems = ({ sub, subItems, + withPriceId = false, + withItemId = false, }: { sub?: Stripe.Subscription; subItems?: Stripe.SubscriptionItem[]; + withPriceId?: boolean; + withItemId?: boolean; }) => { const finalSubItems = subItems || sub!.items.data; for (const item of finalSubItems) { @@ -24,7 +28,7 @@ export const logSubItems = ({ const price = atmnPrice; const subInterval = subItemToAutumnInterval(item); console.log( - `${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval}`, + `${price} ${item.price.currency}${item.quantity !== 1 ? ` x ${item.quantity}` : ""} / ${subInterval?.intervalCount} ${subInterval?.interval} ${withPriceId ? `(${item.price.id})` : ""} ${withItemId ? `(${item.id})` : ""}`, ); } } diff --git a/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts b/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts index 5785decde..6de5d3fdd 100644 --- a/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts +++ b/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts @@ -26,7 +26,7 @@ export const getCusSub = async ({ }); const cusProduct = fullCus.customer_products.find( - (cp) => cp.product.id == productId, + (cp) => cp.product.id === productId, ); const sub = await cusProductToSub({ cusProduct, stripeCli }); diff --git a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts new file mode 100644 index 000000000..441f394a4 --- /dev/null +++ b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts @@ -0,0 +1,79 @@ +import { ApiVersion } from "@autumn/shared"; +import type { CustomerData } from "autumn-js"; +import type { TestContext } from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { attachPaymentMethod } from "../initCustomer.js"; + +// V3 initializes the customer in Stripe, then creates the customer in Autumn +export const initCustomerV3 = async ({ + ctx, + customerId, + customerData, + attachPm, + withTestClock = true, +}: { + ctx: TestContext; + customerId: string; + attachPm?: "success" | "fail"; + customerData?: CustomerData; + withTestClock?: boolean; +}) => { + const name = customerId; + const email = `${customerId}@example.com`; + const fingerprint_ = ""; + const { stripeCli } = ctx; + const autumn = new AutumnInt({ version: ApiVersion.V1_2 }); + + let testClockId: string | undefined; + + if (withTestClock) { + const testClock = await stripeCli.testHelpers.testClocks.create({ + frozen_time: Math.floor(Date.now() / 1000), + }); + testClockId = testClock.id; + } + + // 1. Create stripe customer + const stripeCus = await stripeCli.customers.create({ + email, + name, + test_clock: testClockId, + }); + + // 2. Create customer + try { + await autumn.customers.delete(customerId); + } catch (_error) {} + + await autumn.customers.create({ + id: customerId, + name, + email, + // @ts-expect-error + fingerprint: customerData?.fingerprint || fingerprint_, + stripe_id: stripeCus.id, + }); + + // 3. Attach payment method + if (attachPm) { + await attachPaymentMethod({ + stripeCli, + stripeCusId: stripeCus.id, + type: attachPm, + }); + } + + const { db, org, env } = ctx; + const customer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env: env, + }); + + return { + testClockId: testClockId || "", + customer, + }; +}; diff --git a/server/src/utils/scriptUtils/testUtils/initProductsV0.ts b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts new file mode 100644 index 000000000..5936be259 --- /dev/null +++ b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts @@ -0,0 +1,33 @@ +import { ApiVersion, type ProductV2 } from "@autumn/shared"; +import { addPrefixToProducts } from "tests/attach/utils.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import type { TestContext } from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +export const initProductsV0 = async ({ + ctx, + products, + prefix, +}: { + ctx: TestContext; + products: ProductV2[]; + prefix?: string; +}) => { + // 1. Add prefix to products + if (prefix) { + addPrefixToProducts({ + products, + prefix, + }); + } + + // 2. Create + const autumn = new AutumnInt({ version: ApiVersion.V1_2 }); + await createProducts({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + autumn: autumn, + products, + }); +}; diff --git a/server/test.sh b/server/test.sh index afe3a363f..0d27dbfe5 100755 --- a/server/test.sh +++ b/server/test.sh @@ -10,7 +10,6 @@ if [ "$1" == "custom" ]; then else npx mocha --bail --timeout 10000000 "tests/$FILE_TO_TEST.ts" fi - fi # MOCHA_SETUP="npx mocha tests/00_setup.ts" diff --git a/server/tests/00_setup.ts b/server/tests/00_setup.ts index 2b1549360..1f703781f 100644 --- a/server/tests/00_setup.ts +++ b/server/tests/00_setup.ts @@ -48,7 +48,3 @@ describe("Initialize org for tests", () => { console.log("--------------------------------"); }); }); - -// after(async function () { -// await this.client.end(); -// }); diff --git a/server/tests/MOCHA_TO_BUN_MIGRATION.md b/server/tests/MOCHA_TO_BUN_MIGRATION.md new file mode 100644 index 000000000..fd6c360fb --- /dev/null +++ b/server/tests/MOCHA_TO_BUN_MIGRATION.md @@ -0,0 +1,256 @@ +# Mocha to Bun Test Migration Guide + +## Quick Reference + +### 1. Update Imports + +**Remove:** +```typescript +import { expect } from "chai"; +import { setupBefore } from "tests/before.js"; +``` + +**Add:** +```typescript +import { beforeAll, describe, expect, test } from "bun:test"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +``` + +**Keep:** All AutumnCli, global products/features imports, and utility imports. + +### 2. Replace Test Hooks + +**Before:** +```typescript +before(async function () { + await setupBefore(this); + db = this.db; + org = this.org; + env = this.env; + + await initCustomer({ + autumn: this.autumnJs, + customerId, + db, + org, + env, + fingerprint: "test", + withTestClock: false, + }); +}); +``` + +**After:** +```typescript +beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + customerData: { fingerprint: "test" }, + withTestClock: false, + }); +}); +``` + +### 3. Initialize Products + +**Before:** +```typescript +addPrefixToProducts({ + products: [product1, product2], + prefix: testCase, +}); + +await createProducts({ + db, + orgId: org.id, + env, + autumn, + products: [product1, product2], +}); +``` + +**After:** +```typescript +await initProductsV0({ + ctx, + products: [product1, product2], + prefix: testCase, +}); +``` + +**Important:** `initProductsV0` combines both `addPrefixToProducts` and `createProducts` into a single call. + +### 4. Update Test Functions + +Replace `it` with `test`: +```typescript +// Before +it("should do something", async () => { ... }); + +// After +test("should do something", async () => { ... }); +``` + +### 5. Update Assertions + +| Chai | Bun | +|------|-----| +| `expect(x).to.be.true` | `expect(x).toBe(true)` | +| `expect(x).to.be.false` | `expect(x).toBe(false)` | +| `expect(x).to.exist` | `expect(x).toBeDefined()` | +| `expect(x).to.not.exist` | `expect(x).toBeUndefined()` | +| `expect(x).to.equal(y)` | `expect(x).toBe(y)` | +| `expect(x).to.have.lengthOf(n)` | `expect(x).toHaveLength(n)` | +| `expect(x).to.be.greaterThan(n)` | `expect(x).toBeGreaterThan(n)` | + +### 6. Important Parameters + +Always preserve these from the original test: + +**customerData.fingerprint:** +- Default: no fingerprint (or empty string) +- Common: `{ fingerprint: "test" }` +- Trials: `{ fingerprint: Math.random().toString(36).substring(2, 15) }` + +**attachPm:** +- Not needed: tests with checkout flow +- `"success"`: tests that need payment method pre-attached +- `"fail"`: tests for failed payment scenarios + +**withTestClock:** +- `true`: most tests (allows time travel) +- `false`: tests that don't need test clocks + +### 7. Rename Files + +```bash +mv test-name.ts test-name.test.ts +``` + +All test files must end with `.test.ts` for Bun to recognize them. + +### 8. Update Shell Scripts + +**Before:** +```bash +$MOCHA_CMD 'tests/attach/basic/*.ts' +``` + +**After:** +```bash +$BUN_PARALLEL tests/attach/basic +``` + +## Common Patterns + +### Pattern 1: Basic Test +```typescript +import { beforeAll, describe, expect, test } from "bun:test"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +const testCase = "test1"; + +describe("Test description", () => { + const customerId = testCase; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + }); + }); + + test("should do something", async () => { + // test code + }); +}); +``` + +### Pattern 2: With Custom Products +```typescript +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +beforeAll(async () => { + await initCustomerV3({ ctx, customerId, withTestClock: true }); + + await initProductsV0({ + ctx, + products: [customProduct], + prefix: testCase, + }); +}); +``` + +### Pattern 3: With Payment Method +```typescript +beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: true, + }); +}); +``` + +## Checklist + +- [ ] Update imports (add bun:test, ctx, initCustomerV3) +- [ ] Replace `before` with `beforeAll` +- [ ] Replace `it` with `test` +- [ ] Update all chai assertions to Bun +- [ ] Replace initCustomer with initCustomerV3 +- [ ] Replace createProducts with initProductsV0 +- [ ] Verify fingerprint parameter +- [ ] Verify attachPm parameter +- [ ] Verify withTestClock parameter +- [ ] Rename file to `.test.ts` +- [ ] Update shell script if needed +- [ ] Remove `this` context references +- [ ] Test runs successfully + +## Critical Rules + +### Order of Operations +**YOU MUST MAINTAIN THE EXACT ORDER** of initialization calls from the original test: + +1. **If products are created BEFORE customer init** โ†’ Keep that order in migration +2. **If customer is created BEFORE products** โ†’ Keep that order in migration + +**Example 1: Products First** +```typescript +// Original (Mocha) +addPrefixToProducts({ products: [pro, premium], prefix: testCase }); +await createProducts({ autumn, products: [pro, premium], db, orgId: org.id, env }); +await initCustomer({ autumn: autumnJs, customerId, db, org, env, attachPm: "success" }); + +// Migrated (Bun) - SAME ORDER +await initProductsV0({ ctx, products: [pro, premium], prefix: testCase }); +await initCustomerV3({ ctx, customerId, customerData: {}, attachPm: "success", withTestClock: true }); +``` + +**Example 2: Customer First** +```typescript +// Original (Mocha) +await initCustomer({ autumn: autumnJs, customerId, db, org, env, attachPm: "success" }); +addPrefixToProducts({ products: [pro, premium], prefix: testCase }); +await createProducts({ autumn, products: [pro, premium], db, orgId: org.id, env }); + +// Migrated (Bun) - SAME ORDER +await initCustomerV3({ ctx, customerId, customerData: {}, attachPm: "success", withTestClock: true }); +await initProductsV0({ ctx, products: [pro, premium], prefix: testCase }); +``` + +## Notes + +- **No timeout needed**: Tests run with `--timeout 0` globally +- **Keep AutumnCli**: Don't replace with AutumnInt for API calls +- **Preserve test logic**: Only change framework, not test behavior +- **ctx is global**: Imported from createTestContext, contains db/org/env/stripeCli +- **initProductsV0 is a helper**: It combines `addPrefixToProducts` + `createProducts` into one call + diff --git a/server/tests/README.md b/server/tests/README.md new file mode 100644 index 000000000..6eb484b49 --- /dev/null +++ b/server/tests/README.md @@ -0,0 +1,164 @@ +# Autumn Test Suite + +This directory contains all tests for the Autumn server application. + +## Quick Start + +```bash +# Run setup to initialize the test organization +bun tests setup + +# Run a specific test group +bun tests g1 + +# Run a test group with setup +bun tests g1 setup + +# Run a specific test file (fuzzy matching) +bun tests basic1 +``` + +## Test Structure + +### Test Organization + +Tests are organized into logical groups that can be run via shell scripts in `server/shell/`: +- `g1.sh` - Check API tests +- `g2.sh` - Attach API tests (basic, upgrade, downgrade) +- `g3.sh` - Attach API tests (checkout, entities, free, addOn) +- `g4.sh` - Product and pricing tests +- `g5.sh` - Customer and subscription tests +- `g6.sh` - Reward and referral tests + +### Test Files + +- **setupMain.ts** - Main setup script that clears and initializes the test organization +- **global.ts** - Shared test data (features, products, rewards, etc.) +- **utils/** - Test utilities and helper functions + - `setupUtils/clearOrg.ts` - Clears test org data + - `setupUtils/setupOrg.ts` - Sets up test org with features and products + - `setup.ts` - Re-exports setup utilities + - `init.ts` - Test initialization helpers + - `stripeUtils.ts` - Stripe-specific test utilities + +## Test Setup Process + +The setup process (`setupMain.ts`) does the following: + +1. **Clears the test organization** (`clearOrg`): + - Reconnects Stripe with test credentials + - Resets the default Stripe account (automatically removes all Stripe resources) + - Deletes all customers from database + - Deletes all products from database + - Deletes all rewards from database + - Deletes all features from database + +2. **Sets up the test organization** (`setupOrg`): + - Creates all test features + - Creates all test products with pricing and entitlements + - Creates all test rewards/coupons + - Creates all reward triggers (referral programs) + - Initializes Stripe products if running in parallel mode + +## Running Tests + +### Setup Command + +The `setup` command runs the `setupMain.ts` script to initialize the test environment: + +```bash +bun tests setup +``` + +This is required before running any tests to ensure a clean state. + +### Shell Scripts + +Shell scripts in `server/shell/` define test groups. Each script: +- Can run setup automatically if "setup" is passed as an argument +- Runs multiple test files in parallel or serial mode +- Uses configuration from `server/shell/config.sh` + +Example: +```bash +# Run g1 tests with setup +bun tests g1 setup + +# Run g1 tests without setup (assumes setup was already run) +bun tests g1 +``` + +### Individual Test Files + +You can run individual test files by name (fuzzy matching): + +```bash +# Runs the first matching test file +bun tests basic1 + +# Runs with path pattern +bun tests attach/basic1 +``` + +The test runner will: +1. Search for matching test files +2. Automatically detect if it's a Bun test or Mocha test +3. Run with the appropriate test runner + +## Test Frameworks + +Tests can use either: +- **Bun Test** - Files with `from "bun:test"` import (faster) +- **Mocha** - Traditional mocha tests (legacy) + +The test runner auto-detects which framework to use based on imports. + +## Environment Variables + +Required environment variables for tests: +- `TESTS_ORG` - Test organization slug (e.g., "unit-test-org") +- `TESTS_ORG_ID` - Test organization ID +- `STRIPE_TEST_KEY` - Stripe test secret key +- `UNIT_TEST_AUTUMN_SECRET_KEY` - Autumn API secret key for tests +- `UNIT_TEST_AUTUMN_PUBLIC_KEY` - Autumn API public key for tests + +## Configuration + +Test configuration is defined in `server/shell/config.sh`: + +```bash +MOCHA_SETUP="bun tests/setupMain.ts" +MOCHA_CMD="bunx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts" +BUN_CMD="bun test --concurrent" +``` + +## Best Practices + +1. **Always run setup first** - Use `bun tests setup` or `bun tests setup` to ensure clean state +2. **Use parallel execution** - Set `MOCHA_PARALLEL=true` for faster test runs +3. **Isolate test data** - Each test should be independent and not rely on other tests +4. **Clean up resources** - Tests should clean up any resources they create +5. **Use descriptive names** - Test files should have clear, descriptive names + +## Internal API Endpoints + +The test suite uses internal API endpoints for efficiency: + +- `POST /organization/reset_default_account` - Resets the default Stripe account (clears all Stripe data) + - Only works for `TESTS_ORG_ID` + - Only works in sandbox environment + - Faster than manually deleting Stripe resources + +## Troubleshooting + +### Tests failing with "Org not found" +Run setup: `bun tests setup` + +### Stripe errors +Check that `STRIPE_TEST_KEY` is set correctly + +### Cache issues +The setup script automatically invalidates cache for test API keys + +### Parallel execution issues +If tests interfere with each other, try running in serial mode by setting `MOCHA_PARALLEL=false` diff --git a/server/tests/_guides/check-endpoint-tests.md b/server/tests/_guides/check-endpoint-tests.md new file mode 100644 index 000000000..8a30348fc --- /dev/null +++ b/server/tests/_guides/check-endpoint-tests.md @@ -0,0 +1,275 @@ +# Guide: Writing /check Endpoint Tests + +## What is /check? + +The `/check` endpoint validates whether a customer has access to a feature and returns their usage balance. + +**Parameters:** +- `customer_id` (required) - The customer to check +- `feature_id` (required) - The feature to check access for +- `required_balance` (optional) - How much balance/usage is needed (defaults to 1) + +**Returns:** Whether the customer is `allowed` to use the feature, along with balance information. + +## Step-by-Step: Writing a /check Test + +### Step 1: Define What You're Testing + +Identify the specific scenario: +- Feature not attached to customer +- Boolean feature (on/off access) +- Metered feature with usage limits +- Unlimited feature +- Credit system (actions that consume from a credit pool) +- Overage behavior + +### Step 2: Construct Features & Products + +#### Feature Types + +**Boolean Features** - Simple on/off access: +```typescript +const dashboardFeature = constructFeatureItem({ + featureId: TestFeature.Dashboard, + isBoolean: true, +}); +``` + +**Metered Features** - Usage-based with limits: +```typescript +// Basic metered (resets monthly) +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, +}); + +// Unlimited +const storageFeature = constructFeatureItem({ + featureId: TestFeature.Storage, + unlimited: true, +}); +``` + +**Pay-per-use (Arrear)** - Overage pricing: +```typescript +const apiCallsFeature = constructArrearItem({ + featureId: TestFeature.ApiCalls, + includedUsage: 10000, + price: 0.1, // Price per billing_units + billingUnits: 1000, // Charged per 1000 calls + usageLimit: 50000, // Hard cap (optional) +}); +``` + +**Prepaid (Allocated)** - Pre-purchased units (seats, licenses): +```typescript +const seatsFeature = constructPrepaidItem({ + featureId: TestFeature.Seats, + price: 10, + billingUnits: 1, + includedUsage: 5, +}); +``` + +**Credit Systems** - A credit pool that multiple features consume from: +```typescript +// The credit pool +const creditsFeature = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, +}); + +// When testing, check Action1 or Action2 features +// These will consume from the Credits pool +``` + +**IMPORTANT for Credit Systems:** +- Attach the `Credits` feature to the product +- Call `/check` on `Action1` or `Action2` (NOT on Credits directly) +- The response will show the Credits balance in the `balances` array +- When testing v0 responses, use `getCreditCost({ featureId, creditSystem, amount })` from `@/internal/features/creditSystemUtils.js` to calculate the expected `required` field in balances +- Example: Customer has 100 credits, checking Action1 for 50 units โ†’ allowed, shows 100 credit balance + +#### Combine into Products + +```typescript +const proProd = constructProduct({ + type: "pro", + isDefault: false, + items: [messagesFeature, dashboardFeature], +}); +``` + +### Step 3: Initialize Test Environment + +**Always use this exact order in `beforeAll`:** + +```typescript +const testCase = "your-test-name"; +const customerId = "your-test-name"; + +beforeAll(async () => { + // 1. Create customer + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", // Include if testing paid features + withTestClock: false, + }); + + // 2. Create products + await initProductsV0({ + ctx, + products: [proProd], + prefix: testCase, + }); + + // 3. Attach product to customer (if testing attached features) + await autumnV1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); +}); +``` + +### Step 4: Write Test Cases + +Test both v0 and v1 APIs: + +```typescript +test("v0 response", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 100, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(true); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + feature_id: TestFeature.Messages, + balance: 1000, + required: 100, + }); +}); + +test("v1 response", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 100, + })) as unknown as CheckResponse; + + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages, + balance: 1000, + required_balance: 100, + code: SuccessCode.FeatureFound, + usage: 0, + included_usage: 1000, + overage_allowed: false, + }); + + expect(res.next_reset_at).toBeDefined(); +}); +``` + +## Common Scenarios + +### Feature Not Attached +```typescript +// Don't call autumnV1.attach() in beforeAll +const res = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, +}); +expect(res.allowed).toBe(false); +``` + +### Exceeds Limit +```typescript +const res = await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 9999, // More than available +}); +expect(res.allowed).toBe(false); +``` + +### Boolean Feature +```typescript +const res = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, +}); +expect(res.allowed).toBe(true); +// No balance field for boolean features +``` + +### Credit System +```typescript +// Product has Credits feature attached +const res = await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, // Check the action, not Credits + required_balance: 50, +}); +expect(res.allowed).toBe(true); +expect(res.balance).toBe(100); // Shows Credits balance +``` + +## Required Imports + +```typescript +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem, constructArrearItem, constructPrepaidItem } 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"; +``` + +## Test File Template + +```typescript +const testCase = "check-X"; +const customerId = "check-X"; + +describe(`${chalk.yellowBright("check-X: description")}`, () => { + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + // Initialize customer, products, attach + }); + + test("v0 response", async () => { + // Test v0 + }); + + test("v1 response", async () => { + // Test v1 + }); +}); +``` + +## Checklist + +- [ ] Unique test case name (e.g., "credit-systems1") +- [ ] Use chalk for describe block +- [ ] Test both v0 and v1 APIs +- [ ] Initialize in correct order: customer โ†’ products โ†’ attach +- [ ] For credit systems: attach Credits, check Action1/Action2 +- [ ] Verify `next_reset_at` is defined (v1 time-based features) +- [ ] Use `.toMatchObject()` for partial matches, `.toStrictEqual()` for exact diff --git a/server/tests/attach/addOn/addOn1.ts b/server/tests/attach/addOn/addOn1.test.ts similarity index 62% rename from server/tests/attach/addOn/addOn1.ts rename to server/tests/attach/addOn/addOn1.test.ts index 16eb67780..01172972e 100644 --- a/server/tests/attach/addOn/addOn1.ts +++ b/server/tests/attach/addOn/addOn1.test.ts @@ -1,21 +1,19 @@ -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { LegacyVersion } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct, constructRawProduct, } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts, replaceItems } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { replaceItems } from "../utils.js"; export const pro = constructProduct({ items: [ @@ -43,60 +41,34 @@ const testCase = "addOn1"; describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating free add on`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - addPrefixToProducts({ + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: true, + }); + await initProductsV0({ + ctx, products: [pro, addOn], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro, addOn], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; }); - it("should should attach pro product, then add on product", async () => { + test("should should attach pro product, then add on product", async () => { await attachAndExpectCorrect({ autumn, - db, - org, - env, - stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, + stripeCli: ctx.stripeCli, customerId, product: pro, }); }); - it("should should attach add on product", async () => { + test("should should attach add on product", async () => { const preview = await autumn.attachPreview({ customer_id: customerId, product_id: addOn.id, @@ -109,7 +81,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f const customer = await autumn.customers.get(customerId); - expect(customer.products.length).to.equal(3); + expect(customer.products.length).toBe(3); expectProductAttached({ customer, product: addOn, @@ -129,7 +101,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f }), }); - it("should update add on product", async () => { + test("should update add on product", async () => { const preview = await autumn.attachPreview({ customer_id: customerId, product_id: addOn.id, @@ -146,7 +118,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f const customer = await autumn.customers.get(customerId); - expect(customer.products.length).to.equal(3); + expect(customer.products.length).toBe(3); expectProductAttached({ customer, product: addOn, diff --git a/server/tests/attach/addOn/addOn2.test.ts b/server/tests/attach/addOn/addOn2.test.ts index 8141359a0..8dfdd33b3 100644 --- a/server/tests/attach/addOn/addOn2.test.ts +++ b/server/tests/attach/addOn/addOn2.test.ts @@ -1,12 +1,12 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; @@ -14,8 +14,8 @@ import { constructProduct, constructRawProduct, } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; export const pro = constructProduct({ type: "pro", @@ -40,39 +40,28 @@ describe(`${chalk.yellowBright(`${testCase}: Testing attach free add on twice (s let db: DrizzleCli, org: Organization, env: AppEnv; let stripeCli: Stripe; - before(async function () { - await setupBefore(this); - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; - - await initCustomer({ - db, - org, - env, - autumn: this.autumnJs, + await initCustomerV3({ + ctx, customerId, - fingerprint: "test", + customerData: { fingerprint: "test" }, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, addOn], prefix: testCase, }); - - await createProducts({ - db, - orgId: org.id, - env, - autumn, - products: [pro, addOn], - }); }); - it("should attach pro product and free add on", async () => { + test("should attach pro product and free add on", async () => { await attachAndExpectCorrect({ autumn, customerId, diff --git a/server/tests/attach/basic/basic1.ts b/server/tests/attach/basic/basic1.test.ts similarity index 63% rename from server/tests/attach/basic/basic1.ts rename to server/tests/attach/basic/basic1.test.ts index eb25bf2e8..fa07bdc69 100644 --- a/server/tests/attach/basic/basic1.ts +++ b/server/tests/attach/basic/basic1.test.ts @@ -1,19 +1,18 @@ +import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion } from "@autumn/shared"; -import { expect } from "chai"; import chalk from "chalk"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { features, products } from "tests/global.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { compareMainProduct } from "tests/utils/compare.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const freeProd = constructProduct({ type: "free", @@ -29,44 +28,28 @@ const freeProd = constructProduct({ ], }); -// UNCOMMENT FROM HERE const testCase = "basic1"; + describe(`${chalk.yellowBright("basic1: Testing attach free product")}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let db, org, env; - before(async function () { - await setupBefore(this); - db = this.db; - org = this.org; - env = this.env; - - await initCustomer({ - autumn: this.autumnJs, + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db, - org, - env, - fingerprint: "test", + customerData: { fingerprint: "test" }, withTestClock: false, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [freeProd], prefix: testCase, }); - - await createProducts({ - db, - orgId: org.id, - env, - autumn, - products: [freeProd], - }); }); - it("should create customer and have default free active", async () => { + test("should create customer and have default free active", async () => { const data = await AutumnCli.getCustomer(customerId); compareMainProduct({ @@ -75,7 +58,7 @@ describe(`${chalk.yellowBright("basic1: Testing attach free product")}`, () => { }); }); - it("should have correct entitlements", async () => { + test("should have correct entitlements", async () => { const expectedEntitlement = products.free.entitlements.metered1; const entitled = (await AutumnCli.entitled( @@ -87,18 +70,18 @@ describe(`${chalk.yellowBright("basic1: Testing attach free product")}`, () => { (balance: any) => balance.feature_id === features.metered1.id, ); - expect(entitled.allowed).to.be.true; - expect(metered1Balance).to.exist; - expect(metered1Balance.balance).to.equal(expectedEntitlement.allowance); - expect(metered1Balance.unlimited).to.not.exist; + expect(entitled.allowed).toBe(true); + expect(metered1Balance).toBeDefined(); + expect(metered1Balance.balance).toBe(expectedEntitlement.allowance); + expect(metered1Balance.unlimited).toBeUndefined(); }); - it("should have correct boolean1 entitlement", async () => { + test("should have correct boolean1 entitlement", async () => { const entitled = await AutumnCli.entitled(customerId, features.boolean1.id); - expect(entitled!.allowed).to.be.false; + expect(entitled!.allowed).toBe(false); }); - it("should attach free (with $0 price) and force checkout and succeed", async () => { + test("should attach free (with $0 price) and force checkout and succeed", async () => { await autumn.attach({ customer_id: customerId, product_id: freeProd.id, diff --git a/server/tests/attach/basic/basic10.ts b/server/tests/attach/basic/basic10.test.ts similarity index 78% rename from server/tests/attach/basic/basic10.ts rename to server/tests/attach/basic/basic10.test.ts index da86b3a3e..2cc23e44b 100644 --- a/server/tests/attach/basic/basic10.ts +++ b/server/tests/attach/basic/basic10.test.ts @@ -1,5 +1,4 @@ -import { expect } from "chai"; - +import { beforeAll, describe, expect, test } from "bun:test"; import chalk from "chalk"; import { Decimal } from "decimal.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; @@ -10,8 +9,9 @@ import { getUsagePriceTiers, timeout, } from "tests/utils/genUtils.js"; -import { initCustomer } from "tests/utils/init.js"; import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "basic10"; @@ -24,16 +24,16 @@ describe(`${chalk.yellowBright("basic10: Multi attach, all one off")}`, () => { quantity, }, ]; - before(async function () { - await initCustomer({ + + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db: this.db, - org: this.org, - env: this.env, + withTestClock: true, }); }); - it("should attach monthly with one time", async () => { + test("should attach monthly with one time", async () => { const res = await AutumnCli.attach({ customerId, productIds: [ @@ -47,7 +47,7 @@ describe(`${chalk.yellowBright("basic10: Multi attach, all one off")}`, () => { await timeout(20000); }); - it("should have correct main product and entitlements", async () => { + test("should have correct main product and entitlements", async () => { const cusRes = await AutumnCli.getCustomer(customerId); compareMainProduct({ @@ -79,6 +79,6 @@ describe(`${chalk.yellowBright("basic10: Multi attach, all one off")}`, () => { .add(metered1Amount) .toNumber(); - expect(invoices[0].total).to.equal(expectedTotal); + expect(invoices[0].total).toBe(expectedTotal); }); }); diff --git a/server/tests/attach/basic/basic2.ts b/server/tests/attach/basic/basic2.test.ts similarity index 65% rename from server/tests/attach/basic/basic2.ts rename to server/tests/attach/basic/basic2.test.ts index 143a0b526..418f781ac 100644 --- a/server/tests/attach/basic/basic2.ts +++ b/server/tests/attach/basic/basic2.test.ts @@ -1,39 +1,30 @@ -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; import chalk from "chalk"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; import { timeout } from "tests/utils/genUtils.js"; import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -// UNCOMMENT FROM HERE const testCase = "basic2"; + describe(`${chalk.yellowBright("basic2: Testing attach pro")}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt(); - let db, org, env; - before(async function () { - await setupBefore(this); - - db = this.db; - org = this.org; - env = this.env; - - await initCustomer({ - autumn: this.autumnJs, + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db, - org, - env, - fingerprint: "test", + customerData: { fingerprint: "test" }, + withTestClock: true, }); }); - it("should attach pro through checkout", async () => { + test("should attach pro through checkout", async () => { const { checkout_url } = await autumn.attach({ customer_id: customerId, product_id: products.pro.id, @@ -43,16 +34,16 @@ describe(`${chalk.yellowBright("basic2: Testing attach pro")}`, () => { await timeout(12000); }); - it("should have correct product & entitlements", async () => { + test("should have correct product & entitlements", async () => { const res = await AutumnCli.getCustomer(customerId); compareMainProduct({ sent: products.pro, cusRes: res, }); - expect(res.invoices.length).to.be.greaterThan(0); + expect(res.invoices.length).toBeGreaterThan(0); }); - it("should have correct result when calling /check", async () => { + test("should have correct result when calling /check", async () => { const proEntitlements = products.pro.entitlements; for (const entitlement of Object.values(proEntitlements)) { @@ -68,12 +59,11 @@ describe(`${chalk.yellowBright("basic2: Testing attach pro")}`, () => { ); try { - expect(res!.allowed).to.be.true; - expect(entBalance).to.exist; + expect(res!.allowed).toBe(true); + expect(entBalance).toBeDefined(); if (entitlement.allowance) { - expect(entBalance!.balance).to.equal(allowance); + expect(entBalance!.balance).toBe(allowance); } - // console.log(` - ${entitlement.feature_id} -- Passed`); } catch (error) { console.group(); console.group(); diff --git a/server/tests/attach/basic/basic3.ts b/server/tests/attach/basic/basic3.test.ts similarity index 64% rename from server/tests/attach/basic/basic3.ts rename to server/tests/attach/basic/basic3.test.ts index 369f6c299..c95e4cf02 100644 --- a/server/tests/attach/basic/basic3.ts +++ b/server/tests/attach/basic/basic3.test.ts @@ -1,23 +1,17 @@ -import type { AppEnv, Organization } from "@autumn/shared"; -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; import chalk from "chalk"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { features, products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; import { timeout } from "tests/utils/genUtils.js"; import { createProducts } from "tests/utils/productUtils.js"; import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -// const oneTimeQuantity = 2; -// const oneTimePurchaseCount = 2; -// const oneTimeOverrideQuantity = 4; -// const monthlyQuantity = 2; const oneTimeItem = constructPrepaidItem({ featureId: features.metered1.id, price: 9, @@ -48,38 +42,30 @@ const monthly = constructRawProduct({ ], }); -// UNCOMMENT FROM HERE const testCase = "basic3"; + describe(`${chalk.yellowBright("basic3: Testing attach one time / monthly add ons")}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt(); - let db: DrizzleCli, org: Organization, env: AppEnv; - before(async function () { - await setupBefore(this); - db = this.db; - org = this.org; - env = this.env; - - await initCustomer({ - autumn: this.autumnJs, + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db, - org, - env, attachPm: "success", + withTestClock: true, }); await createProducts({ - autumn: this.autumnJs, - db, - orgId: org.id, - env, + autumn: autumn, + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, products: [oneTime, monthly], }); }); - it("should attach pro", async () => { + test("should attach pro", async () => { await autumn.attach({ customer_id: customerId, product_id: products.pro.id, @@ -96,7 +82,7 @@ describe(`${chalk.yellowBright("basic3: Testing attach one time / monthly add on const oneTimeBillingUnits = oneTimeItem.billing_units; const oneTimePurchaseCount = 2; - it("should attach one time add on twice, force checkout", async () => { + test("should attach one time add on twice, force checkout", async () => { for (let i = 0; i < 2; i++) { const res = await autumn.attach({ customer_id: customerId, @@ -112,7 +98,7 @@ describe(`${chalk.yellowBright("basic3: Testing attach one time / monthly add on } }); - it("should have correct product & entitlements", async () => { + test("should have correct product & entitlements", async () => { const cusRes = await AutumnCli.getCustomer(customerId); const addOnBalance = cusRes.entitlements.find( @@ -124,37 +110,25 @@ describe(`${chalk.yellowBright("basic3: Testing attach one time / monthly add on const expectedAmt = oneTimeQuantity * oneTimePurchaseCount; - expect(addOnBalance!.balance).to.equal( - expectedAmt, - "add on balance should be correct", - ); + expect(addOnBalance!.balance).toBe(expectedAmt); - expect(cusRes.add_ons).to.have.lengthOf( - 1, - "should only have one add on product after two purchases (since they combine)", - ); - expect(cusRes.add_ons[0].id).to.equal( - oneTime.id, - "add on product should exist", - ); - expect(cusRes.invoices.length).to.equal( - 1 + oneTimePurchaseCount, - "invoices should be correct", - ); + expect(cusRes.add_ons).toHaveLength(1); + expect(cusRes.add_ons[0].id).toBe(oneTime.id); + expect(cusRes.invoices.length).toBe(1 + oneTimePurchaseCount); }); - it("should have correct /check result for metered1", async () => { + test("should have correct /check result for metered1", async () => { const res: any = await AutumnCli.entitled(customerId, features.metered1.id); - expect(res!.allowed).to.be.true; + expect(res!.allowed).toBe(true); const proMetered1Amt = products.pro.entitlements.metered1.allowance; const addOnBalance = res!.balances.find( (b: any) => b.feature_id === features.metered1.id, ); - expect(res!.allowed).to.be.true; - expect(addOnBalance!.balance).to.equal( + expect(res!.allowed).toBe(true); + expect(addOnBalance!.balance).toBe( proMetered1Amt! + oneTimeQuantity * oneTimePurchaseCount, ); }); diff --git a/server/tests/attach/basic/basic4.ts b/server/tests/attach/basic/basic4.test.ts similarity index 72% rename from server/tests/attach/basic/basic4.ts rename to server/tests/attach/basic/basic4.test.ts index 077a11681..593819929 100644 --- a/server/tests/attach/basic/basic4.ts +++ b/server/tests/attach/basic/basic4.test.ts @@ -1,14 +1,14 @@ -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; import chalk from "chalk"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { features, products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; import { createProducts } from "tests/utils/productUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const monthlyItem = constructPrepaidItem({ featureId: features.metered1.id, @@ -22,36 +22,29 @@ const monthly = constructRawProduct({ }); const testCase = "basic4"; + describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt(); - let db, org, env; - before(async function () { - await setupBefore(this); - db = this.db; - org = this.org; - env = this.env; - - await initCustomer({ - autumn: this.autumnJs, + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db, - org, - env, attachPm: "success", + withTestClock: true, }); await createProducts({ - autumn: this.autumnJs, - db, - orgId: org.id, - env, + autumn: autumn, + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, products: [monthly], }); }); - it("should attach pro", async () => { + test("should attach pro", async () => { await autumn.attach({ customer_id: customerId, product_id: products.pro.id, @@ -66,7 +59,7 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => const monthlyQuantity = 500; - it("should attach monthly add on", async () => { + test("should attach monthly add on", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.monthlyAddOnMetered1.id, @@ -80,7 +73,7 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => }); }); - it("should have correct product & entitlements", async () => { + test("should have correct product & entitlements", async () => { const cusRes = await AutumnCli.getCustomer(customerId); const proMetered1 = products.pro.entitlements.metered1.allowance; @@ -92,20 +85,18 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => products.monthlyAddOnMetered1.entitlements.metered1.interval, ); - expect(monthlyMetered1Balance!.balance).to.equal( - proMetered1! + monthlyQuantity, - ); + expect(monthlyMetered1Balance!.balance).toBe(proMetered1! + monthlyQuantity); - expect(cusRes.add_ons).to.have.lengthOf(1); + expect(cusRes.add_ons).toHaveLength(1); const monthlyAddOnId = cusRes.add_ons.find( (a: any) => a.id === products.monthlyAddOnMetered1.id, ); - expect(monthlyAddOnId).to.exist; - expect(cusRes.invoices.length).to.equal(2); + expect(monthlyAddOnId).toBeDefined(); + expect(cusRes.invoices.length).toBe(2); }); - it("should have correct /check result for metered1", async () => { + test("should have correct /check result for metered1", async () => { const res: any = await AutumnCli.entitled(customerId, features.metered1.id); const metered1Balance = res!.balances.find( @@ -115,7 +106,7 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => const proMetered1Amt = products.pro.entitlements.metered1.allowance; const monthlyAddOnMetered1Amt = monthlyQuantity; - expect(metered1Balance!.balance).to.equal( + expect(metered1Balance!.balance).toBe( proMetered1Amt! + monthlyAddOnMetered1Amt, ); }); diff --git a/server/tests/attach/basic/basic5.ts b/server/tests/attach/basic/basic5.test.ts similarity index 61% rename from server/tests/attach/basic/basic5.ts rename to server/tests/attach/basic/basic5.test.ts index 7cc0e3827..2601c5cc7 100644 --- a/server/tests/attach/basic/basic5.ts +++ b/server/tests/attach/basic/basic5.test.ts @@ -1,44 +1,39 @@ +import { beforeAll, describe, expect, test } from "bun:test"; import { CusProductStatus } from "@autumn/shared"; -import { expect } from "chai"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { timeout } from "@/utils/genUtils.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "basic5"; -describe(`${chalk.yellowBright( - "basic5: Testing cancel through Stripe at period end and now", -)}`, () => { + +describe(`${chalk.yellowBright("basic5: Testing cancel through Stripe at period end and now")}`, () => { const customerId = testCase; let stripeCli: Stripe; - before(async function () { - await setupBefore(this); - stripeCli = this.stripeCli; - await initCustomer({ + beforeAll(async () => { + stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + await initCustomerV3({ + ctx, customerId, - db: this.db, - org: this.org, - env: this.env, - autumn: this.autumnJs, attachPm: "success", + withTestClock: true, }); }); - it("should attach pro product", async () => { + test("should attach pro product", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.pro.id, }); }); - it("should cancel pro product (at period end)", async function () { - const stripeCli = createStripeCli({ org: this.org, env: this.env }); + test("should cancel pro product (at period end)", async () => { const cusRes: any = await AutumnCli.getCustomer(customerId); const proProduct = cusRes.products.find( @@ -53,9 +48,7 @@ describe(`${chalk.yellowBright( await timeout(5000); }); - return; - - it("should have pro product active, and canceled_at != null, and free scheduled", async () => { + test("should have pro product active, and canceled_at != null, and free scheduled", async () => { const cusRes: any = await AutumnCli.getCustomer(customerId); compareMainProduct({ sent: products.pro, @@ -65,17 +58,17 @@ describe(`${chalk.yellowBright( const proProduct = cusRes.products.find( (p: any) => p.id === products.pro.id, ); - expect(proProduct.canceled_at).to.not.equal(null); - expect(proProduct.status).to.equal(CusProductStatus.Active); + expect(proProduct.canceled_at).not.toBe(null); + expect(proProduct.status).toBe(CusProductStatus.Active); const freeProduct = cusRes.products.find( (p: any) => p.id === products.free.id, ); - expect(freeProduct).to.exist; - expect(freeProduct.status).to.equal(CusProductStatus.Scheduled); + expect(freeProduct).toBeDefined(); + expect(freeProduct.status).toBe(CusProductStatus.Scheduled); }); - it("should cancel pro product (now)", async () => { + test("should cancel pro product (now)", async () => { const cusRes: any = await AutumnCli.getCustomer(customerId); const proProduct = cusRes.products.find( (p: any) => p.id === products.pro.id, @@ -87,7 +80,7 @@ describe(`${chalk.yellowBright( await timeout(5000); }); - it("should have free product active, and no pro product", async () => { + test("should have free product active, and no pro product", async () => { const cusRes: any = await AutumnCli.getCustomer(customerId); compareMainProduct({ sent: products.free, diff --git a/server/tests/attach/basic/basic6.ts b/server/tests/attach/basic/basic6.test.ts similarity index 56% rename from server/tests/attach/basic/basic6.ts rename to server/tests/attach/basic/basic6.test.ts index 170837382..1985a0797 100644 --- a/server/tests/attach/basic/basic6.ts +++ b/server/tests/attach/basic/basic6.test.ts @@ -1,43 +1,38 @@ +import { beforeAll, describe, expect, test } from "bun:test"; import { CusProductStatus, type Customer } from "@autumn/shared"; -import { expect } from "chai"; import chalk from "chalk"; import { addHours, addMonths } from "date-fns"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { products } from "tests/global.js"; import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "basic6"; -describe(`${chalk.yellowBright( - "basic6: Testing subscription past_due", -)}`, () => { + +describe(`${chalk.yellowBright("basic6: Testing subscription past_due")}`, () => { const customerId = testCase; let stripeCli: Stripe; let testClockId: string; let customer: Customer; - before(async function () { - await setupBefore(this); - stripeCli = this.stripeCli; + beforeAll(async () => { + stripeCli = ctx.stripeCli; - const { testClockId: testClockId_, customer: customer_ } = - await initCustomer({ - customerId, - db: this.db, - org: this.org, - env: this.env, - autumn: this.autumnJs, - attachPm: "success", - }); - testClockId = testClockId_; - customer = customer_; + const result = await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: true, + }); + testClockId = result.testClockId; + customer = result.customer; }); - it("should attach pro product and switch to failed payment method", async () => { + test("should attach pro product and switch to failed payment method", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.pro.id, @@ -49,7 +44,7 @@ describe(`${chalk.yellowBright( }); }); - it("should advance to next cycle", async () => { + test("should advance to next cycle", async () => { await advanceTestClock({ stripeCli, testClockId, @@ -61,12 +56,12 @@ describe(`${chalk.yellowBright( }); }); - it("should have pro product in past due status", async () => { + test("should have pro product in past due status", async () => { const cusRes: any = await AutumnCli.getCustomer(customerId); const proProduct = cusRes.products.find( (p: any) => p.id === products.pro.id, ); - expect(proProduct).to.exist; - expect(proProduct.status).to.equal(CusProductStatus.PastDue); + expect(proProduct).toBeDefined(); + expect(proProduct.status).toBe(CusProductStatus.PastDue); }); }); diff --git a/server/tests/attach/basic/basic7.ts b/server/tests/attach/basic/basic7.test.ts similarity index 57% rename from server/tests/attach/basic/basic7.ts rename to server/tests/attach/basic/basic7.test.ts index 11d68246a..5b787097d 100644 --- a/server/tests/attach/basic/basic7.ts +++ b/server/tests/attach/basic/basic7.test.ts @@ -1,34 +1,30 @@ -import chalk from "chalk"; -import { compareMainProduct } from "tests/utils/compare.js"; -import { products } from "tests/global.js"; -import { AutumnCli } from "tests/cli/AutumnCli.js"; -import { assert, expect } from "chai"; -import { timeout } from "tests/utils/genUtils.js"; +import { beforeAll, describe, expect, test } from "bun:test"; import { CusProductStatus } from "@autumn/shared"; -import { setupBefore } from "tests/before.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import chalk from "chalk"; +import { AutumnCli } from "tests/cli/AutumnCli.js"; +import { products } from "tests/global.js"; +import { compareMainProduct } from "tests/utils/compare.js"; +import { timeout } from "tests/utils/genUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "basic7"; describe(`${chalk.yellowBright("basic7: Testing trial duplicates (same customer)")}`, () => { const customerId = testCase; - let customerId2 = testCase + "2"; const autumn = new AutumnInt(); - before(async function () { - await setupBefore(this); - await initCustomer({ + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db: this.db, - org: this.org, - env: this.env, - autumn: this.autumnJs, attachPm: "success", + withTestClock: true, }); }); - it("should attach pro with trial and have correct product & invoice", async function () { + test("should attach pro with trial and have correct product & invoice", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.proWithTrial.id, @@ -43,20 +39,20 @@ describe(`${chalk.yellowBright("basic7: Testing trial duplicates (same customer) }); const invoices = customer.invoices; - expect(invoices.length).to.equal(1, "Invoice length should be 1"); - expect(invoices[0].total).to.equal(0, "Invoice total should be 0"); + expect(invoices.length).toBe(1); + expect(invoices[0].total).toBe(0); }); - it("should cancel pro with trial", async function () { + test("should cancel pro with trial", async () => { await autumn.cancel({ customer_id: customerId, product_id: products.proWithTrial.id, cancel_immediately: true, }); - await timeout(5000); // for webhook to be processed + await timeout(5000); }); - it("should be able to attach pro with trial again (renewal flow)", async function () { + test("should be able to attach pro with trial again (renewal flow)", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.proWithTrial.id, @@ -70,10 +66,7 @@ describe(`${chalk.yellowBright("basic7: Testing trial duplicates (same customer) }); const invoices = customer.invoices; - expect(invoices.length).to.equal(2, "Invoice length should be 1"); - expect(invoices[0].amount).to.equal( - products.proWithTrial.prices[0].amount, - "should have paid full amount (trial already used once)", - ); + expect(invoices.length).toBe(2); + expect(invoices[0].amount).toBe(products.proWithTrial.prices[0].amount); }); }); diff --git a/server/tests/attach/basic/basic8.ts b/server/tests/attach/basic/basic8.test.ts similarity index 52% rename from server/tests/attach/basic/basic8.ts rename to server/tests/attach/basic/basic8.test.ts index f1f98dd3d..5f260712d 100644 --- a/server/tests/attach/basic/basic8.ts +++ b/server/tests/attach/basic/basic8.test.ts @@ -1,12 +1,12 @@ +import { beforeAll, describe, expect, test } from "bun:test"; import { CusProductStatus } from "@autumn/shared"; -import { expect } from "chai"; import chalk from "chalk"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "basic8"; @@ -14,32 +14,27 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri const customerId = testCase; const customerId2 = testCase + "2"; const autumn = new AutumnInt(); + const randFingerprint = Math.random().toString(36).substring(2, 15); - before(async function () { - const randFingerprint = Math.random().toString(36).substring(2, 15); - await setupBefore(this); - await initCustomer({ + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db: this.db, - org: this.org, - env: this.env, - autumn: this.autumnJs, - fingerprint: randFingerprint, + customerData: { fingerprint: randFingerprint }, attachPm: "success", + withTestClock: true, }); - await initCustomer({ + await initCustomerV3({ + ctx, customerId: customerId2, - db: this.db, - org: this.org, - env: this.env, - autumn: this.autumnJs, - fingerprint: randFingerprint, + customerData: { fingerprint: randFingerprint }, attachPm: "success", + withTestClock: true, }); }); - it("should attach pro with trial and have correct product & invoice", async () => { + test("should attach pro with trial and have correct product & invoice", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.proWithTrial.id, @@ -54,17 +49,16 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri }); const invoices = customer.invoices; - expect(invoices.length).to.equal(1, "Invoice length should be 1"); - expect(invoices[0].total).to.equal(0, "Invoice total should be 0"); + expect(invoices.length).toBe(1); + expect(invoices[0].total).toBe(0); }); - it("should attach pro with trial to second customer and have correct product & invoice (pro with trial, full price)", async () => { + test("should attach pro with trial to second customer and have correct product & invoice (pro with trial, full price)", async () => { await autumn.attach({ customer_id: customerId2, product_id: products.proWithTrial.id, }); - // await timeout(5000); // for webhook to be processed const customer = await AutumnCli.getCustomer(customerId2); compareMainProduct({ @@ -73,12 +67,8 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri status: CusProductStatus.Active, }); - // Check invoice is equal monthly price const invoices = customer.invoices; - expect(invoices.length).to.equal(1, "Invoice length should be 1"); - expect(invoices[0].total).to.equal( - 10, - "Invoice total should be full price", - ); + expect(invoices.length).toBe(1); + expect(invoices[0].total).toBe(10); }); }); diff --git a/server/tests/attach/basic/basic9.ts b/server/tests/attach/basic/basic9.test.ts similarity index 56% rename from server/tests/attach/basic/basic9.ts rename to server/tests/attach/basic/basic9.test.ts index fbbf83939..7c2485d7c 100644 --- a/server/tests/attach/basic/basic9.ts +++ b/server/tests/attach/basic/basic9.test.ts @@ -1,19 +1,19 @@ +import { beforeAll, describe, test } from "bun:test"; import chalk from "chalk"; -import { features } from "tests/global.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; -import { products } from "tests/global.js"; -import { timeout } from "tests/utils/genUtils.js"; -import { initCustomer } from "tests/utils/init.js"; -import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; +import { features, products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; +import { timeout } from "tests/utils/genUtils.js"; +import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "basic9"; -describe(`${chalk.yellowBright( - "basic9: attach monthly with one time prepaid, and quantity = 0", -)}`, () => { - let customerId = testCase; - let options = [ +describe(`${chalk.yellowBright("basic9: attach monthly with one time prepaid, and quantity = 0")}`, () => { + const customerId = testCase; + + const options = [ { feature_id: features.metered1.id, quantity: 0, @@ -23,16 +23,16 @@ describe(`${chalk.yellowBright( quantity: 4, }, ]; - before(async function () { - await initCustomer({ + + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db: this.db, - org: this.org, - env: this.env, + withTestClock: true, }); }); - it("should attach monthly with one time", async function () { + test("should attach monthly with one time", async () => { const res = await AutumnCli.attach({ customerId, productId: products.monthlyWithOneTime.id, @@ -43,7 +43,7 @@ describe(`${chalk.yellowBright( await timeout(12000); }); - it("should have correct main product and entitlements", async function () { + test("should have correct main product and entitlements", async () => { const cusRes = await AutumnCli.getCustomer(customerId); compareMainProduct({ diff --git a/server/tests/attach/checkout/checkout3.ts b/server/tests/attach/checkout/checkout3.test.ts similarity index 62% rename from server/tests/attach/checkout/checkout3.ts rename to server/tests/attach/checkout/checkout3.test.ts index c47b55289..2c5efbe18 100644 --- a/server/tests/attach/checkout/checkout3.ts +++ b/server/tests/attach/checkout/checkout3.test.ts @@ -1,19 +1,17 @@ -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; +import { beforeAll, describe, test } from "bun:test"; +import { LegacyVersion } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; export const pro = constructProduct({ items: [ @@ -40,47 +38,22 @@ const testCase = "checkout3"; describe(`${chalk.yellowBright(`${testCase}: Testing multi attach checkout, pro + one off`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + }); - stripeCli = this.stripeCli; - - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, oneOff], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro, oneOff], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - // attachPm: "success", - }); - - testClockId = testClockId1!; }); - it("should attach pro and one off product", async () => { + test("should attach pro and one off product", async () => { const res = await autumn.attach({ customer_id: customerId, product_ids: [pro.id, oneOff.id], diff --git a/server/tests/attach/checkout/checkout4.ts b/server/tests/attach/checkout/checkout4.test.ts similarity index 56% rename from server/tests/attach/checkout/checkout4.ts rename to server/tests/attach/checkout/checkout4.test.ts index 1615d97dc..5583adee6 100644 --- a/server/tests/attach/checkout/checkout4.ts +++ b/server/tests/attach/checkout/checkout4.test.ts @@ -1,14 +1,12 @@ -import { type AppEnv, type Organization, RewardType } from "@autumn/shared"; -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { RewardType } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts, createReward } from "tests/utils/productUtils.js"; +import { createReward } from "tests/utils/productUtils.js"; import { completeCheckoutForm } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; @@ -16,8 +14,8 @@ import { constructCoupon, constructProduct, } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const pro = constructProduct({ items: [ @@ -40,56 +38,31 @@ const testCase = "checkout4"; describe(`${chalk.yellowBright(`${testCase}: Testing attach coupon`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt(); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + }); - stripeCli = this.stripeCli; - - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - // attachPm: "success", - }); - await createReward({ - orgId: org.id, - env, - db, + orgId: ctx.org.id, + env: ctx.env, + db: ctx.db, autumn, reward, productId: pro.id, }); - - testClockId = testClockId1!; }); - it("should attach pro and one off product", async () => { + test("should attach pro and one off product", async () => { const res = await autumn.attach({ customer_id: customerId, product_id: pro.id, @@ -106,8 +79,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing attach coupon`)}`, () => { product: pro, }); - expect(customer.invoices.length).to.equal(1); + expect(customer.invoices.length).toBe(1); const totalPrice = getBasePrice({ product: pro }); - expect(customer.invoices[0].total).to.equal(totalPrice * 0.5); + expect(customer.invoices[0].total).toBe(totalPrice * 0.5); }); }); diff --git a/server/tests/attach/checkout/checkout5.ts b/server/tests/attach/checkout/checkout5.test.ts similarity index 55% rename from server/tests/attach/checkout/checkout5.ts rename to server/tests/attach/checkout/checkout5.test.ts index 46f523565..d59c77fb1 100644 --- a/server/tests/attach/checkout/checkout5.ts +++ b/server/tests/attach/checkout/checkout5.test.ts @@ -1,20 +1,17 @@ -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { LegacyVersion } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; export const pro = constructProduct({ items: [ @@ -30,47 +27,21 @@ const testCase = "checkout5"; describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout, no product till paid`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - addPrefixToProducts({ + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + }); + await initProductsV0({ + ctx, products: [pro], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - // attachPm: "success", - }); - - testClockId = testClockId1!; }); - it("should attach pro product", async () => { + test("should attach pro product", async () => { const res = await autumn.attach({ customer_id: customerId, product_id: pro.id, @@ -81,12 +52,12 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout, no produc const invoice = customer.invoices?.[0]; - expect(invoice).to.exist; - expect(invoice.total).to.equal(getBasePrice({ product: pro })); - expect(invoice.status).to.equal("open"); + expect(invoice).toBeDefined(); + expect(invoice.total).toBe(getBasePrice({ product: pro })); + expect(invoice.status).toBe("open"); const product = customer.products.find((p) => p.id === pro.id); - expect(product).to.not.exist; + expect(product).toBeUndefined(); await completeInvoiceCheckout({ url: res.checkout_url, @@ -96,9 +67,9 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout, no produc const invoice2 = customer2.invoices?.[0]; - expect(customer2.invoices.length).to.equal(1); - expect(invoice2).to.exist; - expect(invoice2.status).to.equal("paid"); + expect(customer2.invoices.length).toBe(1); + expect(invoice2).toBeDefined(); + expect(invoice2.status).toBe("paid"); expectProductAttached({ customer: customer2, diff --git a/server/tests/attach/checkout/checkout6.ts b/server/tests/attach/checkout/checkout6.test.ts similarity index 63% rename from server/tests/attach/checkout/checkout6.ts rename to server/tests/attach/checkout/checkout6.test.ts index 41e9985ae..70a643916 100644 --- a/server/tests/attach/checkout/checkout6.ts +++ b/server/tests/attach/checkout/checkout6.test.ts @@ -1,21 +1,18 @@ -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { LegacyVersion } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; export const pro = constructProduct({ items: [ @@ -41,54 +38,30 @@ const testCase = "checkout6"; describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checkout endpoint`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: true, + }); - stripeCli = this.stripeCli; - - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro, premium], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; }); - it("should attach pro product via invoice checkout", async () => { + test("should attach pro product via invoice checkout", async () => { const res = await autumn.checkout({ customer_id: customerId, product_id: pro.id, invoice: true, }); - expect(res.url).to.exist; + expect(res.url).toBeDefined(); await completeInvoiceCheckout({ url: res.url!, @@ -107,7 +80,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checko }); }); - it("should have no URL returned if try to attach premium (with invoice true)", async () => { + test("should have no URL returned if try to attach premium (with invoice true)", async () => { await expectAutumnError({ func: async () => { await autumn.attach({ @@ -124,10 +97,10 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checko invoice: true, }); - expect(res.url).to.not.exist; + expect(res.url).toBeNull(); }); - it("should attach premium product via invoice enable immediately", async () => { + test("should attach premium product via invoice enable immediately", async () => { const res = await autumn.attach({ customer_id: customerId, product_id: premium.id, @@ -148,9 +121,9 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checko }); const invoices = customer.invoices; - expect(invoices.length).to.equal(2); - expect(invoices[0].status).to.equal("draft"); - expect(invoices[0].total).to.equal( + expect(invoices.length).toBe(2); + expect(invoices[0].status).toBe("draft"); + expect(invoices[0].total).toBe( getBasePrice({ product: premium }) - getBasePrice({ product: pro }), ); // proration... }); diff --git a/server/tests/attach/checkout/checkout7.ts b/server/tests/attach/checkout/checkout7.test.ts similarity index 66% rename from server/tests/attach/checkout/checkout7.ts rename to server/tests/attach/checkout/checkout7.test.ts index 14a2df46c..441166a7e 100644 --- a/server/tests/attach/checkout/checkout7.ts +++ b/server/tests/attach/checkout/checkout7.test.ts @@ -1,14 +1,11 @@ -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { LegacyVersion } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { completeInvoiceCheckout } from "tests/utils/stripeUtils/completeInvoiceCheckout.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem, @@ -18,8 +15,8 @@ import { constructProduct, constructRawProduct, } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; export const pro = constructProduct({ items: [ @@ -47,47 +44,23 @@ const testCase = "checkout7"; describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout with one off product`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_2 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: true, + }); - stripeCli = this.stripeCli; - - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, addOn], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro, addOn], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; }); - it("should attach pro product, then add on product via invoice checkout", async () => { + test("should attach pro product, then add on product via invoice checkout", async () => { await autumn.attach({ customer_id: customerId, product_id: pro.id, @@ -107,7 +80,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout with one o options, }); - expect(res2.url).to.exist; + expect(res2.url).toBeDefined(); await completeInvoiceCheckout({ url: res2.url!, diff --git a/server/tests/attach/downgrade/downgrade1.ts b/server/tests/attach/downgrade/downgrade1.test.ts similarity index 70% rename from server/tests/attach/downgrade/downgrade1.ts rename to server/tests/attach/downgrade/downgrade1.test.ts index 3a3295582..6019edca8 100644 --- a/server/tests/attach/downgrade/downgrade1.ts +++ b/server/tests/attach/downgrade/downgrade1.test.ts @@ -1,20 +1,20 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectDowngradeCorrect, expectNextCycleCorrect, } from "tests/utils/expectUtils/expectScheduleUtils.js"; -import { createProducts } from "tests/utils/productUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "downgrade1"; @@ -35,42 +35,30 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> p let db: DrizzleCli, org: Organization, env: AppEnv; let stripeCli: Stripe; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro, premium], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - testClockId = testClockId1!; }); - it("should attach premium product", async () => { + test("should attach premium product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -85,7 +73,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> p // let nextCycle = Date.now(); let preview = null; - it("should downgrade to pro", async () => { + test("should downgrade to pro", async () => { const { preview: preview_ } = await expectDowngradeCorrect({ autumn, customerId, @@ -100,7 +88,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> p preview = preview_; }); - it("should have pro attached on next cycle", async () => { + test("should have pro attached on next cycle", async () => { await expectNextCycleCorrect({ preview: preview!, autumn, diff --git a/server/tests/attach/downgrade/downgrade2.ts b/server/tests/attach/downgrade/downgrade2.test.ts similarity index 72% rename from server/tests/attach/downgrade/downgrade2.ts rename to server/tests/attach/downgrade/downgrade2.test.ts index 511c0a8d3..f66293c13 100644 --- a/server/tests/attach/downgrade/downgrade2.ts +++ b/server/tests/attach/downgrade/downgrade2.test.ts @@ -1,21 +1,21 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectDowngradeCorrect, expectNextCycleCorrect, } from "tests/utils/expectUtils/expectScheduleUtils.js"; -import { createProducts } from "tests/utils/productUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/internal/products/product-items/productItemUtils.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "downgrade2"; @@ -44,42 +44,30 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> f const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [free, premium], prefix: testCase, }); - await createProducts({ - autumn, - products: [free, premium], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - testClockId = testClockId1!; }); - it("should attach premium product", async () => { + test("should attach premium product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -93,7 +81,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> f // let nextCycle = Date.now(); let preview = null; - it("should downgrade to free", async () => { + test("should downgrade to free", async () => { const { preview: preview_ } = await expectDowngradeCorrect({ autumn, customerId, @@ -108,7 +96,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade from premium -> f preview = preview_; }); - it("should have pro attached on next cycle", async () => { + test("should have pro attached on next cycle", async () => { await expectNextCycleCorrect({ preview: preview!, autumn, diff --git a/server/tests/attach/downgrade/downgrade3.ts b/server/tests/attach/downgrade/downgrade3.test.ts similarity index 77% rename from server/tests/attach/downgrade/downgrade3.ts rename to server/tests/attach/downgrade/downgrade3.test.ts index 6ea933771..4276b1dc2 100644 --- a/server/tests/attach/downgrade/downgrade3.ts +++ b/server/tests/attach/downgrade/downgrade3.test.ts @@ -1,3 +1,4 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, type Customer, @@ -6,19 +7,18 @@ import { } from "@autumn/shared"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { expectDowngradeCorrect } from "tests/utils/expectUtils/expectScheduleUtils.js"; -import { createProducts } from "tests/utils/productUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/internal/products/product-items/productItemUtils.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "downgrade3"; @@ -53,44 +53,32 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: premium -> pro - const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; + const { testClockId: testClockId1, customer: customer_ } = + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [free, pro, premium], prefix: testCase, }); - await createProducts({ - autumn, - products: [free, pro, premium], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1, customer: customer_ } = - await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - testClockId = testClockId1!; customer = customer_!; }); - it("should attach premium product", async () => { + test("should attach premium product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -104,7 +92,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: premium -> pro - // let nextCycle = Date.now(); let preview = null; - it("should downgrade to pro", async () => { + test("should downgrade to pro", async () => { const { preview: preview_ } = await expectDowngradeCorrect({ autumn, customerId, @@ -119,7 +107,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: premium -> pro - preview = preview_; }); - it("should downgrade to free", async () => { + test("should downgrade to free", async () => { const { preview: preview_ } = await expectDowngradeCorrect({ autumn, customerId, @@ -134,7 +122,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: premium -> pro - preview = preview_; }); - it("should change downgrade to pro", async () => { + test("should change downgrade to pro", async () => { const { preview: preview_ } = await expectDowngradeCorrect({ autumn, customerId, @@ -147,7 +135,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: premium -> pro - }); }); - it("should renew premium", async () => { + test("should renew premium", async () => { await autumn.attach({ customer_id: customerId, product_id: premium.id, diff --git a/server/tests/attach/downgrade/downgrade4.ts b/server/tests/attach/downgrade/downgrade4.test.ts similarity index 76% rename from server/tests/attach/downgrade/downgrade4.ts rename to server/tests/attach/downgrade/downgrade4.test.ts index 5b956657a..3b71a8ff1 100644 --- a/server/tests/attach/downgrade/downgrade4.ts +++ b/server/tests/attach/downgrade/downgrade4.test.ts @@ -1,3 +1,4 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, BillingInterval, @@ -7,22 +8,21 @@ import { } from "@autumn/shared"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectDowngradeCorrect, expectNextCycleCorrect, } from "tests/utils/expectUtils/expectScheduleUtils.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { advanceMonths } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "downgrade4"; @@ -52,44 +52,32 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: pro-quarter -> p const curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; + const { testClockId: testClockId1, customer: customer_ } = + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [proQuarter, pro, premium], prefix: testCase, }); - await createProducts({ - autumn, - products: [proQuarter, pro, premium], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1, customer: customer_ } = - await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - testClockId = testClockId1!; customer = customer_!; }); - it("should attach pro quarterly product", async () => { + test("should attach pro quarterly product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -101,7 +89,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: pro-quarter -> p }); }); - it("should downgrade to premium", async () => { + test("should downgrade to premium", async () => { await expectDowngradeCorrect({ autumn, customerId, @@ -116,7 +104,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: pro-quarter -> p let preview = null; - it("should downgrade to pro", async () => { + test("should downgrade to pro", async () => { const { preview: preview_ } = await expectDowngradeCorrect({ autumn, customerId, @@ -131,7 +119,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrade: pro-quarter -> p preview = preview_; }); - it("should have correct invoice after cycle", async () => { + test("should have correct invoice after cycle", async () => { await advanceMonths({ stripeCli, testClockId, numberOfMonths: 3 }); await timeout(10000); diff --git a/server/tests/attach/downgrade/downgrade5.ts b/server/tests/attach/downgrade/downgrade5.test.ts similarity index 71% rename from server/tests/attach/downgrade/downgrade5.ts rename to server/tests/attach/downgrade/downgrade5.test.ts index 8a6ac61f1..a48d5c599 100644 --- a/server/tests/attach/downgrade/downgrade5.ts +++ b/server/tests/attach/downgrade/downgrade5.test.ts @@ -1,16 +1,16 @@ +import { beforeAll, describe, expect, test } from "bun:test"; import { CusProductStatus } from "@autumn/shared"; -import { expect } from "chai"; import chalk from "chalk"; import { addHours, addMonths } from "date-fns"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "downgrade5"; describe(`${chalk.yellowBright(`${testCase}: testing basic downgrade (paid to paid)`)}`, () => { @@ -19,39 +19,35 @@ describe(`${chalk.yellowBright(`${testCase}: testing basic downgrade (paid to pa let testClockId: string; let stripeCli: Stripe; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - stripeCli = this.stripeCli; + beforeAll(async () => { + stripeCli = ctx.stripeCli; - const { customer: customer_, testClockId: testClockId_ } = - await initCustomer({ - customerId, - db: this.db, - org: this.org, - env: this.env, - attachPm: "success", - autumn: autumnJs, - }); + const { testClockId: testClockId_ } = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); testClockId = testClockId_; }); - it("should attach premium", async () => { + test("should attach premium", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.premium.id, }); }); - it("should attach pro", async () => { + test("should attach pro", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.pro.id, }); }); - it("should have correct product and entitlements for scheduled pro", async () => { + test("should have correct product and entitlements for scheduled pro", async () => { const res = await AutumnCli.getCustomer(customerId); compareMainProduct({ @@ -66,10 +62,10 @@ describe(`${chalk.yellowBright(`${testCase}: testing basic downgrade (paid to pa p.id === products.pro.id && p.status === CusProductStatus.Scheduled, ); - expect(resPro).to.exist; + expect(resPro).toBeDefined(); }); - it("should attach premium and remove scheduled pro", async () => { + test("should attach premium and remove scheduled pro", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.premium.id, @@ -81,7 +77,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing basic downgrade (paid to pa p.id === products.pro.id && p.status === CusProductStatus.Scheduled, ); - expect(resPro).to.not.exist; + expect(resPro).toBeUndefined(); compareMainProduct({ sent: products.premium, @@ -90,7 +86,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing basic downgrade (paid to pa }); // Advance time 1 month - it("should attach pro, advance stripe clock and have pro is attached", async () => { + test("should attach pro, advance stripe clock and have pro is attached", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.pro.id, diff --git a/server/tests/attach/downgrade/downgrade6.ts b/server/tests/attach/downgrade/downgrade6.test.ts similarity index 64% rename from server/tests/attach/downgrade/downgrade6.ts rename to server/tests/attach/downgrade/downgrade6.test.ts index 54d82e3f2..2bb182d75 100644 --- a/server/tests/attach/downgrade/downgrade6.ts +++ b/server/tests/attach/downgrade/downgrade6.test.ts @@ -1,44 +1,42 @@ -import { Customer } from "@autumn/shared"; +import { beforeAll, describe, test } from "bun:test"; +import type { Customer } from "@autumn/shared"; +import chalk from "chalk"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { products } from "tests/global.js"; -import chalk from "chalk"; import { compareMainProduct } from "tests/utils/compare.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { setupBefore } from "tests/before.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "downgrade6"; describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => { - let customerId = testCase; + const customerId = testCase; let testClockId: string; - let autumn: AutumnInt = new AutumnInt(); + const autumn: AutumnInt = new AutumnInt(); let customer: Customer; - before(async function () { - await setupBefore(this); - + beforeAll(async () => { const { testClockId: testClockId_, customer: customer_ } = - await initCustomer({ + await initCustomerV3({ + ctx, customerId, - db: this.db, - org: this.org, - env: this.env, - autumn: this.autumnJs, + customerData: {}, attachPm: "success", + withTestClock: true, }); customer = customer_; testClockId = testClockId_; }); - it("should attach premium", async function () { + test("should attach premium", async () => { await autumn.attach({ customer_id: customerId, product_id: products.premium.id, }); }); - it("should expire premium", async function () { + test("should expire premium", async () => { // const cusProduct = await getMainCusProduct({ // db: this.db, // internalCustomerId: customer.internal_id, @@ -52,7 +50,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire button`)}`, () => { }); }); - it("should have correct product and entitlements after expiration", async function () { + test("should have correct product and entitlements after expiration", async () => { const res = await AutumnCli.getCustomer(customerId); compareMainProduct({ diff --git a/server/tests/attach/downgrade/downgrade7.ts b/server/tests/attach/downgrade/downgrade7.test.ts similarity index 62% rename from server/tests/attach/downgrade/downgrade7.ts rename to server/tests/attach/downgrade/downgrade7.test.ts index 736baede1..5fc263f38 100644 --- a/server/tests/attach/downgrade/downgrade7.ts +++ b/server/tests/attach/downgrade/downgrade7.test.ts @@ -1,33 +1,33 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { Customer } from "@autumn/shared"; import chalk from "chalk"; -import { Customer } from "@autumn/shared"; +import type Stripe from "stripe"; import { AutumnCli } from "tests/cli/AutumnCli.js"; import { products } from "tests/global.js"; import { compareMainProduct } from "tests/utils/compare.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { setupBefore } from "tests/before.js"; -import { findCusProductById } from "@/internal/customers/cusProducts/cusProductUtils/findCusProduct.js"; -import { expect } from "chai"; import { getSubsFromCusId } from "tests/utils/expectUtils/expectSubUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; const testCase = "downgrade7"; describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)}`, () => { - let customerId = testCase; + const customerId = testCase; let testClockId: string; let customer: Customer; - let autumn: AutumnInt = new AutumnInt(); + const autumn: AutumnInt = new AutumnInt(); + let stripeCli: Stripe; - before(async function () { - await setupBefore(this); + beforeAll(async () => { + stripeCli = ctx.stripeCli; const { testClockId: testClockId_, customer: customer_ } = - await initCustomer({ + await initCustomerV3({ + ctx, customerId, - db: this.db, - org: this.org, - env: this.env, - autumn: this.autumnJs, + customerData: {}, attachPm: "success", + withTestClock: true, }); customer = customer_; @@ -35,7 +35,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)} }); // 2. Get premium - it("should attach premium, then attach pro", async function () { + test("should attach premium, then attach pro", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.premium.id, @@ -47,7 +47,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)} }); }); - it("should expire scheduled product (pro)", async function () { + test("should expire scheduled product (pro)", async () => { // const cusProduct = await findCusProductById({ // db: this.db, // internalCustomerId: customer.internal_id, @@ -63,8 +63,7 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)} // await AutumnCli.expire(cusProduct!.id); }); - it("should have correct product and entitlements (premium)", async function () { - this.timeout(30000); + test("should have correct product and entitlements (premium)", async () => { // Check that free is attached const res = await AutumnCli.getCustomer(customerId); compareMainProduct({ @@ -73,14 +72,14 @@ describe(`${chalk.yellowBright(`${testCase}: testing expire scheduled product`)} }); const { subs } = await getSubsFromCusId({ - stripeCli: this.stripeCli, + stripeCli, customerId: customerId, productId: products.premium.id, - db: this.db, - org: this.org, - env: this.env, + db: ctx.db, + org: ctx.org, + env: ctx.env, }); - expect(subs).to.have.lengthOf(1); - expect(subs[0].canceled_at).to.be.null; + expect(subs).toHaveLength(1); + expect(subs[0].canceled_at).toBe(null); }); }); diff --git a/server/tests/attach/downgrade/downgrade9.ts b/server/tests/attach/downgrade/downgrade9.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/tests/attach/entities/entity1.ts b/server/tests/attach/entities/entity1.test.ts similarity index 56% rename from server/tests/attach/entities/entity1.ts rename to server/tests/attach/entities/entity1.test.ts index 6f3164213..190cf9c9c 100644 --- a/server/tests/attach/entities/entity1.ts +++ b/server/tests/attach/entities/entity1.test.ts @@ -1,17 +1,15 @@ -import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; +import { beforeAll, describe, test } from "bun:test"; +import { LegacyVersion } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "aentity1"; @@ -28,44 +26,19 @@ const pro = constructProduct({ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach to entity via checkout`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - await initCustomer({ - autumn: autumnJs, + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db, - org, - env, - // attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro], - db, - orgId: org.id, - env, - }); - - // testClockId = testClockId1!; }); const newEntities = [ @@ -76,7 +49,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach to entity via }, ]; - it("should attach pro product to entity 1", async () => { + test("should attach pro product to entity 1", async () => { await autumn.entities.create(customerId, newEntities); const entityId = newEntities[0].id; @@ -84,10 +57,10 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach to entity via autumn, customerId, product: pro, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, entityId, }); diff --git a/server/tests/attach/entities/entity2.ts b/server/tests/attach/entities/entity2.test.ts similarity index 62% rename from server/tests/attach/entities/entity2.ts rename to server/tests/attach/entities/entity2.test.ts index 865a7d415..add58aeb1 100644 --- a/server/tests/attach/entities/entity2.ts +++ b/server/tests/attach/entities/entity2.test.ts @@ -1,27 +1,24 @@ -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { AppEnv, Organization } from "@autumn/shared"; +import { beforeAll, describe, test } from "bun:test"; import chalk from "chalk"; -import Stripe from "stripe"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { setupBefore } from "tests/before.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "../utils.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { defaultApiVersion } from "tests/constants.js"; -import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; -import { advanceTestClock } from "tests/utils/stripeUtils.js"; import { addHours, addMonths } from "date-fns"; +import { defaultApiVersion } from "tests/constants.js"; +import { TestFeature } from "tests/setup/v2Features.js"; import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; -import { timeout } from "@/utils/genUtils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectInvoiceAfterUsage } from "tests/utils/expectUtils/expectSingleUse/expectUsageInvoice.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructArrearItem } 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"; const testCase = "aentity2"; -export let proAnnual = constructProduct({ +export const proAnnual = constructProduct({ items: [ constructArrearItem({ featureId: TestFeature.Words, @@ -33,46 +30,26 @@ export let proAnnual = constructProduct({ }); describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to entity via checkout`)}`, () => { - let customerId = testCase; - let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; let curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + beforeAll(async () => { + const result = await initCustomerV3({ + ctx, customerId, - db, - org, - env, - // attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + testClockId = result.testClockId!; + + await initProductsV0({ + ctx, products: [proAnnual], prefix: testCase, }); - - await createProducts({ - autumn, - products: [proAnnual], - db, - orgId: org.id, - env, - }); - - testClockId = testClockId1; }); const newEntities = [ @@ -84,7 +61,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to ]; let entityId = newEntities[0].id; - it("should attach pro annual product to entity 2", async function () { + test("should attach pro annual product to entity 2", async () => { await autumn.entities.create(customerId, newEntities); entityId = newEntities[0].id; @@ -92,16 +69,16 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to autumn, customerId, product: proAnnual, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, entityId, }); }); - let usage = 1250130; - it("should track usage", async function () { + const usage = 1250130; + test("should track usage", async () => { await autumn.track({ customer_id: customerId, entity_id: entityId, @@ -110,7 +87,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to }); await timeout(5000); - let entity = await autumn.entities.get(customerId, entityId); + const entity = await autumn.entities.get(customerId, entityId); expectFeaturesCorrect({ customer: entity, @@ -124,9 +101,9 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to }); }); - it("should have correct invoice after cycle", async function () { + test("should have correct invoice after cycle", async () => { curUnix = await advanceTestClock({ - stripeCli, + stripeCli: ctx.stripeCli, testClockId, advanceTo: addHours( addMonths(curUnix, 1), @@ -142,10 +119,10 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to featureId: TestFeature.Words, product: proAnnual, usage, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, numInvoices: 2, }); }); diff --git a/server/tests/attach/entities/entity3.ts b/server/tests/attach/entities/entity3.test.ts similarity index 63% rename from server/tests/attach/entities/entity3.ts rename to server/tests/attach/entities/entity3.test.ts index c1211d093..7dd97d0a8 100644 --- a/server/tests/attach/entities/entity3.ts +++ b/server/tests/attach/entities/entity3.test.ts @@ -1,26 +1,23 @@ +import { beforeAll, describe, test } from "bun:test"; import chalk from "chalk"; -import Stripe from "stripe"; import { addHours, addMonths } from "date-fns"; -import { timeout } from "@/utils/genUtils.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { AppEnv, Organization } from "@autumn/shared"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { setupBefore } from "tests/before.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "../utils.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { defaultApiVersion } from "tests/constants.js"; -import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { TestFeature } from "tests/setup/v2Features.js"; import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectInvoiceAfterUsage } from "tests/utils/expectUtils/expectSingleUse/expectUsageInvoice.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructArrearItem } 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"; const testCase = "aentity3"; -export let proAnnual = constructProduct({ +export const proAnnual = constructProduct({ items: [ constructArrearItem({ featureId: TestFeature.Words, @@ -31,46 +28,27 @@ export let proAnnual = constructProduct({ }); describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to entity via checkout`)}`, () => { - let customerId = testCase; - let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; let curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + beforeAll(async () => { + const result = await initCustomerV3({ + ctx, customerId, - db, - org, - env, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + testClockId = result.testClockId!; + + await initProductsV0({ + ctx, products: [proAnnual], prefix: testCase, }); - - await createProducts({ - autumn, - products: [proAnnual], - db, - orgId: org.id, - env, - }); - - testClockId = testClockId1; }); const newEntities = [ @@ -82,7 +60,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to ]; let entityId = newEntities[0].id; - it("should attach pro product to entity 2", async function () { + test("should attach pro product to entity 2", async () => { await autumn.entities.create(customerId, newEntities); entityId = newEntities[0].id; @@ -90,16 +68,16 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to autumn, customerId, product: proAnnual, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, entityId, }); }); - let nextUsage = 1032100; - it("should cancel and have correct final invoice", async function () { + const nextUsage = 1032100; + test("should cancel and have correct final invoice", async () => { await autumn.track({ customer_id: customerId, entity_id: entityId, @@ -116,7 +94,7 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to await timeout(5000); curUnix = await advanceTestClock({ - stripeCli, + stripeCli: ctx.stripeCli, testClockId, advanceTo: addHours( addMonths(curUnix, 1), @@ -131,10 +109,10 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to featureId: TestFeature.Words, product: proAnnual, usage: nextUsage, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, numInvoices: 2, expectExpired: true, }); diff --git a/server/tests/attach/entities/entity4.ts b/server/tests/attach/entities/entity4.test.ts similarity index 56% rename from server/tests/attach/entities/entity4.ts rename to server/tests/attach/entities/entity4.test.ts index d692b7c20..00f85af8d 100644 --- a/server/tests/attach/entities/entity4.ts +++ b/server/tests/attach/entities/entity4.test.ts @@ -1,23 +1,20 @@ +import { beforeAll, describe, test } from "bun:test"; import chalk from "chalk"; -import Stripe from "stripe"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { AppEnv, Organization } from "@autumn/shared"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { setupBefore } from "tests/before.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import { addPrefixToProducts } from "../utils.js"; -import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { TestFeature } from "tests/setup/v2Features.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { defaultApiVersion } from "tests/constants.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; +import { constructArrearItem } 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"; const testCase = "aentity4"; -export let pro = constructProduct({ +export const pro = constructProduct({ items: [ constructArrearItem({ featureId: TestFeature.Words, @@ -28,46 +25,22 @@ export let pro = constructProduct({ }); describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff entities and testing track / check`)}`, () => { - let customerId = testCase; - let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); - let curUnix = new Date().getTime(); - - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db, - org, - env, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro], - db, - orgId: org.id, - env, - }); - - testClockId = testClockId1; }); const newEntities = [ @@ -83,41 +56,41 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff enti }, ]; - let entity1 = newEntities[0]; - let entity2 = newEntities[1]; + const entity1 = newEntities[0]; + const entity2 = newEntities[1]; - it("should attach pro product to entity 1", async function () { + test("should attach pro product to entity 1", async () => { await autumn.entities.create(customerId, newEntities); await attachAndExpectCorrect({ autumn, customerId, product: pro, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, entityId: entity1.id, numSubs: 1, }); }); - it("should attach pro product to entity 2", async function () { + test("should attach pro product to entity 2", async () => { await attachAndExpectCorrect({ autumn, customerId, product: pro, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, entityId: entity2.id, numSubs: 2, }); }); - let entity1Usage = Math.random() * 1000000; - it("should track usage on entity 1", async function () { + const entity1Usage = Math.random() * 1000000; + test("should track usage on entity 1", async () => { await autumn.track({ customer_id: customerId, entity_id: entity1.id, @@ -126,8 +99,8 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff enti }); await timeout(3000); - let entity1Res = await autumn.entities.get(customerId, entity1.id); - let entity2Res = await autumn.entities.get(customerId, entity2.id); + const entity1Res = await autumn.entities.get(customerId, entity1.id); + const entity2Res = await autumn.entities.get(customerId, entity2.id); expectFeaturesCorrect({ customer: entity1Res, @@ -146,8 +119,8 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff enti }); }); - let entity2Usage = Math.random() * 1000000; - it("should track usage on entity 2", async function () { + const entity2Usage = Math.random() * 1000000; + test("should track usage on entity 2", async () => { await autumn.track({ customer_id: customerId, entity_id: entity2.id, @@ -157,8 +130,8 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro diff enti await timeout(3000); - let entity1Res = await autumn.entities.get(customerId, entity1.id); - let entity2Res = await autumn.entities.get(customerId, entity2.id); + const entity1Res = await autumn.entities.get(customerId, entity1.id); + const entity2Res = await autumn.entities.get(customerId, entity2.id); expectFeaturesCorrect({ customer: entity1Res, diff --git a/server/tests/attach/entities/entity5.ts b/server/tests/attach/entities/entity5.test.ts similarity index 57% rename from server/tests/attach/entities/entity5.ts rename to server/tests/attach/entities/entity5.test.ts index 1ee82f3d1..1b0781513 100644 --- a/server/tests/attach/entities/entity5.ts +++ b/server/tests/attach/entities/entity5.test.ts @@ -1,25 +1,18 @@ -import { - type AppEnv, - CusProductStatus, - type Organization, -} from "@autumn/shared"; -import { expect } from "chai"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; import chalk from "chalk"; import { addHours, addMonths } from "date-fns"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { defaultApiVersion } from "tests/constants.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "aentity5"; @@ -46,43 +39,22 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion }); let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + beforeAll(async () => { + const result = await initCustomerV3({ + ctx, customerId, - db, - org, - env, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + testClockId = result.testClockId!; + + await initProductsV0({ + ctx, products: [pro, premium], prefix: testCase, }); - - await createProducts({ - autumn, - products: [pro, premium], - db, - orgId: org.id, - env, - }); - - testClockId = testClockId1; }); const newEntities = [ @@ -101,37 +73,37 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro const entity1 = newEntities[0]; const entity2 = newEntities[1]; - it("should attach premium product to entity 1", async () => { + test("should attach premium product to entity 1", async () => { await autumn.entities.create(customerId, newEntities); await attachAndExpectCorrect({ autumn, customerId, product: premium, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, entityId: entity1.id, numSubs: 1, }); }); - it("should attach premium product to entity 2", async () => { + test("should attach premium product to entity 2", async () => { await attachAndExpectCorrect({ autumn, customerId, product: premium, - stripeCli, - db, - org, - env, + stripeCli: ctx.stripeCli, + db: ctx.db, + org: ctx.org, + env: ctx.env, entityId: entity2.id, numSubs: 2, }); }); - it("should attach pro product to entity 1", async () => { + test("should attach pro product to entity 1", async () => { await autumn.attach({ customer_id: customerId, entity_id: entity1.id, @@ -139,14 +111,14 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro }); const entity = await autumn.entities.get(customerId, entity1.id); - const proProd = entity.products.find((p: any) => p.id === pro.id); - expect(proProd).to.exist; - expect(proProd.status).to.equal(CusProductStatus.Scheduled); + const proProd = entity.products.find((p: any) => p.id == pro.id); + expect(proProd).toBeDefined(); + expect(proProd.status).toBe(CusProductStatus.Scheduled); }); - it("should advance test clock and have pro attached to entity 1", async () => { + test("should advance test clock and have pro attached to entity 1", async () => { await advanceTestClock({ - stripeCli, + stripeCli: ctx.stripeCli, testClockId, advanceTo: addHours( addMonths(new Date(), 1), @@ -156,17 +128,17 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro }); const entity = await autumn.entities.get(customerId, entity1.id); - const proProd = entity.products.find((p: any) => p.id === pro.id); - expect(proProd).to.exist; - expect(proProd.status).to.equal(CusProductStatus.Active); - expect(entity.products.length).to.equal(2); + const proProd = entity.products.find((p: any) => p.id == pro.id); + expect(proProd).toBeDefined(); + expect(proProd.status).toBe(CusProductStatus.Active); + expect(entity.products.length).toBe(2); const entity2Res = await autumn.entities.get(customerId, entity2.id); const premiumProd = entity2Res.products.find( (p: any) => p.id === premium.id, ); - expect(premiumProd).to.exist; - expect(premiumProd.status).to.equal(CusProductStatus.Active); - expect(entity2Res.products.length).to.equal(2); + expect(premiumProd).toBeDefined(); + expect(premiumProd.status).toBe(CusProductStatus.Active); + expect(entity2Res.products.length).toBe(2); }); }); diff --git a/server/tests/attach/free/free1.ts b/server/tests/attach/free/free1.test.ts similarity index 50% rename from server/tests/attach/free/free1.ts rename to server/tests/attach/free/free1.test.ts index c736eb0ae..19c9814d3 100644 --- a/server/tests/attach/free/free1.ts +++ b/server/tests/attach/free/free1.test.ts @@ -1,28 +1,19 @@ +import { beforeAll, describe, expect, test } from "bun:test"; import { - type AppEnv, CreateFreeTrialSchema, CusProductStatus, FreeTrialDuration, LegacyVersion, - type Organization, - organizations, } from "@autumn/shared"; -import { expect } from "chai"; import chalk from "chalk"; import { addDays } from "date-fns"; -import { eq } from "drizzle-orm"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { CacheManager } from "@/external/caching/CacheManager.js"; -import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "free1"; @@ -58,66 +49,31 @@ export const addOn = constructProduct({ describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and attaching add on`)}`, () => { const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + const autumn: AutumnInt = new AutumnInt({ + version: LegacyVersion.v1_4, + orgConfig: { multiple_trials: true }, + }); let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - const numUsers = 0; - - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - await db - .update(organizations) - .set({ - config: { - ...this.org.config, - multiple_trials: true, - }, - }) - .where(eq(organizations.id, org.id)); - - await clearOrgCache({ - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + beforeAll(async () => { + const result = await initCustomerV3({ + ctx, customerId, - db, - org, - env, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + testClockId = result.testClockId!; + + await initProductsV0({ + ctx, products: [free, addOn], prefix: testCase, }); - - await createProducts({ - autumn, - products: [free, addOn], - db, - orgId: org.id, - env, - }); - - testClockId = testClockId1!; }); - const approximateDiff = 1000 * 60 * 30; // 30 minutes - it("should attach free product with trial", async () => { + const approximateDiff = 1000 * 60 * 80; // 60 minutes + test("should attach free product with trial", async () => { const attachPreview = await autumn.attachPreview({ customer_id: customerId, product_id: free.id, @@ -131,11 +87,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and const customer = await autumn.customers.get(customerId); const freeProduct = customer.products.find((p) => p.id === free.id); - expect(freeProduct).to.exist; - expect(freeProduct?.status).to.equal(CusProductStatus.Trialing); - expect(freeProduct?.current_period_end).to.approximately( - addDays(Date.now(), trial1.length).getTime(), - approximateDiff, + expect(freeProduct).toBeDefined(); + expect(freeProduct?.status).toBe(CusProductStatus.Trialing); + expect(freeProduct?.current_period_end).toBeGreaterThanOrEqual( + addDays(Date.now(), trial1.length).getTime() - approximateDiff, + ); + expect(freeProduct?.current_period_end).toBeLessThanOrEqual( + addDays(Date.now(), trial1.length).getTime() + approximateDiff, ); }); @@ -144,7 +102,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and duration: FreeTrialDuration.Day, }); - it("should update free product's trial end date", async () => { + test("should update free product's trial end date", async () => { const attachPreview = await autumn.attachPreview({ customer_id: customerId, product_id: free.id, @@ -162,14 +120,16 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and const customer = await autumn.customers.get(customerId); const freeProduct = customer.products.find((p) => p.id === free.id); - expect(freeProduct?.status).to.equal(CusProductStatus.Trialing); - expect(freeProduct?.current_period_end).to.approximately( - addDays(Date.now(), trial2.length).getTime(), - approximateDiff, + expect(freeProduct?.status).toBe(CusProductStatus.Trialing); + expect(freeProduct?.current_period_end).toBeGreaterThanOrEqual( + addDays(Date.now(), trial2.length).getTime() - approximateDiff, + ); + expect(freeProduct?.current_period_end).toBeLessThanOrEqual( + addDays(Date.now(), trial2.length).getTime() + approximateDiff, ); }); - it("should attach add on product", async () => { + test("should attach add on product", async () => { const attachPreview = await autumn.attachPreview({ customer_id: customerId, product_id: addOn.id, @@ -184,28 +144,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and const addOnProduct = customer.products.find((p) => p.id === addOn.id); const freeProduct = customer.products.find((p) => p.id === free.id); - expect(addOnProduct).to.exist; - expect(addOnProduct?.status).to.equal(CusProductStatus.Active); - expect(freeProduct?.status).to.equal(CusProductStatus.Trialing); - }); - - after(async function () { - await db - .update(organizations) - .set({ - config: { - ...this.org.config, - multiple_trials: false, - }, - }) - .where(eq(organizations.id, org.id)); - - await clearOrgCache({ - db, - orgId: org.id, - env, - }); - - await CacheManager.disconnect(); + expect(addOnProduct).toBeDefined(); + expect(addOnProduct?.status).toBe("active"); + expect(freeProduct?.status).toBe("trialing"); }); }); diff --git a/server/tests/attach/free/free2.ts b/server/tests/attach/free/free2.test.ts similarity index 63% rename from server/tests/attach/free/free2.ts rename to server/tests/attach/free/free2.test.ts index e85805233..10b3a354f 100644 --- a/server/tests/attach/free/free2.ts +++ b/server/tests/attach/free/free2.test.ts @@ -1,23 +1,16 @@ -import { - type AppEnv, - BillingInterval, - LegacyVersion, - type Organization, -} from "@autumn/shared"; +import { beforeAll, describe, test } from "bun:test"; +import { BillingInterval, LegacyVersion } from "@autumn/shared"; import chalk from "chalk"; -import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; -import { createProducts } from "tests/utils/productUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; export const free = constructProduct({ items: [ @@ -47,49 +40,24 @@ const testCase = "free2"; describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and attaching add on`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = new Date().getTime(); - const numUsers = 0; - - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + beforeAll(async () => { + await initCustomerV3({ + ctx, customerId, - db, - org, - env, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [free], prefix: testCase, }); - - await createProducts({ - autumn, - products: [free], - db, - orgId: org.id, - env, - }); - - testClockId = testClockId1!; }); const approximateDiff = 1000 * 60 * 30; // 30 minutes - it("should attach free product", async () => { + test("should attach free product", async () => { await autumn.attach({ customer_id: customerId, product_id: free.id, @@ -109,14 +77,14 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free product with trial and interval: BillingInterval.Month, }), ]; - it("should update free product with price", async () => { + test("should update free product with price", async () => { await attachAndExpectCorrect({ autumn, customerId, product: free, - db, - org, - env, + db: ctx.db, + org: ctx.org, + env: ctx.env, attachParams: { // @ts-expect-error is_custom: true, diff --git a/server/tests/attach/others/others5.ts b/server/tests/attach/others/others5.ts index aaa438495..d403b067f 100644 --- a/server/tests/attach/others/others5.ts +++ b/server/tests/attach/others/others5.ts @@ -1,10 +1,10 @@ -import chalk from "chalk"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { features, products } from "tests/global.js"; -import { AutumnCli } from "tests/cli/AutumnCli.js"; -import { timeout } from "../../utils/genUtils.js"; import { expect } from "chai"; +import chalk from "chalk"; import { setupBefore } from "tests/before.js"; +import { AutumnCli } from "tests/cli/AutumnCli.js"; +import { features, products } from "tests/global.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { timeout } from "../../utils/genUtils.js"; const checkEntitledOnProduct = async ({ customerId, @@ -112,7 +112,7 @@ describe(`${chalk.yellowBright( let curAllowance = 0; const oneTimeBillingUnits = products.oneTimeAddOnMetered1.prices[0].config.billing_units!; - let oneTimeQuantity = 2 * oneTimeBillingUnits; + const oneTimeQuantity = 2 * oneTimeBillingUnits; before(async function () { await setupBefore(this); @@ -134,14 +134,14 @@ describe(`${chalk.yellowBright( // }); // }); - it("should attach pro", async function () { + it("should attach pro", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.pro.id, }); }); - it("should have correct entitlements (pro)", async function () { + it("should have correct entitlements (pro)", async () => { const used = await checkEntitledOnProduct({ customerId: customerId, product: products.pro, @@ -151,7 +151,7 @@ describe(`${chalk.yellowBright( curAllowance = products.pro.entitlements.metered1.allowance! - used; }); - it("should attach one time top up", async function () { + it("should attach one time top up", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.oneTimeAddOnMetered1.id, @@ -164,7 +164,7 @@ describe(`${chalk.yellowBright( }); }); - it("should have correct entitlements (one time top up)", async function () { + it("should have correct entitlements (one time top up)", async () => { // const oneTimeAmt = oneTimeBillingUnits * oneTimeQuantity; await checkEntitledOnProduct({ @@ -195,14 +195,14 @@ describe(`${chalk.yellowBright( }); // PRO WITH OVERAGE - it("should attach pro (with overage)", async function () { + it("should attach pro (with overage)", async () => { await AutumnCli.attach({ customerId: customerId, productId: products.proWithOverage.id, }); }); - it("should have correct entitlements (pro with overage)", async function () { + it("should have correct entitlements (pro with overage)", async () => { await checkEntitledOnProduct({ customerId: customerId, product: products.proWithOverage, @@ -212,7 +212,7 @@ describe(`${chalk.yellowBright( }); }); - it("should have correct usage-based balance (balance < 0)", async function () { + it("should have correct usage-based balance (balance < 0)", async () => { const { allowed, balanceObj }: any = await AutumnCli.entitled( customerId, features.metered1.id, diff --git a/server/tests/attach/upgrade/upgrade1.ts b/server/tests/attach/upgrade/upgrade1.test.ts similarity index 74% rename from server/tests/attach/upgrade/upgrade1.ts rename to server/tests/attach/upgrade/upgrade1.test.ts index eaf2e3d0d..139e7aff4 100644 --- a/server/tests/attach/upgrade/upgrade1.ts +++ b/server/tests/attach/upgrade/upgrade1.test.ts @@ -1,18 +1,18 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import { addWeeks } from "date-fns"; import type { Stripe } from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; // UNCOMMENT FROM HERE const pro = constructProduct({ @@ -42,42 +42,30 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => { let org: Organization; let env: AppEnv; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium, growth], prefix: customerId, }); - await createProducts({ - autumn: autumnJs, - products: [pro, premium, growth], - db, - orgId: org.id, - env, - customerId, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - testClockId = testClockId1!; }); - it("should attach pro product", async () => { + test("should attach pro product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -89,7 +77,7 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => { }); }); - it("should attach premium product", async () => { + test("should attach premium product", async () => { const wordsUsage = 100000; await autumn.track({ customer_id: customerId, @@ -115,7 +103,7 @@ describe(`${chalk.yellowBright("upgrade1: Testing usage upgrades")}`, () => { }); }); - it("should attach growth product", async () => { + test("should attach growth product", async () => { const wordsUsage = 200000; await autumn.track({ customer_id: customerId, diff --git a/server/tests/attach/upgrade/upgrade2.ts b/server/tests/attach/upgrade/upgrade2.test.ts similarity index 77% rename from server/tests/attach/upgrade/upgrade2.ts rename to server/tests/attach/upgrade/upgrade2.test.ts index d4081eecc..23863077a 100644 --- a/server/tests/attach/upgrade/upgrade2.ts +++ b/server/tests/attach/upgrade/upgrade2.test.ts @@ -1,18 +1,18 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import { addWeeks } from "date-fns"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; // Shared products for attach tests const testCase = "upgrade2"; @@ -54,42 +54,30 @@ describe(`${chalk.yellowBright("upgrade2: Testing usage upgrades with monthly -> let curUnix = new Date().getTime(); - before(async function () { - await setupBefore(this); - const { autumnJs } = this; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - db = this.db; - org = this.org; - env = this.env; - - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, customerId, - db, - org, - env, + customerData: {}, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, proAnnual, premiumAnnual], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro, proAnnual, premiumAnnual], - db, - orgId: org.id, - env, - }); - testClockId = testClockId1!; }); - it("should attach pro product", async () => { + test("should attach pro product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -101,7 +89,7 @@ describe(`${chalk.yellowBright("upgrade2: Testing usage upgrades with monthly -> }); }); - it("should attach pro annual product", async () => { + test("should attach pro annual product", async () => { await autumn.track({ customer_id: customerId, feature_id: TestFeature.Words, @@ -127,7 +115,7 @@ describe(`${chalk.yellowBright("upgrade2: Testing usage upgrades with monthly -> }); return; - it("should attach premium annual product", async () => { + test("should attach premium annual product", async () => { await autumn.track({ customer_id: customerId, feature_id: TestFeature.Words, diff --git a/server/tests/attach/upgrade/upgrade3.ts b/server/tests/attach/upgrade/upgrade3.test.ts similarity index 79% rename from server/tests/attach/upgrade/upgrade3.ts rename to server/tests/attach/upgrade/upgrade3.test.ts index 612a065fa..f9b9a76ed 100644 --- a/server/tests/attach/upgrade/upgrade3.ts +++ b/server/tests/attach/upgrade/upgrade3.test.ts @@ -1,19 +1,19 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import { addWeeks } from "date-fns"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "upgrade3"; @@ -69,41 +69,30 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with arrear prorat let curUnix = new Date().getTime(); let numUsers = 0; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, customerId, - db, - org, - env, + customerData: {}, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium, proAnnual], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro, premium, proAnnual], - db, - orgId: org.id, - env, - }); - testClockId = testClockId1!; }); - it("should attach pro product (arrear prorated)", async () => { + test("should attach pro product (arrear prorated)", async () => { // 1. Create multiple entities const entities = await autumn.entities.create(customerId, [ { @@ -136,7 +125,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with arrear prorat }); }); - it("should create entity, then upgrade to premium product (arrear prorated)", async () => { + test("should create entity, then upgrade to premium product (arrear prorated)", async () => { curUnix = await advanceTestClock({ stripeCli, testClockId, @@ -172,7 +161,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with arrear prorat }); }); - it("should upgrade to pro-annual product (arrear prorated)", async () => { + test("should upgrade to pro-annual product (arrear prorated)", async () => { curUnix = await advanceTestClock({ stripeCli, testClockId, diff --git a/server/tests/attach/upgrade/upgrade4.ts b/server/tests/attach/upgrade/upgrade4.test.ts similarity index 77% rename from server/tests/attach/upgrade/upgrade4.ts rename to server/tests/attach/upgrade/upgrade4.test.ts index 48996f0d4..5d5fbf52d 100644 --- a/server/tests/attach/upgrade/upgrade4.ts +++ b/server/tests/attach/upgrade/upgrade4.test.ts @@ -1,18 +1,18 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import { addWeeks } from "date-fns"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "upgrade4"; @@ -71,37 +71,26 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid conti let curUnix = new Date().getTime(); const numUsers = 0; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, customerId, - db, - org, - env, + customerData: {}, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium, proAnnual], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro, premium, proAnnual], - db, - orgId: org.id, - env, - }); - testClockId = testClockId1!; }); @@ -112,7 +101,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid conti }, ]; - it("should attach pro product (arrear prorated)", async () => { + test("should attach pro product (arrear prorated)", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -132,7 +121,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid conti }, ]; - it("should create entity, then upgrade to premium product (arrear prorated)", async () => { + test("should create entity, then upgrade to premium product (arrear prorated)", async () => { curUnix = await advanceTestClock({ stripeCli, testClockId, @@ -158,7 +147,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid conti }, ]; - it("should upgrade to pro-annual product (arrear prorated)", async () => { + test("should upgrade to pro-annual product (arrear prorated)", async () => { curUnix = await advanceTestClock({ stripeCli, testClockId, diff --git a/server/tests/attach/upgrade/upgrade5.ts b/server/tests/attach/upgrade/upgrade5.test.ts similarity index 73% rename from server/tests/attach/upgrade/upgrade5.ts rename to server/tests/attach/upgrade/upgrade5.test.ts index 0c1d0f74c..1db7f5e3b 100644 --- a/server/tests/attach/upgrade/upgrade5.ts +++ b/server/tests/attach/upgrade/upgrade5.test.ts @@ -1,18 +1,18 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import { addWeeks } from "date-fns"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "tests/utils/productUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "upgrade5"; @@ -48,37 +48,26 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid singl let curUnix = new Date().getTime(); const numUsers = 0; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, customerId, - db, - org, - env, + customerData: {}, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro, premium], - db, - orgId: org.id, - env, - }); - testClockId = testClockId1!; }); @@ -89,7 +78,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid singl }, ]; - it("should attach pro product (prepaid single use)", async () => { + test("should attach pro product (prepaid single use)", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -109,7 +98,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid singl }, ]; - it("should upgrade to premium product (prepaid single use)", async () => { + test("should upgrade to premium product (prepaid single use)", async () => { curUnix = await advanceTestClock({ stripeCli, testClockId, diff --git a/server/tests/attach/upgrade/upgrade6.ts b/server/tests/attach/upgrade/upgrade6.test.ts similarity index 81% rename from server/tests/attach/upgrade/upgrade6.ts rename to server/tests/attach/upgrade/upgrade6.test.ts index 53c650656..55c8ad3c9 100644 --- a/server/tests/attach/upgrade/upgrade6.ts +++ b/server/tests/attach/upgrade/upgrade6.test.ts @@ -1,14 +1,14 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; import { expectSubItemsCorrect } from "tests/utils/expectUtils/expectSubUtils.js"; -import { createProducts } from "tests/utils/productUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; @@ -19,8 +19,8 @@ import { constructArrearProratedItem, } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "upgrade6"; @@ -53,41 +53,30 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => let db: DrizzleCli, org: Organization, env: AppEnv; let stripeCli: Stripe; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, customerId, - db, - org, - env, + customerData: {}, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro, premium], - db, - orgId: org.id, - env, - }); - testClockId = testClockId1!; }); - it("should attach pro product", async () => { + test("should attach pro product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -100,7 +89,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => }); const usage = 100012; - it("should upgrade to premium product and fail", async () => { + test("should upgrade to premium product and fail", async () => { await autumn.track({ customer_id: customerId, feature_id: TestFeature.Words, diff --git a/server/tests/attach/upgrade/upgrade7.ts b/server/tests/attach/upgrade/upgrade7.test.ts similarity index 65% rename from server/tests/attach/upgrade/upgrade7.ts rename to server/tests/attach/upgrade/upgrade7.test.ts index 3ce18ed83..019c6c3d5 100644 --- a/server/tests/attach/upgrade/upgrade7.ts +++ b/server/tests/attach/upgrade/upgrade7.test.ts @@ -1,14 +1,14 @@ +import { beforeAll, describe, test } from "bun:test"; import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared"; import chalk from "chalk"; import type Stripe from "stripe"; -import { setupBefore } from "tests/before.js"; import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "tests/utils/productUtils.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { addPrefixToProducts } from "../utils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const testCase = "upgrade7"; @@ -29,41 +29,30 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrade via cancel + attach let db: DrizzleCli, org: Organization, env: AppEnv; let stripeCli: Stripe; - before(async function () { - await setupBefore(this); - const { autumnJs } = this; - db = this.db; - org = this.org; - env = this.env; + beforeAll(async () => { + db = ctx.db; + org = ctx.org; + env = ctx.env; + stripeCli = ctx.stripeCli; - stripeCli = this.stripeCli; - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, + const { testClockId: testClockId1 } = await initCustomerV3({ + ctx, customerId, - db, - org, - env, + customerData: {}, attachPm: "success", + withTestClock: true, }); - addPrefixToProducts({ + await initProductsV0({ + ctx, products: [pro, premium], prefix: testCase, }); - await createProducts({ - autumn, - products: [pro, premium], - db, - orgId: org.id, - env, - }); - testClockId = testClockId1!; }); - it("should attach pro product", async () => { + test("should attach pro product", async () => { await attachAndExpectCorrect({ autumn, customerId, @@ -75,7 +64,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing upgrade via cancel + attach }); }); - it("should cancel than attach premium product", async () => { + test("should cancel than attach premium product", async () => { await autumn.cancel({ customer_id: customerId, product_id: pro.id, diff --git a/server/tests/check/basic/check1.test.ts b/server/tests/check/basic/check1.test.ts new file mode 100644 index 000000000..d1beabcd3 --- /dev/null +++ b/server/tests/check/basic/check1.test.ts @@ -0,0 +1,76 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +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"; + +const dashboardFeature = constructFeatureItem({ + featureId: TestFeature.Dashboard, + isBoolean: true, +}); + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [dashboardFeature, messagesFeature], +}); + +const testCase = "check1"; + +describe(`${chalk.yellowBright("check1: test /check when no feature attached")}`, () => { + const customerId = "check1"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + }); + + test("should have correct check response when feature not attached (v1)", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponse; + + expect(res.allowed).toBe(false); + expect(res.customer_id).toBe(customerId); + expect(res.feature_id).toBe(TestFeature.Messages); + expect(res.code).toBeDefined(); + expect(res.required_balance).toBe(1); + }); + + test("should have correct check response when feature not attached (v0)", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(false); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(0); + }); +}); diff --git a/server/tests/check/basic/check2.test.ts b/server/tests/check/basic/check2.test.ts new file mode 100644 index 000000000..2cc09a36c --- /dev/null +++ b/server/tests/check/basic/check2.test.ts @@ -0,0 +1,87 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +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"; + +const dashboardFeature = constructFeatureItem({ + featureId: TestFeature.Dashboard, + isBoolean: true, +}); + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [dashboardFeature, messagesFeature], +}); + +const testCase = "check2"; + +describe(`${chalk.yellowBright("check2: test /check on boolean feature")}`, () => { + const customerId = "check2"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + }); + + test("v0 response", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(true); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toStrictEqual({ + feature_id: TestFeature.Dashboard, + balance: null, + }); + }); + + test("v1 response", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + })) as unknown as CheckResponse; + + expect(res).toStrictEqual({ + customer_id: customerId, + feature_id: TestFeature.Dashboard, + code: SuccessCode.FeatureFound, + allowed: true, + }); + }); +}); diff --git a/server/tests/check/basic/check3.test.ts b/server/tests/check/basic/check3.test.ts new file mode 100644 index 000000000..38f14dcd0 --- /dev/null +++ b/server/tests/check/basic/check3.test.ts @@ -0,0 +1,99 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + EntInterval, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +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"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check3"; + +describe(`${chalk.yellowBright("check3: test /check on metered feature")}`, () => { + const customerId = "check3"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + }); + + test("v0 response", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(true); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toStrictEqual({ + feature_id: TestFeature.Messages, + required: 1, + balance: 1000, + }); + }); + + test("v1 response", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponse; + + const expectedRes = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 1, + code: SuccessCode.FeatureFound, + interval: EntInterval.Month, + interval_count: 1, + unlimited: false, + balance: 1000, + usage: 0, + included_usage: 1000, + // next_reset_at: 1763833597035, + overage_allowed: false, + }; + + for (const key in expectedRes) { + expect(res[key as keyof CheckResponse]).toBe( + expectedRes[key as keyof typeof expectedRes], + ); + } + }); +}); diff --git a/server/tests/check/basic/check4.test.ts b/server/tests/check/basic/check4.test.ts new file mode 100644 index 000000000..f61d41263 --- /dev/null +++ b/server/tests/check/basic/check4.test.ts @@ -0,0 +1,96 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +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"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + unlimited: true, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check4"; + +describe(`${chalk.yellowBright("check4: test /check on unlimited feature")}`, () => { + const customerId = "check4"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + }); + + test("v0 response", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(true); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toStrictEqual({ + balance: null, + feature_id: TestFeature.Messages, + unlimited: true, + usage_allowed: false, + required: null, + }); + }); + + test("v1 response", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponse; + + console.log(res); + + const expectedRes = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages as string, + required_balance: 1, + code: SuccessCode.FeatureFound, + unlimited: true, + balance: null, + usage: 0, + included_usage: 0, + next_reset_at: null, + overage_allowed: false, + }; + + expect(expectedRes).toMatchObject(res); + }); +}); diff --git a/server/tests/check/basic/check5.test.ts b/server/tests/check/basic/check5.test.ts new file mode 100644 index 000000000..9fbb08d9d --- /dev/null +++ b/server/tests/check/basic/check5.test.ts @@ -0,0 +1,99 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + type LimitedItem, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructArrearItem } 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"; + +const messagesFeature = constructArrearItem({ + featureId: TestFeature.Messages, + price: 0.5, + includedUsage: 100, +}) as LimitedItem; + +const proProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check5"; + +describe(`${chalk.yellowBright("check5: test /check on usage-based feature")}`, () => { + const customerId = "check5"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [proProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + }); + + test("v0 response", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(true); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + balance: messagesFeature.included_usage, + feature_id: TestFeature.Messages, + unlimited: false, + usage_allowed: true, + required: null, + }); + }); + + test("v1 response", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponse; + + const expectedRes = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages as string, + required_balance: 1, + code: SuccessCode.FeatureFound, + unlimited: false, + balance: messagesFeature.included_usage, + usage: 0, + included_usage: messagesFeature.included_usage, + overage_allowed: true, + interval: messagesFeature.interval, + interval_count: 1, + }; + + expect(res).toMatchObject(expectedRes); + expect(res.next_reset_at).toBeDefined(); + }); +}); diff --git a/server/tests/check/basic/check6.test.ts b/server/tests/check/basic/check6.test.ts new file mode 100644 index 000000000..f6490345d --- /dev/null +++ b/server/tests/check/basic/check6.test.ts @@ -0,0 +1,130 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + type LimitedItem, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + constructArrearItem, + 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"; + +const monthlyMessages = constructArrearItem({ + featureId: TestFeature.Messages, + price: 0.5, + includedUsage: 100, +}) as LimitedItem; + +const lifetimeMessages = constructFeatureItem({ + featureId: TestFeature.Messages, + interval: null, + includedUsage: 1000, +}) as LimitedItem; + +const proProd = constructProduct({ + type: "pro", + isDefault: false, + items: [monthlyMessages, lifetimeMessages], +}); + +const testCase = "check6"; + +describe(`${chalk.yellowBright("check6: test /check on feature with multiple balances (one off + monthly)")}`, () => { + const customerId = "check6"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [proProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + }); + + test("v0 response", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV0; + + console.log(res); + + expect(res.allowed).toBe(true); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + balance: monthlyMessages.included_usage + lifetimeMessages.included_usage, + feature_id: TestFeature.Messages, + required: null, + unlimited: false, + usage_allowed: true, + }); + }); + + test("v1 response", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponse; + + const totalIncludedUsage = + monthlyMessages.included_usage + lifetimeMessages.included_usage; + + const lifetimeBreakdown = { + balance: lifetimeMessages.included_usage, + included_usage: lifetimeMessages.included_usage, + interval: "lifetime", + interval_count: 1, + next_reset_at: null, + usage: 0, + }; + + const monthlyBreakdown = { + balance: monthlyMessages.included_usage, + included_usage: monthlyMessages.included_usage, + interval: "month", + interval_count: 1, + usage: 0, + }; + + const expectedRes = { + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages as string, + required_balance: 1, + code: SuccessCode.FeatureFound, + unlimited: false, + balance: totalIncludedUsage, + interval: "multiple", + interval_count: null, + usage: 0, + included_usage: totalIncludedUsage, + overage_allowed: true, + breakdown: [lifetimeBreakdown, monthlyBreakdown], + }; + + expect(res).toMatchObject(expectedRes); + }); +}); diff --git a/server/tests/check/basic/check7.test.ts b/server/tests/check/basic/check7.test.ts new file mode 100644 index 000000000..53188301f --- /dev/null +++ b/server/tests/check/basic/check7.test.ts @@ -0,0 +1,102 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + type LimitedItem, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructArrearItem } 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"; + +const messagesFeature = constructArrearItem({ + featureId: TestFeature.Messages, + price: 0.5, + includedUsage: 100, + usageLimit: 500, +}) as LimitedItem; + +const proProd = constructProduct({ + type: "pro", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check7"; + +describe(`${chalk.yellowBright("check7: test /check on feature with usage limits")}`, () => { + const customerId = "check7"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [proProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + }); + + test("v0 response", async () => { + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: messagesFeature.usage_limit! + 1, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(false); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + balance: messagesFeature.included_usage, + required: messagesFeature.usage_limit! + 1, + feature_id: TestFeature.Messages, + }); + }); + + test("v1 response", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: messagesFeature.usage_limit! + 1, + })) as unknown as CheckResponse; + + const expectedRes = { + allowed: false, + customer_id: customerId, + balance: messagesFeature.included_usage, + feature_id: TestFeature.Messages as string, + required_balance: messagesFeature.usage_limit! + 1, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: messagesFeature.included_usage, + overage_allowed: false, + + usage_limit: messagesFeature.usage_limit!, + interval: "month", + interval_count: 1, + }; + + expect(res).toMatchObject(expectedRes); + expect(res.next_reset_at).toBeDefined(); + }); +}); diff --git a/server/tests/check/check1.test.ts b/server/tests/check/check1.test.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/tests/check/credit-systems/credit-systems1.test.ts b/server/tests/check/credit-systems/credit-systems1.test.ts new file mode 100644 index 000000000..67e06034b --- /dev/null +++ b/server/tests/check/credit-systems/credit-systems1.test.ts @@ -0,0 +1,173 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + type LimitedItem, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +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 { + featureToCreditSystem, + getCreditCost, +} from "../../../src/internal/features/creditSystemUtils.js"; + +const creditsFeature = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, +}) as LimitedItem; + +const proProd = constructProduct({ + type: "pro", + isDefault: false, + items: [creditsFeature], +}); + +const testCase = "credit-systems1"; + +describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses credit system")}`, () => { + const customerId = "credit-systems1"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [proProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + }); + + test("v0 response - action1 within credit balance", async () => { + const requiredAction1Units = 50.75; + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + })) as unknown as CheckResponseV0; + + const meteredCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: requiredAction1Units, + }); + + expect(res.allowed).toBe(true); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + balance: creditsFeature.included_usage, + required: meteredCost, + feature_id: TestFeature.Credits, + }); + }); + + test("v1 response - action1 within credit balance", async () => { + const requiredAction1Units = 50.75; + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + })) as unknown as CheckResponse; + + const convertedCreditCost = featureToCreditSystem({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: requiredAction1Units, + }); + + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + balance: creditsFeature.included_usage, + feature_id: TestFeature.Credits, + required_balance: convertedCreditCost, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: creditsFeature.included_usage, + overage_allowed: false, + interval: "month", + interval_count: 1, + }); + + expect(res.next_reset_at).toBeDefined(); + }); + + test("v0 response - action2 exceeds credit balance", async () => { + const requiredAction2Units = 167.33; + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Action2, + required_balance: requiredAction2Units, + })) as unknown as CheckResponseV0; + + const meteredCost = getCreditCost({ + featureId: TestFeature.Action2, + creditSystem: creditFeature!, + amount: requiredAction2Units, + }); + + expect(res.allowed).toBe(false); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + balance: creditsFeature.included_usage, + required: meteredCost, + feature_id: TestFeature.Credits, + }); + }); + + test("v1 response - action2 exceeds credit balance", async () => { + const requiredAction2Units = 167.33; + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action2, + required_balance: requiredAction2Units, + })) as unknown as CheckResponse; + + const convertedCreditCost = featureToCreditSystem({ + featureId: TestFeature.Action2, + creditSystem: creditFeature!, + amount: requiredAction2Units, + }); + + expect(res).toMatchObject({ + allowed: false, + customer_id: customerId, + balance: creditsFeature.included_usage, + feature_id: TestFeature.Credits, + required_balance: convertedCreditCost, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: creditsFeature.included_usage, + overage_allowed: false, + interval: "month", + interval_count: 1, + }); + + expect(res.next_reset_at).toBeDefined(); + }); +}); diff --git a/server/tests/check/credit-systems/credit-systems2.test.ts b/server/tests/check/credit-systems/credit-systems2.test.ts new file mode 100644 index 000000000..4b9b1fa5c --- /dev/null +++ b/server/tests/check/credit-systems/credit-systems2.test.ts @@ -0,0 +1,143 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type CheckResponseV0, + type LimitedItem, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +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"; + +const creditsFeature = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, +}) as LimitedItem; + +const proProd = constructProduct({ + type: "pro", + isDefault: false, + items: [creditsFeature], +}); + +const testCase = "credit-systems2"; + +describe(`${chalk.yellowBright("credit-systems2: test /check on credit system feature")}`, () => { + const customerId = "credit-systems2"; + const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [proProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + }); + + test("v0 response - within balance", async () => { + const requiredCredits = 50.25; + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: requiredCredits, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(true); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + balance: creditsFeature.included_usage, + required: requiredCredits, + feature_id: TestFeature.Credits, + }); + }); + + test("v1 response - within balance", async () => { + const requiredCredits = 50.25; + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: requiredCredits, + })) as unknown as CheckResponse; + + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + balance: creditsFeature.included_usage, + feature_id: TestFeature.Credits, + required_balance: requiredCredits, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: creditsFeature.included_usage, + overage_allowed: false, + interval: "month", + interval_count: 1, + }); + + expect(res.next_reset_at).toBeDefined(); + }); + + test("v0 response - exceeds balance", async () => { + const requiredCredits = 100.5; + const res = (await autumnV0.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: requiredCredits, + })) as unknown as CheckResponseV0; + + expect(res.allowed).toBe(false); + expect(res.balances).toBeDefined(); + expect(res.balances).toHaveLength(1); + expect(res.balances[0]).toMatchObject({ + balance: creditsFeature.included_usage, + required: requiredCredits, + feature_id: TestFeature.Credits, + }); + }); + + test("v1 response - exceeds balance", async () => { + const requiredCredits = 100.5; + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: requiredCredits, + })) as unknown as CheckResponse; + + expect(res).toMatchObject({ + allowed: false, + customer_id: customerId, + balance: creditsFeature.included_usage, + feature_id: TestFeature.Credits, + required_balance: requiredCredits, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: creditsFeature.included_usage, + overage_allowed: false, + interval: "month", + interval_count: 1, + }); + + expect(res.next_reset_at).toBeDefined(); + }); +}); diff --git a/server/tests/check/credit-systems/credit-systems3.test.ts b/server/tests/check/credit-systems/credit-systems3.test.ts new file mode 100644 index 000000000..f6586a6be --- /dev/null +++ b/server/tests/check/credit-systems/credit-systems3.test.ts @@ -0,0 +1,184 @@ +// import { beforeAll, describe, expect, test } from "bun:test"; +// import { +// ApiVersion, +// type CheckResponse, +// type CheckResponseV0, +// type LimitedItem, +// SuccessCode, +// } from "@autumn/shared"; +// import chalk from "chalk"; +// import { TestFeature } from "tests/setup/v2Features.js"; +// import ctx from "tests/utils/testInitUtils/createTestContext.js"; +// 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 { +// featureToCreditSystem, +// getCreditCost, +// } from "../../../src/internal/features/creditSystemUtils.js"; + +// const action1Feature = constructFeatureItem({ +// featureId: TestFeature.Action1, +// includedUsage: 50, +// }) as LimitedItem; + +// const creditsFeature = constructFeatureItem({ +// featureId: TestFeature.Credits, +// includedUsage: 100, +// }) as LimitedItem; + +// const proProd = constructProduct({ +// type: "pro", +// isDefault: false, +// items: [action1Feature, creditsFeature], +// }); + +// const testCase = "credit-systems3"; + +// describe(`${chalk.yellowBright("credit-systems3: test /check fallback from metered feature to credit system")}`, () => { +// const customerId = "credit-systems3"; +// const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); +// const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); +// const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits); + +// beforeAll(async () => { +// await initCustomerV3({ +// ctx, +// customerId, +// attachPm: "success", +// withTestClock: false, +// }); + +// await initProductsV0({ +// ctx, +// products: [proProd], +// prefix: testCase, +// }); + +// await autumnV1.attach({ +// customer_id: customerId, +// product_id: proProd.id, +// }); +// }); + +// test("v0 response - before consuming metered feature, returns Action1", async () => { +// const requiredAction1Units = 25.5; +// const res = (await autumnV0.check({ +// customer_id: customerId, +// feature_id: TestFeature.Action1, +// required_balance: requiredAction1Units, +// })) as unknown as CheckResponseV0; + +// expect(res).toMatchObject({ +// allowed: true, +// balances: [ +// { +// balance: action1Feature.included_usage, +// required: requiredAction1Units, +// feature_id: TestFeature.Action1, +// }, +// ], +// }); +// }); + +// test("v1 response - before consuming metered feature, returns Action1", async () => { +// const requiredAction1Units = 25.5; +// const res = (await autumnV1.check({ +// customer_id: customerId, +// feature_id: TestFeature.Action1, +// required_balance: requiredAction1Units, +// })) as unknown as CheckResponse; + +// expect(res).toMatchObject({ +// allowed: true, +// customer_id: customerId, +// balance: action1Feature.included_usage, +// feature_id: TestFeature.Action1, +// required_balance: requiredAction1Units, +// code: SuccessCode.FeatureFound, +// unlimited: false, +// usage: 0, +// included_usage: action1Feature.included_usage, +// overage_allowed: false, +// interval: "month", +// interval_count: 1, +// }); + +// expect(res.next_reset_at).toBeDefined(); +// }); +// return; + +// test("consume all Action1 balance", async () => { +// await autumnV1.track({ +// customer_id: customerId, +// feature_id: TestFeature.Action1, +// value: action1Feature.included_usage, +// }); +// }); + +// test("v0 response - after consuming metered feature, returns Credits", async () => { +// const requiredAction1Units = 25.5; +// const res = (await autumnV0.check({ +// customer_id: customerId, +// feature_id: TestFeature.Action1, +// required_balance: requiredAction1Units, +// })) as unknown as CheckResponseV0; + +// const creditFeature = ctx.features.find( +// (f) => f.id === TestFeature.Credits, +// ); + +// const meteredCost = getCreditCost({ +// featureId: TestFeature.Action1, +// creditSystem: creditFeature!, +// amount: requiredAction1Units, +// }); + +// expect(res.allowed).toBe(true); +// expect(res.balances).toBeDefined(); +// expect(res.balances).toHaveLength(1); +// expect(res.balances[0]).toMatchObject({ +// balance: creditsFeature.included_usage, +// required: meteredCost, +// feature_id: TestFeature.Credits, +// }); +// }); + +// test("v1 response - after consuming metered feature, returns Credits", async () => { +// const requiredAction1Units = 25.5; +// const res = (await autumnV1.check({ +// customer_id: customerId, +// feature_id: TestFeature.Action1, +// required_balance: requiredAction1Units, +// })) as unknown as CheckResponse; + +// const creditFeature = ctx.features.find( +// (f) => f.id === TestFeature.Credits, +// ); + +// const convertedCreditCost = featureToCreditSystem({ +// featureId: TestFeature.Action1, +// creditSystem: creditFeature!, +// amount: requiredAction1Units, +// }); + +// expect(res).toMatchObject({ +// allowed: true, +// customer_id: customerId, +// balance: creditsFeature.included_usage, +// feature_id: TestFeature.Credits, +// required_balance: convertedCreditCost, +// code: SuccessCode.FeatureFound, +// unlimited: false, +// usage: action1Feature.included_usage, +// included_usage: creditsFeature.included_usage, +// overage_allowed: false, +// interval: "month", +// interval_count: 1, +// }); + +// expect(res.next_reset_at).toBeDefined(); +// }); +// }); diff --git a/server/tests/global.ts b/server/tests/global.ts index 9836fedcb..3a1b68c7a 100644 --- a/server/tests/global.ts +++ b/server/tests/global.ts @@ -5,7 +5,6 @@ dotenv.config(); import { AggregateType, AllowanceType, - AppEnv, BillingInterval, CouponDurationType, EntInterval, @@ -16,9 +15,7 @@ import { RewardTriggerEvent, RewardType, } from "@autumn/shared"; -import { initDrizzle } from "@/db/initDrizzle.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; import { initEntitlement, initFeature, @@ -28,6 +25,7 @@ import { initReward, initRewardProgram, } from "./utils/init.js"; +import { createTestContext } from "./utils/testInitUtils/createTestContext.js"; export const features: Record = { boolean1: initFeature({ @@ -947,22 +945,14 @@ export const referralPrograms = { const ORG_SLUG = process.env.TESTS_ORG!; -before(async function () { +export const cleanFeatures = async () => { + const ctx = await createTestContext(); + const { db, org, env } = ctx; try { - this.env = AppEnv.Sandbox; - const { db, client } = initDrizzle(); - this.db = db; - this.client = client; - - this.org = await OrgService.getBySlug({ - db: this.db, - slug: ORG_SLUG, - }); - const dbFeatures = await FeatureService.list({ - db: this.db, - orgId: this.org.id, - env: this.env, + db, + orgId: org.id, + env, }); const cleanFeatures = (features: Record) => { @@ -989,8 +979,54 @@ before(async function () { } catch (error) { console.error(error); } -}); +}; -after(async function () { - await this.client?.end(); -}); +await cleanFeatures(); + +// before(async function () { +// try { +// this.env = AppEnv.Sandbox; +// const { db, client } = initDrizzle(); +// this.db = db; +// this.client = client; + +// this.org = await OrgService.getBySlug({ +// db: this.db, +// slug: ORG_SLUG, +// }); + +// const dbFeatures = await FeatureService.list({ +// db: this.db, +// orgId: this.org.id, +// env: this.env, +// }); + +// const cleanFeatures = (features: Record) => { +// for (const featureId in features) { +// const feature = features[featureId as keyof typeof features]; +// const dbFeature = dbFeatures.find((f: any) => f.id === feature.id); +// if (!dbFeature) { +// // throw new Error(`Feature ${feature.id} not found`); +// continue; +// } +// features[featureId as keyof typeof features].internal_id = +// dbFeature.internal_id; +// if (feature.type === FeatureType.Metered) { +// // Ignore this for now +// // @ts-expect-error eventName is manually set +// features[featureId as keyof typeof features].eventName = +// dbFeature.event_names?.[0] || dbFeature.id; +// } +// } +// }; + +// cleanFeatures(features); +// cleanFeatures(creditSystems); +// } catch (error) { +// console.error(error); +// } +// }); + +// after(async function () { +// await this.client?.end(); +// }); diff --git a/server/tests/setup/v2Features.ts b/server/tests/setup/v2Features.ts index 580f28474..5582a258a 100644 --- a/server/tests/setup/v2Features.ts +++ b/server/tests/setup/v2Features.ts @@ -1,14 +1,16 @@ import dotenv from "dotenv"; + dotenv.config(); +import { AppEnv, FeatureUsageType } from "@autumn/shared"; import { constructBooleanFeature, constructCreditSystem, constructMeteredFeature, } from "@/internal/features/utils/constructFeatureUtils.js"; -import { AppEnv, FeatureUsageType, UsageModel } from "@autumn/shared"; export enum TestFeature { + Dashboard = "dashboard", // boolean feature Messages = "messages", // single use (prepaid) Users = "users", // cont use Admin = "admin", // cont use @@ -23,6 +25,11 @@ export enum TestFeature { const orgId = process.env.TESTS_ORG_ID!; export const features = { + [TestFeature.Dashboard]: constructBooleanFeature({ + featureId: TestFeature.Dashboard, + orgId, + env: AppEnv.Sandbox, + }), [TestFeature.AdminRights]: constructBooleanFeature({ featureId: TestFeature.AdminRights, orgId, diff --git a/server/tests/setupMain.ts b/server/tests/setupMain.ts new file mode 100644 index 000000000..9037144ff --- /dev/null +++ b/server/tests/setupMain.ts @@ -0,0 +1,56 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +import { AppEnv } from "@autumn/shared"; +import { clearOrg } from "tests/utils/setupUtils/clearOrg.js"; +import { setupOrg } from "tests/utils/setupUtils/setupOrg.js"; +import { + advanceProducts, + attachProducts, + cleanFeatures, + creditSystems, + entityProducts, + features, + oneTimeProducts, + products, + referralPrograms, + rewards, +} from "./global.js"; + +const ORG_SLUG = process.env.TESTS_ORG!; +const DEFAULT_ENV = AppEnv.Sandbox; + +async function main() { + console.log("๐Ÿงน Clearing org..."); + const org = await clearOrg({ orgSlug: ORG_SLUG, env: DEFAULT_ENV }); + + console.log("๐Ÿ—๏ธ Setting up org..."); + await cleanFeatures(); + await setupOrg({ + orgId: org.id, + env: DEFAULT_ENV, + features: { ...features, ...creditSystems } as any, + products: { + ...products, + ...advanceProducts, + ...attachProducts, + ...oneTimeProducts, + ...entityProducts, + } as any, + rewards: { ...rewards } as any, + rewardTriggers: { ...referralPrograms } as any, + }); + + console.log("โœ… Setup complete!"); + console.log("--------------------------------"); +} + +main() + .catch((error) => { + console.error("โŒ Setup failed:", error); + process.exit(1); + }) + .finally(async () => { + process.exit(0); + }); diff --git a/server/tests/utils/setup.ts b/server/tests/utils/setup.ts index edadaff19..e159dac1b 100644 --- a/server/tests/utils/setup.ts +++ b/server/tests/utils/setup.ts @@ -1,44 +1,8 @@ -import { - AppEnv, - type CreateReward, - type Feature, - FeatureType, - type FullProduct, - type Organization, - type Price, - PriceType, - type RewardProgram, - RewardType, -} from "@autumn/shared"; -import axios from "axios"; -import type Stripe from "stripe"; -import { AutumnCli } from "tests/cli/AutumnCli.js"; -import { features as v2Features } from "tests/setup/v2Features.js"; -import { initDrizzle } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { CacheManager } from "@/external/caching/CacheManager.js"; -import { CacheType } from "@/external/caching/cacheActions.js"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { deactivateStripeMeters } from "@/external/stripe/stripeProductUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { mapToProductItems } from "@/internal/products/productV2Utils.js"; -import { RewardService } from "@/internal/rewards/RewardService.js"; -import { deleteAllStripeTestClocks } from "./stripeUtils.js"; +// Re-export functions from setupUtils +export { clearOrg } from "./setupUtils/clearOrg.js"; +export { getAxiosInstance, setupOrg } from "./setupUtils/setupOrg.js"; -export const getAxiosInstance = ( - apiKey: string = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!, -) => { - return axios.create({ - baseURL: "http://localhost:8080", - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); -}; +import axios from "axios"; export const getPublicAxiosInstance = ({ withBearer, @@ -59,432 +23,3 @@ export const getPublicAxiosInstance = ({ headers: headers, }); }; - -export const clearOrg = async ({ - orgSlug, - env, -}: { - orgSlug: string; - env?: AppEnv; -}) => { - if (env !== AppEnv.Sandbox) { - console.error("Cannot clear non-sandbox orgs"); - process.exit(1); - } - - const autumn = new AutumnInt(); - - if (process.env.STRIPE_TEST_KEY) { - console.log(`Reconnecting stripe...`); - - try { - await autumn.stripe.delete(); - } catch (error) {} - try { - await autumn.stripe.connect({ - secret_key: process.env.STRIPE_TEST_KEY!, - success_url: "https://useautumn.com", - default_currency: "usd", - }); - } catch (error: any) { - console.error("Error reconnecting stripe", error.message); - process.exit(1); - } - } - - const { db, client } = initDrizzle(); - const org = await OrgService.getBySlug({ db, slug: orgSlug }); - - await Promise.all([ - CacheManager.invalidate({ - action: CacheType.SecretKey, - value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!), - }), - CacheManager.invalidate({ - action: CacheType.PublicKey, - value: process.env.UNIT_TEST_AUTUMN_PUBLIC_KEY!, - }), - ]); - await CacheManager.disconnect(); - - if (!org) { - throw new Error(`Org ${orgSlug} not found`); - } - - if (!(org.slug === "unit-test-org" || org.slug === "ci_cd")) { - console.error("Cannot clear non-unit-test-orgs"); - process.exit(1); - } - - const orgId = org.id; - - // 1. Delete all customers - await CusService.deleteByOrgId({ db, orgId, env }); - console.log(" โœ… Deleted customers"); - - const stripeCli = createStripeCli({ org, env: env! }); - const stripeCustomers = await stripeCli.customers.list({ - limit: 100, - }); - const deleteCustomer = async (customer: Stripe.Customer) => { - try { - await stripeCli.customers.del(customer.id); - } catch (error) { - console.error("Error deleting stripe customer", customer.id); - } - }; - - const cusBatchSize = 5; - for (let i = 0; i < stripeCustomers.data.length; i += cusBatchSize) { - const batch = stripeCustomers.data.slice(i, i + cusBatchSize); - const batchDeleteCustomers = []; - for (const customer of batch) { - batchDeleteCustomers.push(deleteCustomer(customer)); - } - await Promise.all(batchDeleteCustomers); - console.log( - ` โœ… Deleted ${i + batch.length}/${ - stripeCustomers.data.length - } Stripe customers`, - ); - } - - console.log(" โœ… Deleted Stripe customers"); - - // 2. Delete all products - await ProductService.deleteByOrgId({ db, orgId, env }); - - console.log(" โœ… Deleted products"); - - const stripeProducts = await stripeCli.products.list({ - limit: 100, - active: true, - }); - - const batchSize = 5; - const removeStripeProduct = async (product: Stripe.Product) => { - try { - await stripeCli.products.del(product.id); - } catch (error) { - await stripeCli.products.update(product.id, { - active: false, - }); - } - }; - - for (let i = 0; i < stripeProducts.data.length; i += batchSize) { - const batch = stripeProducts.data.slice(i, i + batchSize); - const batchDeleteProducts = []; - for (const product of batch) { - batchDeleteProducts.push(removeStripeProduct(product)); - } - await Promise.all(batchDeleteProducts); - // await timeout(800); - console.log( - ` โœ… Deleted ${i + batch.length}/${ - stripeProducts.data.length - } Stripe products`, - ); - } - - console.log(" โœ… Deleted Stripe products"); - - await deleteAllStripeTestClocks({ stripeCli }); - console.log(" โœ… Deleted Stripe test clocks"); - - // Delete all stripe meters - await deactivateStripeMeters({ org, env }); - console.log(" โœ… Deactivated Stripe meters"); - - // Batch delete coupons - - const batchDeleteCoupons = []; - await RewardService.deleteByOrgId({ db, orgId, env }); - - const stripeCoupons = await stripeCli.coupons.list({ - limit: 100, - }); - for (const coupon of stripeCoupons.data) { - batchDeleteCoupons.push(stripeCli.coupons.del(coupon.id)); - } - - await Promise.all(batchDeleteCoupons); - console.log(" โœ… Deleted Stripe coupons"); - - await FeatureService.deleteByOrgId({ db, orgId, env }); - - console.log(`โœ… Cleared org ${orgSlug} (${env})`); - - await client.end(); - return org; -}; - -export const setupOrg = async ({ - orgId, - env, - features, - products, - rewards, - rewardTriggers, -}: { - orgId: string; - env: AppEnv; - features: Record; - products: Record; - rewards: Record; - rewardTriggers: Record; -}) => { - const axiosInstance = getAxiosInstance(); - const { client, db } = initDrizzle(); - - const autumn = new AutumnInt(); - - const insertFeatures = []; - for (const feature of Object.values(features)) { - insertFeatures.push(axiosInstance.post("/v1/internal_features", feature)); - } - - await Promise.all(insertFeatures); - - await FeatureService.insert({ - db, - data: Object.values(v2Features), - logger: console, - }); - - // const allFeatures = await FeatureService.list({ db, orgId, env }); - - let org: Organization | null = null; - let newFeatures: Feature[] = []; - try { - org = await OrgService.get({ db, orgId }); - await OrgService.update({ - db, - orgId, - updates: { - config: { - ...org.config, - bill_upgrade_immediately: true, - }, - }, - }); - - newFeatures = (await FeatureService.list({ db, orgId, env })).filter((f) => - Object.keys(features).includes(f.id), - ); - } catch (error) { - console.error("Error updating org", error); - } - - for (const feature of newFeatures!) { - features[feature.id].internal_id = feature.internal_id; - - if (feature.type === FeatureType.Metered) { - features[feature.id].eventName = feature.event_names?.[0] || feature.id; - } - } - - console.log("โœ… Inserted features"); - - // 2. Create products - const insertProducts = []; - - const productValues = Object.values(products); - const batchSize = 5; - - for ( - let batchStart = 0; - batchStart < productValues.length; - batchStart += batchSize - ) { - const batch = productValues.slice(batchStart, batchStart + batchSize); - const batchPromises = []; - - for (const product of batch) { - const insertProduct = async () => { - await autumn.products.create({ - id: product.id, - name: product.name, - group: product.group, - is_add_on: product.is_add_on, - is_default: product.is_default, - }); - - const prices = product.prices.map((p: any) => ({ - ...p, - config: { - ...p.config, - internal_feature_id: newFeatures!.find( - (f) => f.id === (p.config as any)?.feature_id, - )?.internal_id, - }, - })); - - const entitlements = Object.values(product.entitlements).map( - (ent: any) => ({ - ...ent, - internal_feature_id: newFeatures!.find( - (f) => f.id === ent.feature_id, - )?.internal_id, - }), - ); - - const entWithFeatures = entitlements.map((ent) => ({ - ...ent, - feature: newFeatures!.find((f) => f.id === ent.feature_id), - })); - - const items = mapToProductItems({ - prices, - entitlements: entWithFeatures, - allowFeatureMatch: true, - features: newFeatures!, - }); - - try { - await axiosInstance.post(`/v1/products/${product.id}`, { - // prices: prices, - // entitlements: entitlements, - items, - free_trial: product.free_trial, - }); - } catch (error) { - console.log("Product:", product.name); - console.error("Error creating product prices / ents"); - console.log("Items", items); - } - return; - }; - - batchPromises.push(insertProduct()); - } - - // Wait for the current batch to complete before proceeding to the next - await Promise.all(batchPromises); - insertProducts.push(...batchPromises); - } - - await Promise.all(insertProducts); - console.log("โœ… Inserted products"); - - if (process.env.MOCHA_PARALLEL === "true") { - console.log("MOCHA RUNNING IN PARALLEL"); - await AutumnCli.initStripeProducts(); - console.log("โœ… Initialized stripe products / prices"); - } else { - console.log("MOCHA RUNNING IN SERIAL"); - } - - // return; - - // Fetch all products - const { list: allProducts } = await AutumnCli.getProducts(); - const productIds = allProducts.map((p: any) => p.id); - - // Insert coupons - const insertCoupons = []; - for (const reward of Object.values(rewards)) { - const createReward = async () => { - let priceIds = []; - - const rewardData: any = { - id: reward.id, - name: reward.name, - promo_codes: [ - { - code: reward.id, - }, - ], - type: reward.type, - }; - - if (reward.type === RewardType.FreeProduct) { - rewardData.free_product_id = reward.free_product_id; - rewardData.free_product_config = reward.free_product_config; - } else { - if (reward.only_usage_prices) { - const filteredProducts = allProducts.filter( - (product: FullProduct) => { - if (reward.product_ids) { - return reward.product_ids.includes(product.id); - } else return true; - }, - ); - - priceIds = filteredProducts.flatMap((product: FullProduct) => - product.prices - .filter((price: Price) => price.config!.type === PriceType.Usage) - .map((price) => { - return price.id; - }), - ); - } else if (reward.product_ids) { - priceIds = allProducts - .filter((product: FullProduct) => - reward.product_ids.includes(product.id), - ) - .flatMap((product: FullProduct) => - product.prices.map((price) => price.id), - ); - } - - rewardData.discount_config = { - discount_value: reward.discount_config.discount_value, - duration_type: reward.discount_config.duration_type, - duration_value: reward.discount_config.duration_value, - apply_to_all: reward.discount_config.apply_to_all, - price_ids: priceIds, - }; - } - - const newReward: CreateReward & { internal_id: string } = { - internal_id: reward.id, - id: reward.id, - name: reward.name, - promo_codes: [ - { - code: reward.id, - }, - ], - type: reward.type, - discount_config: rewardData.discount_config, - free_product_id: rewardData.free_product_id, - free_product_config: - rewardData.free_product_config?.duration_type && - rewardData.free_product_config?.duration_value - ? rewardData.free_product_config - : undefined, - }; - - const rewardRes = await autumn.rewards.create(newReward); - - return { - id: reward.id, - rewardRes, - }; - }; - - console.log("Creating reward", reward.id); - insertCoupons.push(createReward()); - } - - await Promise.all(insertCoupons); - console.log("โœ… Inserted coupons"); - - // CREATE REWARD TRIGGERS - const insertRewardTriggers = []; - const insertedRewards = await RewardService.list({ db, orgId, env }); - for (const rewardTrigger of Object.values(rewardTriggers)) { - const rt = { - ...rewardTrigger, - internal_reward_id: insertedRewards.find( - (r) => r.id === rewardTrigger.internal_reward_id, - )?.internal_id!, - }; - insertRewardTriggers.push(autumn.rewardPrograms.create(rt)); - } - await Promise.all(insertRewardTriggers); - console.log("โœ… Inserted reward triggers"); - - await client.end(); -}; diff --git a/server/tests/utils/setupUtils/clearOrg.ts b/server/tests/utils/setupUtils/clearOrg.ts new file mode 100644 index 000000000..eac6f502b --- /dev/null +++ b/server/tests/utils/setupUtils/clearOrg.ts @@ -0,0 +1,105 @@ +import { AppEnv } from "@autumn/shared"; +import { initDrizzle } from "@/db/initDrizzle.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { CacheManager } from "@/external/caching/CacheManager.js"; +import { CacheType } from "@/external/caching/cacheActions.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { RewardService } from "@/internal/rewards/RewardService.js"; + +export const clearOrg = async ({ + orgSlug, + env, +}: { + orgSlug: string; + env?: AppEnv; +}) => { + if (env !== AppEnv.Sandbox) { + console.error("Cannot clear non-sandbox orgs"); + process.exit(1); + } + + const autumn = new AutumnInt(); + + // if (process.env.STRIPE_TEST_KEY) { + // console.log(`Reconnecting stripe...`); + + // try { + // await autumn.stripe.delete(); + // } catch (_error) {} + // try { + // await autumn.stripe.connect({ + // secret_key: process.env.STRIPE_TEST_KEY!, + // success_url: "https://useautumn.com", + // default_currency: "usd", + // }); + // } catch (error: any) { + // console.error("Error reconnecting stripe", error.message); + // process.exit(1); + // } + // } + + const { db, client } = initDrizzle(); + const org = await OrgService.getBySlug({ db, slug: orgSlug }); + + await Promise.all([ + CacheManager.invalidate({ + action: CacheType.SecretKey, + value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!), + }), + CacheManager.invalidate({ + action: CacheType.PublicKey, + value: process.env.UNIT_TEST_AUTUMN_PUBLIC_KEY!, + }), + ]); + await CacheManager.disconnect(); + + if (!org) { + throw new Error(`Org ${orgSlug} not found`); + } + + if (!(org.slug === "unit-test-org" || org.slug === "ci_cd")) { + console.error("Cannot clear non-unit-test-orgs"); + process.exit(1); + } + + const orgId = org.id; + + // Reset default account using the new internal endpoint + // This will delete and recreate the Stripe account, which automatically removes all Stripe resources + console.log(" ๐Ÿ”„ Resetting default account..."); + try { + const data = await autumn.organization.resetDefaultAccount(); + console.log( + " โœ… Reset default account, new account:", + data?.new_account_id, + ); + } catch (error: any) { + console.error(" โŒ Failed to reset default account:", error.message); + // Continue anyway as this is not critical + } + + // Delete all customers from our database + await CusService.deleteByOrgId({ db, orgId, env }); + console.log(" โœ… Deleted customers"); + + // Delete all products from our database + await ProductService.deleteByOrgId({ db, orgId, env }); + console.log(" โœ… Deleted products"); + + // Delete all rewards from our database + await RewardService.deleteByOrgId({ db, orgId, env }); + console.log(" โœ… Deleted rewards"); + + // Delete all features from our database + await FeatureService.deleteByOrgId({ db, orgId, env }); + console.log(" โœ… Deleted features"); + + console.log(`โœ… Cleared org ${orgSlug} (${env})`); + + await client.end(); + return org; +}; diff --git a/server/tests/utils/setupUtils/setupOrg.ts b/server/tests/utils/setupUtils/setupOrg.ts new file mode 100644 index 000000000..b5b2a5617 --- /dev/null +++ b/server/tests/utils/setupUtils/setupOrg.ts @@ -0,0 +1,295 @@ +import { + type AppEnv, + type Feature, + FeatureType, + type FullProduct, + type Organization, + type Price, + PriceType, + type RewardProgram, + RewardType, +} from "@autumn/shared"; +import axios from "axios"; +import { AutumnCli } from "tests/cli/AutumnCli.js"; +import { features as v2Features } from "tests/setup/v2Features.js"; +import { initDrizzle } from "@/db/initDrizzle.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { mapToProductItems } from "@/internal/products/productV2Utils.js"; +import { RewardService } from "@/internal/rewards/RewardService.js"; + +export const getAxiosInstance = ( + apiKey: string = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!, +) => { + return axios.create({ + baseURL: "http://localhost:8080", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); +}; + +export const setupOrg = async ({ + orgId, + env, + features, + products, + rewards, + rewardTriggers, +}: { + orgId: string; + env: AppEnv; + features: Record; + products: Record; + rewards: Record; + rewardTriggers: Record; +}) => { + const axiosInstance = getAxiosInstance(); + const { client, db } = initDrizzle(); + + const autumn = new AutumnInt(); + + const insertFeatures = []; + for (const feature of Object.values(features)) { + insertFeatures.push(axiosInstance.post("/v1/internal_features", feature)); + } + + await Promise.all(insertFeatures); + + await FeatureService.insert({ + db, + data: Object.values(v2Features), + logger: console, + }); + + let org: Organization | null = null; + let newFeatures: Feature[] = []; + try { + org = await OrgService.get({ db, orgId }); + await OrgService.update({ + db, + orgId, + updates: { + config: { + ...org.config, + bill_upgrade_immediately: true, + }, + }, + }); + + newFeatures = (await FeatureService.list({ db, orgId, env })).filter((f) => + Object.keys(features).includes(f.id), + ); + } catch (error) { + console.error("Error updating org", error); + } + + for (const feature of newFeatures!) { + features[feature.id].internal_id = feature.internal_id; + + if (feature.type === FeatureType.Metered) { + features[feature.id].eventName = feature.event_names?.[0] || feature.id; + } + } + + console.log("โœ… Inserted features"); + + // 2. Create products + const insertProducts = []; + + const productValues = Object.values(products); + const batchSize = 5; + + for ( + let batchStart = 0; + batchStart < productValues.length; + batchStart += batchSize + ) { + const batch = productValues.slice(batchStart, batchStart + batchSize); + const batchPromises = []; + + for (const product of batch) { + const insertProduct = async () => { + await autumn.products.create({ + id: product.id, + name: product.name, + group: product.group, + is_add_on: product.is_add_on, + is_default: product.is_default, + }); + + const prices = product.prices.map((p: any) => ({ + ...p, + config: { + ...p.config, + internal_feature_id: newFeatures!.find( + (f) => f.id === (p.config as any)?.feature_id, + )?.internal_id, + }, + })); + + const entitlements = Object.values(product.entitlements).map( + (ent: any) => ({ + ...ent, + internal_feature_id: newFeatures!.find( + (f) => f.id === ent.feature_id, + )?.internal_id, + }), + ); + + const entWithFeatures = entitlements.map((ent) => ({ + ...ent, + feature: newFeatures!.find((f) => f.id === ent.feature_id), + })); + + const items = mapToProductItems({ + prices, + entitlements: entWithFeatures, + allowFeatureMatch: true, + features: newFeatures!, + }); + + try { + await axiosInstance.post(`/v1/products/${product.id}`, { + items, + free_trial: product.free_trial, + }); + } catch (_error) { + console.log("Product:", product.name); + console.error("Error creating product prices / ents"); + console.log("Items", items); + } + return; + }; + + batchPromises.push(insertProduct()); + } + + await Promise.all(batchPromises); + insertProducts.push(...batchPromises); + } + + await Promise.all(insertProducts); + console.log("โœ… Inserted products"); + + if (process.env.MOCHA_PARALLEL === "true") { + console.log("MOCHA RUNNING IN PARALLEL"); + await AutumnCli.initStripeProducts(); + console.log("โœ… Initialized stripe products / prices"); + } else { + console.log("MOCHA RUNNING IN SERIAL"); + } + + // Fetch all products + const { list: allProducts } = await AutumnCli.getProducts(); + const _productIds = allProducts.map((p: any) => p.id); + + // Insert coupons + const insertCoupons = []; + for (const reward of Object.values(rewards)) { + const createReward = async () => { + let priceIds = []; + + const rewardData: any = { + id: reward.id, + name: reward.name, + promo_codes: [ + { + code: reward.id, + }, + ], + type: reward.type, + }; + + if (reward.type === RewardType.FreeProduct) { + rewardData.free_product_id = reward.free_product_id; + rewardData.free_product_config = reward.free_product_config; + } else { + if (reward.only_usage_prices) { + const filteredProducts = allProducts.filter( + (product: FullProduct) => { + if (reward.product_ids) { + return reward.product_ids.includes(product.id); + } + return true; + }, + ); + + priceIds = filteredProducts.flatMap((product: FullProduct) => + product.prices + .filter((price: Price) => price.config!.type === PriceType.Usage) + .map((price) => { + return price.id; + }), + ); + } else if (reward.product_ids) { + priceIds = allProducts + .filter((product: FullProduct) => + reward.product_ids.includes(product.id), + ) + .flatMap((product: FullProduct) => + product.prices.map((price) => price.id), + ); + } + + rewardData.discount_config = { + discount_value: reward.discount_config.discount_value, + duration_type: reward.discount_config.duration_type, + duration_value: reward.discount_config.duration_value, + apply_to_all: reward.discount_config.apply_to_all, + price_ids: priceIds, + }; + } + + const newReward: any = { + internal_id: reward.id, + id: reward.id, + name: reward.name, + promo_codes: [ + { + code: reward.id, + }, + ], + type: reward.type, + discount_config: rewardData.discount_config, + free_product_id: rewardData.free_product_id, + free_product_config: + rewardData.free_product_config?.duration_type && + rewardData.free_product_config?.duration_value + ? rewardData.free_product_config + : undefined, + }; + + const rewardRes = await autumn.rewards.create(newReward); + + return { + id: reward.id, + rewardRes, + }; + }; + + console.log("Creating reward", reward.id); + insertCoupons.push(createReward()); + } + + await Promise.all(insertCoupons); + console.log("โœ… Inserted coupons"); + + // CREATE REWARD TRIGGERS + const insertRewardTriggers = []; + const insertedRewards = await RewardService.list({ db, orgId, env }); + for (const rewardTrigger of Object.values(rewardTriggers)) { + const rt = { + ...rewardTrigger, + internal_reward_id: insertedRewards.find( + (r) => r.id === rewardTrigger.internal_reward_id, + )?.internal_id!, + }; + insertRewardTriggers.push(autumn.rewardPrograms.create(rt)); + } + await Promise.all(insertRewardTriggers); + console.log("โœ… Inserted reward triggers"); + + await client.end(); +}; diff --git a/server/tests/utils/stripeUtils.ts b/server/tests/utils/stripeUtils.ts index a8cc14597..b0762b42b 100644 --- a/server/tests/utils/stripeUtils.ts +++ b/server/tests/utils/stripeUtils.ts @@ -34,27 +34,40 @@ export const completeCheckoutForm = async ( ) => { let browser; - if (process.env.NODE_ENV === "development" && !isLocal) { - const session = await client.sessions.create(); - browser = await puppeteer.connect({ - browserWSEndpoint: session!.wsEndpoint, - defaultViewport: null, - }); - } else { - browser = await puppeteer.launch({ - headless: false, - executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", - args: ["--no-sandbox", "--disable-setuid-sandbox"], - }); - } + // if (process.env.NODE_ENV === "development" && !isLocal) { + // const session = await client.sessions.create(); + // browser = await puppeteer.connect({ + // browserWSEndpoint: session!.wsEndpoint, + // defaultViewport: null, + // }); + // } else { + // browser = await puppeteer.launch({ + // headless: false, + // executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", + // args: ["--no-sandbox", "--disable-setuid-sandbox"], + // }); + // } + browser = await puppeteer.launch({ + headless: false, + executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); // Set standard desktop viewport size - await page.goto(url); + await page.goto(url, { waitUntil: "networkidle2" }); - // await page.waitForSelector("#payment-method-accordion-item-title-card"); - // await page.click("#payment-method-accordion-item-title-card"); + // Try to click accordion if it exists (wait up to 2 seconds) + try { + await page.waitForSelector("#payment-method-accordion-item-title-card", { + timeout: 2000, + }); + await page.click("#payment-method-accordion-item-title-card"); + await timeout(500); // Brief wait for accordion to expand + } catch (e) { + // Accordion doesn't exist or didn't appear, continue without clicking + } await page.waitForSelector("#cardNumber"); await page.type("#cardNumber", "4242424242424242"); diff --git a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts index 7da20dcc8..d8b13e54d 100644 --- a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts +++ b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts @@ -1,15 +1,11 @@ import "dotenv/config"; -import { Stripe } from "stripe"; import puppeteer from "puppeteer-core"; -import Browserbase from "@browserbasehq/sdk"; - -import { Hyperbrowser } from "@hyperbrowser/sdk"; import { timeout } from "../genUtils.js"; -const client = new Hyperbrowser({ - apiKey: process.env.HYPERBROWSER_API_KEY, -}); +// const client = new Hyperbrowser({ +// apiKey: process.env.HYPERBROWSER_API_KEY, +// }); export const completeInvoiceCheckout = async ({ url, @@ -20,19 +16,20 @@ export const completeInvoiceCheckout = async ({ }) => { let browser; - if (process.env.NODE_ENV === "development" && !isLocal) { - const session = await client.sessions.create(); - browser = await puppeteer.connect({ - browserWSEndpoint: session!.wsEndpoint, - defaultViewport: null, - }); - } else { - browser = await puppeteer.launch({ - headless: false, - executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", - args: ["--no-sandbox", "--disable-setuid-sandbox"], - }); - } + // if (process.env.NODE_ENV === "development" && !isLocal) { + // const session = await client.sessions.create(); + // browser = await puppeteer.connect({ + // browserWSEndpoint: session!.wsEndpoint, + // defaultViewport: null, + // }); + // } else { + + // } + browser = await puppeteer.launch({ + headless: false, + executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); try { const page = await browser.newPage(); diff --git a/server/tests/utils/testInitUtils/createTestContext.ts b/server/tests/utils/testInitUtils/createTestContext.ts new file mode 100644 index 000000000..e89297733 --- /dev/null +++ b/server/tests/utils/testInitUtils/createTestContext.ts @@ -0,0 +1,61 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Try loading .env from current directory, then parent directory +// dotenv.config({ path: resolve(__dirname, ".env") }); +dotenv.config({ path: resolve(__dirname, "..", "..", "..", ".env") }); + +import { AppEnv, type Feature, type Organization } from "@autumn/shared"; +import type Stripe from "stripe"; +import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { FeatureService } from "../../../src/internal/features/FeatureService.js"; + +const ORG_SLUG = process.env.TESTS_ORG!; +const DEFAULT_ENV = AppEnv.Sandbox; + +export type TestContext = { + org: Organization; + env: AppEnv; + stripeCli: Stripe; + db: DrizzleCli; + features: Feature[]; +}; + +export const createTestContext = async () => { + const { db } = initDrizzle(); + + const org = await OrgService.getBySlug({ db, slug: ORG_SLUG }); + if (!org) throw new Error("Org not found"); + + const env = DEFAULT_ENV; + const stripeCli = createStripeCli({ org, env }); + const features = await FeatureService.list({ db, orgId: org.id, env }); + + return { + org, + env, + stripeCli, + db, + features, + }; +}; + +// Only create test context if we're actually running tests +const isTestEnvironment = + process.env.NODE_ENV === "test" || + process.argv.some((arg) => arg.includes("test")) || + process.argv[1]?.includes("/tests/"); + +let testContext: TestContext | null = null; + +if (isTestEnvironment) { + testContext = await createTestContext(); +} + +export default testContext as TestContext; diff --git a/shared/api/_prevVersions/openapi1.2.0.ts b/shared/api/_prevVersions/openapi1.2.0.ts new file mode 100644 index 000000000..4beb5b4eb --- /dev/null +++ b/shared/api/_prevVersions/openapi1.2.0.ts @@ -0,0 +1,456 @@ +import { writeFileSync } from "node:fs"; +import { + getListResponseSchema, + SuccessResponseSchema, +} from "@api/common/commonResponses.js"; +import yaml from "yaml"; +import { z } from "zod/v4"; +import { createDocument, type ZodOpenApiPathsObject } from "zod-openapi"; +import { UpdateBalancesParamsSchema } from "../balances/updateBalanceModels.js"; +import { SetUsageParamsSchema } from "../balances/usageModels.js"; +import { CustomerDataSchema } from "../common/customerData.js"; +import { EntityDataSchema } from "../common/entityData.js"; +import { setUsageJsDoc } from "../common/jsDocs.js"; +import { ApiEntityWithMeta } from "../entities/entitiesOpenApi.js"; +import { + ApiCusFeatureSchema, + ApiCusProductSchema, + ApiCustomerSchema, + ApiFeatureSchema, + ApiProductItemSchema, + CreateCustomerParamsSchema, + CreateCustomerQuerySchema, + CreateFeatureParamsSchema, + FEATURE_EXAMPLE, + GetCustomerQuerySchema, + ListCustomersResponseSchema, + UpdateCustomerParamsSchema, + UpdateFeatureParamsSchema, +} from "../models.js"; +import { ApiProductSchema, PRODUCT_EXAMPLE } from "../products/apiProduct.js"; +import { + CreateProductV2ParamsSchema, + UpdateProductV2ParamsSchema, +} from "../products/productOpModels.js"; + +// Register schema with .meta() for OpenAPI spec generation +export const ApiProductWithMeta = ApiProductSchema.meta({ + id: "Product", + examples: [PRODUCT_EXAMPLE], +}); + +// Register the schema with .meta() for OpenAPI spec generation +const ApiFeatureWithMeta = ApiFeatureSchema.extend({ + type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]), +}).meta({ + id: "Feature", + examples: [FEATURE_EXAMPLE], +}); + +// Register schema with .meta() for OpenAPI spec generation +const ApiCustomerWithMeta = ApiCustomerSchema.meta({ + id: "Customer", +}); + +const productOps = { + "/products": { + get: { + summary: "List Products", + tags: ["products"], + responses: { + "200": { + description: "", + content: { + "application/json": { + schema: z.object({ + list: z.array(ApiProductWithMeta), + }), + }, + }, + }, + }, + }, + post: { + summary: "Create Product", + tags: ["products"], + requestBody: { + content: { + "application/json": { schema: CreateProductV2ParamsSchema }, + }, + }, + responses: { + "200": { + description: "", + content: { "application/json": { schema: ApiProductWithMeta } }, + }, + }, + }, + }, + "/products/{product_id}": { + get: { + summary: "Get Product", + tags: ["products"], + requestParams: { + path: z.object({ + product_id: z.string(), + }), + }, + responses: { + "200": { + description: "", + content: { "application/json": { schema: ApiProductWithMeta } }, + }, + }, + }, + post: { + summary: "Update Product", + tags: ["products"], + requestParams: { + path: z.object({ + product_id: z.string(), + }), + }, + requestBody: { + content: { + "application/json": { schema: UpdateProductV2ParamsSchema }, + }, + }, + responses: { + "200": { + description: "", + content: { "application/json": { schema: ApiProductWithMeta } }, + }, + }, + }, + delete: { + summary: "Delete Product", + tags: ["products"], + requestParams: { + path: z.object({ + product_id: z.string(), + }), + query: z.object({ + all_versions: z.boolean().optional(), + }), + }, + responses: { + "200": { + description: "", + content: { + "application/json": { + schema: SuccessResponseSchema, + }, + }, + }, + }, + }, + }, +}; + +const featureOps = { + "/features": { + get: { + summary: "List Features", + tags: ["features"], + responses: { + "200": { + description: "", + content: { + "application/json": { + schema: getListResponseSchema({ schema: ApiFeatureWithMeta }), + }, + }, + }, + }, + }, + post: { + summary: "Create Feature", + tags: ["features"], + requestBody: { + content: { + "application/json": { schema: CreateFeatureParamsSchema }, + }, + }, + responses: { + "200": { + description: "", + content: { "application/json": { schema: ApiFeatureWithMeta } }, + }, + }, + }, + }, + "/features/{feature_id}": { + get: { + summary: "Get Feature", + tags: ["features"], + requestParams: { + path: z.object({ + feature_id: z.string(), + }), + }, + responses: { + "200": { + description: "", + content: { "application/json": { schema: ApiFeatureWithMeta } }, + }, + }, + }, + post: { + summary: "Update Feature", + tags: ["features"], + requestParams: { + path: z.object({ + feature_id: z.string(), + }), + }, + requestBody: { + content: { + "application/json": { schema: UpdateFeatureParamsSchema }, + }, + }, + responses: { + "200": { + description: "", + content: { "application/json": { schema: ApiFeatureWithMeta } }, + }, + }, + }, + delete: { + summary: "Delete Feature", + tags: ["features"], + requestParams: { + path: z.object({ + feature_id: z.string(), + }), + }, + responses: { + "200": { + description: "", + content: { + "application/json": { + schema: SuccessResponseSchema, + }, + }, + }, + }, + }, + }, +}; + +const customerOps = { + "/customers": { + get: { + summary: "List Customers", + tags: ["customers"], + requestParams: { + query: z.object({ + limit: z.number().int().min(10).max(100).optional(), + offset: z.number().int().min(0).optional(), + }), + }, + responses: { + "200": { + description: "200 OK", + content: { + "application/json": { schema: ListCustomersResponseSchema }, + }, + }, + }, + }, + post: { + summary: "Create Customer", + tags: ["customers"], + requestParams: { + query: CreateCustomerQuerySchema, + }, + requestBody: { + content: { + "application/json": { schema: CreateCustomerParamsSchema }, + }, + }, + responses: { + "200": { + description: "200 OK", + content: { "application/json": { schema: ApiCustomerWithMeta } }, + }, + }, + }, + }, + "/customers/{customer_id}": { + get: { + summary: "Get Customer", + tags: ["customers"], + requestParams: { + path: z.object({ + customer_id: z.string(), + }), + query: GetCustomerQuerySchema, + }, + responses: { + "200": { + description: "200 OK", + content: { "application/json": { schema: ApiCustomerWithMeta } }, + }, + }, + }, + post: { + summary: "Update Customer", + tags: ["customers"], + requestParams: { + path: z.object({ + customer_id: z.string(), + }), + query: z.object({ + expand: z.string().optional(), + }), + }, + requestBody: { + content: { + "application/json": { schema: UpdateCustomerParamsSchema }, + }, + }, + responses: { + "200": { + description: "200 OK", + content: { "application/json": { schema: ApiCustomerWithMeta } }, + }, + }, + }, + delete: { + summary: "Delete Customer", + tags: ["customers"], + requestParams: { + path: z.object({ + customer_id: z.string(), + }), + }, + responses: { + "200": { + description: "200 OK", + content: { + "application/json": { + schema: SuccessResponseSchema, + }, + }, + }, + }, + }, + }, + "/customers/{customer_id}/balances": { + post: { + summary: "Set Feature Balances", + description: "Set the balance of a feature for a specific customer", + tags: ["customers"], + requestParams: { + path: z.object({ + customer_id: z.string(), + }), + }, + requestBody: { + content: { + "application/json": { schema: UpdateBalancesParamsSchema }, + }, + }, + responses: { + "200": { + description: "", + content: { + "application/json": { schema: SuccessResponseSchema }, + }, + }, + }, + }, + }, +}; + +const coreOps: ZodOpenApiPathsObject = { + "/usage": { + post: { + summary: "Set Usage", + description: setUsageJsDoc, + tags: ["core"], + requestBody: { + content: { + "application/json": { schema: SetUsageParamsSchema }, + }, + }, + responses: { + "200": { + description: "", + content: { "application/json": { schema: SuccessResponseSchema } }, + }, + }, + }, + }, +}; + +const OPENAPI_1_2_0 = createDocument( + { + openapi: "3.1.0", + info: { + title: "Autumn API", + version: "1.2.0", + }, + + servers: [ + { + url: "https://api.useautumn.com", + description: "Production server", + }, + ], + + security: [ + { + secretKey: [], + }, + ], + components: { + schemas: { + CustomerData: CustomerDataSchema, + EntityData: EntityDataSchema.meta({ + id: "EntityData", + description: "Entity data for creating an entity", + }), + Customer: ApiCustomerWithMeta, + CustomerProduct: ApiCusProductSchema, + CustomerFeature: ApiCusFeatureSchema.meta({ + id: "CustomerFeature", + description: "Customer feature object returned by the API", + }), + Product: ApiProductWithMeta, + ProductItem: ApiProductItemSchema, + Feature: ApiFeatureWithMeta, + Entity: ApiEntityWithMeta, + }, + securitySchemes: { + secretKey: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }, + }, + }, + + paths: { + ...productOps, + ...featureOps, + ...customerOps, + ...coreOps, + }, + }, + { + // Disable the "Output" suffix that zod-openapi adds to response schemas + outputIdSuffix: "", + }, +); + +export const writeOpenApi_1_2_0 = () => { + const yamlContent = yaml.stringify( + JSON.parse(JSON.stringify(OPENAPI_1_2_0, null, 2)), + ); + writeFileSync( + `${process.env.STAINLESS_PATH?.replace("\\ ", " ")}/openapi.yml`, + yamlContent, + "utf8", + ); +}; diff --git a/shared/api/balances/check/changes/V0.2_CheckChange.ts b/shared/api/balances/check/changes/V0.2_CheckChange.ts new file mode 100644 index 000000000..6b533276b --- /dev/null +++ b/shared/api/balances/check/changes/V0.2_CheckChange.ts @@ -0,0 +1,120 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { CheckResultSchema } from "../../../core/checkModels.js"; +import { + type CheckResponseV0, + CheckResponseV0Schema, +} from "../previousVersions/CheckResponseV0.js"; + +/** + * V0_2_CheckChange: Transforms check response TO V0_2 format + * + * Applied when: targetVersion <= V0_2 + * + * Breaking changes introduced in V0.2 (that we reverse here): + * + * 1. Structure: Single check result object โ†’ balances array format + * - V0.2+: Single object with allowed, feature_id, balance, unlimited, etc. + * - V0_2: { allowed, balances: [{ feature_id, required, balance, unlimited, usage_allowed }] } + * + * 2. Boolean features: No balance fields โ†’ balance: null + * 3. Unlimited features: Return unlimited: true, usage_allowed based on overage_allowed + * 4. Metered features: Return required_balance and balance + * + * Input: CheckResult (V0.2+ format) + * Output: CheckResponseV0 (V0_2 balances array format) + */ + +export const V0_2_CheckChange = defineVersionChange({ + name: "V0.2 Check Change", + newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1 + oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2 + description: [ + "Check response transformed to balances array format", + "Single check result object โ†’ { allowed, balances: [...] }", + ], + affectedResources: [AffectedResource.Check], + newSchema: CheckResultSchema, + oldSchema: CheckResponseV0Schema, + affectsResponse: true, + + // Response: V1.1+ (CheckResult) โ†’ V0_2 (CheckResponseV0) + transformResponse: ({ + input, + }: { + input: z.infer; + }): CheckResponseV0 => { + const { + allowed, + feature_id, + required_balance, + balance, + unlimited, + overage_allowed, + } = input; + + // If not allowed and no entitlements, return empty balances array + if (!allowed && balance === null && !unlimited) { + return { + allowed: false, + balances: [], + }; + } + + // Build the balance object based on feature type + const balanceObj: CheckResponseV0["balances"][number] = { + feature_id, + }; + + // Case 1: Boolean/Static features (no balance, no unlimited, no overage) + if ( + balance === null && + !unlimited && + !overage_allowed && + required_balance === undefined + ) { + balanceObj.balance = null; + return { + allowed, + balances: [balanceObj], + }; + } + + // Case 2: Unlimited features + if (unlimited) { + balanceObj.balance = null; + balanceObj.unlimited = true; + balanceObj.usage_allowed = overage_allowed || false; + balanceObj.required = null; + return { + allowed, + balances: [balanceObj], + }; + } + + // Case 3: Overage allowed (usage allowed but not unlimited) + if (overage_allowed) { + balanceObj.balance = balance; + balanceObj.unlimited = false; + balanceObj.usage_allowed = true; + balanceObj.required = null; + return { + allowed, + balances: [balanceObj], + }; + } + + // Case 4: Metered features with balance tracking + balanceObj.required = required_balance ?? null; + balanceObj.balance = balance; + + return { + allowed, + balances: [balanceObj], + }; + }, +}); diff --git a/shared/api/balances/check/previousVersions/CheckResponseV0.ts b/shared/api/balances/check/previousVersions/CheckResponseV0.ts new file mode 100644 index 000000000..b47cd4dd8 --- /dev/null +++ b/shared/api/balances/check/previousVersions/CheckResponseV0.ts @@ -0,0 +1,20 @@ +import { z } from "zod/v4"; + +export const CheckResponseV0Schema = z.object({ + allowed: z.boolean(), + balances: z.array( + z.object({ + feature_id: z.string(), + required: z.number().nullish(), + balance: z.number().nullish(), + unlimited: z.boolean().optional(), + usage_allowed: z.boolean().optional(), + // unlimited: z.boolean().nullish(), + // interval: z.enum(EntInterval).nullish(), + // balance: z.number().nullish(), + // used: z.number().nullish(), + }), + ), +}); + +export type CheckResponseV0 = z.infer; diff --git a/shared/api/balances/updateBalanceModels.ts b/shared/api/balances/updateBalanceModels.ts new file mode 100644 index 000000000..a630714c5 --- /dev/null +++ b/shared/api/balances/updateBalanceModels.ts @@ -0,0 +1,25 @@ +import { z } from "zod/v4"; + +export const UpdateBalancesParamsSchema = z + .object({ + balances: z.array( + z.object({ + feature_id: z.string().meta({ + description: "The ID of the feature to update balance for.", + }), + balance: z.number().meta({ + description: "The new balance value.", + }), + }), + ), + }) + .meta({ + example: { + balances: [ + { + feature_id: "tokens", + balance: 1000, + }, + ], + }, + }); diff --git a/shared/api/balances/usageModels.ts b/shared/api/balances/usageModels.ts new file mode 100644 index 000000000..108c03c23 --- /dev/null +++ b/shared/api/balances/usageModels.ts @@ -0,0 +1,22 @@ +import { z } from "zod/v4"; +import { CustomerDataSchema } from "../common/customerData.js"; +export const SetUsageParamsSchema = z.object({ + customer_id: z.string().nonempty().meta({ + description: "The ID of the customer.", + }), + + feature_id: z.string().meta({ + description: "The ID of the feature to set usage for.", + }), + + value: z.number().meta({ + description: + "The value you want to set this customer's usage of the feature to.", + }), + + entity_id: z.string().optional().meta({ + description: "The ID of the entity to set usage for.", + }), + + customer_data: CustomerDataSchema.optional(), +}); diff --git a/shared/api/common/customerData.ts b/shared/api/common/customerData.ts index 28811e060..fbf050261 100644 --- a/shared/api/common/customerData.ts +++ b/shared/api/common/customerData.ts @@ -2,31 +2,29 @@ import { z } from "zod/v4"; // Base schema without top-level .meta() to avoid side effects during imports // Individual field descriptions are kept as they don't cause registry conflicts -export const CustomerDataSchema = z.object({ - name: z.string().nullish().meta({ - description: "Customer's name", - example: "John Doe", - }), - email: z.string().nullish().meta({ - description: "Customer's email address", - example: "john@example.com", - }), - fingerprint: z.string().nullish().meta({ - description: - "Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse", - example: "fp_123abc", - }), - metadata: z - .record(z.any(), z.any()) - .nullish() - .meta({ - description: "Additional metadata for the customer", - example: { company: "Acme Inc" }, +export const CustomerDataSchema = z + .object({ + name: z.string().nullish().meta({ + description: "Customer's name", }), - stripe_id: z.string().nullish().meta({ - description: "Stripe customer ID if you already have one", - example: "cus_stripe123", - }), -}); + email: z.string().nullish().meta({ + description: "Customer's email address", + }), + fingerprint: z.string().nullish().meta({ + description: + "Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse", + }), + metadata: z.record(z.any(), z.any()).nullish().meta({ + description: "Additional metadata for the customer", + }), + stripe_id: z.string().nullish().meta({ + description: "Stripe customer ID if you already have one", + }), + }) + .meta({ + id: "CustomerData", + description: + "Used to add customer details like name or email when auto-creating a customer.", + }); export type CustomerData = z.infer; diff --git a/shared/api/common/jsDocs.ts b/shared/api/common/jsDocs.ts new file mode 100644 index 000000000..cf4193f43 --- /dev/null +++ b/shared/api/common/jsDocs.ts @@ -0,0 +1,229 @@ +import { ExtAttachBodySchema, ExtCheckoutParamsSchema } from "@api/models.js"; +import { + createJSDocDescription, + docLink, + example, +} from "@api/utils/openApiHelpers.js"; +import { SetUsageParamsSchema } from "../balances/usageModels.js"; +import { CheckParamsSchema } from "../core/checkModels.js"; +import { + BillingPortalParamsSchema, + CancelBodySchema, + QueryParamsSchema, + SetupPaymentParamsSchema, + TrackParamsSchema, +} from "../core/coreOpModels.js"; + +/** + * Centralized JSDoc declarations for all core API methods. + * These are used by the OpenAPI spec generator and propagate to SDK documentation. + */ + +export const attachJsDoc = createJSDocDescription({ + description: + "Enables a product for a customer and processes payment if their payment method is already on file.", + whenToUse: + "Use this when the customer already has a payment method saved. For new customers without payment info, use `checkout` instead.", + body: ExtAttachBodySchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + product_id: "pro_plan", + }, + description: "Attach a product to a customer", + }), + ], + methodName: "attach", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/attach", + title: "Product Attachments", + }), + ], +}); + +export const checkoutJsDoc = createJSDocDescription({ + description: + "Creates a checkout session for a customer to purchase a product with payment collection.", + whenToUse: + "Use this for new customers or when payment info is needed. For customers with existing payment methods, use `attach` instead.", + body: ExtCheckoutParamsSchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + product_id: "pro_plan", + }, + description: "Create a checkout session", + }), + ], + methodName: "checkout", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/checkout", + title: "Checkout Sessions", + }), + ], +}); + +export const checkJsDoc = createJSDocDescription({ + description: + "Check whether a customer has access to a product, feature or remaining usage.", + body: CheckParamsSchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + feature_id: "api_calls", + }, + description: "Check feature access", + }), + ], + methodName: "check", +}); + +export const trackJsDoc = createJSDocDescription({ + description: + "Track usage events for metered features or record analytics events.", + whenToUse: + "Use this to increment usage counters for pay-as-you-go features or track customer activity.", + body: TrackParamsSchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + feature_id: "api_calls", + value: 1, + }, + description: "Track a usage event", + }), + ], + methodName: "track", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/track", + title: "Usage Tracking", + }), + ], +}); + +export const cancelJsDoc = createJSDocDescription({ + description: "Cancel a customer's subscription to a product.", + whenToUse: + "Use this when a customer wants to stop their subscription. Supports immediate or end-of-period cancellation.", + body: CancelBodySchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + product_id: "pro_plan", + }, + description: "Cancel a subscription", + }), + ], + methodName: "cancel", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/cancel", + title: "Cancel Subscriptions", + }), + ], +}); + +export const setupPaymentJsDoc = createJSDocDescription({ + description: + "Creates a session for a customer to add or update their payment method.", + whenToUse: + "Use this to collect payment information without immediately charging the customer.", + body: SetupPaymentParamsSchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + success_url: "https://example.com/success", + }, + description: "Setup payment method", + }), + ], + methodName: "setupPayment", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/setup-payment", + title: "Payment Setup", + }), + ], +}); + +export const billingPortalJsDoc = createJSDocDescription({ + description: + "Creates a billing portal session where customers can manage their subscription and payment methods.", + whenToUse: + "Use this to give customers self-service access to view invoices, update payment info, and manage subscriptions.", + body: BillingPortalParamsSchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + return_url: "https://example.com/account", + }, + description: "Open billing portal", + }), + ], + methodName: "billingPortal", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/billing-portal", + title: "Billing Portal", + }), + ], +}); + +export const queryJsDoc = createJSDocDescription({ + description: + "Query usage analytics for a customer's features over a specified time range.", + whenToUse: + "Use this to retrieve historical usage data for dashboards, reports, or usage displays.", + body: QueryParamsSchema, + examples: [ + example({ + values: { + customer_id: "cus_123", + feature_id: "api_calls", + range: "7d", + }, + description: "Query 7-day usage", + }), + ], + methodName: "query", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/query", + title: "Analytics Queries", + }), + ], +}); + +export const setUsageJsDoc = createJSDocDescription({ + description: + "Set usage for a feature. This is similar to /track instead of incrementing usage, it sets the usage value to exactly what is provided.", + whenToUse: "Use this to set usage for a feature instead of incrementing it.", + body: SetUsageParamsSchema, + examples: [ + example({ + values: { + customer_id: "123", + feature_id: "api_calls", + value: 10000, + }, + description: "Set usage for a feature", + }), + ], + methodName: "usage", + docs: [ + docLink({ + url: "https://docs.useautumn.com/api-reference/core/usage", + title: "Set Usage", + }), + ], +}); diff --git a/shared/api/core/attachModels.ts b/shared/api/core/attachModels.ts index d73f360c5..37a71dfff 100644 --- a/shared/api/core/attachModels.ts +++ b/shared/api/core/attachModels.ts @@ -13,37 +13,87 @@ export const ProductOptions = z.object({ options: z.array(FeatureOptionsSchema).nullish(), }); -export const ExtAttachBodySchema = z.object({ - // Customer / Entity Info +export const ExtAttachBodySchema = z + .object({ + // Customer / Entity Info + customer_id: z.string().describe("Your unique identifier for the customer"), + // Product Info + product_id: z + .string() + .nullish() + .describe( + "Product ID, set when creating the product in the Autumn dashboard", + ), - customer_id: z - .string() - .describe("ID of the customer to attach the product to"), + entity_id: z + .string() + .nullish() + .describe( + "If attaching a product to an entity, can be used to auto create the entity", + ), - customer_data: CustomerDataSchema.optional().describe( - "Customer data if using attach to auto create customer", - ), + customer_data: CustomerDataSchema.optional().describe( + "If auto creating a customer, the properties from this field will be used.", + ), - entity_id: z.string().nullish(), - entity_data: EntityDataSchema.optional(), + entity_data: EntityDataSchema.optional().describe( + "If attaching a product to an entity and auto creating the entity, the properties from this field will be used. feature_id is required.", + ), - // Product Info - product_id: z.string().nullish(), - product_ids: z.array(z.string()).min(1).nullish(), - options: z.array(FeatureOptionsSchema).nullish(), - free_trial: z.boolean().optional(), + product_ids: z + .array(z.string()) + .min(1) + .nullish() + .describe( + "Can be used to attach multiple products to the customer at once. For example, attaching a main product and an add-on.", + ), - // Others - success_url: z.string().optional(), - force_checkout: z.boolean().optional(), - checkout_session_params: z.any().optional(), - reward: z.string().or(z.array(z.string())).optional(), - invoice: z.boolean().optional(), + options: z + .array(FeatureOptionsSchema) + .nullish() + .describe("Pass in quantities for prepaid features"), - // Checkout params - skip_checkout: z.boolean().optional(), - setup_payment: z.boolean().optional(), -}); + free_trial: z + .boolean() + .optional() + .describe( + "If the product has a free trial, this field can be used to disable it when attaching (by passing in false)", + ), + + // Others + success_url: z + .string() + .optional() + .describe("URL to redirect to after the purchase is successful"), + force_checkout: z + .boolean() + .optional() + .describe( + "Always return a Stripe Checkout URL, even if the customer's card is already on file", + ), + checkout_session_params: z + .any() + .optional() + .describe( + "Additional parameters to pass onto Stripe when creating the checkout session", + ), + reward: z + .string() + .or(z.array(z.string())) + .optional() + .describe("An Autumn promo_code or reward_id to apply at checkout"), + invoice: z.boolean().optional(), + + // Checkout params + // skip_checkout: z.boolean().optional(), + setup_payment: z.boolean().optional(), + }) + .meta({ + example: { + customer_id: "cus_123", + product_id: "pro", + }, + }); export const AttachBodySchema = ExtAttachBodySchema.extend({ products: z.array(ProductOptions).nullish(), diff --git a/shared/api/core/checkModels.ts b/shared/api/core/checkModels.ts index 898903b5e..7ff5e7c72 100644 --- a/shared/api/core/checkModels.ts +++ b/shared/api/core/checkModels.ts @@ -113,10 +113,13 @@ export const CheckResultSchema = z description: "The ID of the entity (if provided)", example: "entity_123", }), - required_balance: z.number().meta({ - description: "The required balance for this check", - example: 1, - }), + required_balance: z + .number() + .meta({ + description: "The required balance for this check", + example: 1, + }) + .optional(), // not present for boolean features code: z.string().meta({ description: "Response code indicating the result", example: "allowed", diff --git a/shared/api/core/checkoutModels.ts b/shared/api/core/checkoutModels.ts index 4440250cf..716b5bb05 100644 --- a/shared/api/core/checkoutModels.ts +++ b/shared/api/core/checkoutModels.ts @@ -6,10 +6,13 @@ import { AttachBodySchema, ExtAttachBodySchema } from "./attachModels.js"; export const ExtCheckoutParamsSchema = ExtAttachBodySchema.extend({ setup_payment: z.boolean().optional(), +}).meta({ + description: + "Returns a Stripe Checkout URL for the customer to make a payment, or returns payment confirmation information.", }); export const CheckoutParamsSchema = AttachBodySchema.extend({ - skip_checkout: z.boolean().optional(), + // skip_checkout: z.boolean().optional(), setup_payment: z.boolean().optional(), }); @@ -32,8 +35,8 @@ export const CheckoutResponseSchema = z.object({ has_prorations: z.boolean().nullish(), next_cycle: z .object({ - starts_at: z.number().nullish(), - total: z.number().nullish(), + starts_at: z.number(), + total: z.number(), }) .nullish(), }); diff --git a/shared/api/core/coreOpModels.ts b/shared/api/core/coreOpModels.ts index 06b84ca3b..af0f81419 100644 --- a/shared/api/core/coreOpModels.ts +++ b/shared/api/core/coreOpModels.ts @@ -121,56 +121,76 @@ export const TrackResultSchema = z.object({ }); // Query Schemas -export const QueryParamsSchema = z.object({ - customer_id: z.string().meta({ - description: "The ID of the customer to query analytics for", - example: "cus_123", - }), - feature_id: z.union([z.string(), z.array(z.string())]).meta({ - description: "The feature ID(s) to query", - example: "api_calls", - }), - range: z - .enum(["24h", "7d", "30d", "90d", "last_cycle"] as const) - .nullish() - .meta({ - description: - "Time range for the query (defaults to last_cycle if not provided)", - example: "7d", +export const QueryParamsSchema = z + .object({ + customer_id: z.string().meta({ + description: "The ID of the customer to query analytics for", + example: "cus_123", }), -}); + feature_id: z.union([z.string(), z.array(z.string())]).meta({ + description: "The feature ID(s) to query", + example: "api_calls", + }), + range: z + .enum(["24h", "7d", "30d", "90d", "last_cycle"] as const) + .nullish() + .meta({ + description: + "Time range for the query (defaults to last_cycle if not provided)", + example: "7d", + }), + }) + .meta({ + id: "QueryParams", + description: "Parameters for querying analytics data", + }); -export const QueryResultSchema = z.object({ - list: z.array(z.any()).meta({ - description: "List of usage data points", - example: [{ period: 1717000000000, count: 100 }], - }), -}); +export const QueryResultSchema = z + .object({ + list: z.array(z.any()).meta({ + description: "List of usage data points", + example: [{ period: 1717000000000, count: 100 }], + }), + }) + .meta({ + id: "QueryResult", + description: "Result of an analytics query", + }); -export const SetupPaymentParamsSchema = z.object({ - customer_id: z.string().meta({ - description: "The ID of the customer", - example: "cus_123", - }), - success_url: z.string().optional().meta({ - description: "URL to redirect to after successful payment setup", - example: "https://example.com/success", - }), - checkout_session_params: z.record(z.any(), z.any()).optional().meta({ - description: "Additional parameters for the checkout session", - }), -}); +export const SetupPaymentParamsSchema = z + .object({ + customer_id: z.string().meta({ + description: "The ID of the customer", + example: "cus_123", + }), + success_url: z.string().optional().meta({ + description: "URL to redirect to after successful payment setup", + example: "https://example.com/success", + }), + checkout_session_params: z.record(z.string(), z.unknown()).optional().meta({ + description: "Additional parameters for the checkout session", + }), + }) + .meta({ + id: "SetupPaymentParams", + description: "Parameters for setting up a payment method", + }); -export const SetupPaymentResultSchema = z.object({ - customer_id: z.string().meta({ - description: "The ID of the customer", - example: "cus_123", - }), - url: z.string().meta({ - description: "URL to the payment setup page", - example: "https://checkout.stripe.com/...", - }), -}); +export const SetupPaymentResultSchema = z + .object({ + customer_id: z.string().meta({ + description: "The ID of the customer", + example: "cus_123", + }), + url: z.string().meta({ + description: "URL to the payment setup page", + example: "https://checkout.stripe.com/...", + }), + }) + .meta({ + id: "SetupPaymentResult", + description: "Result of setting up a payment method", + }); export const BillingPortalParamsSchema = z.object({ customer_id: z.string().meta({ @@ -179,8 +199,8 @@ export const BillingPortalParamsSchema = z.object({ }), return_url: z.string().optional().meta({ description: - "URL to return to after exiting the billing portal. Must include http:// or https://", - example: "https://example.com/dashboard", + "Time range for the query (defaults to last_cycle if not provided)", + example: "7d", }), }); diff --git a/shared/api/core/coreOpenApi.ts b/shared/api/core/coreOpenApi.ts index ed7c23ac9..296a36c82 100644 --- a/shared/api/core/coreOpenApi.ts +++ b/shared/api/core/coreOpenApi.ts @@ -4,6 +4,20 @@ import { ExtAttachBodySchema, ExtCheckoutParamsSchema, } from "@api/models.js"; +import type { ZodOpenApiPathsObject } from "zod-openapi"; +import { SetUsageParamsSchema } from "../balances/usageModels.js"; +import { SuccessResponseSchema } from "../common/commonResponses.js"; +import { + attachJsDoc, + billingPortalJsDoc, + cancelJsDoc, + checkJsDoc, + checkoutJsDoc, + queryJsDoc, + setUsageJsDoc, + setupPaymentJsDoc, + trackJsDoc, +} from "../common/jsDocs.js"; import { CheckParamsSchema, CheckResultSchema } from "./checkModels.js"; import { BillingPortalParamsSchema, @@ -18,20 +32,39 @@ import { TrackResultSchema, } from "./coreOpModels.js"; -export const coreOps = { +export const coreOps: ZodOpenApiPathsObject = { "/attach": { post: { summary: "Attach Product", + description: attachJsDoc, + tags: ["core"], requestBody: { content: { - "application/json": { schema: ExtAttachBodySchema }, + "application/json": { + schema: ExtAttachBodySchema, + examples: { + basic: { + summary: "Attach a product immediately", + description: + "Enable a product for a customer with immediate activation", + value: { + customer_id: "cus_123", + product_id: "pro_plan", + }, + }, + }, + }, }, }, responses: { "200": { - description: "200 OK", - content: { "application/json": { schema: AttachResultSchema } }, + description: "Product attached successfully", + content: { + "application/json": { + schema: AttachResultSchema, + }, + }, }, }, }, @@ -39,6 +72,7 @@ export const coreOps = { "/checkout": { post: { summary: "Checkout", + description: checkoutJsDoc, tags: ["core"], requestBody: { content: { "application/json": { schema: ExtCheckoutParamsSchema } }, @@ -54,6 +88,7 @@ export const coreOps = { "/cancel": { post: { summary: "Cancel Product", + description: cancelJsDoc, tags: ["core"], requestBody: { content: { @@ -71,6 +106,7 @@ export const coreOps = { "/track": { post: { summary: "Track Event", + description: trackJsDoc, tags: ["core"], requestBody: { content: { @@ -85,9 +121,11 @@ export const coreOps = { }, }, }, + "/query": { post: { summary: "Query Analytics", + description: queryJsDoc, tags: ["core"], requestBody: { content: { @@ -105,6 +143,7 @@ export const coreOps = { "/check": { post: { summary: "Check Feature Access", + description: checkJsDoc, tags: ["core"], requestBody: { content: { @@ -122,6 +161,7 @@ export const coreOps = { "/setup_payment": { post: { summary: "Setup Payment Method", + description: setupPaymentJsDoc, tags: ["core"], requestBody: { content: { @@ -139,6 +179,7 @@ export const coreOps = { "/billing_portal": { post: { summary: "Create Billing Portal Session", + description: billingPortalJsDoc, tags: ["core"], requestBody: { content: { @@ -155,4 +196,23 @@ export const coreOps = { }, }, }, + + "/usage": { + post: { + summary: "Set Usage", + description: setUsageJsDoc, + tags: ["core"], + requestBody: { + content: { + "application/json": { schema: SetUsageParamsSchema }, + }, + }, + responses: { + "200": { + description: "200 OK", + content: { "application/json": { schema: SuccessResponseSchema } }, + }, + }, + }, + }, }; diff --git a/shared/api/customers/apiCustomer.ts b/shared/api/customers/apiCustomer.ts index a170523c7..6ef7f6a1c 100644 --- a/shared/api/customers/apiCustomer.ts +++ b/shared/api/customers/apiCustomer.ts @@ -1,12 +1,12 @@ import { ApiCusReferralSchema } from "@api/customers/components/apiCusReferral.js"; import { ApiCusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js"; -import { ApiCusProductSchema } from "@api/customers/cusProducts/apiCusProduct.js"; import { ApiBaseEntitySchema } from "@api/entities/apiEntity.js"; import { ApiCusRewardsSchema } from "@api/others/apiDiscount.js"; import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; import { ApiCusUpcomingInvoiceSchema } from "./components/apiCusUpcomingInvoice.js"; +import { ApiCusProductSchema } from "./cusProducts/apiCusProduct.js"; export const ApiTrialsUsedSchema = z.object({ product_id: z.string(), @@ -30,23 +30,40 @@ export const ApiCustomerSchema = z.object({ description: "Your internal ID for the customer", example: "cus_123", }), - created_at: z.number().meta({ - description: - "The date and time the customer was created in milliseconds since epoch", + description: "Timestamp of customer creation in milliseconds since epoch", example: 1717000000, }), - - name: z.string().nullable(), - email: z.string().nullable(), - fingerprint: z.string().nullable(), - stripe_id: z.string().nullable().default(null), - env: z.enum(AppEnv), + name: z.string().nullable().meta({ + description: "Customerโ€™s name", + example: "John Doe", + }), + email: z.string().nullable().meta({ + description: "Customerโ€™s email address", + example: "john@doe.com", + }), + fingerprint: z.string().nullable().meta({ + description: + "Unique identifier (eg. serial number) to detect duplicate customers and prevent key leaks", + example: "fp_9184And92839123hda", + }), + stripe_id: z.string().nullable().default(null).meta({ + description: "Stripe customer ID", + example: "cus_J8A5c31A8tlpwN", + }), + env: z.enum(AppEnv).meta({ + description: "Environment the customer is in", + example: "production", + }), metadata: z.record(z.any(), z.any()).default({}), - - products: z.array(ApiCusProductSchema), - features: z.record(z.string(), ApiCusFeatureSchema), - + products: z.array(ApiCusProductSchema).meta({ + description: "List of products the customer has access to", + example: [], + }), + features: z.record(z.string(), ApiCusFeatureSchema).meta({ + description: "List of features the customer has access to", + example: {}, + }), ...ApiCusExpandSchema.shape, }); diff --git a/shared/api/customers/changes/V0_1_CustomerChange.ts b/shared/api/customers/changes/V0_1_CustomerChange.ts index f65e33d27..ca7d79330 100644 --- a/shared/api/customers/changes/V0_1_CustomerChange.ts +++ b/shared/api/customers/changes/V0_1_CustomerChange.ts @@ -31,6 +31,7 @@ import { ApiCustomerV1Schema } from "../previousVersions/apiCustomerV1.js"; */ export const V0_1_CustomerChange = defineVersionChange({ + name: "V0_1_CustomerChange", oldVersion: ApiVersion.V0_1, // Applied when targetVersion <= V0_1 newVersion: ApiVersion.V0_2, // Breaking change introduced in V0_2 description: [ diff --git a/shared/api/customers/changes/V0_2_CustomerChange.ts b/shared/api/customers/changes/V0_2_CustomerChange.ts index 24d8e84df..d394295c0 100644 --- a/shared/api/customers/changes/V0_2_CustomerChange.ts +++ b/shared/api/customers/changes/V0_2_CustomerChange.ts @@ -37,6 +37,7 @@ import { ApiCustomerV2Schema } from "../previousVersions/apiCustomerV2.js"; */ export const V0_2_CustomerChange = defineVersionChange({ + name: "V0_2_CustomerChange", newVersion: ApiVersion.V1_1, // Breaking change introduced in V1_1 oldVersion: ApiVersion.V0_2, // Applied when targetVersion <= V0_2 description: [ diff --git a/shared/api/customers/changes/V0_2_InvoicesAlwaysExpanded.ts b/shared/api/customers/changes/V0_2_InvoicesAlwaysExpanded.ts index 8b2fa5e31..3869e74e7 100644 --- a/shared/api/customers/changes/V0_2_InvoicesAlwaysExpanded.ts +++ b/shared/api/customers/changes/V0_2_InvoicesAlwaysExpanded.ts @@ -24,6 +24,7 @@ export class V0_2_InvoicesAlwaysExpanded extends VersionChange< typeof NoOpSchema, typeof NoOpSchema > { + readonly name = "V0_2_InvoicesAlwaysExpanded"; readonly newVersion = ApiVersion.V1_1; // Breaking change introduced in V1_1 readonly oldVersion = ApiVersion.V0_2; // Applied when targetVersion <= V0_2 readonly description = diff --git a/shared/api/customers/changes/V1_1_FeaturesArrayToObject.ts b/shared/api/customers/changes/V1_1_FeaturesArrayToObject.ts index 12bdf8497..5a7c2f8b4 100644 --- a/shared/api/customers/changes/V1_1_FeaturesArrayToObject.ts +++ b/shared/api/customers/changes/V1_1_FeaturesArrayToObject.ts @@ -32,6 +32,7 @@ const V1_1_CustomerSchema = ApiCustomerSchema.extend({ }); export const V1_1_FeaturesArrayToObject = defineVersionChange({ + name: "V1_1_FeaturesArrayToObject", newVersion: ApiVersion.V1_2, oldVersion: ApiVersion.V1_1, diff --git a/shared/api/customers/cusFeatures/apiCusFeature.ts b/shared/api/customers/cusFeatures/apiCusFeature.ts index fa8ec63f2..e925d2183 100644 --- a/shared/api/customers/cusFeatures/apiCusFeature.ts +++ b/shared/api/customers/cusFeatures/apiCusFeature.ts @@ -3,51 +3,133 @@ import { EntInterval } from "@models/productModels/entModels/entEnums.js"; import { z } from "zod/v4"; export const ApiCusRolloverSchema = z.object({ - balance: z.number(), - expires_at: z.number(), + balance: z.number().meta({ + description: "The remaining balance amount that has rolled over", + example: 100, + }), + expires_at: z.number().meta({ + description: "Timestamp when the rollover balance expires", + example: 1759247877000, + }), }); // Version 3 of cus feature response export const ApiCusFeatureBreakdownSchema = z.object({ - interval: z.enum(EntInterval), - interval_count: z.number().nullish(), - balance: z.number().nullish(), - usage: z.number().nullish(), - included_usage: z.number().nullish(), - next_reset_at: z.number().nullish(), - usage_limit: z.number().nullish(), - rollovers: z.array(ApiCusRolloverSchema).nullish(), + interval: z.enum(EntInterval).meta({ + description: "The billing interval for this feature breakdown", + example: "month", + }), + interval_count: z.number().nullish().meta({ + description: "The number of intervals between resets", + example: 1, + }), + balance: z.number().nullish().meta({ + description: "The remaining balance for this interval", + example: 500, + }), + usage: z.number().nullish().meta({ + description: "The current usage amount", + example: 250, + }), + included_usage: z.number().nullish().meta({ + description: "The amount of usage included in this interval", + example: 1000, + }), + next_reset_at: z.number().nullish().meta({ + description: "Timestamp when the usage resets", + example: 1759247877000, + }), + usage_limit: z.number().nullish().meta({ + description: "The maximum usage allowed", + example: 1000, + }), + rollovers: z.array(ApiCusRolloverSchema).nullish().meta({ + description: "Array of rollover balances from previous periods", + example: [{ balance: 100, expires_at: 1759247877000 }], + }), }); export const CoreCusFeatureSchema = z.object({ - interval: z.enum(EntInterval).or(z.literal("multiple")).nullish(), - interval_count: z.number().nullish(), - unlimited: z.boolean().nullish(), - balance: z.number().nullish(), - usage: z.number().nullish(), - included_usage: z.number().nullish(), - next_reset_at: z.number().nullish(), - overage_allowed: z.boolean().nullish(), + interval: z.enum(EntInterval).or(z.literal("multiple")).nullish().meta({ + description: "The billing interval or 'multiple' if the feature has multiple intervals", + example: "month", + }), + interval_count: z.number().nullish().meta({ + description: "The number of intervals between resets", + example: 1, + }), + unlimited: z.boolean().nullish().meta({ + description: "Whether the feature has unlimited usage", + example: false, + }), + balance: z.number().nullish().meta({ + description: "The remaining balance for this feature", + example: 500, + }), + usage: z.number().nullish().meta({ + description: "The current usage amount", + example: 250, + }), + included_usage: z.number().nullish().meta({ + description: "The amount of usage included", + example: 1000, + }), + next_reset_at: z.number().nullish().meta({ + description: "Timestamp when the usage resets", + example: 1759247877000, + }), + overage_allowed: z.boolean().nullish().meta({ + description: "Whether overage usage beyond the limit is allowed", + example: true, + }), - breakdown: z.array(ApiCusFeatureBreakdownSchema).nullish(), + breakdown: z.array(ApiCusFeatureBreakdownSchema).nullish().meta({ + description: "Detailed breakdown by interval for features with multiple intervals", + example: [{ interval: "month", interval_count: 1, balance: 500, usage: 250 }], + }), credit_schema: z .array( z.object({ - feature_id: z.string(), - credit_amount: z.number(), + feature_id: z.string().meta({ + description: "The ID of the feature that credits are applied to", + example: "feature_123", + }), + credit_amount: z.number().meta({ + description: "The amount of credits applied per usage", + example: 10, + }), }), ) - .nullish(), + .nullish() + .meta({ + description: "Credit conversion schema for credit system features", + example: [{ feature_id: "feature_123", credit_amount: 10 }], + }), - usage_limit: z.number().nullish(), - rollovers: z.array(ApiCusRolloverSchema).nullish(), + usage_limit: z.number().nullish().meta({ + description: "The maximum usage allowed", + example: 1000, + }), + rollovers: z.array(ApiCusRolloverSchema).nullish().meta({ + description: "Array of rollover balances from previous periods", + example: [{ balance: 100, expires_at: 1759247877000 }], + }), }); export const ApiCusFeatureSchema = z .object({ - id: z.string(), - type: z.enum(ApiFeatureType), - name: z.string().nullish(), + id: z.string().meta({ + description: "The unique identifier of the feature", + example: "feature_123", + }), + type: z.enum(ApiFeatureType).meta({ + description: "The type of the feature", + example: "single_use", + }), + name: z.string().nullish().meta({ + description: "The name of the feature", + example: "API Calls", + }), }) .extend(CoreCusFeatureSchema.shape); diff --git a/shared/api/customers/cusProducts/apiCusProduct.ts b/shared/api/customers/cusProducts/apiCusProduct.ts index e75b8c2d5..e038a1ba6 100644 --- a/shared/api/customers/cusProducts/apiCusProduct.ts +++ b/shared/api/customers/cusProducts/apiCusProduct.ts @@ -1,27 +1,101 @@ import { ApiProductItemSchema } from "@api/products/apiProductItem.js"; import { z } from "zod/v4"; -export const ApiCusProductSchema = z.object({ - id: z.string(), - name: z.string().nullable(), - group: z.string().nullable(), - status: z.enum(["active", "expired", "scheduled", "trialing", "past_due"]), - - canceled_at: z.number().nullish(), - started_at: z.number(), - is_default: z.boolean(), - is_add_on: z.boolean(), - version: z.number().nullish(), - - stripe_subscription_ids: z.array(z.string()).nullish(), - current_period_start: z.number().nullish(), - current_period_end: z.number().nullish(), - - entity_id: z.string().nullish(), - - items: z.array(ApiProductItemSchema).nullish(), - - quantity: z.number().optional(), -}); +export const ApiCusProductSchema = z + .object({ + id: z.string().meta({ + description: "The unique identifier for the product", + example: "pro_plan", + }), + name: z.string().nullable().meta({ + description: "The name of the product", + example: "Pro Plan", + }), + group: z.string().nullable().meta({ + description: "The group the product belongs to", + example: "product_set_1", + }), + status: z + .enum(["active", "expired", "scheduled", "trialing", "past_due"]) + .meta({ + description: "Current status of the product for this customer", + example: "active", + }), + canceled_at: z.number().nullish().meta({ + description: "Timestamp when the product was canceled for the customer", + example: 1717000000, + }), + started_at: z.number().meta({ + description: "Timestamp when the customer started this product", + example: 1700000000000, + }), + is_default: z.boolean().meta({ + description: "Whether this product is the default for the customer", + example: true, + }), + is_add_on: z.boolean().meta({ + description: "Whether the product is an add-on", + example: false, + }), + version: z.number().nullish().meta({ + description: "Version of the product", + example: 1, + }), + stripe_subscription_ids: z + .array(z.string()) + .nullish() + .meta({ + description: + "List of Stripe subscription IDs associated with this product, if any", + example: ["sub_1Nc0JzBAbcxyz", "sub_1Nc0xyBAnopq"], + }), + current_period_start: z.number().nullish().meta({ + description: "Start of the current billing period", + example: 1717000000, + }), + current_period_end: z.number().nullish().meta({ + description: "End of the current billing period", + example: 1719600000, + }), + entity_id: z.string().nullish().meta({ + description: + "ID of the entity this customer product is attached to, if applicable", + example: "entity_1234abcd", + }), + items: z + .array(ApiProductItemSchema) + .nullish() + .meta({ + description: "Array of product items defining the features and pricing", + example: [ + { + feature_id: "", + feature_type: "single_use", + included_usage: 123, + interval: "month", + usage_model: "prepaid", + price: 123, + billing_units: 1000, + entity_feature_id: "", + reset_usage_when_enabled: true, + tiers: [ + { + to: 100, + amount: 10, + }, + ], + }, + ], + }), + quantity: z.number().optional().meta({ + description: + "The number of units of this product held by the customer, if applicable", + example: 1, + }), + }) + .meta({ + id: "CustomerProduct", + description: "Customer product object returned by the API", + }); export type ApiCusProduct = z.infer; diff --git a/shared/api/customers/customersOpenApi.ts b/shared/api/customers/customersOpenApi.ts index f4f1104a7..925760566 100644 --- a/shared/api/customers/customersOpenApi.ts +++ b/shared/api/customers/customersOpenApi.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { UpdateBalancesParamsSchema } from "../balances/updateBalanceModels.js"; import { SuccessResponseSchema } from "../common/commonResponses.js"; import { ApiCustomerSchema } from "./apiCustomer.js"; import { @@ -10,7 +11,7 @@ import { } from "./customerOpModels.js"; // Register schema with .meta() for OpenAPI spec generation -const ApiCustomerWithMeta = ApiCustomerSchema.meta({ +export const ApiCustomerWithMeta = ApiCustomerSchema.meta({ id: "Customer", description: "Customer object returned by the API", }); @@ -114,4 +115,30 @@ export const customerOps = { }, }, }, + "/customers/{customer_id}/balances": { + post: { + summary: "Update Feature Balances", + description: + "Update or set feature balances for a customer. Can set specific balance values or make features unlimited.", + tags: ["customers"], + requestParams: { + path: z.object({ + customer_id: z.string(), + }), + }, + requestBody: { + content: { + "application/json": { schema: UpdateBalancesParamsSchema }, + }, + }, + responses: { + "200": { + description: "200 OK", + content: { + "application/json": { schema: SuccessResponseSchema }, + }, + }, + }, + }, + }, }; diff --git a/shared/api/entities/apiEntity.ts b/shared/api/entities/apiEntity.ts index d9b61816f..c416c9309 100644 --- a/shared/api/entities/apiEntity.ts +++ b/shared/api/entities/apiEntity.ts @@ -5,20 +5,43 @@ import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; export const ApiBaseEntitySchema = z.object({ - id: z.string().nullable(), - name: z.string().nullable(), - - customer_id: z.string().nullish(), + id: z.string().nullable().meta({ + description: "The unique identifier of the entity", + example: "", + }), + name: z.string().nullable().meta({ + description: "The name of the entity", + example: "", + }), + customer_id: z.string().nullish().meta({ + description: "The customer ID this entity belongs to", + example: "", + }), feature_id: z.string().nullish(), - - created_at: z.number(), - env: z.enum(AppEnv), + created_at: z.number().meta({ + description: "Unix timestamp when the entity was created", + example: 1686168121, + }), + env: z.enum(AppEnv).meta({ + description: "The environment (sandbox/live)", + example: "live", + }), }); export const ApiEntitySchema = ApiBaseEntitySchema.extend({ - products: z.array(ApiCusProductSchema).optional(), - features: z.record(z.string(), ApiCusFeatureSchema).optional(), - invoices: z.array(ApiInvoiceSchema).optional(), + products: z.array(ApiCusProductSchema).optional().meta({ + description: "Products associated with this entity", + example: [], + }), + features: z.record(z.string(), ApiCusFeatureSchema).optional().meta({ + description: "Features associated with this entity", + example: {}, + }), + invoices: z.array(ApiInvoiceSchema).optional().meta({ + description: + "Invoices for this entity (only included when expand=invoices)", + example: [], + }), }); export type EntityResponse = z.infer; diff --git a/shared/api/entities/entitiesOpenApi.ts b/shared/api/entities/entitiesOpenApi.ts index 1c82cdbb9..902cd8092 100644 --- a/shared/api/entities/entitiesOpenApi.ts +++ b/shared/api/entities/entitiesOpenApi.ts @@ -6,7 +6,7 @@ import { ApiEntitySchema } from "./apiEntity.js"; import { CreateEntityParamsSchema } from "./entityOpModels.js"; // Register schema with .meta() for OpenAPI spec generation -const ApiEntityWithMeta = ApiEntitySchema.meta({ +export const ApiEntityWithMeta = ApiEntitySchema.meta({ id: "Entity", description: "Entity object returned by the API", }); diff --git a/shared/api/features/apiFeature.ts b/shared/api/features/apiFeature.ts index 6be4e0e38..5e3f5ba2d 100644 --- a/shared/api/features/apiFeature.ts +++ b/shared/api/features/apiFeature.ts @@ -9,28 +9,64 @@ export enum ApiFeatureType { CreditSystem = "credit_system", } +export const FEATURE_EXAMPLE = { + id: "tokens", + name: "Tokens", + type: "single_use", + display: { + singular: "token", + plural: "tokens", + }, + credit_schema: null, + archived: false, +}; + // Base schema without .meta() to avoid side effects during imports export const ApiFeatureSchema = z.object({ - id: z.string(), - name: z.string().nullish(), - type: z.enum(ApiFeatureType), + id: z.string().meta({ + description: + "The ID of the feature, used to refer to it in other API calls like /track or /check.", + }), + name: z.string().nullish().meta({ + description: "The name of the feature.", + }), + type: z.enum(ApiFeatureType).meta({ + description: "The type of the feature", + }), display: z .object({ - singular: z.string(), - plural: z.string(), + singular: z.string().meta({ + description: "The singular display name for the feature.", + }), + plural: z.string().meta({ + description: "The plural display name for the feature.", + }), }) - .nullish(), + .nullish() + .meta({ + description: "Singular and plural display names for the feature.", + }), credit_schema: z .array( z.object({ - metered_feature_id: z.string(), - credit_cost: z.number(), + metered_feature_id: z.string().meta({ + description: + "The ID of the metered feature (should be a single_use feature).", + }), + credit_cost: z.number().meta({ + description: "The credit cost of the metered feature.", + }), }), ) - .nullish(), + .nullish() + .meta({ + description: "Credit cost schema for credit system features.", + }), - archived: z.boolean().nullish(), + archived: z.boolean().nullish().meta({ + description: "Whether or not the feature is archived.", + }), }); export type ApiFeature = z.infer; diff --git a/shared/api/features/featureOpModels.ts b/shared/api/features/featureOpModels.ts index 10b6c41c8..d2221be42 100644 --- a/shared/api/features/featureOpModels.ts +++ b/shared/api/features/featureOpModels.ts @@ -1,30 +1,33 @@ import { z } from "zod/v4"; import { ApiFeatureType } from "./apiFeature.js"; +const featureDescriptions = { + id: "The ID of the feature. This is used to refer to it in other API calls like /track or /check.", + name: "The name of the feature.", + type: "The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.", + display: + "Singular and plural display names for the feature in your user interface.", + + credit_schema: + "A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.", + + archived: + "Whether the feature is archived. Archived features are hidden from the dashboard and list features endpoint.", +}; + // Create Feature Params export const CreateFeatureParamsSchema = z.object({ - id: z.string().meta({ - description: "The ID of the feature", - example: "feature_123", - }), - name: z.string().nullish().meta({ - description: "The name of the feature", - example: "API Calls", - }), - type: z.enum(ApiFeatureType).meta({ - description: "The type of the feature", - example: "single_use", - }), + id: z.string().meta({ description: featureDescriptions.id }), + name: z.string().nullish().meta({ description: featureDescriptions.name }), + type: z.enum(ApiFeatureType).meta({ description: featureDescriptions.type }), display: z .object({ singular: z.string(), plural: z.string(), }) .nullish() - .meta({ - description: "Display names for the feature", - example: { singular: "API Call", plural: "API Calls" }, - }), + .meta({ description: featureDescriptions.display }), + credit_schema: z .array( z.object({ @@ -33,37 +36,25 @@ export const CreateFeatureParamsSchema = z.object({ }), ) .nullish() - .meta({ - description: - "Credit schema for credit system features (only applicable when type is credit_system)", - example: [{ metered_feature_id: "api_calls", credit_cost: 10 }], - }), + .meta({ description: featureDescriptions.credit_schema }), }); // Update Feature Params export const UpdateFeatureParamsSchema = z.object({ - id: z.string().optional().meta({ - description: "The ID of the feature", - example: "feature_123", - }), - name: z.string().optional().meta({ - description: "The name of the feature", - example: "API Calls", - }), - type: z.enum(ApiFeatureType).optional().meta({ - description: "The type of the feature", - example: "single_use", - }), + id: z.string().optional().meta({ description: featureDescriptions.id }), + name: z.string().optional().meta({ description: featureDescriptions.name }), + type: z + .enum(ApiFeatureType) + .optional() + .meta({ description: featureDescriptions.type }), display: z .object({ singular: z.string(), plural: z.string(), }) .optional() - .meta({ - description: "Display names for the feature", - example: { singular: "API Call", plural: "API Calls" }, - }), + .meta({ description: featureDescriptions.display }), + credit_schema: z .array( z.object({ @@ -72,15 +63,11 @@ export const UpdateFeatureParamsSchema = z.object({ }), ) .optional() - .meta({ - description: - "Credit schema for credit system features (only applicable when type is credit_system)", - example: [{ metered_feature_id: "api_calls", credit_cost: 10 }], - }), - archived: z.boolean().optional().meta({ - description: "Whether the feature is archived", - example: false, - }), + .meta({ description: featureDescriptions.credit_schema }), + archived: z + .boolean() + .optional() + .meta({ description: featureDescriptions.archived }), }); export type CreateFeatureParams = z.infer; diff --git a/shared/api/features/featuresOpenApi.ts b/shared/api/features/featuresOpenApi.ts index 135074bb8..342966e4b 100644 --- a/shared/api/features/featuresOpenApi.ts +++ b/shared/api/features/featuresOpenApi.ts @@ -3,16 +3,19 @@ import { getListResponseSchema, SuccessResponseSchema, } from "../common/commonResponses.js"; -import { ApiFeatureSchema } from "./apiFeature.js"; +import { ApiFeatureSchema, FEATURE_EXAMPLE } from "./apiFeature.js"; import { CreateFeatureParamsSchema, UpdateFeatureParamsSchema, } from "./featureOpModels.js"; // Register the schema with .meta() for OpenAPI spec generation -const ApiFeatureWithMeta = ApiFeatureSchema.meta({ +export const ApiFeatureWithMeta = ApiFeatureSchema.extend({ + type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]), +}).meta({ id: "Feature", - description: "Feature object returned by the API", + description: "", + example: FEATURE_EXAMPLE, }); export const featureOps = { @@ -20,14 +23,9 @@ export const featureOps = { get: { summary: "List Features", tags: ["features"], - requestParams: { - query: z.object({ - include_archived: z.boolean().optional(), - }), - }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: getListResponseSchema({ schema: ApiFeatureWithMeta }), @@ -46,24 +44,24 @@ export const featureOps = { }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: ApiFeatureWithMeta } }, }, }, }, }, - "/features/{featureId}": { + "/features/{feature_id}": { get: { summary: "Get Feature", tags: ["features"], requestParams: { path: z.object({ - featureId: z.string(), + feature_id: z.string(), }), }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: ApiFeatureWithMeta } }, }, }, @@ -73,7 +71,7 @@ export const featureOps = { tags: ["features"], requestParams: { path: z.object({ - featureId: z.string(), + feature_id: z.string(), }), }, requestBody: { @@ -83,7 +81,7 @@ export const featureOps = { }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: ApiFeatureWithMeta } }, }, }, @@ -93,12 +91,12 @@ export const featureOps = { tags: ["features"], requestParams: { path: z.object({ - featureId: z.string(), + feature_id: z.string(), }), }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: SuccessResponseSchema, diff --git a/shared/api/models.ts b/shared/api/models.ts index 48301d770..2a415c6c9 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -5,6 +5,10 @@ export * from "./core/checkoutModels.js"; // NOTE: coreOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation export * from "./core/coreOpModels.js"; +// Helpers +export * from "./utils/openApiHelpers.js"; +export * from "./utils/zodToJSDoc.js"; + // Customers export * from "./customers/apiCustomer.js"; @@ -37,12 +41,16 @@ export * from "./features/featureOpModels.js"; // Others export * from "./others/apiDiscount.js"; export * from "./others/apiInvoice.js"; - // Product export * from "./products/apiFreeTrial.js"; export * from "./products/apiProduct.js"; export * from "./products/apiProductItem.js"; export * from "./products/productOpModels.js"; +export * from "./products/productsOpenApi.js"; +// Referrals +export * from "./referrals/apiReferralCode.js"; +export * from "./referrals/referralOpModels.js"; +export * from "./referrals/referralsOpenApi.js"; // NOTE: productsOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation @@ -50,6 +58,8 @@ export * from "./products/productOpModels.js"; // export * from "./products/apiProduct.js"; // export * from "./products/apiProductItem.js"; +// Balances +export * from "./balances/check/previousVersions/CheckResponseV0.js"; // Errors export * from "./errors/index.js"; // Models diff --git a/shared/api/openApiHelpers.ts b/shared/api/openApiHelpers.ts deleted file mode 100644 index b40395442..000000000 --- a/shared/api/openApiHelpers.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Standard error responses for OpenAPI endpoints - * Use this to keep error responses consistent across all endpoints - */ -export const standardErrorResponses = { - "400": { - description: "Bad Request", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, - "401": { - description: "Unauthorized", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, - "404": { - description: "Not Found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, - "500": { - description: "Internal Server Error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/AutumnError" }, - }, - }, - }, -} as const; - -/** - * Merge standard error responses with custom success responses - */ -export function withErrorResponses>( - successResponses: T, -) { - return { - ...successResponses, - ...standardErrorResponses, - }; -} diff --git a/shared/api/openapi.ts b/shared/api/openapi.ts index 531aaf1d4..8b93bb967 100644 --- a/shared/api/openapi.ts +++ b/shared/api/openapi.ts @@ -1,99 +1,157 @@ -import "dotenv/config"; -import { writeFileSync } from "node:fs"; -import { AppEnv } from "@models/genModels/genEnums.js"; -import yaml from "yaml"; -import { z } from "zod/v4"; -import { createDocument } from "zod-openapi"; -import { CustomerDataSchema } from "./common/customerData.js"; -import { EntityDataSchema } from "./common/entityData.js"; -import { coreOps } from "./core/coreOpenApi.js"; -import { customerOps } from "./customers/customersOpenApi.js"; -import { entityOps } from "./entities/entitiesOpenApi.js"; -import { featureOps } from "./features/featuresOpenApi.js"; -import { productOps } from "./products/productsOpenApi.js"; +// import "dotenv/config"; +// import { execSync } from "node:child_process"; +// import { existsSync, writeFileSync } from "node:fs"; +// import yaml from "yaml"; +// import { createDocument } from "zod-openapi"; +// import { OPENAPI_1_2_0 } from "./_prevVersions/openapi1.2.0.js"; +// import { CustomerDataSchema } from "./common/customerData.js"; +// import { EntityDataSchema } from "./common/entityData.js"; +// import { coreOps } from "./core/coreOpenApi.js"; +// import { ApiCusFeatureSchema } from "./customers/cusFeatures/apiCusFeature.js"; +// import { ApiCusProductSchema } from "./customers/cusProducts/apiCusProduct.js"; +// import { +// ApiCustomerWithMeta, +// customerOps, +// } from "./customers/customersOpenApi.js"; +// import { ApiEntityWithMeta } from "./entities/entitiesOpenApi.js"; +// import { ApiFeatureWithMeta, featureOps } from "./features/featuresOpenApi.js"; +// import { ApiProductItemSchema } from "./products/apiProductItem.js"; +// import { ApiProductWithMeta, productOps } from "./products/productsOpenApi.js"; -const API_VERSION = "1.2.0"; +// const API_VERSION = "1.2.0"; -const document = createDocument({ - openapi: "3.1.0", - info: { - title: "Autumn API", - version: API_VERSION, - }, +// const document = createDocument( +// { +// openapi: "3.1.0", +// info: { +// title: "Autumn API", +// version: API_VERSION, +// }, - servers: [ - { - url: "https://api.useautumn.com", - description: "Production server", - }, - ], +// servers: [ +// { +// url: "https://api.useautumn.com", +// description: "Production server", +// }, +// ], - security: [ - { - secretKey: [], - }, - ], - components: { - schemas: { - autumnError: z - .object({ - message: z.string(), - code: z.string(), - env: z.enum(AppEnv), - }) - .meta({ - id: "AutumnError", - description: "An error that occurred in the API", - }), - customerData: CustomerDataSchema.meta({ - id: "CustomerData", - description: "Customer data for creating or updating a customer", - }), - entityData: EntityDataSchema.meta({ - id: "EntityData", - description: "Entity data for creating an entity", - }), - }, - securitySchemes: { - secretKey: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - }, - }, - }, +// security: [ +// { +// secretKey: [], +// }, +// ], +// components: { +// schemas: { +// CustomerData: CustomerDataSchema, +// EntityData: EntityDataSchema.meta({ +// id: "EntityData", +// description: "Entity data for creating an entity", +// }), +// Customer: ApiCustomerWithMeta, +// CustomerProduct: ApiCusProductSchema, +// CustomerFeature: ApiCusFeatureSchema.meta({ +// id: "CustomerFeature", +// description: "Customer feature object returned by the API", +// }), +// Product: ApiProductWithMeta, +// ProductItem: ApiProductItemSchema, +// Feature: ApiFeatureWithMeta, +// Entity: ApiEntityWithMeta, +// }, +// securitySchemes: { +// secretKey: { +// type: "http", +// scheme: "bearer", +// bearerFormat: "JWT", +// }, +// }, +// }, - paths: { - ...productOps, - ...coreOps, - ...featureOps, - ...customerOps, - ...entityOps, - }, -}); +// paths: { +// ...coreOps, +// ...customerOps, +// ...productOps, +// ...featureOps, +// // ...entityOps, +// // ...referralOps, +// }, +// }, +// { +// // Disable the "Output" suffix that zod-openapi adds to response schemas +// outputIdSuffix: "", +// }, +// ); + +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { writeOpenApi_1_2_0 } from "./_prevVersions/openapi1.2.0.js"; + +// import { OPENAPI_1_2_0 } from "./_prevVersions/openapi1.2.0.js"; // Export to YAML file during build if (process.env.NODE_ENV !== "production") { try { + // If --no-build flag is present, return after writing openapi.yml + if (process.argv.includes("--no-build")) { + writeOpenApi_1_2_0(); + console.log( + `OpenAPI document exported to ${process.env.STAINLESS_PATH}/openapi.yml`, + ); + process.exit(0); + } // Convert to JSON first to strip out Zod schemas and function references - const jsonStr = JSON.stringify(document, null, 2); - - // Convert JSON to YAML (this avoids function serialization issues) - const jsonObj = JSON.parse(jsonStr); - const yamlContent = yaml.stringify(jsonObj); if (process.env.STAINLESS_PATH) { - writeFileSync( - `${process.env.STAINLESS_PATH}/openapi.yml`, - yamlContent, - "utf8", - ); + writeOpenApi_1_2_0(); + // writeFileSync( + // `${process.env.STAINLESS_PATH.replace("\\ ", " ")}/openapi.yml`, + // yamlContent, + // "utf8", + // ); + + // console.log( + // `OpenAPI document exported to ${process.env.STAINLESS_PATH}/openapi.yml`, + // ); + + // Run the run.sh script if it exists + const runScriptPath = `${process.env.STAINLESS_PATH.replace("\\ ", " ")}/run.sh`; + const runStainless = !process.argv.includes("--noEmit"); + if (existsSync(runScriptPath) && runStainless) { + try { + console.log("Running Stainless generation script..."); + execSync(`chmod +x "${runScriptPath}" && "${runScriptPath}"`, { + stdio: "inherit", + cwd: process.env.STAINLESS_PATH, + }); + console.log("Stainless generation completed successfully"); + } catch (error) { + console.error("Failed to run Stainless generation script:", error); + } + } else + console.log( + `\n${!runStainless ? "Stainless generation skipped due to --noEmit flag" : "Stainless generation script not found"}`, + ); } - console.log( - `OpenAPI document exported to ${process.env.STAINLESS_PATH}/openapi.yml`, - ); + // If docs path, run bun pull to update documentation + if (process.env.DOCS_PATH) { + const docsPath = process.env.DOCS_PATH.replace("\\ ", " "); + try { + console.log("Updating documentation with Mintlify..."); + execSync("bun pull", { + stdio: "inherit", + cwd: docsPath, + }); + console.log("Documentation updated successfully"); + } catch (error) { + console.error("Failed to update documentation:", error); + } + } } catch (error) { console.error("Failed to export OpenAPI document:", error); + process.exit(1); } + + // Exit the process after completion + process.exit(0); } diff --git a/shared/api/others/apiDiscount.ts b/shared/api/others/apiDiscount.ts index 8e31e4f35..698cde892 100644 --- a/shared/api/others/apiDiscount.ts +++ b/shared/api/others/apiDiscount.ts @@ -5,24 +5,61 @@ import { import { z } from "zod/v4"; export const ApiDiscountSchema = z.object({ - id: z.string(), // either from Autumn or Stripe - name: z.string(), + id: z.string().meta({ + description: "The unique identifier for this discount", + example: "disc_123456", + }), + name: z.string().meta({ + description: "The name of the discount or coupon", + example: "SUMMER20", + }), - type: z.enum(RewardType), - discount_value: z.number(), - duration_type: z.enum(CouponDurationType), - duration_value: z.number().nullish(), - currency: z.string().nullish(), + type: z.enum(RewardType).meta({ + description: "The type of reward", + example: "percentage", + }), + discount_value: z.number().meta({ + description: "The discount value (percentage or fixed amount)", + example: 20, + }), + duration_type: z.enum(CouponDurationType).meta({ + description: "How long the discount lasts", + example: "forever", + }), + duration_value: z.number().nullish().meta({ + description: + "Number of billing periods the discount applies for repeating durations", + example: 3, + }), + currency: z.string().nullish().meta({ + description: "The currency code for fixed amount discounts", + example: "usd", + }), - start: z.number().nullish(), - end: z.number().nullish(), + start: z.number().nullish().meta({ + description: "Timestamp when the discount becomes active", + example: 1759247877000, + }), + end: z.number().nullish().meta({ + description: "Timestamp when the discount expires", + example: 1761839877000, + }), - subscription_id: z.string().nullish(), - total_discount_amount: z.number().nullish(), + subscription_id: z.string().nullish().meta({ + description: "The Stripe subscription ID this discount is applied to", + example: "sub_1A2B3C4D5E6F7G8H", + }), + total_discount_amount: z.number().nullish().meta({ + description: "Total amount saved from this discount", + example: 599, + }), }); export const ApiCusRewardsSchema = z.object({ - discounts: z.array(ApiDiscountSchema), + discounts: z.array(ApiDiscountSchema).meta({ + description: "Array of active discounts applied to the customer", + example: [{ id: "disc_123456", name: "SUMMER20", type: "percentage", discount_value: 20 }], + }), }); export type ApiDiscount = z.infer; diff --git a/shared/api/others/apiInvoice.ts b/shared/api/others/apiInvoice.ts index 4f2a8e7dd..612791dba 100644 --- a/shared/api/others/apiInvoice.ts +++ b/shared/api/others/apiInvoice.ts @@ -1,22 +1,58 @@ import { z } from "zod/v4"; export const ApiInvoiceItemSchema = z.object({ - description: z.string(), - period_start: z.number(), - period_end: z.number(), + description: z.string().meta({ + description: "Description of the invoice line item", + example: "Pro Plan - Monthly Subscription", + }), + period_start: z.number().meta({ + description: "Timestamp when the billing period starts", + example: 1759247877000, + }), + period_end: z.number().meta({ + description: "Timestamp when the billing period ends", + example: 1761839877000, + }), - feature_id: z.string().optional(), - feature_name: z.string().optional(), + feature_id: z.string().optional().meta({ + description: "The ID of the feature associated with this line item", + example: "feature_123", + }), + feature_name: z.string().optional().meta({ + description: "The name of the feature associated with this line item", + example: "API Calls", + }), }); export const ApiInvoiceSchema = z.object({ - product_ids: z.array(z.string()), - stripe_id: z.string(), - status: z.string(), - total: z.number(), - currency: z.string(), - created_at: z.number(), - hosted_invoice_url: z.string().nullish(), + product_ids: z.array(z.string()).meta({ + description: "Array of product IDs included in this invoice", + example: ["pro_plan", "addon_feature"], + }), + stripe_id: z.string().meta({ + description: "The Stripe invoice ID", + example: "in_1A2B3C4D5E6F7G8H", + }), + status: z.string().meta({ + description: "The status of the invoice", + example: "paid", + }), + total: z.number().meta({ + description: "The total amount of the invoice", + example: 2999, + }), + currency: z.string().meta({ + description: "The currency code for the invoice", + example: "usd", + }), + created_at: z.number().meta({ + description: "Timestamp when the invoice was created", + example: 1759247877000, + }), + hosted_invoice_url: z.string().nullish().meta({ + description: "URL to the Stripe-hosted invoice page", + example: "https://invoice.stripe.com/i/acct_123/test_456", + }), // period_start: z.number().nullish(), // period_end: z.number().nullish(), }); diff --git a/shared/api/products/apiFreeTrial.ts b/shared/api/products/apiFreeTrial.ts index aebec3169..8d2a87cd0 100644 --- a/shared/api/products/apiFreeTrial.ts +++ b/shared/api/products/apiFreeTrial.ts @@ -2,13 +2,27 @@ import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTri import { z } from "zod/v4"; export const APIFreeTrialSchema = z.object({ - duration: z.enum(FreeTrialDuration), - length: z.number(), - unique_fingerprint: z.boolean(), - card_required: z.boolean(), + duration: z.enum(FreeTrialDuration).meta({ + description: "The duration type of the free trial", + }), + length: z.number().meta({ + description: "The length of the duration type specified", + }), + unique_fingerprint: z.boolean().meta({ + description: + "Whether the free trial is limited to one per customer fingerprint", + }), + + card_required: z.boolean().meta({ + description: + "Whether the free trial requires a card. If false, the customer can attach the product without going through a checkout flow or having a card on file.", + }), // For Cus Product - trial_available: z.boolean().nullish().default(true), + trial_available: z.boolean().nullish().default(true).meta({ + description: + "Used in customer context. Whether the free trial is available for the customer if they were to attach the product.", + }), }); export type APIFreeTrial = z.infer; diff --git a/shared/api/products/apiProduct.ts b/shared/api/products/apiProduct.ts index b7bdfd293..ff0e37e7d 100644 --- a/shared/api/products/apiProduct.ts +++ b/shared/api/products/apiProduct.ts @@ -4,6 +4,105 @@ import { z } from "zod/v4"; import { APIFreeTrialSchema } from "./apiFreeTrial.js"; import { ApiProductItemSchema } from "./apiProductItem.js"; +export const PRODUCT_EXAMPLE = { + id: "Pro Product", + name: "Pro Plan", + group: null, + env: "sandbox", + is_add_on: false, + is_default: false, + archived: false, + version: 1, + created_at: 1761296829908, + items: [ + { + type: "price", + feature_id: null, + interval: "month", + interval_count: 1, + price: 20, + display: { + primary_text: "$20", + secondary_text: "per month", + }, + }, + { + type: "priced_feature", + feature_id: "words", + included_usage: 1000, + interval: "month", + interval_count: 1, + price: 0.5, + usage_model: "pay_per_use", + billing_units: 1000, + reset_usage_when_enabled: true, + entity_feature_id: null, + display: { + primary_text: "1,000 Words", + secondary_text: "then $0.5 per 1,000 Words", + }, + }, + { + type: "feature", + feature_id: "dashboard", + // feature_type: "static", + // feature: { + // id: "dashboard", + // name: "Dashboard", + // type: "boolean", + // display: { + // singular: "Dashboard", + // plural: "Dashboard", + // }, + // archived: false, + // }, + entity_feature_id: null, + display: { + primary_text: "Dashboard", + }, + }, + { + type: "feature", + feature_id: "messages", + // feature_type: "single_use", + // feature: { + // id: "messages", + // name: "Messages", + // type: "single_use", + // display: { + // singular: "Messages", + // plural: "Messages", + // }, + // archived: false, + // }, + included_usage: 10, + interval: "month", + interval_count: 1, + reset_usage_when_enabled: true, + entity_feature_id: null, + display: { + primary_text: "10 Messages", + }, + }, + ], + free_trial: { + duration: "day", + length: 7, + unique_fingerprint: false, + card_required: true, + }, + + base_variant_id: null, + scenario: "new", + // properties: { + // is_free: false, + // is_one_off: false, + // interval_group: "month", + // has_trial: false, + // updateable: false, + // }, +}; + export const ApiProductPropertiesSchema = z.object({ is_free: z.boolean().meta({ description: "True if the product has no base price or usage prices", @@ -30,107 +129,64 @@ export const ApiProductPropertiesSchema = z.object({ }); export const ApiProductSchema = z.object({ - id: z.string().meta({ - description: "The ID of the product you set when creating the product", - example: "pro_plan", - }), + id: z + .string() + .describe("The ID of the product you set when creating the product"), - name: z.string().meta({ - description: "The name of the product", - example: "Pro Plan", - }), + name: z.string().describe("The name of the product"), - group: z.string().nullable().meta({ - description: "The group of the product", - example: "product_set_1", - }), + group: z + .string() + .nullable() + .describe("Product group which this product belongs to"), - env: z.enum(AppEnv).meta({ - description: "The environment of the product", - example: "production", - }), + env: z.enum(AppEnv).describe("The environment of the product"), - is_add_on: z.boolean().meta({ - description: + is_add_on: z + .boolean() + .describe( "Whether the product is an add-on and can be purchased alongside other products", - example: true, - }), + ), - is_default: z.boolean().meta({ - description: "Whether the product is the default product", - example: true, - }), + is_default: z + .boolean() + .describe("Whether the product is the default product"), - archived: z.boolean({ message: "archived should be a boolean" }).meta({ - description: + archived: z + .boolean({ message: "archived should be a boolean" }) + .describe( "Whether this product has been archived and is no longer available", - example: false, - }), + ), - version: z.number().meta({ - description: "The version of the product", - example: 1, - }), + version: z.number().describe("The current version of the product"), - created_at: z.number().meta({ - description: + created_at: z + .number() + .describe( "The timestamp of when the product was created in milliseconds since epoch", - example: 1759247877000, - }), + ), - items: z.array(ApiProductItemSchema).meta({ - description: "Array of product items that define the features and pricing", - example: [ - { - feature_id: "", - feature_type: "single_use", - included_usage: 123, - interval: "", - usage_model: "prepaid", - price: 123, - billing_units: 123, - entity_feature_id: "", - reset_usage_when_enabled: true, - tiers: [ - { - to: 123, - amount: 123, - }, - ], - }, - ], - }), + items: z + .array(ApiProductItemSchema) + .describe( + "Array of product items that define the product's features and pricing", + ), - free_trial: APIFreeTrialSchema.nullable().meta({ - description: "Free trial configuration for this product, if available", - example: { - duration: "", - length: 123, - unique_fingerprint: true, - }, - }), + free_trial: APIFreeTrialSchema.nullable().describe( + "Free trial configuration for this product, if available", + ), - base_variant_id: z.string().nullable().meta({ - description: "ID of the base variant this product is derived from", - example: "var_1234567890abcdef", - }), + base_variant_id: z + .string() + .nullable() + .describe("ID of the base variant this product is derived from"), - scenario: z.enum(AttachScenario).optional().meta({ - description: - "Scenario context for when this product is used in attach flows", - example: "upgrade", - }), + scenario: z + .enum(AttachScenario) + .optional() + .describe("Scenario for when this product is used in attach flows"), - properties: ApiProductPropertiesSchema.optional().meta({ - description: "Additional properties and metadata for the product", - example: { - is_free: false, - is_one_off: false, - interval_group: "monthly", - has_trial: true, - updateable: true, - }, - }), + properties: ApiProductPropertiesSchema.optional(), }); export type ApiProduct = z.infer; diff --git a/shared/api/products/apiProductItem.ts b/shared/api/products/apiProductItem.ts index 4f8d76927..36264716b 100644 --- a/shared/api/products/apiProductItem.ts +++ b/shared/api/products/apiProductItem.ts @@ -9,35 +9,96 @@ import { } from "@models/productV2Models/productItemModels/productItemModels.js"; import { z } from "zod/v4"; -export const ApiProductItemSchema = z.object({ - // Feature stuff - type: z.enum(ProductItemType).nullish(), - feature_id: z.string().nullish(), - feature_type: z.enum(ProductItemFeatureType).nullish(), +export const ApiProductItemSchema = z + .object({ + // Feature stuff + type: z + .enum(ProductItemType) + .nullish() + .describe("The type of the product item"), - // Feature response - feature: ApiFeatureSchema.nullish(), + feature_id: z + .string() + .nullish() + .describe( + "The feature ID of the product item. If the item is a fixed price, should be `null`", + ), - included_usage: z.number().or(z.literal(Infinite)).nullish(), - interval: z.enum(ProductItemInterval).nullish(), - interval_count: z.number().nullish(), + feature_type: z.enum(ProductItemFeatureType).nullish().meta({ + description: + "Single use features are used once and then depleted, like API calls or credits. Continuous use features are those being used on an ongoing-basis, like storage or seats.", + }), - // Price config - price: z.number().nullish(), - tiers: z.array(PriceTierSchema).nullish(), - usage_model: z.enum(UsageModel).nullish(), - billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) - reset_usage_when_enabled: z.boolean().nullish(), - quantity: z.number().nullish(), - next_cycle_quantity: z.number().nullish(), - entity_feature_id: z.string().nullish(), + // Feature response + feature: ApiFeatureSchema.nullish().meta({ + internal: true, + }), - display: z - .object({ - primary_text: z.string(), - secondary_text: z.string().nullish(), - }) - .nullish(), -}); + included_usage: z.number().or(z.literal(Infinite)).nullish().meta({ + description: "The amount of usage included for this feature.", + }), + + interval: z.enum(ProductItemInterval).nullish().meta({ + description: + "The reset or billing interval of the product item. If null, feature will have no reset date, and if there's a price, it will be billed one-off.", + }), + + interval_count: z.number().nullish().meta({ + description: "The interval count of the product item.", + }), + + // Price config + price: z.number().nullish().meta({ + description: + "The price of the product item. Should be `null` if tiered pricing is set.", + }), + + tiers: z.array(PriceTierSchema).nullish().meta({ + description: + "Tiered pricing for the product item. Not applicable for fixed price items.", + }), + + usage_model: z.enum(UsageModel).nullish().meta({ + description: + "Whether the feature should be prepaid upfront or billed for how much they use end of billing period.", + }), + + billing_units: z.number().nullish().meta({ + description: "The amount per billing unit (eg. $9 / 250 units)", + }), + + reset_usage_when_enabled: z.boolean().nullish().meta({ + description: + "Whether the usage should be reset when the product is enabled.", + }), + + entity_feature_id: z.string().nullish().meta({ + description: "The entity feature ID of the product item if applicable.", + }), + + display: z + .object({ + primary_text: z.string(), + secondary_text: z.string().nullish(), + }) + .nullish() + .meta({ + description: "The display of the product item.", + }), + + quantity: z.number().nullish().meta({ + description: + "Used in customer context. Quantity of the feature the customer has prepaid for.", + }), + + next_cycle_quantity: z.number().nullish().meta({ + description: + "Used in customer context. Quantity of the feature the customer will prepay for in the next cycle.", + }), + }) + .meta({ + id: "ProductItem", + description: "Product item defining features and pricing within a product", + }); export type ApiProductItem = z.infer; diff --git a/shared/api/products/productOpModels.ts b/shared/api/products/productOpModels.ts index 8c884df00..87438338e 100644 --- a/shared/api/products/productOpModels.ts +++ b/shared/api/products/productOpModels.ts @@ -3,43 +3,133 @@ import { ProductItemSchema } from "@models/productV2Models/productItemModels/pro import { idRegex } from "@utils/utils.js"; import { z } from "zod/v4"; -export const CreateProductItemParamsSchema = ProductItemSchema; +// Use the full ProductItemSchema but mark backend fields as internal +export const CreateProductItemParamsSchema = ProductItemSchema.meta({ + id: "CreateProductItemParams", + description: "Product item defining features and pricing within a product", +}); // Base product params -export const CreateProductV2ParamsSchema = z.object({ - id: z.string().nonempty().regex(idRegex), +const CREATE_PRODUCT_EXAMPLE = { + id: "Pro Product", + name: "Pro Plan", + is_add_on: false, + is_default: false, + items: [ + { + // Price + price: 20, + interval: "month", + }, + { + // Priced Feature + feature_id: "messages", + included_usage: 1000, + price: 0.5, + interval: "month", + usage_model: "pay_per_use", + }, + ], + free_trial: { + duration: "day", + length: 7, + unique_fingerprint: false, + card_required: true, + }, +}; - name: z.string().refine((val) => val.length > 0, { - message: "name must be a non-empty string", - }), +const descriptions = { + id: "The ID of the product. Used to identify the product in other API calls like checkout or update product.", + name: "The name of the product", + is_add_on: + "Whether the product is an add-on. Add-on products can be attached multiple times and don't to through upgrade / downgrade flows.", + is_default: + "Whether the product is the default product. Default products are enabled by default for new customers.", + group: + "Product group which this product belongs to. Products within a group have upgrade / downgrade logic when the customer moves between them.", + items: + "Array of product items that define the product's features and pricing", + free_trial: "Free trial configuration for this product, if available", - is_add_on: z.boolean().default(false), - is_default: z.boolean().default(false), - version: z.number().optional(), - group: z.string().nullable().default(""), + // Update only + archived: + "Archive this product using this flag. Archived products are hidden on the dashboard.", +}; - items: z.array(CreateProductItemParamsSchema).optional(), - free_trial: CreateFreeTrialSchema.nullish().default(null), -}); +export const CreateProductV2ParamsSchema = z + .object({ + id: z.string().nonempty().regex(idRegex).meta({ + description: descriptions.id, + }), + + name: z + .string() + .refine((val) => val.length > 0, { + message: "name must be a non-empty string", + }) + .meta({ + description: descriptions.name, + }), + + is_add_on: z.boolean().default(false).meta({ + description: descriptions.is_add_on, + }), + + is_default: z.boolean().default(false).meta({ + description: descriptions.is_default, + }), + + group: z.string().nullable().default("").meta({ + description: descriptions.group, + }), + + items: z.array(CreateProductItemParamsSchema).optional().meta({ + description: descriptions.items, + }), + + free_trial: CreateFreeTrialSchema.nullish().default(null).meta({ + description: descriptions.free_trial, + }), + }) + .meta({ + examples: [CREATE_PRODUCT_EXAMPLE], + }); export const UpdateProductV2ParamsSchema = z.object({ - id: z.string().nonempty().regex(idRegex).optional(), + id: z.string().nonempty().regex(idRegex).optional().meta({ + description: descriptions.id, + }), name: z .string() .refine((val) => val.length > 0, { message: "name must be a non-empty string", }) - .optional(), + .optional() + .meta({ + description: descriptions.name, + }), - is_add_on: z.boolean().optional(), - is_default: z.boolean().optional(), - version: z.number().optional(), - group: z.string().nonempty().nullable().optional(), - archived: z.boolean().optional(), + is_add_on: z.boolean().optional().meta({ + description: descriptions.is_add_on, + }), + is_default: z.boolean().optional().meta({ + description: descriptions.is_default, + }), + // version: z.number().optional().meta({ + // internal: true, + // }), + group: z.string().nonempty().nullable().optional().meta({ + description: descriptions.group, + }), + archived: z.boolean().optional().meta({ + description: descriptions.archived, + }), items: z.array(CreateProductItemParamsSchema).optional(), - free_trial: CreateFreeTrialSchema.nullish(), + free_trial: CreateFreeTrialSchema.nullish().meta({ + description: descriptions.free_trial, + }), }); export const UpdateProductQuerySchema = z.object({ diff --git a/shared/api/products/productsOpenApi.ts b/shared/api/products/productsOpenApi.ts index ee03e16ad..895e4cf04 100644 --- a/shared/api/products/productsOpenApi.ts +++ b/shared/api/products/productsOpenApi.ts @@ -1,14 +1,15 @@ +import { SuccessResponseSchema } from "@api/common/commonResponses.js"; +import { z } from "zod/v4"; +import { ApiProductSchema, PRODUCT_EXAMPLE } from "./apiProduct.js"; import { CreateProductV2ParamsSchema, UpdateProductV2ParamsSchema, -} from "@api/models.js"; -import { z } from "zod/v4"; -import { ApiProductSchema } from "./apiProduct.js"; +} from "./productOpModels.js"; // Register schema with .meta() for OpenAPI spec generation -const ApiProductWithMeta = ApiProductSchema.meta({ - id: "Product", - description: "A product", +export const ApiProductWithMeta = ApiProductSchema.meta({ + // id: "Product", + examples: [PRODUCT_EXAMPLE], }); export const productOps = { @@ -16,14 +17,14 @@ export const productOps = { get: { summary: "List Products", tags: ["products"], - requestParams: { - query: z.object({ - customer_id: z.string().optional(), - }), - }, + // requestParams: { + // query: z.object({ + // customer_id: z.string().optional(), + // }), + // }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: z.object({ @@ -44,7 +45,7 @@ export const productOps = { }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: ApiProductWithMeta } }, }, }, @@ -61,26 +62,18 @@ export const productOps = { }, responses: { "200": { - description: "Product retrieved successfully", + description: "", content: { "application/json": { schema: ApiProductWithMeta } }, }, - "404": { - description: "Product not found", - }, }, }, - patch: { + post: { summary: "Update Product", tags: ["products"], requestParams: { path: z.object({ product_id: z.string(), }), - query: z.object({ - version: z.string().optional(), - upsert: z.string().optional(), - disable_version: z.string().optional(), - }), }, requestBody: { content: { @@ -89,12 +82,9 @@ export const productOps = { }, responses: { "200": { - description: "200 OK", + description: "", content: { "application/json": { schema: ApiProductWithMeta } }, }, - "404": { - description: "Product not found", - }, }, }, delete: { @@ -110,22 +100,13 @@ export const productOps = { }, responses: { "200": { - description: "Product deleted successfully", + description: "", content: { "application/json": { - schema: z.object({ - success: z.boolean(), - }), + schema: SuccessResponseSchema, }, }, }, - "400": { - description: - "Product cannot be deleted because it has been attached to customers", - }, - "404": { - description: "Product not found", - }, }, }, }, diff --git a/shared/api/referrals/apiReferralCode.ts b/shared/api/referrals/apiReferralCode.ts new file mode 100644 index 000000000..fb8d149c4 --- /dev/null +++ b/shared/api/referrals/apiReferralCode.ts @@ -0,0 +1,48 @@ +import { z } from "zod/v4"; + +export const CreateReferralCodeResponseSchema = z + .object({ + code: z.string().meta({ + description: "The referral code that can be shared with customers", + example: "REF123ABC", + }), + customer_id: z.string().meta({ + description: "Your unique identifier for the customer", + example: "cus_123", + }), + created_at: z.number().meta({ + description: "The timestamp of when the referral code was created", + example: 1717000000, + }), + }) + .meta({ + id: "ReferralCode", + description: "Referral code object returned by the API", + }); + +export const RedeemReferralCodeResponseSchema = z + .object({ + id: z.string().meta({ + description: "The ID of the redemption event", + example: "red_123", + }), + customer_id: z.string().meta({ + description: "Your unique identifier for the customer", + example: "cus_456", + }), + reward_id: z.string().meta({ + description: "The ID of the reward that will be granted", + example: "reward_789", + }), + }) + .meta({ + id: "RedeemReferralCodeResponse", + description: "Redemption response object returned by the API", + }); + +export type CreateReferralCodeResponse = z.infer< + typeof CreateReferralCodeResponseSchema +>; +export type RedeemReferralCodeResponse = z.infer< + typeof RedeemReferralCodeResponseSchema +>; diff --git a/shared/api/referrals/referralOpModels.ts b/shared/api/referrals/referralOpModels.ts new file mode 100644 index 000000000..e222d45be --- /dev/null +++ b/shared/api/referrals/referralOpModels.ts @@ -0,0 +1,32 @@ +import { z } from "zod/v4"; + +// Create Referral Code Request +export const CreateReferralCodeParamsSchema = z.object({ + customer_id: z.string().meta({ + description: "The unique identifier of the customer", + example: "cus_123", + }), + program_id: z.string().meta({ + description: "ID of your referral program", + example: "prog_123", + }), +}); + +// Redeem Referral Code Request +export const RedeemReferralCodeParamsSchema = z.object({ + code: z.string().meta({ + description: "The referral code to redeem", + example: "REF123ABC", + }), + customer_id: z.string().meta({ + description: "The unique identifier of the customer redeeming the code", + example: "cus_456", + }), +}); + +export type CreateReferralCodeParams = z.infer< + typeof CreateReferralCodeParamsSchema +>; +export type RedeemReferralCodeParams = z.infer< + typeof RedeemReferralCodeParamsSchema +>; diff --git a/shared/api/referrals/referralsOpenApi.ts b/shared/api/referrals/referralsOpenApi.ts new file mode 100644 index 000000000..05018eeda --- /dev/null +++ b/shared/api/referrals/referralsOpenApi.ts @@ -0,0 +1,49 @@ +import { + CreateReferralCodeResponseSchema, + RedeemReferralCodeResponseSchema, +} from "./apiReferralCode.js"; +import { + CreateReferralCodeParamsSchema, + RedeemReferralCodeParamsSchema, +} from "./referralOpModels.js"; + +export const referralOps = { + "/referrals/code": { + post: { + summary: "Create a referral code", + tags: ["referrals"], + requestBody: { + content: { + "application/json": { schema: CreateReferralCodeParamsSchema }, + }, + }, + responses: { + "200": { + description: "Referral code generated successfully", + content: { + "application/json": { schema: CreateReferralCodeResponseSchema }, + }, + }, + }, + }, + }, + "/referrals/redeem": { + post: { + summary: "Redeem a referral code", + tags: ["referrals"], + requestBody: { + content: { + "application/json": { schema: RedeemReferralCodeParamsSchema }, + }, + }, + responses: { + "200": { + description: "Referral code redeemed successfully", + content: { + "application/json": { schema: RedeemReferralCodeResponseSchema }, + }, + }, + }, + }, + }, +}; diff --git a/shared/api/utils/openApiHelpers.ts b/shared/api/utils/openApiHelpers.ts new file mode 100644 index 000000000..163373dcc --- /dev/null +++ b/shared/api/utils/openApiHelpers.ts @@ -0,0 +1,210 @@ +import type { z } from "zod/v4"; + +export interface JSDocParam { + name: string; + description: string; + optional?: boolean; +} + +interface JSDocExample { + description?: string; + values: Record; +} + +interface JSDocLink { + url: string; + title: string; +} + +interface JSDocOptions { + description: string; + whenToUse?: string; + // Provide the request schemas + body?: z.ZodObject; + query?: z.ZodObject; + path?: z.ZodObject; + docs?: JSDocLink[]; + examples?: JSDocExample[]; + methodName?: string; + returns?: string; + throws?: Array<{ type: string; description: string }>; +} + +/** + * Generates a formatted JSDoc description string for OpenAPI specs + * that Stainless will convert to SDK documentation. + */ +export function createJSDocDescription(options: JSDocOptions): string { + const extractParamsFromSchema = ( + schema: z.ZodObject, + prefix?: string, + ): JSDocParam[] => { + const params: JSDocParam[] = []; + const shape = schema.shape; + + for (const [fieldName, fieldSchema] of Object.entries(shape)) { + // biome-ignore lint/suspicious/noExplicitAny: accessing Zod internal properties + const zodField = fieldSchema as any; + const def = zodField._def; + + let description = ""; + if (zodField.description) { + description = zodField.description; + } else if (def?.description) { + description = def.description; + } + + if (description) { + const paramName = prefix ? `${prefix}.${fieldName}` : fieldName; + params.push({ + name: paramName, + description, + optional: + def?.typeName === "ZodOptional" || + def?.typeName === "ZodNullable" || + def?.defaultValue !== undefined, + }); + } + } + + return params; + }; + + const parts: string[] = []; + + // Main description + parts.push(options.description); + + // When to use section + if (options.whenToUse) { + parts.push(""); + parts.push(options.whenToUse); + } + + // Examples (show before params) + if (options.examples && options.examples.length > 0) { + const methodName = options.methodName || "method"; + + for (const example of options.examples) { + parts.push(""); + parts.push("@example"); + parts.push("```typescript"); + + // Add example description as a comment if provided + if (example.description) { + parts.push(`// ${example.description}`); + } + + // Format the example object + const exampleStr = JSON.stringify(example.values, null, 2) + .split("\n") + .map((line, idx) => (idx === 0 ? line : ` ${line}`)) + .join("\n"); + + parts.push(`const response = await client.${methodName}(${exampleStr});`); + parts.push("```"); + } + } + + // Parameters - determine prefix based on Stainless naming logic + const allParams: JSDocParam[] = []; + const hasBody = !!options.body; + const hasQuery = !!options.query; + const hasPath = !!options.path; + + // Path params always have no prefix + if (hasPath && options.path) { + allParams.push(...extractParamsFromSchema(options.path)); + } + + // Determine prefix for body/query based on Stainless logic: + // - Only body โ†’ "body" + // - Only query โ†’ "query" + // - Query + Body (mixed) โ†’ "params" + // - Path + Body โ†’ "params" + let paramPrefix: string | undefined; + + if (hasBody && hasQuery) { + // Mixed query + body โ†’ Stainless uses "params" + paramPrefix = "params"; + } else if (hasBody && hasPath) { + // Path + body โ†’ Stainless uses "params" for body fields + paramPrefix = "params"; + } else if (hasBody) { + // Only body โ†’ Stainless uses "body" + paramPrefix = "body"; + } else if (hasQuery) { + // Only query โ†’ Stainless uses "query" + paramPrefix = "query"; + } + + // Extract body params with appropriate prefix + if (hasBody && options.body) { + allParams.push(...extractParamsFromSchema(options.body, paramPrefix)); + } + + // Extract query params with appropriate prefix + if (hasQuery && options.query) { + allParams.push(...extractParamsFromSchema(options.query, paramPrefix)); + } + + if (allParams.length > 0) { + parts.push(""); + for (const param of allParams) { + const optional = param.optional ? " (optional)" : ""; + parts.push(`@param ${param.name} - ${param.description}${optional}`); + } + } + + // Returns + if (options.returns) { + parts.push(""); + parts.push(`@returns ${options.returns}`); + } + + // Throws + if (options.throws && options.throws.length > 0) { + parts.push(""); + for (const error of options.throws) { + parts.push(`@throws {${error.type}} ${error.description}`); + } + } + + // Documentation links + if (options.docs && options.docs.length > 0) { + parts.push(""); + for (const doc of options.docs) { + parts.push(`@see {@link ${doc.url} ${doc.title}}`); + } + } + + return parts.join("\n"); +} + +/** + * Shorthand helper for creating parameter definitions + */ +export function param( + name: string, + description: string, + optional = false, +): JSDocParam { + return { name, description, optional }; +} + +/** + * Shorthand helper for creating example definitions + */ +export function example(options: { + values: Record; + description?: string; +}): JSDocExample { + return { values: options.values, description: options.description }; +} + +/** + * Shorthand helper for creating documentation link definitions + */ +export function docLink(options: { url: string; title: string }): JSDocLink { + return { url: options.url, title: options.title }; +} diff --git a/shared/api/utils/zodToJSDoc.ts b/shared/api/utils/zodToJSDoc.ts new file mode 100644 index 000000000..fa84c4aa6 --- /dev/null +++ b/shared/api/utils/zodToJSDoc.ts @@ -0,0 +1,116 @@ +import type { z } from "zod/v4"; +import type { JSDocParam } from "./openApiHelpers.js"; + +type ZodAnyObject = z.ZodObject; +type ZodAnyField = z.ZodTypeAny; + +/** + * Extracts parameter information from a Zod schema to create JSDoc params + * + * @param schema - A Zod object schema (e.g., z.object({ ... })) + * @returns Array of JSDocParam objects with name, description, and optional flag + * + * @example + * ```typescript + * const AttachSchema = z.object({ + * customer_id: z.string().describe("The customer ID"), + * product_id: z.string().describe("The product ID"), + * entity_id: z.string().optional().describe("Optional entity ID"), + * }); + * + * const params = extractParamsFromSchema(AttachSchema); + * // Returns: [ + * // { name: "customer_id", description: "The customer ID", optional: false }, + * // { name: "product_id", description: "The product ID", optional: false }, + * // { name: "entity_id", description: "Optional entity ID", optional: true }, + * // ] + * ``` + */ +export function extractParamsFromSchema(schema: ZodAnyObject): JSDocParam[] { + const params: JSDocParam[] = []; + + // Get the shape of the object schema + const shape = schema.shape; + + for (const [fieldName, fieldSchema] of Object.entries(shape)) { + const zodField = fieldSchema as ZodAnyField; + + // Extract description from .describe() or .meta() + let description = ""; + // biome-ignore lint/suspicious/noExplicitAny: accessing Zod internal properties + const def = (zodField as any)._def; + if (zodField.description) { + description = zodField.description; + } else if (def?.description) { + description = def.description; + } + + // Check if field is optional/nullable + const isOptional = isZodFieldOptional(zodField); + + // Only add params that have descriptions (to avoid cluttering docs) + if (description) { + params.push({ + name: fieldName, + description, + optional: isOptional, + }); + } + } + + return params; +} + +/** + * Checks if a Zod field is optional or nullable + */ +function isZodFieldOptional(field: ZodAnyField): boolean { + // biome-ignore lint/suspicious/noExplicitAny: accessing Zod internal properties + const def = (field as any)._def; + + // Check for .optional() + if (def?.typeName === "ZodOptional") { + return true; + } + + // Check for .nullish() + if (def?.typeName === "ZodNullable") { + return true; + } + + // Check if it's wrapped in optional/nullable + if (def?.innerType) { + return isZodFieldOptional(def.innerType); + } + + // Check for default values (also makes it optional) + if (def?.defaultValue !== undefined) { + return true; + } + + return false; +} + +/** + * Creates a filtered list of params from a schema, including only specified fields + * + * @param schema - A Zod object schema + * @param includeFields - Array of field names to include in the output + * @returns Filtered array of JSDocParam objects + * + * @example + * ```typescript + * const params = filterSchemaParams(AttachSchema, [ + * 'customer_id', + * 'product_id', + * 'entity_id' + * ]); + * ``` + */ +export function filterSchemaParams( + schema: ZodAnyObject, + includeFields: string[], +): JSDocParam[] { + const allParams = extractParamsFromSchema(schema); + return allParams.filter((param) => includeFields.includes(param.name)); +} diff --git a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts index 733d164ea..5a68a4064 100644 --- a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts +++ b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts @@ -11,6 +11,7 @@ export enum AffectedResource { CusBalance = "cus_balance", Invoice = "invoice", Product = "product", + Check = "check", // Add more as needed } @@ -43,6 +44,11 @@ export abstract class VersionChange< TOldSchema extends ZodType = ZodType, TLegacyDataSchema extends ZodType = ZodType, > { + /** + * Optional name for debugging purposes + */ + readonly name?: string; + /** * The newer version where the breaking change was introduced */ @@ -146,13 +152,6 @@ export abstract class VersionChange< affects(resource: AffectedResource): boolean { return this.affectedResources.includes(resource); } - - /** - * Get the name of this change class - */ - get name(): string { - return this.constructor.name; - } } /** @@ -173,6 +172,9 @@ export interface VersionChangeConfig< TOldSchema extends ZodType = ZodType, TLegacyDataSchema extends ZodType = ZodType, > { + /** Optional name for debugging purposes */ + name?: string; + /** The newer version where the breaking change was introduced */ newVersion: ApiVersion; @@ -268,6 +270,7 @@ export function defineVersionChange< TOldSchema, TLegacyDataSchema > { + readonly name = config.name; readonly newVersion = config.newVersion; readonly oldVersion = config.oldVersion; readonly description = config.description; diff --git a/shared/api/versionUtils/versionChangeUtils/VersionChangeRegistryClass.ts b/shared/api/versionUtils/versionChangeUtils/VersionChangeRegistryClass.ts index 112922a28..cb00518cb 100644 --- a/shared/api/versionUtils/versionChangeUtils/VersionChangeRegistryClass.ts +++ b/shared/api/versionUtils/versionChangeUtils/VersionChangeRegistryClass.ts @@ -24,17 +24,20 @@ export const VersionChangeRegistryClass = { getChangesForVersion({ version }: { version: ApiVersion }): VersionChange[] { const changeClasses = changes.get(version) || []; - return changeClasses.map((ChangeClass) => { - const key = `${version}-${ChangeClass.name}`; - if (!instances.has(key)) { - instances.set(key, new ChangeClass()); - } - const instance = instances.get(key); - if (!instance) { - throw new Error(`Failed to create instance for ${ChangeClass.name}`); - } - return instance; - }); + // Simply instantiate each change class - no caching needed since they're stateless + return changeClasses.map((ChangeClass) => new ChangeClass()); + // const changeClasses = changes.get(version) || []; + // return changeClasses.map((ChangeClass) => { + // const key = `${version}-${ChangeClass.name}`; + // if (!instances.has(key)) { + // instances.set(key, new ChangeClass()); + // } + // const instance = instances.get(key); + // if (!instance) { + // throw new Error(`Failed to create instance for ${ChangeClass.name}`); + // } + // return instance; + // }); }, getRegisteredVersions(): ApiVersion[] { diff --git a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts index 80fe04295..ca02d4f24 100644 --- a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts +++ b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts @@ -62,6 +62,10 @@ export function applyResponseVersionChanges({ const changes = VersionChangeRegistryClass.getChangesForVersion({ version, }); + // console.log(`Changes for version ${version}`); + // for (const change of changes) { + // console.log(`[${change.oldVersion}]`, change.name); + // } for (const change of changes) { // Skip if this change doesn't affect our resource @@ -84,11 +88,6 @@ export function applyResponseVersionChanges({ const shouldApply = targetVersion.lte(change.oldVersion); if (!shouldApply) continue; - const description = Array.isArray(change.description) - ? change.description.join("; ") - : change.description; - - // console.log(`Applying change [${change.oldVersion}]: ${description}`); // Apply the response transformation (backward) transformedData = change.transformResponse({ input: transformedData, diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index b0560a281..ccae19a07 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -1,9 +1,11 @@ // Import customer feature changes + +// Import check changes +import { V0_2_CheckChange } from "@api/balances/check/changes/V0.2_CheckChange.js"; import { V0_1_CustomerChange } from "@api/customers/changes/V0_1_CustomerChange.js"; // Import customer changes import { V0_2_CustomerChange } from "@api/customers/changes/V0_2_CustomerChange.js"; import { V0_2_InvoicesAlwaysExpanded } from "@api/customers/changes/V0_2_InvoicesAlwaysExpanded.js"; -import { V1_1_FeaturesArrayToObject } from "@api/customers/changes/V1_1_FeaturesArrayToObject.js"; // Import customer product changes import { ApiVersion } from "../ApiVersion.js"; @@ -15,12 +17,13 @@ export const V1_4_CHANGES: VersionChangeConstructor[] = [ ]; export const V1_2_CHANGES: VersionChangeConstructor[] = [ - V1_1_FeaturesArrayToObject, // Transforms TO V1_1 + // V1_1_FeaturesArrayToObject, // Transforms TO V1_1 ]; export const V1_1_CHANGES: VersionChangeConstructor[] = [ V0_2_CustomerChange, // Transforms TO V0_2: splits structure + transforms features V0_2_InvoicesAlwaysExpanded, // Side effect: invoices always expanded for V0_2 and older + V0_2_CheckChange, // Transforms TO V0_2: check response to balances array format ]; export const V0_2_CHANGES: VersionChangeConstructor[] = [ diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index 6f108e7ac..544c05173 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -27,8 +27,14 @@ export enum ProductItemType { } export const PriceTierSchema = z.object({ - to: z.number().or(z.literal(TierInfinite)), - amount: z.number(), + to: z.number().or(z.literal(TierInfinite)).meta({ + description: "The maximum amount of usage for this tier.", + example: 100, + }), + amount: z.number().meta({ + description: "The price of the product item for this tier.", + example: 10, + }), }); export enum UsageModel { @@ -55,53 +61,111 @@ export const RolloverConfigSchema = z.object({ }); const ProductItemConfigSchema = z.object({ - on_increase: z.nativeEnum(OnIncrease).nullish(), - on_decrease: z.nativeEnum(OnDecrease).nullish(), + on_increase: z.enum(OnIncrease).nullish(), + on_decrease: z.enum(OnDecrease).nullish(), rollover: RolloverConfigSchema.nullish(), }); export const ProductItemSchema = z.object({ // Feature stuff - feature_id: z.string().nullish(), - feature_type: z.nativeEnum(ProductItemFeatureType).nullish(), - included_usage: z.union([z.number(), z.literal(Infinite)]).nullish(), + feature_id: z.string().nullish().meta({ + description: + "The feature ID of the product item. Should be null for fixed price items.", + }), + + feature_type: z.enum(ProductItemFeatureType).nullish().meta({ + internal: true, + }), + + included_usage: z + .union([z.number(), z.literal(Infinite)]) + .nullish() + .meta({ + description: + "The amount of usage included for this feature (per interval).", + }), + interval: z - .enum(ProductItemInterval, { - error: (issue) => { - if (issue.input === "") { - return { - message: "Interval cannot be empty.", - }; - } - }, - }) - .nullish(), - interval_count: z.number().nullish(), - entity_feature_id: z.string().nullish(), + .preprocess((val) => { + if (val === "") { + throw new Error("Interval cannot be empty."); + } + return val; + }, z.enum(ProductItemInterval).nullish()) + .meta({ + description: + "The reset or billing interval of the product item. If null, feature will have no reset date, and if there's a price, it will be billed one-off.", + }), + + interval_count: z.number().nullish().meta({ + description: "Interval count of the feature.", + }), + + entity_feature_id: z.string().nullish().meta({ + description: + "The feature ID of the entity (like seats) to track sub-balances for.", + }), // Price config - usage_model: z.enum(UsageModel).nullish(), - price: z.number().nullish(), - tiers: z.array(PriceTierSchema).nullish(), - billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) - usage_limit: z.number().nullish(), + usage_model: z.enum(UsageModel).nullish().meta({ + description: + "Whether the feature should be prepaid upfront or billed for how much they use end of billing period.", + }), + + price: z.number().nullish().meta({ + description: + "The price of the product item. Should be null if tiered pricing is set.", + }), + + tiers: z.array(PriceTierSchema).nullish().meta({ + description: + "Tiered pricing for the product item. Not applicable for fixed price items.", + }), + + billing_units: z.number().nullish().meta({ + description: + "The billing units of the product item (eg $1 for 30 credits).", + }), // Others // carry_over_usage: z.boolean().nullish(), - reset_usage_when_enabled: z.boolean().nullish(), - config: ProductItemConfigSchema.nullish(), + reset_usage_when_enabled: z.boolean().nullish().meta({ + description: + "Whether the usage should be reset when the product is enabled.", + }), + display: z .object({ primary_text: z.string(), secondary_text: z.string().nullish(), }) - .nullish(), + .nullish() + .meta({ + internal: true, + }), + + // Hidden from users for now. + usage_limit: z.number().nullish().meta({ + internal: true, + }), + + config: ProductItemConfigSchema.nullish().meta({ + internal: true, + }), // Stored in backend - created_at: z.number().nullish(), - entitlement_id: z.string().nullish(), - price_id: z.string().nullish(), - price_config: z.any().nullish(), + created_at: z.number().nullish().meta({ + internal: true, + }), + entitlement_id: z.string().nullish().meta({ + internal: true, + }), + price_id: z.string().nullish().meta({ + internal: true, + }), + price_config: z.any().nullish().meta({ + internal: true, + }), }); export const LimitedItemSchema = ProductItemSchema.extend({ diff --git a/shared/utils/cusProductUtils/productIdToCusProduct.ts b/shared/utils/cusProductUtils/productIdToCusProduct.ts index 9dd72cf13..6b13fed59 100644 --- a/shared/utils/cusProductUtils/productIdToCusProduct.ts +++ b/shared/utils/cusProductUtils/productIdToCusProduct.ts @@ -1,5 +1,5 @@ -import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; -import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import { nullish } from "../utils.js"; export const productToCusProduct = ({ @@ -18,19 +18,44 @@ export const productToCusProduct = ({ inStatuses?: CusProductStatus[]; }) => { if (cusProductId) { - return cusProducts.find((cusProduct) => cusProduct.id === cusProductId); + return cusProducts.find((cusProduct) => { + const cusProductIdMatch = cusProduct.id === cusProductId; + const versionMatch = version + ? cusProduct.product.version === version + : true; + + const prodIdMatch = cusProduct.product.id === productId; + + const entityMatch = internalEntityId + ? cusProduct.internal_entity_id === internalEntityId + : nullish(cusProduct.internal_entity_id); + + const statusMatch = inStatuses + ? inStatuses.includes(cusProduct.status) + : true; + + return ( + cusProductIdMatch && + versionMatch && + prodIdMatch && + entityMatch && + statusMatch + ); + }); } return cusProducts.find((cusProduct) => { - let prodIdMatch = cusProduct.product.id === productId; + const versionMatch = version + ? cusProduct.product.version === version + : true; - let entityMatch = internalEntityId + const prodIdMatch = cusProduct.product.id === productId; + + const entityMatch = internalEntityId ? cusProduct.internal_entity_id === internalEntityId : nullish(cusProduct.internal_entity_id); - let versionMatch = version ? cusProduct.product.version === version : true; - - let statusMatch = inStatuses + const statusMatch = inStatuses ? inStatuses.includes(cusProduct.status) : true; diff --git a/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts b/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts index a8a559599..d3b8fae6f 100644 --- a/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts +++ b/shared/utils/productV3Utils/productItemUtils/productV3ItemUtils.ts @@ -1,3 +1,4 @@ +import type { FixedPriceConfig } from "@models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; import type { ProductItem, ProductItemInterval, @@ -14,6 +15,8 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): { interval: ProductItemInterval; intervalCount: number; item: ProductItem; + config: FixedPriceConfig; + priceId: string; } | null { const item = product.items.find((x) => isPriceItem(x)); @@ -23,6 +26,8 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): { interval: (item.interval as unknown as ProductItemInterval) || null, intervalCount: item.interval_count || 1, item: item, + config: item.price_config as FixedPriceConfig, + priceId: item.price_id || "", }; } diff --git a/vite/src/components/general/AdminHover.tsx b/vite/src/components/general/AdminHover.tsx index 296a66ece..7d40accb0 100644 --- a/vite/src/components/general/AdminHover.tsx +++ b/vite/src/components/general/AdminHover.tsx @@ -1,16 +1,13 @@ -import React, { - useState, - forwardRef, - cloneElement, - isValidElement, -} from "react"; -import { Check } from "lucide-react"; -import { Tooltip, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; - -import { TooltipContent } from "../ui/tooltip"; -import { Copy } from "lucide-react"; -import { useSession } from "@/lib/auth-client"; +import { Check, Copy } from "lucide-react"; +import type React from "react"; +import { cloneElement, forwardRef, isValidElement, useState } from "react"; import { useAdmin } from "@/views/admin/hooks/useAdmin"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; export const AdminHover = forwardRef< HTMLElement, @@ -19,53 +16,59 @@ export const AdminHover = forwardRef< texts: (string | { key: string; value: string } | undefined | null)[]; hide?: boolean; asChild?: boolean; + side?: "top" | "bottom" | "left" | "right"; } ->(({ children, texts, hide = false, asChild = false }, ref) => { - const { isAdmin } = useAdmin(); +>( + ( + { children, texts, hide = false, asChild = false, side = "bottom" }, + ref, + ) => { + const { isAdmin } = useAdmin(); - if (!isAdmin || hide) return <>{children}; + if (!isAdmin || hide) return <>{children}; - // Try to forward the ref to the child if possible - let triggerChild = children; - if (isValidElement(children)) { - triggerChild = cloneElement(children as React.ReactElement, { ref }); - } + // Try to forward the ref to the child if possible + let triggerChild = children; + if (isValidElement(children)) { + triggerChild = cloneElement(children as React.ReactElement, { ref }); + } - return ( - - - - {triggerChild} - - {isAdmin && ( - -
- {texts.map((text: any) => { - if (!text) return; - if (typeof text === "object") { - return ( -
-

- {text.key} -

- -
- ); - } else { - return ; - } - })} -
-
- )} -
-
- ); -}); + return ( + + + + {triggerChild} + + {isAdmin && ( + +
+ {texts.map((text: any) => { + if (!text) return; + if (typeof text === "object") { + return ( +
+

+ {text.key} +

+ +
+ ); + } else { + return ; + } + })} +
+
+ )} +
+
+ ); + }, +); const CopyText = ({ text }: { text: string }) => { const [isHover, setIsHover] = useState(false); diff --git a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx index 5fc574c14..2a3aaaea6 100644 --- a/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx +++ b/vite/src/views/customers/customer/product/hooks/useCusProductQuery.tsx @@ -12,7 +12,7 @@ export const useCusProductQuery = () => { const { customer_id, product_id } = useParams(); const [queryStates] = useQueryStates({ version: parseAsInteger, - customer_product_id: parseAsString, + id: parseAsString, entity_id: parseAsString, }); @@ -23,7 +23,7 @@ export const useCusProductQuery = () => { productId: product_id, queryStates: { version: stableStates.version ?? undefined, - customerProductId: stableStates.customer_product_id ?? undefined, + customerProductId: stableStates.id ?? undefined, entityId: stableStates.entity_id ?? undefined, }, }); @@ -33,7 +33,7 @@ export const useCusProductQuery = () => { const fetcher = async () => { const queryParams = { version: stableStates.version, - customer_product_id: stableStates.customer_product_id, + customer_product_id: stableStates.id, entity_id: stableStates.entity_id, }; @@ -57,7 +57,7 @@ export const useCusProductQuery = () => { customer_id, product_id, stableStates.version, - stableStates.customer_product_id, + stableStates.id, stableStates.entity_id, ], queryFn: fetcher, diff --git a/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx b/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx index 8fef80f90..4ac39be78 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx @@ -1,4 +1,5 @@ import { mapToProductV3 } from "@autumn/shared"; +import { AdminHover } from "@/components/general/AdminHover"; import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges"; import { CardHeader } from "@/components/v2/cards/Card"; import { useOrg } from "@/hooks/common/useOrg"; @@ -16,16 +17,30 @@ export const PlanCardHeader = () => { const isPlanBeingEdited = useIsEditingPlan(); const productV3 = mapToProductV3({ product }); + const adminHoverText = () => { + return [ + { + key: "Price ID", + value: productV3.price?.priceId || "N/A", + }, + { + key: "Stripe Price ID", + value: productV3.price?.config?.stripe_price_id || "N/A", + }, + ]; + }; return (
- - {product.name.length > MAX_PLAN_NAME_LENGTH - ? `${product.name.slice(0, MAX_PLAN_NAME_LENGTH)}...` - : product.name} - + + + {product.name.length > MAX_PLAN_NAME_LENGTH + ? `${product.name.slice(0, MAX_PLAN_NAME_LENGTH)}...` + : product.name} + + MAX_PLAN_NAME_LENGTH - 10} diff --git a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx index fd5e9a588..a2c2fbd3f 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx @@ -4,6 +4,7 @@ import type { ProductItem } from "@autumn/shared"; import { getProductItemDisplay, productV2ToFeatureItems } from "@autumn/shared"; import { TrashIcon } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; +import { AdminHover } from "@/components/general/AdminHover"; import { CopyButton } from "@/components/v2/buttons/CopyButton"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { useOrg } from "@/hooks/common/useOrg"; @@ -108,6 +109,51 @@ export const PlanFeatureRow = ({ } }; + const adminHoverText = () => { + return [ + ...(item.entitlement_id + ? [ + { + key: "Entitlement ID", + value: item.entitlement_id || "N/A", + }, + ] + : []), + ...(item.price_id + ? [ + { + key: "Price ID", + value: item.price_id || "N/A", + }, + ] + : []), + ...(item.price_config?.stripe_price_id + ? [ + { + key: "Stripe Price ID", + value: item.price_config?.stripe_price_id || "N/A", + }, + ] + : []), + ...(item.price_config?.stripe_empty_price_id + ? [ + { + key: "Stripe Empty Price ID", + value: item.price_config?.stripe_empty_price_id || "N/A", + }, + ] + : []), + ...(item.price_config?.stripe_product_id + ? [ + { + key: "Stripe Product ID", + value: item.price_config?.stripe_product_id || "N/A", + }, + ] + : []), + ]; + }; + return (
{/* Left side - Icons and text */}
-
- - - -
+ +
+ + + + + +
+

{displayText} + {" "} {display.secondary_text}

+