diff --git a/.gitignore b/.gitignore index 87930686c..a502a6d08 100644 --- a/.gitignore +++ b/.gitignore @@ -101,4 +101,5 @@ supabase/ # To load supabase # 1. Start supabase locally -# 2. Load files \ No newline at end of file +# 2. Load files +migration.sh \ No newline at end of file diff --git a/bun.lock b/bun.lock index 8ec7a3e28..d5e5598ed 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ "@supabase/supabase-js": "^2.46.2", "@upstash/redis": "^1.35.1", "ai": "^4.3.10", - "autumn-js": "^0.0.77", + "autumn-js": "^0.1.4", "axios": "^1.8.3", "better-auth": "^1.2.9", "body-parser": "^1.20.3", @@ -147,7 +147,7 @@ "@clerk/clerk-react": "^5.24.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", - "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -167,7 +167,7 @@ "ag-charts-community": "^12.0.2", "ag-grid-community": "^34.0.2", "ag-grid-react": "^34.0.2", - "autumn-js": "^0.0.93", + "autumn-js": "^0.1.8", "axios": "^1.8.3", "better-auth": "^1.2.9", "class-variance-authority": "^0.7.1", @@ -313,15 +313,15 @@ "@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=="], - "@clerk/backend": ["@clerk/backend@2.6.2", "", { "dependencies": { "@clerk/shared": "^3.17.0", "@clerk/types": "^4.72.0", "cookie": "1.0.2", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-IUTjLmA1QkqoJnB97S8Ay/oeFR1QtBxxzi9V2J8zncGdUUpAHRp9PfbUwe203VEZuoDD8n6PGfK4oiiq5CoKhQ=="], + "@clerk/backend": ["@clerk/backend@2.6.3", "", { "dependencies": { "@clerk/shared": "^3.18.0", "@clerk/types": "^4.73.0", "cookie": "1.0.2", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-x73eu/zDMc1E2YvvW+KW55B6BOfiUrgFhSJngBsZJ6+W5hWZph+XeCS7oEcZfhvQyUcb9M6tqM2R1w7htNgh1Q=="], - "@clerk/clerk-react": ["@clerk/clerk-react@5.38.1", "", { "dependencies": { "@clerk/shared": "^3.17.0", "@clerk/types": "^4.72.0", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-IOn/Raet3jwkug8P/gLMe2nsw2wKllWGOGPFOAaaYxbXfIZ8MPngNv2/MMgVRF7cAX1UwrmU1PzrLNtBJ/EHPQ=="], + "@clerk/clerk-react": ["@clerk/clerk-react@5.39.0", "", { "dependencies": { "@clerk/shared": "^3.18.0", "@clerk/types": "^4.73.0", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-c1GJMj8wrzFrT3BbvZ4JwYY91BAY1M4yMypaAs3UNvtU0dDze7bmUVs02eW+z0GgaEQbGBVO4f5ru/pBXCGSKA=="], - "@clerk/express": ["@clerk/express@1.7.15", "", { "dependencies": { "@clerk/backend": "^2.6.2", "@clerk/shared": "^3.17.0", "@clerk/types": "^4.72.0", "tslib": "2.8.1" }, "peerDependencies": { "express": "^4.17.0 || ^5.0.0" } }, "sha512-75yh1fAl8rjPcHZKR0FT84yJLyOaAIOufHLs0SBkGVzYSX+V6pZpriiuh3QtAeXR8gSxQKF5/qMzDKpqUa/nCA=="], + "@clerk/express": ["@clerk/express@1.7.16", "", { "dependencies": { "@clerk/backend": "^2.6.3", "@clerk/shared": "^3.18.0", "@clerk/types": "^4.73.0", "tslib": "2.8.1" }, "peerDependencies": { "express": "^4.17.0 || ^5.0.0" } }, "sha512-T+Gw0vjRl9gRG1aEFfJVf3EQ9r6jfHtvOq9NU4q64GrsgvCZmvhjsx514sx3mqKfXM5CzFnT5biUpNwY27aAwQ=="], - "@clerk/shared": ["@clerk/shared@3.17.0", "", { "dependencies": { "@clerk/types": "^4.72.0", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-eYbA0xmKG1DluFmdVykXiElgZGTpCruEyXmIBAwokpxypd5nOpDsS1xvEKwYvZieLTZkFz21Z3Y6HdDI5cPxBQ=="], + "@clerk/shared": ["@clerk/shared@3.18.0", "", { "dependencies": { "@clerk/types": "^4.73.0", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QGYbtPfJEp66AgaI0JtiRX04ejoUI8SmqUqqbAm3UXAeUAsKOyXoNbghNal97X7qeq1vPRRiDugK5B9WYN69uA=="], - "@clerk/types": ["@clerk/types@4.72.0", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-SEkgiQNeTstC0/mQjHCGBEyX0/ALyWAa5QZBBvVOok204r48MLipfIKsXQhyWE2Hk6FIo5WT6YyqD36jaxUEIw=="], + "@clerk/types": ["@clerk/types@4.73.0", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-SFM29RFVqs0KyTds3F7s7DBYNoqeBDSZiWuso/WCgfQVI4H3j0gI+0BdBEiqwCILXr4jKBG2srDmzP5FVvx8Hg=="], "@clickhouse/client": ["@clickhouse/client@1.12.0", "", { "dependencies": { "@clickhouse/client-common": "1.12.0" } }, "sha512-vJUSX8THhTzlVn0WxPukVjOgNRaSoY02ubQkB0LpqNoHFxXuF5jQZZAYvGZWpBGbYQ/4gfPrqu8g4TX5UKeNxA=="], @@ -571,23 +571,23 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], - "@next/env": ["@next/env@15.4.5", "", {}, "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ=="], + "@next/env": ["@next/env@15.4.6", "", {}, "sha512-yHDKVTcHrZy/8TWhj0B23ylKv5ypocuCwey9ZqPyv4rPdUdRzpGCkSi03t04KBPyU96kxVtUqx6O3nE1kpxASQ=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-667R0RTP4DwxzmrqTs4Lr5dcEda9OxuZsVFsjVtxVMVhzSpo6nLclXejJVfQo2/g7/Z9qF3ETDmN3h65mTjpTQ=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-CL6mfGsKuFSyQjx36p2ftwMNSb8PQog8y0HO/ONLdQqDql7x3aJb/wB+LA651r4we2pp/Ck+qoRVUeZZEvSurA=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-KMSFoistFkaiQYVQQnaU9MPWtp/3m0kn2Xed1Ces5ll+ag1+rlac20sxG+MqhH2qYWX1O2GFOATQXEyxKiIscg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-1hTVd9n6jpM/thnDc5kYHD1OjjWYpUJrJxY4DlEacT7L5SEOXIifIdTye6SQNNn8JDZrcN+n8AWOmeJ8u3KlvQ=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-PnOx1YdO0W7m/HWFeYd2A6JtBO8O8Eb9h6nfJia2Dw1sRHoHpNf6lN1U4GKFRzRDBi9Nq2GrHk9PF3Vmwf7XVw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-4W+D/nw3RpIwGrqpFi7greZ0hjrCaioGErI7XHgkcTeWdZd146NNu1s4HnaHonLeNTguKnL2Urqvj28UJj6Gqw=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-XBbuQddtY1p5FGPc2naMO0kqs4YYtLYK/8aPausI5lyOjr4J77KTG9mtlU4P3NwkLI1+OjsPzKVvSJdMs3cFaw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-N6Mgdxe/Cn2K1yMHge6pclffkxzbSGOydXVKYOjYqQXZYjLCfN/CuFkaYDeDHY2VBwSHyM2fUjYBiQCIlxIKDA=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-+WTeK7Qdw82ez3U9JgD+igBAP75gqZ1vbK6R8PlEEuY0OIe5FuYXA4aTjL811kWPf7hNeslD4hHK2WoM9W0IgA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-YZ3bNDrS8v5KiqgWE0xZQgtXgCTUacgFtnEgI4ccotAASwSvcMPDLua7BWLuTfucoRv6mPidXkITJLd8IdJplQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-XP824mCbgQsK20jlXKrUpZoh/iO3vUWhMpxCz8oYeagoiZ4V0TQiKy0ASji1KK6IAe3DYGfj5RfKP6+L2020OQ=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-9Wr4t9GkZmMNcTVvSloFtjzbH4vtT4a8+UHqDoVnxA5QyfWe6c5flTH1BIWPGNWSUlofc8dVJAE7j84FQgskvQ=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FxrsenhUz0LbgRkNWx6FRRJIPe/MI1JRA4W4EPd5leXO00AZ6YU8v5vfx4MDXTvN77lM/EqsE3+6d2CIeF5NYg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-voWk7XtGvlsP+w8VBz7lqp8Y+dYw/MTI4KeS0gTVtfdhdJ5QwhXLmNrndFOin/MDoCvUaLWMkYKATaCoUkt2/A=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-T4ufqnZ4u88ZheczkBTtOF+eKaM14V8kbjud/XrAakoM5DKQWjW09vD6B9fsdsWS2T7D5EY31hRHdta7QKWOng=="], "@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="], @@ -961,13 +961,13 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - "@sentry/core": ["@sentry/core@9.44.0", "", {}, "sha512-U+KBNGgq/eXIj226CPtRk+n5dx0q1xGVvbLbyfAyeek9C/wxQ3f+mvqeVqF9cx8FfrWIOeDM1F8ISH5uRkjjQg=="], + "@sentry/core": ["@sentry/core@9.44.2", "", {}, "sha512-4wduCY9vz+VRMZXTT1dzk08L2nReeR+lzpY8hCcc+Wu100BoJR+TNlrSn1rG5iIo98NDW860JsRA7SVDUDOiNQ=="], - "@sentry/node": ["@sentry/node@9.44.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.2", "@opentelemetry/instrumentation-amqplib": "^0.46.1", "@opentelemetry/instrumentation-connect": "0.43.1", "@opentelemetry/instrumentation-dataloader": "0.16.1", "@opentelemetry/instrumentation-express": "0.47.1", "@opentelemetry/instrumentation-fs": "0.19.1", "@opentelemetry/instrumentation-generic-pool": "0.43.1", "@opentelemetry/instrumentation-graphql": "0.47.1", "@opentelemetry/instrumentation-hapi": "0.45.2", "@opentelemetry/instrumentation-http": "0.57.2", "@opentelemetry/instrumentation-ioredis": "0.47.1", "@opentelemetry/instrumentation-kafkajs": "0.7.1", "@opentelemetry/instrumentation-knex": "0.44.1", "@opentelemetry/instrumentation-koa": "0.47.1", "@opentelemetry/instrumentation-lru-memoizer": "0.44.1", "@opentelemetry/instrumentation-mongodb": "0.52.0", "@opentelemetry/instrumentation-mongoose": "0.46.1", "@opentelemetry/instrumentation-mysql": "0.45.1", "@opentelemetry/instrumentation-mysql2": "0.45.2", "@opentelemetry/instrumentation-pg": "0.51.1", "@opentelemetry/instrumentation-redis-4": "0.46.1", "@opentelemetry/instrumentation-tedious": "0.18.1", "@opentelemetry/instrumentation-undici": "0.10.1", "@opentelemetry/resources": "^1.30.1", "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.11.1", "@sentry/core": "9.44.0", "@sentry/node-core": "9.44.0", "@sentry/opentelemetry": "9.44.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-rU96Q7q7hL4s328z9zFS+ZRK6eHnLFjYbH8XHCxAxGFDLyg9kpkR5to9PjoI+QVPZ/LYAE+Xw0wStoMjWMCFsA=="], + "@sentry/node": ["@sentry/node@9.44.2", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.2", "@opentelemetry/instrumentation-amqplib": "^0.46.1", "@opentelemetry/instrumentation-connect": "0.43.1", "@opentelemetry/instrumentation-dataloader": "0.16.1", "@opentelemetry/instrumentation-express": "0.47.1", "@opentelemetry/instrumentation-fs": "0.19.1", "@opentelemetry/instrumentation-generic-pool": "0.43.1", "@opentelemetry/instrumentation-graphql": "0.47.1", "@opentelemetry/instrumentation-hapi": "0.45.2", "@opentelemetry/instrumentation-http": "0.57.2", "@opentelemetry/instrumentation-ioredis": "0.47.1", "@opentelemetry/instrumentation-kafkajs": "0.7.1", "@opentelemetry/instrumentation-knex": "0.44.1", "@opentelemetry/instrumentation-koa": "0.47.1", "@opentelemetry/instrumentation-lru-memoizer": "0.44.1", "@opentelemetry/instrumentation-mongodb": "0.52.0", "@opentelemetry/instrumentation-mongoose": "0.46.1", "@opentelemetry/instrumentation-mysql": "0.45.1", "@opentelemetry/instrumentation-mysql2": "0.45.2", "@opentelemetry/instrumentation-pg": "0.51.1", "@opentelemetry/instrumentation-redis-4": "0.46.1", "@opentelemetry/instrumentation-tedious": "0.18.1", "@opentelemetry/instrumentation-undici": "0.10.1", "@opentelemetry/resources": "^1.30.1", "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.11.1", "@sentry/core": "9.44.2", "@sentry/node-core": "9.44.2", "@sentry/opentelemetry": "9.44.2", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-HTUDD73Tdr4GvvcNGQunkqEKeijHb4WYq/NX4YZP5VOeOsKsgIUsv55EgWk1BSHAFGTW6bfeMSoqaNVWiRHn0w=="], - "@sentry/node-core": ["@sentry/node-core@9.44.0", "", { "dependencies": { "@sentry/core": "9.44.0", "@sentry/opentelemetry": "9.44.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-M6HOcA73WWzRuhqw4Fd2dqv9zEsvMteSNYOguTexIQCT2pzk1srACrt4uFfLY01s9FIKjw+tjrQfTbni2adv7Q=="], + "@sentry/node-core": ["@sentry/node-core@9.44.2", "", { "dependencies": { "@sentry/core": "9.44.2", "@sentry/opentelemetry": "9.44.2", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-TnyKZQ4FOCA+mkLLaOzFPePUBRBf0FU62hnNMscJviwb0UloOvHXx4Ub1DudfFFdnIeVSSMU96ou8vW1zR/1Uw=="], - "@sentry/opentelemetry": ["@sentry/opentelemetry@9.44.0", "", { "dependencies": { "@sentry/core": "9.44.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-OeMiVoLqEXtpYE2VBAGmhK4GfbUa5ivDtL+AF4B+cR+NZkqZFlnA7ItquVfAa2Jd45TIyueEK8yjan5hluQYJQ=="], + "@sentry/opentelemetry": ["@sentry/opentelemetry@9.44.2", "", { "dependencies": { "@sentry/core": "9.44.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-KeW5MPXyq9Q8ieYUHO0PuzNNYEYizmTH6x02PG400GwmoeNxnT59Afa4TuPcrXN0QUmK76HJuPfC+7CTuCgoKA=="], "@sentry/types": ["@sentry/types@8.55.0", "", { "dependencies": { "@sentry/core": "8.55.0" } }, "sha512-6LRT0+r6NWQ+RtllrUW2yQfodST0cJnkOmdpHA75vONgBUhpKwiJ4H7AmgfoTET8w29pU6AnntaGOe0LJbOmog=="], @@ -1129,7 +1129,7 @@ "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], - "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], + "@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -1181,41 +1181,41 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.38.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/type-utils": "8.38.0", "@typescript-eslint/utils": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/type-utils": "8.39.0", "@typescript-eslint/utils": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.38.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.38.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.38.0", "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.0", "@typescript-eslint/types": "^8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0" } }, "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.38.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.38.0", "", {}, "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.0", "@typescript-eslint/tsconfig-utils": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20250802.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20250802.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20250802.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20250802.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20250802.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20250802.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20250802.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20250802.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-CuxxalPlgXIiAN2b884D/3UX6M1FvAi+MgruRVp4kuKOfpmBjJr6PRhN12iekFQwRG2fPil8svIm4mPnSM71lw=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20250806.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20250806.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20250806.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20250806.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20250806.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20250806.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20250806.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20250806.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-dQWOle1t2QJHiGK+ILB5SCz1Q6VfGtkrZybT4FPSaddkskYN1djY6voxza8hd8yl1Yw0PmBmbqk3E4vTKyibig=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20250802.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b8qDaguy/41W2PsA4TmGZ8wlaAg/SzG3jGJTPxv5o3tsyLQ7J/KqHljTox7RPC4tOnTzlsHYJXIuOUM+ZEIGcw=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20250806.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rRlWJEkaNCJmd5Ph4NQAJK4NMVwhWuycMKeGxRw8Lj68kNymZTVsNi/TDYN3I1InBj7Fg2kwzygessHHzZTb8A=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20250802.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-WnHvj1RcW5PeQ7vPcUhGV4F1iyl749EjiWc1FIji1nFK46AJ0NXEO6I+AeqkNTyhpnenDj1YRlqW0JyziiBZSg=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20250806.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-n2RRV0+SHR6QnaTlJoTX6Q4M7T/P0GtWfOGiKa7QUzQFRQQNdZpTv6P2KAB38b/cfCfX7PRb6mhCWK9FMHwB3w=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20250802.1", "", { "os": "linux", "cpu": "arm" }, "sha512-oEtMLt/iqADX7Dy4eVPQXzEA3kGVw47MEosHPg9CTnb9bZrEabwL8yC9knux12pqIH6zhdZAYkQIWxL6jUMOTA=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20250806.1", "", { "os": "linux", "cpu": "arm" }, "sha512-uriaW4phv1E6YkGTacGq8YzdZ11F1lgH1kfRjwDpzNB+UDV4ONqihU0mN1y0JRHhlqqhtKhBL6UnSqd4mbu6zA=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20250802.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpDnhJTsbdOihfBCbnWfF6terDfgNtW/+rwFK1VbLY6JzOQdxWHiTwP+vYA1XBmNmQTidYGNF5ahfCYwx2kftw=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20250806.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwRhKF3t4OCp4ClfNrXIXwS5G+02AymnmgFGrhvnF0Wg5II8ReVVu9mvazVcfrTN9RtJo4xVofstSF2EbgQ0Sg=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20250802.1", "", { "os": "linux", "cpu": "x64" }, "sha512-z+QPLdLcjkFPQ+HbBunq6U50Rd6Ywmu5ICAY4NaKgu5l1u/AyyMMhVaSINWjlZacB8CHGRl7ONb1z/vtH/iVyA=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20250806.1", "", { "os": "linux", "cpu": "x64" }, "sha512-EJ5/obfzrDse9KYXzZBpwzdpPad7+y/R+atXApAekIdULHLjYZ/5j/tZ9UMEnMWkCmZaFK6JpfHOG1jOGy71nA=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20250802.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-diHuHgkeqKvQeP9VHMMBv7KJ0RvnPQ+ROenosRiw+sC5wButa5gR+vl1CLOtuhZXawnuRu20cxx9OFOgoODTLA=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20250806.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-hHY/4xBsV9SWqb2nAmd9J9FQfsbte5Q4WMB2JVXSbjni0EUdDJmQgdmQ+V7v5teMaaTYhpnRrdsO95jPiR3JAA=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20250802.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tGTxLuIrEZG8+Pze1QWDoB6AcnoiQ6S1txaeGub0SD8nOCgAiNmqAmaJekeEYlmHm8cj8j0u2G/l+geC9StYDA=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20250806.1", "", { "os": "win32", "cpu": "x64" }, "sha512-CFFcMz1CwZPL4TKaR10h/sZuRzGE0kLNfXq1zDDgsL1aO8gcOSDuVKrYDdwYGdedLUCINSort7kyv9nIFUKSqQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1237,19 +1237,19 @@ "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], - "ag-charts-community": ["ag-charts-community@12.1.0", "", { "dependencies": { "ag-charts-core": "12.1.0", "ag-charts-locale": "12.1.0", "ag-charts-types": "12.1.0" } }, "sha512-UEY5SAnW46sE/QNq0hTAz3VlpE2qNScP1DEqAcDzNlxO10y16/Q3E9m0uY0Jprn1jGDrkRTdAFhcSPbkqMZ+Aw=="], + "ag-charts-community": ["ag-charts-community@12.1.1", "", { "dependencies": { "ag-charts-core": "12.1.1", "ag-charts-locale": "12.1.1", "ag-charts-types": "12.1.1" } }, "sha512-5RfYXwqUr4yR1KIQAPvCsF9uaNRV6Ghs6Bj9tMn5bHL0xf1EOncEpkzXZjIXpM4yGxYk8UJKRTI3mByPjYRe8A=="], - "ag-charts-core": ["ag-charts-core@12.1.0", "", { "dependencies": { "ag-charts-types": "12.1.0" } }, "sha512-jfEqb9Ag1Bac7EoYOV8dtlGPL8AXce7+36ggL/2zfrvH/TgSflTYSh1lpGnfDkC0Q4zsl4L4StuBOK25y5/SSQ=="], + "ag-charts-core": ["ag-charts-core@12.1.1", "", { "dependencies": { "ag-charts-types": "12.1.1" } }, "sha512-Kp/fXAtNmHWWo4MTHfYN1O4O5F3wWF5ZhLcjbfrdBcx3rBdbQGw46K/FcclK8Wyer3tmITBG9EZQFO/CDeu4vA=="], - "ag-charts-locale": ["ag-charts-locale@12.1.0", "", {}, "sha512-J7hO9oYAExsN/qv7MZRB6way+q+Z3ePoTXYBvCz+GA7NQ9dgpK/dnyZbWrakF2wlAW0OqasirzhSUM93PVB8kQ=="], + "ag-charts-locale": ["ag-charts-locale@12.1.1", "", {}, "sha512-kpmtZvC12idQcNPnM3BIALmFUXvbb2TtCZtTuXqWdmtT7ykgnQCWWFczLOcM4EsgfnaudATI952JLAeQ/C2wwQ=="], - "ag-charts-react": ["ag-charts-react@12.1.0", "", { "dependencies": { "ag-charts-community": "12.1.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-V4RtA03zhUdqJNVAO/CUfCTpW2Q/Ior/cx5E6lKBtIDQkAfO0AxI/jTnukeOC3jflyiJWHe3kzGWYUhiGokjlg=="], + "ag-charts-react": ["ag-charts-react@12.1.1", "", { "dependencies": { "ag-charts-community": "12.1.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-FaFFXdICK1CVfIzCkIZN3L44ME9H6ddDAFWGcHGviA2PtfSz6WiT8fgYLIFvn7l6Qx9IcWMfqFpJ3OTf+DV8tw=="], - "ag-charts-types": ["ag-charts-types@12.1.0", "", {}, "sha512-qeODwJ1EqKjpwEbp0mQ2wQ0arRNYaZo2BafdAGfcuOwjOBlagSwJvUg5MCvAYZ/W/mg2uEmt7jKMNfDy4ul4+Q=="], + "ag-charts-types": ["ag-charts-types@12.1.1", "", {}, "sha512-VbAOfp1E7+Z/TBufJOIjUYdx70kQRDg49WoluiVKVSB0r0el/uvxARp9xjzx4ByBq9+Xq/V23tJVsRj1MS2A/g=="], - "ag-grid-community": ["ag-grid-community@34.1.0", "", { "dependencies": { "ag-charts-types": "12.1.0" } }, "sha512-3rZiOyyCGqSNqqTsrWafDVj1WfK43jfb53Ka5sqzdOG/yu6ySUFmdc0h/OuGLnkzwW5PC29coQwbS2rkb4c9dA=="], + "ag-grid-community": ["ag-grid-community@34.1.1", "", { "dependencies": { "ag-charts-types": "12.1.1" } }, "sha512-ODVvGoMTkyGvMT8b5lzvum5r93bG6CKdJdNrk6u/aYS7oqZ5rUEXJJHC8n8Zq+o76KhFiXMBQrU39xuhz8i+Tg=="], - "ag-grid-react": ["ag-grid-react@34.1.0", "", { "dependencies": { "ag-grid-community": "34.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CY1p4/JnvcwOt2HipmsqME9CWz7M21nb3OB1DhJGOvNUaxo1wF6Hb/pKpa20F3F/E93wQCNmCz+gfrSLPuJrQw=="], + "ag-grid-react": ["ag-grid-react@34.1.1", "", { "dependencies": { "ag-grid-community": "34.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ZffTltCJe5+yzC57LVTMFpjZiYUzFiX8+G4NNfM1MfiLV6cMl5kCEE6w3Afc0nSt8b1JCAZOiX6K0+VXqjIfYQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -1293,7 +1293,7 @@ "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], - "autumn-js": ["autumn-js@0.0.77", "", { "dependencies": { "chalk": "^5.4.1", "rou3": "^0.6.1", "swr": "^2.3.3" } }, "sha512-P5btZQjaq/y4A7lcd4aK3QgZBtLZKwwQypCvJrX8SmLG/Oe+wyuWzIhdNsa/fO5ATExNmAkdP344dSUlfbgQnQ=="], + "autumn-js": ["autumn-js@0.1.8", "", { "dependencies": { "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^3.24.1" }, "peerDependencies": { "better-auth": "^1.2.12", "better-call": "^1.0.12" }, "optionalPeers": ["better-auth", "better-call"] }, "sha512-tayOSj4hFYUvfaIkmjwDyMMnKZ4duTMzuLCOXBT1HZSgO/3guAqEmaz3OQupdw/JbhpnsOEVsQfrFV2dFUU2Mw=="], "axios": ["axios@1.11.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA=="], @@ -1321,7 +1321,7 @@ "better-auth": ["better-auth@1.3.4", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.12", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.28.1", "nanostores": "^0.11.3", "zod": "^4.0.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-JbZYam6Cs3Eu5CSoMK120zSshfaKvrCftSo/+v7524H1RvhryQ7UtMbzagBcXj0Digjj8hZtVkkR4tTZD/wK2g=="], - "better-call": ["better-call@1.0.12", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-ssq5OfB9Ungv2M1WVrRnMBomB0qz1VKuhkY2WxjHaLtlsHoSe9EPolj1xf7xf8LY9o3vfk3Rx6rCWI4oVHeBRg=="], + "better-call": ["better-call@1.0.13", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-auqdP9lnNOli9tKpZIiv0nEIwmmyaD/RotM3Mucql+Ef88etoZi/t7Ph5LjlmZt/hiSahhNTt6YVnx6++rziXA=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -1367,7 +1367,7 @@ "chai-http": ["chai-http@5.1.2", "", { "dependencies": { "@types/superagent": "^8.1.7", "charset": "^1.0.1", "cookiejar": "^2.1.4", "is-ip": "^5.0.1", "methods": "^1.1.2", "qs": "^6.12.1", "superagent": "^10.0.0" } }, "sha512-UFup7mUGkkjmi9bGA7F6vfp3lzGQZjtL//CEd+a4C+vlynSv756XHDUK8PoYk/UpTBBXqSghjQaJOUMUxJXNaA=="], - "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + "chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -1447,7 +1447,7 @@ "cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="], - "core-js": ["core-js@3.44.0", "", {}, "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw=="], + "core-js": ["core-js@3.45.0", "", {}, "sha512-c2KZL9lP4DjkN3hk/an4pWn5b5ZefhRJnAc42n6LJ19kSnbeRbdQZE5dSeE2LBol1OwJD3X1BQvFTAsa8ReeDA=="], "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], @@ -1543,7 +1543,7 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "devtools-protocol": ["devtools-protocol@0.0.1464554", "", {}, "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw=="], + "devtools-protocol": ["devtools-protocol@0.0.1475386", "", {}, "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA=="], "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], @@ -1567,7 +1567,7 @@ "drizzle-orm": ["drizzle-orm@0.43.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="], - "drizzle-zod": ["drizzle-zod@0.8.2", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": "^3.25.1" } }, "sha512-9Do/16OjFFNrQDZgvMtxtDDwKWbFOxUAIwNPKX98SfxrP8H18vhN1BvNXbhelLcdgCE7GEaXDJqBjMExSkhpkA=="], + "drizzle-zod": ["drizzle-zod@0.8.3", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1575,7 +1575,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.194", "", {}, "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.197", "", {}, "sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1587,7 +1587,7 @@ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -2099,7 +2099,7 @@ "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], - "next": ["next@15.4.5", "", { "dependencies": { "@next/env": "15.4.5", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.5", "@next/swc-darwin-x64": "15.4.5", "@next/swc-linux-arm64-gnu": "15.4.5", "@next/swc-linux-arm64-musl": "15.4.5", "@next/swc-linux-x64-gnu": "15.4.5", "@next/swc-linux-x64-musl": "15.4.5", "@next/swc-win32-arm64-msvc": "15.4.5", "@next/swc-win32-x64-msvc": "15.4.5", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-nJ4v+IO9CPmbmcvsPebIoX3Q+S7f6Fu08/dEWu0Ttfa+wVwQRh9epcmsyCPjmL2b8MxC+CkBR97jgDhUUztI3g=="], + "next": ["next@15.4.6", "", { "dependencies": { "@next/env": "15.4.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.6", "@next/swc-darwin-x64": "15.4.6", "@next/swc-linux-arm64-gnu": "15.4.6", "@next/swc-linux-arm64-musl": "15.4.6", "@next/swc-linux-x64-gnu": "15.4.6", "@next/swc-linux-x64-musl": "15.4.6", "@next/swc-win32-arm64-msvc": "15.4.6", "@next/swc-win32-x64-msvc": "15.4.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-us++E/Q80/8+UekzB3SAGs71AlLDsadpFMXVNM/uQ0BMwsh9m3mr0UNQIfjKed8vpWXsASe+Qifrnu1oLIcKEQ=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -2217,7 +2217,7 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "posthog-js": ["posthog-js@1.258.5", "", { "dependencies": { "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" }, "peerDependencies": { "@rrweb/types": "2.0.0-alpha.17", "rrweb-snapshot": "2.0.0-alpha.17" }, "optionalPeers": ["@rrweb/types", "rrweb-snapshot"] }, "sha512-Tx6CzS8MsGAQGPrQth5TbkGxGQgAY01SktNW773/KDmVOWiRVZq/WQF/MRJRiuFxJ7qjethZQi3aBWfWKdr1RA=="], + "posthog-js": ["posthog-js@1.258.6", "", { "dependencies": { "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" }, "peerDependencies": { "@rrweb/types": "2.0.0-alpha.17", "rrweb-snapshot": "2.0.0-alpha.17" }, "optionalPeers": ["@rrweb/types", "rrweb-snapshot"] }, "sha512-vL5AGG+rOoRg3LGquMfBPO55jD4bGl0CiV44SHdHAoBnOVDDAqxczRGDqMdxor+VLx3/ofTFOJ2FNprfAHp70Q=="], "posthog-node": ["posthog-node@4.18.0", "", { "dependencies": { "axios": "^1.8.2" } }, "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw=="], @@ -2255,7 +2255,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "puppeteer-core": ["puppeteer-core@24.15.0", "", { "dependencies": { "@puppeteer/browsers": "2.10.6", "chromium-bidi": "7.2.0", "debug": "^4.4.1", "devtools-protocol": "0.0.1464554", "typed-query-selector": "^2.12.0", "ws": "^8.18.3" } }, "sha512-2iy0iBeWbNyhgiCGd/wvGrDSo73emNFjSxYOcyAqYiagkYt5q4cPfVXaVDKBsukgc2fIIfLAalBZlaxldxdDYg=="], + "puppeteer-core": ["puppeteer-core@24.16.0", "", { "dependencies": { "@puppeteer/browsers": "2.10.6", "chromium-bidi": "7.2.0", "debug": "^4.4.1", "devtools-protocol": "0.0.1475386", "typed-query-selector": "^2.12.0", "ws": "^8.18.3" } }, "sha512-tZ0tJiOYaDGTRzzr2giDpf8O/55JsoqkrafS1Xu4H6S8oP4eeL6RbZzY9OzjShSf5EQvx/zAc55QKpDqzXos/Q=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -2323,7 +2323,7 @@ "recaseai": ["recaseai@0.0.37", "", { "dependencies": { "@anthropic-ai/sdk": "^0.32.1", "@supabase/supabase-js": "^2.47.2", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.10.1", "async-listen": "^3.0.1", "axios": "^1.7.9", "commander": "^12.1.0", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", "figures": "^6.1.0", "inquirer": "^12.1.0", "ksuid": "^3.0.0", "nanoid": "^5.0.9", "openai": "^4.76.0", "ora": "^8.1.1", "picocolors": "^1.1.1", "pino": "^9.5.0", "tsx": "^4.19.2", "typescript": "^5.7.2" }, "bin": { "recase": "dist/cli.js" } }, "sha512-cKVMWTGBnGtm8K+uD2vfMXOzxdHj1U3++vvTePpOoZABI/SY/jjDpW997vM5xeDnzz7Vk/qqVkLQUQ/ZMDXpsQ=="], - "recharts": ["recharts@3.1.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NqAqQcGBmLrfDs2mHX/bz8jJCQtG2FeXfE0GqpZmIuXIjkpIwj8sd9ad0WyvKiBKPd8ZgNG0hL85c8sFDwascw=="], + "recharts": ["recharts@3.1.2", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g=="], "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], @@ -2341,7 +2341,7 @@ "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], - "resend": ["resend@4.7.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-30IbXGBUbmDweQH2IlO53XOXX7ndjYV9xFZ8IEBiWqefqQ/qmTsgrX0Ab6MUnmobJXbpdReVv+iXGRQPubQL5Q=="], + "resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -2431,7 +2431,7 @@ "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], - "sonner": ["sonner@2.0.6", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2487,7 +2487,7 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svix": ["svix@1.70.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "@types/node": "^22.7.5", "es6-promise": "^4.2.8", "fast-sha256": "^1.3.0", "svix-fetch": "^3.0.0", "url-parse": "^1.5.10", "uuid": "^10.0.0" } }, "sha512-ZrYvUoHPPtzp+C3t5c2hvMMleeZL7teFMigt6u/gdlpEKyPopeX74eMJuHtuPixB+nB24gfWJ1W1N3B5ZZ+AJw=="], + "svix": ["svix@1.70.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "@types/node": "^22.7.5", "es6-promise": "^4.2.8", "fast-sha256": "^1.3.0", "svix-fetch": "^3.0.0", "url-parse": "^1.5.10", "uuid": "^10.0.0" } }, "sha512-UDO8VBNyfZcqRjQQLanrOanGK+nCaMhBT7LJyXQBhPLEUcoDzRUhKujFdivl2OrleUVNknmuuz7CnYZ+4Axugg=="], "svix-fetch": ["svix-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw=="], @@ -2567,13 +2567,13 @@ "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], - "typescript-eslint": ["typescript-eslint@8.38.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.38.0", "@typescript-eslint/parser": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg=="], + "typescript-eslint": ["typescript-eslint@8.39.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.0", "@typescript-eslint/parser": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "undefsafe": ["undefsafe@2.0.5", "", {}, "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA=="], - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], @@ -2693,8 +2693,6 @@ "@autumn/vite/@types/node": ["@types/node@22.17.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ=="], - "@autumn/vite/autumn-js": ["autumn-js@0.0.93", "", { "dependencies": { "chalk": "^5.4.1", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^3.24.1" }, "peerDependencies": { "better-auth": "^1.2.12", "better-call": "^1.0.12" }, "optionalPeers": ["better-auth", "better-call"] }, "sha512-zGq9cfkhHL6xAHlervcZHO2WQ28mHNoxKKI4vnkvKifW7ETDcQk0T7xdJS9g/a+BJh7CLqe697gEdMblMUDfUA=="], - "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], "@autumn/vite/stripe": ["stripe@18.4.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ=="], @@ -2713,7 +2711,7 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@better-auth/stripe/zod": ["zod@4.0.14", "", {}, "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw=="], + "@better-auth/stripe/zod": ["zod@4.0.15", "", {}, "sha512-2IVHb9h4Mt6+UXkyMs0XbfICUh1eUrlJJAOupBHUhLRnKkruawyDddYRCs0Eizt900ntIMk9/4RksYl+FgSpcQ=="], "@browserbasehq/sdk/@types/node": ["@types/node@18.19.121", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-bHOrbyztmyYIi4f1R0s17QsPs1uyyYnGcXeZoGEd227oZjry0q6XQBQxd82X1I57zEfwO8h9Xo+Kl5gX1d9MwQ=="], @@ -3039,25 +3037,11 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/eslint-plugin/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - - "@typescript-eslint/parser/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - - "@typescript-eslint/project-service/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - - "@typescript-eslint/tsconfig-utils/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - - "@typescript-eslint/type-utils/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - - "@typescript-eslint/typescript-estree/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - - "@typescript-eslint/utils/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "better-auth/zod": ["zod@4.0.14", "", {}, "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw=="], + "better-auth/zod": ["zod@4.0.15", "", {}, "sha512-2IVHb9h4Mt6+UXkyMs0XbfICUh1eUrlJJAOupBHUhLRnKkruawyDddYRCs0Eizt900ntIMk9/4RksYl+FgSpcQ=="], "better-call/rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="], @@ -3179,8 +3163,6 @@ "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "typescript-eslint/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], - "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/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/server/package.json b/server/package.json index 987c47413..3a1a78571 100644 --- a/server/package.json +++ b/server/package.json @@ -48,7 +48,7 @@ "@supabase/supabase-js": "^2.46.2", "@upstash/redis": "^1.35.1", "ai": "^4.3.10", - "autumn-js": "^0.0.77", + "autumn-js": "^0.1.8", "axios": "^1.8.3", "better-auth": "^1.2.9", "body-parser": "^1.20.3", diff --git a/server/run.sh b/server/run.sh index 1caeaa1a6..308986454 100755 --- a/server/run.sh +++ b/server/run.sh @@ -8,7 +8,7 @@ filename=$1 if [[ $filename == *"shell"* ]]; then - $filename + $filename "${@:2}" elif [[ $filename == *"/tests/"* ]]; then # Extract everything after "/tests/" path_after_tests=$(echo "$filename" | sed 's/.*\/tests\///') diff --git a/server/shell/g1.sh b/server/shell/g1.sh index 157db3eb5..a47b9d773 100755 --- a/server/shell/g1.sh +++ b/server/shell/g1.sh @@ -3,7 +3,10 @@ # Source shared configuration source "$(dirname "$0")/config.sh" -# MOCHA_PARALLEL=true $MOCHA_SETUP \ +# If contains setup then run $MOCHA_SETUP +if [[ "$2" == *"setup"* ]]; then +MOCHA_PARALLEL=true $MOCHA_SETUP +fi $MOCHA_CMD \ 'tests/attach/basic/*.ts' \ diff --git a/server/shell/g3.sh b/server/shell/g3.sh index 8fb17499d..60aa554da 100755 --- a/server/shell/g3.sh +++ b/server/shell/g3.sh @@ -15,4 +15,17 @@ $MOCHA_CMD 'tests/contUse/update/*.ts' $MOCHA_CMD 'tests/contUse/track/*.ts' -$MOCHA_CMD 'tests/contUse/roles/*.ts' \ No newline at end of file +$MOCHA_CMD 'tests/contUse/roles/*.ts' + +# G4 +$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ + 'tests/advanced/coupons/*.ts' \ + 'tests/attach/updateQuantity/*.ts' \ + 'tests/advanced/referrals/*.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' \ No newline at end of file diff --git a/server/shell/g4.sh b/server/shell/g4.sh index 654df5cae..03aa2ece4 100755 --- a/server/shell/g4.sh +++ b/server/shell/g4.sh @@ -12,10 +12,11 @@ $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \ 'tests/advanced/coupons/*.ts' \ 'tests/attach/updateQuantity/*.ts' \ 'tests/advanced/referrals/*.ts' \ - 'tests/advanced/rollovers/*.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' +$MOCHA_CMD 'tests/advanced/usage/*.ts' \ No newline at end of file diff --git a/server/src/check.ts b/server/src/check.ts index 7185b07e7..1381f4c89 100644 --- a/server/src/check.ts +++ b/server/src/check.ts @@ -33,13 +33,14 @@ import { createSupabaseClient } from "@/external/supabaseUtils.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { CusProductService } from "./internal/customers/cusProducts/CusProductService.js"; +import { logSubItems } from "./utils/scriptUtils/logUtils/logSubItems.js"; const { db, client } = initDrizzle({ maxConnections: 5 }); let orgSlugs = process.env.ORG_SLUGS!.split(","); const skipEmails = process.env.SKIP_EMAILS!.split(","); +orgSlugs = ["athenahq"]; -orgSlugs = []; const getSingleCustomer = async ({ stripeCli, customerId, @@ -136,7 +137,7 @@ const checkCustomerCorrect = async ({ "number of stripe subs should be the same as number of subscription ids" ); - const subItems = stripeSubs.flatMap((sub: any) => sub.items.data); + let subItems = stripeSubs.flatMap((sub: any) => sub.items.data); const prices = cusProductToPrices({ cusProduct }); @@ -174,7 +175,7 @@ const checkCustomerCorrect = async ({ let expectedQuantity = options?.upcoming_quantity || options?.quantity; - console.log("Sub item: ", subItem); + // console.log("Sub item: ", subItem); assert( subItem?.quantity == expectedQuantity, `sub item quantity for prepaid price (featureId: ${featureId}) should be ${expectedQuantity}` @@ -282,7 +283,7 @@ export const check = async () => { let customerId; - // customerId = "SwZ6cDgOFAY9S29nHigvPaUk4uq2"; + // customerId = "94fd7303-c8ae-4873-a6cd-0f5241aef232"; let customers: FullCustomer[] = []; let stripeSubs: Stripe.Subscription[] = []; diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts index 23ff45dfc..121d45b2b 100644 --- a/server/src/cron/cronUtils.ts +++ b/server/src/cron/cronUtils.ts @@ -104,10 +104,11 @@ const handleShortDurationCusEnt = async ({ let resetCusEnt = { ...cusEnt, - next_reset_at: getNextResetAt( - new UTCDate(cusEnt.next_reset_at!), - ent.interval as EntInterval - ), + next_reset_at: getNextResetAt({ + curReset: new UTCDate(cusEnt.next_reset_at!), + interval: ent.interval as EntInterval, + intervalCount: ent.interval_count, + }), adjustment: 0, ...getResetBalancesUpdate({ cusEnt, @@ -116,9 +117,6 @@ const handleShortDurationCusEnt = async ({ }; let newCusEnt = resetCusEnt; - // let { customer, customer_product, entitlement, ...rest } = resetCusEnt; - // let newCusEnt = rest as CustomerEntitlement; - let rolloverUpdate = getRolloverUpdates({ cusEnt, nextResetAt: cusEnt.next_reset_at! as number, @@ -248,10 +246,11 @@ export const resetCustomerEntitlement = async ({ // 1. Check if should reset - let nextResetAt = getNextResetAt( - new UTCDate(cusEnt.next_reset_at!), - cusEnt.entitlement.interval as EntInterval - ); + let nextResetAt = getNextResetAt({ + curReset: new UTCDate(cusEnt.next_reset_at!), + interval: cusEnt.entitlement.interval as EntInterval, + intervalCount: cusEnt.entitlement.interval_count, + }); let rolloverUpdate = getRolloverUpdates({ cusEnt, diff --git a/server/src/db/initDrizzle.ts b/server/src/db/initDrizzle.ts index 240738a0e..3a8b4dea8 100644 --- a/server/src/db/initDrizzle.ts +++ b/server/src/db/initDrizzle.ts @@ -8,9 +8,6 @@ import { schemas as schema } from "@autumn/shared"; export let client = postgres(process.env.DATABASE_URL!); export let db = drizzle(client, { schema }); -// export const localClient = postgres("postgresql://postgres:postgres@localhost:54322/postgres") -// export const localDb = drizzle(localClient, { schema }); - export const initDrizzle = (params?: { maxConnections?: number }) => { let maxConnections = params?.maxConnections || 10; const client = postgres(process.env.DATABASE_URL!, { @@ -19,7 +16,6 @@ export const initDrizzle = (params?: { maxConnections?: number }) => { const db = drizzle(client, { schema, - // logger: process.env.NODE_ENV === "development", }); return { db, client }; diff --git a/server/src/errors/logger.ts b/server/src/errors/logger.ts index dc5c81753..5f7589169 100644 --- a/server/src/errors/logger.ts +++ b/server/src/errors/logger.ts @@ -1,4 +1,111 @@ import pino from "pino"; +import { Writable } from "stream"; + +// Custom log formatter for Bun compatibility +const createDevLogStream = () => { + const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", + bgRed: "\x1b[41m", + }; + + const levelColors: Record = { + // Numeric levels + 10: colors.gray, // trace + 20: colors.blue, // debug + 30: colors.green, // info + 40: colors.yellow, // warn + 50: colors.red, // error + 60: colors.bgRed, // fatal + // String levels + TRACE: colors.gray, + DEBUG: colors.blue, + INFO: colors.green, + WARN: colors.yellow, + ERROR: colors.red, + FATAL: colors.bgRed, + }; + + const levelNames: Record = { + // Numeric levels + 10: "TRACE", + 20: "DEBUG", + 30: "INFO", + 40: "WARN", + 50: "ERROR", + 60: "FATAL", + // String levels (pass through) + TRACE: "TRACE", + DEBUG: "DEBUG", + INFO: "INFO", + WARN: "WARN", + ERROR: "ERROR", + FATAL: "FATAL", + }; + + return new Writable({ + write(chunk, encoding, callback) { + try { + const log = JSON.parse(chunk.toString()); + const timestamp = new Date(log.time) + .toISOString() + .replace("T", " ") + .replace("Z", ""); + const level = log.level; + const levelColor = levelColors[level] || colors.white; + const levelName = + levelNames[level] || (typeof level === "string" ? level : "UNKNOWN"); + + // Format the message + let message = log.msg || ""; + + // Add any additional fields (excluding standard pino fields) + const excludeFields = [ + "time", + "level", + "msg", + "pid", + "hostname", + "res", + "statusCode", + "worker", + "context", + "req", + "data", + ]; + const additionalFields = Object.keys(log) + .filter((key) => !excludeFields.includes(key)) + .reduce((acc, key) => { + acc[key] = log[key]; + return acc; + }, {} as any); + + if (Object.keys(additionalFields).length > 0) { + message += " " + JSON.stringify(additionalFields, null, 2); + } + + // Format the final log line + const formattedLog = `${colors.gray}${timestamp}${colors.reset} ${levelColor}${colors.bright}${levelName}${colors.reset} ${message}\n`; + + process.stdout.write(formattedLog); + callback(); + } catch (error) { + // Fallback for malformed JSON + process.stdout.write(chunk); + callback(); + } + }, + }); +}; export const initLogger = () => { // Create separate streams for console and HyperDX @@ -7,26 +114,7 @@ export const initLogger = () => { if (process.env.NODE_ENV === "development") { streams.push({ level: process.env.NODE_ENV === "development" ? "debug" : "info", - stream: pino.transport({ - target: "pino-pretty", - options: { - colorize: true, - translateTime: "UTC:yyyy-mm-dd HH:MM:ss", - ignore: "pid,hostname,res,statusCode,worker,context,req", - customColors: { - default: "white", - 60: "bgRed", - 50: "red", - 40: "yellow", - 30: "green", - 20: "blue", - 10: "gray", - message: "reset", - greyMessage: "gray", - time: "darkGray", - }, - }, - }), + stream: createDevLogStream(), }); } @@ -55,7 +143,7 @@ export const initLogger = () => { }, }, // Use multistream to send logs to multiple destinations - pino.multistream(streams), + pino.multistream(streams) ); return logger; diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 92922a3ed..0557c6ad2 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -12,6 +12,8 @@ import { } from "@autumn/shared"; import { CancelParams, + CheckoutParams, + CheckoutResult, CheckParams, CheckResult, Customer, @@ -168,6 +170,16 @@ export class AutumnInt { return data; } + async checkout(params: CheckoutParams) { + // const data = await this.post(`/attach`, { + // customer_id: customerId, + // product_id: productId, + // options: toSnakeCase(options), + // }); + const data = await this.post(`/checkout`, params); + + return data as CheckoutResult; + } async sendEvent({ customerId, diff --git a/server/src/external/logtail/logtailUtils.ts b/server/src/external/logtail/logtailUtils.ts index 89f054f35..a51df8e75 100644 --- a/server/src/external/logtail/logtailUtils.ts +++ b/server/src/external/logtail/logtailUtils.ts @@ -32,6 +32,7 @@ const createLogMethod = (pinoMethod: any, logtailMethod?: any) => { const strings = args .filter((arg) => typeof arg === "string") .map(rewriteAppPath); + const objects = args .filter((arg) => typeof arg !== "string" && arg !== null) .map((obj) => (obj instanceof Error ? rewriteErrorStack(obj) : obj)); @@ -44,7 +45,7 @@ const createLogMethod = (pinoMethod: any, logtailMethod?: any) => { const errorObject = args.find((arg) => arg instanceof Error); if (errorObject) { message = rewriteAppPath( - errorObject.stack || errorObject.message || "Error occurred", + errorObject.stack || errorObject.message || "Error occurred" ); } } diff --git a/server/src/external/resend/resendUtils.ts b/server/src/external/resend/resendUtils.ts index 76c6e8d5d..84efc6a80 100644 --- a/server/src/external/resend/resendUtils.ts +++ b/server/src/external/resend/resendUtils.ts @@ -11,10 +11,11 @@ export interface ResendEmailProps { body: string; from: string; fromEmail?: string; + replyTo?: string; } export const nameToEmail = (name: string) => { - return `${name.toLowerCase().replace(/\s+/g, ".")}@${process.env.RESEND_DOMAIN}`; + return `${name.toLowerCase().replace(/\s+/g, ".")}@hey.${process.env.RESEND_DOMAIN}`; }; export const sendTextEmail = async ({ @@ -71,6 +72,7 @@ export const sendHtmlEmail = async ({ subject, body, fromEmail, + replyTo, }: ResendEmailProps) => { const resend = createResendCli(); @@ -83,5 +85,6 @@ export const sendHtmlEmail = async ({ to: to, subject: subject, html: body, + replyTo, }); }; diff --git a/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts b/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts index 1c33b65ef..05562fb93 100644 --- a/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts +++ b/server/src/external/stripe/createStripePrice/createStripeArrearProrated.ts @@ -94,7 +94,10 @@ export const createStripeMeteredPrice = async ({ currency: org.default_currency || "usd", nickname: `Autumn Price (${feature!.name}) [Placeholder]`, recurring: { - ...(billingIntervalToStripe(price.config!.interval!) as any), + ...(billingIntervalToStripe({ + interval: price.config!.interval, + intervalCount: price.config!.interval_count, + }) as any), meter: meter!.id, usage_type: "metered", }, @@ -105,7 +108,7 @@ export const createStripeMeteredPrice = async ({ export const arrearProratedToStripeTiers = ( price: Price, - entitlement: EntitlementWithFeature, + entitlement: EntitlementWithFeature ) => { let usageConfig = structuredClone(price.config) as UsagePriceConfig; @@ -161,7 +164,10 @@ export const createStripeArrearProrated = async ({ let recurringData = undefined; if (price.config!.interval != BillingInterval.OneOff) { - recurringData = billingIntervalToStripe(price.config!.interval!); + recurringData = billingIntervalToStripe({ + interval: price.config!.interval, + intervalCount: price.config!.interval_count, + }); } const config = price.config as UsagePriceConfig; diff --git a/server/src/external/stripe/createStripePrice/createStripeFixedPrice.ts b/server/src/external/stripe/createStripePrice/createStripeFixedPrice.ts index dc4fdfd87..a37118b80 100644 --- a/server/src/external/stripe/createStripePrice/createStripeFixedPrice.ts +++ b/server/src/external/stripe/createStripePrice/createStripeFixedPrice.ts @@ -28,7 +28,10 @@ export const createStripeFixedPrice = async ({ unit_amount: amount, currency: org.default_currency!, recurring: { - ...(billingIntervalToStripe(config.interval!) as any), + ...(billingIntervalToStripe({ + interval: config.interval, + intervalCount: config.interval_count, + }) as any), }, nickname: `Autumn Price (Fixed)`, diff --git a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts index 4e3526230..64d898f47 100644 --- a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts +++ b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts @@ -53,7 +53,7 @@ export const searchStripeMeter = async ({ logger.info(`Stripe meter list took ${end - start}ms`); let stripeMeter = allStripeMeters.find( - (m) => m.event_name == eventName || m.id == meterId, + (m) => m.event_name == eventName || m.id == meterId ); return stripeMeter; @@ -87,7 +87,7 @@ export const getStripeMeter = async ({ createNew = true; } else { logger.info( - `✅ Found existing meter for ${product.name} - ${feature!.name}`, + `✅ Found existing meter for ${product.name} - ${feature!.name}` ); return stripeMeter; } @@ -107,7 +107,7 @@ export const getStripeMeter = async ({ // IN ARREAR export const priceToInArrearTiers = ( price: Price, - entitlement: Entitlement, + entitlement: Entitlement ) => { let usageConfig = structuredClone(price.config) as UsagePriceConfig; const tiers: any[] = []; @@ -177,7 +177,7 @@ export const createStripeInArrearPrice = async ({ if (internalEntityId && !useCheckout) { if (!curStripeProduct) { logger.info( - `Creating stripe in arrear product for ${relatedEnt.feature.name} (internal entity ID exists!)`, + `Creating stripe in arrear product for ${relatedEnt.feature.name} (internal entity ID exists!)` ); let stripeProduct = await stripeCli.products.create({ name: `${product.name} - ${feature!.name}`, @@ -199,7 +199,7 @@ export const createStripeInArrearPrice = async ({ } logger.info( - `Creating stripe in arrear price for ${relatedEnt.feature.name} (no internal entity ID)`, + `Creating stripe in arrear price for ${relatedEnt.feature.name} (no internal entity ID)` ); if (!feature) { @@ -223,7 +223,7 @@ export const createStripeInArrearPrice = async ({ const tiers = priceToInArrearTiers( price, - getPriceEntitlement(price, entitlements), + getPriceEntitlement(price, entitlements) ); let priceAmountData = {}; @@ -257,7 +257,10 @@ export const createStripeInArrearPrice = async ({ ...priceAmountData, currency: org.default_currency!, recurring: { - ...(billingIntervalToStripe(price.config!.interval!) as any), + ...(billingIntervalToStripe({ + interval: price.config!.interval, + intervalCount: price.config!.interval_count, + }) as any), meter: meter!.id, usage_type: "metered", }, diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index fa494248b..83ad3ee51 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -71,7 +71,10 @@ export const createStripePrepaid = async ({ let recurringData = undefined; if (price.config!.interval != BillingInterval.OneOff) { - recurringData = billingIntervalToStripe(price.config!.interval!); + recurringData = billingIntervalToStripe({ + interval: price.config!.interval, + intervalCount: price.config!.interval_count, + }); } const config = price.config as UsagePriceConfig; diff --git a/server/src/external/stripe/createStripePrice/createStripePrice.ts b/server/src/external/stripe/createStripePrice/createStripePrice.ts index 8b965a652..18b406602 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrice.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrice.ts @@ -10,9 +10,7 @@ import { Organization, UsagePriceConfig, BillingType, - FeatureOptions, } from "@autumn/shared"; -import { SupabaseClient } from "@supabase/supabase-js"; import Stripe from "stripe"; import { createStripeFixedPrice } from "./createStripeFixedPrice.js"; @@ -46,10 +44,7 @@ export const checkCurStripePrice = async ({ expand: ["product"], }); - if ( - !stripePrice.active || - !(stripePrice.product as Stripe.Product).active - ) { + if (!stripePrice.active) { stripePrice = null; } } catch (error) { @@ -122,7 +117,12 @@ export const createStripePriceIFNotExist = async ({ billingType == BillingType.OneOff ) { if (!stripePrice) { - logger.info("Creating stripe fixed price"); + // logger.info("Creating stripe fixed price: ", { + // data: { + // price, + // stripePrice, + // }, + // }); await createStripeFixedPrice({ db, stripeCli, diff --git a/server/src/external/stripe/stripeInvoiceSubUtils.ts b/server/src/external/stripe/stripeInvoiceSubUtils.ts index a1e5b2d42..24abc26a8 100644 --- a/server/src/external/stripe/stripeInvoiceSubUtils.ts +++ b/server/src/external/stripe/stripeInvoiceSubUtils.ts @@ -46,11 +46,11 @@ export const createStripeSubThroughInvoice = async ({ let subItems = items.filter( (i: any, index: number) => - prices[index].config!.interval !== BillingInterval.OneOff, + prices[index].config!.interval !== BillingInterval.OneOff ); let invoiceItems = items.filter( (i: any, index: number) => - prices[index].config!.interval === BillingInterval.OneOff, + prices[index].config!.interval === BillingInterval.OneOff ); try { diff --git a/server/src/external/stripe/stripeOnboardingUtils.ts b/server/src/external/stripe/stripeOnboardingUtils.ts index 4e14dfe1b..207b864fd 100644 --- a/server/src/external/stripe/stripeOnboardingUtils.ts +++ b/server/src/external/stripe/stripeOnboardingUtils.ts @@ -7,6 +7,7 @@ export const checkKeyValid = async (apiKey: string) => { // Call customers.list const customers = await stripe.customers.list(); + // const account = await stripe.accounts.retrieve(); // console.log("Account", account); // return account; @@ -15,7 +16,7 @@ export const checkKeyValid = async (apiKey: string) => { export const createWebhookEndpoint = async ( apiKey: string, env: AppEnv, - orgId: string, + orgId: string ) => { const stripe = new Stripe(apiKey); diff --git a/server/src/external/stripe/stripePriceUtils.ts b/server/src/external/stripe/stripePriceUtils.ts index c7f374166..ccbc811bf 100644 --- a/server/src/external/stripe/stripePriceUtils.ts +++ b/server/src/external/stripe/stripePriceUtils.ts @@ -23,27 +23,39 @@ export const createSubMeta = ({ features }: { features: Feature[] }) => { return { usage_features: JSON.stringify(usageFeatures) }; }; -export const billingIntervalToStripe = (interval: BillingInterval) => { +export const billingIntervalToStripe = ({ + interval, + intervalCount, +}: { + interval: BillingInterval; + intervalCount?: number | null; +}) => { + const finalCount = intervalCount ?? 1; switch (interval) { + case BillingInterval.Week: + return { + interval: "week", + interval_count: finalCount, + }; case BillingInterval.Month: return { interval: "month", - interval_count: 1, + interval_count: finalCount, }; case BillingInterval.Quarter: return { interval: "month", - interval_count: 3, + interval_count: finalCount * 3, }; case BillingInterval.SemiAnnual: return { interval: "month", - interval_count: 6, + interval_count: finalCount * 6, }; case BillingInterval.Year: return { interval: "year", - interval_count: 1, + interval_count: finalCount, }; default: break; @@ -104,10 +116,12 @@ export const getPlaceholderItem = ({ product, org, interval, + intervalCount, }: { product: FullProduct; org: Organization; interval: BillingInterval; + intervalCount: number; }) => { return { price_data: { @@ -115,7 +129,10 @@ export const getPlaceholderItem = ({ unit_amount: 0, currency: org.default_currency || "usd", recurring: { - ...billingIntervalToStripe(interval as BillingInterval), + ...billingIntervalToStripe({ + interval, + intervalCount, + }), }, }, quantity: 0, diff --git a/server/src/external/stripe/stripeSubUtils.ts b/server/src/external/stripe/stripeSubUtils.ts index 1fa8390e8..d265f0991 100644 --- a/server/src/external/stripe/stripeSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils.ts @@ -45,7 +45,7 @@ export const getStripeSubs = async ({ } catch (error: any) { console.log( `(warning) getStripeSubs: Failed to get sub ${subId}`, - error.message, + error.message ); return null; } @@ -133,7 +133,7 @@ export const getUsageBasedSub = async ({ let autumnSub = autumnSubs?.find((sub) => sub.stripe_id == stripeSub.id); if (autumnSub) { let containsFeature = autumnSub.usage_features.includes( - feature.internal_id!, + feature.internal_id! ); if (containsFeature) { return stripeSub; @@ -149,7 +149,7 @@ export const getUsageBasedSub = async ({ if ( !usageFeatures || usageFeatures.find( - (feat: any) => feat.internal_id == feature.internal_id, + (feat: any) => feat.internal_id == feature.internal_id ) === undefined ) { continue; @@ -179,15 +179,14 @@ export const getSubItemsForCusProduct = async ({ prices.some( (p) => p.config?.stripe_price_id == item.price.id || - (p.config as UsagePriceConfig).stripe_product_id == - item.price.product, + (p.config as UsagePriceConfig).stripe_product_id == item.price.product ) ) { subItems.push(item); } } let otherSubItems = stripeSub.items.data.filter( - (item) => !subItems.some((i) => i.id == item.id), + (item) => !subItems.some((i) => i.id == item.id) ); return { subItems, otherSubItems }; @@ -216,12 +215,17 @@ export const getStripeSchedules = async ({ } const prices = await Promise.all(batchPricesGet); const interval = prices[0].recurring?.interval; - const billingInterval = stripeToAutumnInterval({ - interval: prices[0].recurring?.interval as string, - intervalCount: prices[0].recurring?.interval_count || 1, - }); + // const billingInterval = stripeToAutumnInterval({ + // interval: prices[0].recurring?.interval as string, + // intervalCount: prices[0].recurring?.interval_count || 1, + // }); - return { schedule, interval: billingInterval, prices }; + return { + schedule, + interval: interval as BillingInterval, + intervalCount: prices[0].recurring?.interval_count || 1, + prices, + }; } catch (error: any) { console.log("Error getting stripe schedule.", error.message); return null; @@ -237,6 +241,7 @@ export const getStripeSchedules = async ({ return schedulesAndSubs.filter((schedule) => schedule !== null) as { schedule: Stripe.SubscriptionSchedule; interval: BillingInterval; + intervalCount: number; prices: Stripe.Price[]; }[]; }; diff --git a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts index cc314ca1f..2d8ea6417 100644 --- a/server/src/external/stripe/stripeSubUtils/createStripeSub.ts +++ b/server/src/external/stripe/stripeSubUtils/createStripeSub.ts @@ -25,6 +25,7 @@ export const createStripeSub = async ({ org, freeTrial, invoiceOnly = false, + finalizeInvoice = false, anchorToUnix, itemSet, now, @@ -36,6 +37,7 @@ export const createStripeSub = async ({ freeTrial: FreeTrial | null; org: Organization; invoiceOnly?: boolean; + finalizeInvoice?: boolean; anchorToUnix?: number; itemSet: ItemSet; now?: number; @@ -58,6 +60,7 @@ export const createStripeSub = async ({ ? getAlignedIntervalUnix({ alignWithUnix: anchorToUnix, interval: itemSet.interval, + intervalCount: itemSet.intervalCount, now, }) : undefined; @@ -91,6 +94,12 @@ export const createStripeSub = async ({ coupon: reward ? reward.id : undefined, }); + if (invoiceOnly && finalizeInvoice) { + await stripeCli.invoices.finalizeInvoice( + subscription.latest_invoice as string + ); + } + // Store await SubService.createSub({ db, diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index 38fc34342..2a0723d86 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -28,16 +28,21 @@ import { import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import RecaseError from "@/utils/errorUtils.js"; import { logger } from "@/external/logtail/logtailUtils.js"; +import { + intervalKeyToPrice, + priceToIntervalKey, +} from "@/internal/products/prices/priceUtils/convertPrice.js"; const getIntervalToPrices = (prices: Price[]) => { const intervalToPrices: Record = {}; for (const price of prices) { - const interval = price.config.interval; - if (!intervalToPrices[interval]) { - intervalToPrices[interval] = []; + // const interval = price.config.interval; + const key = priceToIntervalKey(price); + if (!intervalToPrices[key]) { + intervalToPrices[key] = []; } - intervalToPrices[interval].push(price); + intervalToPrices[key].push(price); } let oneOffPrices = @@ -93,8 +98,8 @@ export const getStripeSubItems = async ({ const intervalToPrices = getIntervalToPrices(prices); - for (const interval in intervalToPrices) { - const prices = intervalToPrices[interval]; + for (const intervalKey in intervalToPrices) { + const prices = intervalToPrices[intervalKey]; let subItems: any[] = []; @@ -165,12 +170,14 @@ export const getStripeSubItems = async ({ subItems.push(lineItem); } + const { interval, intervalCount } = intervalKeyToPrice(intervalKey); if (subItems.length == 0) { subItems.push( ...getArrearItems({ prices, org, interval: interval as BillingInterval, + intervalCount, }) ); } @@ -178,6 +185,7 @@ export const getStripeSubItems = async ({ itemSets.push({ items: subItems, interval, + intervalCount, subMeta: { usage_features: JSON.stringify(usage_features), }, @@ -186,7 +194,18 @@ export const getStripeSubItems = async ({ }); } - itemSets.sort((a, b) => compareBillingIntervals(a.interval, b.interval)); + itemSets.sort((a, b) => + compareBillingIntervals({ + configA: { + interval: a.interval, + intervalCount: a.intervalCount, + }, + configB: { + interval: b.interval, + intervalCount: b.intervalCount, + }, + }) + ); return itemSets; }; diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems/getArrearItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems/getArrearItems.ts index faf408a04..475e84887 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems/getArrearItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems/getArrearItems.ts @@ -2,6 +2,7 @@ import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import { BillingInterval, BillingType, + intervalsDifferent, Organization, UsagePriceConfig, } from "@autumn/shared"; @@ -13,15 +14,25 @@ export const getArrearItems = ({ prices, org, interval, + intervalCount, }: { prices: Price[]; interval: BillingInterval; + intervalCount: number; org: Organization; }) => { let placeholderItems: any[] = []; for (const price of prices) { let billingType = getBillingType(price.config!); - if (price.config!.interval! != interval) { + if ( + intervalsDifferent({ + intervalA: { + interval: price.config!.interval!, + intervalCount: price.config!.interval_count!, + }, + intervalB: { interval, intervalCount }, + }) + ) { continue; } @@ -33,7 +44,10 @@ export const getArrearItems = ({ unit_amount: 1, currency: org.default_currency || "usd", recurring: { - ...billingIntervalToStripe(interval), + ...billingIntervalToStripe({ + interval, + intervalCount, + }), }, }, quantity: 0, diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index 0810572e7..7a755dd70 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -204,6 +204,7 @@ stripeWebhookRouter.post( logger, }); break; + case "subscription_schedule.canceled": const canceledSchedule = event.data.object; await handleSubscriptionScheduleCanceled({ diff --git a/server/src/external/stripe/utils.ts b/server/src/external/stripe/utils.ts index 834fa9d2c..1b8ea3c48 100644 --- a/server/src/external/stripe/utils.ts +++ b/server/src/external/stripe/utils.ts @@ -77,44 +77,55 @@ export const calculateMetered1Price = ({ export const subToAutumnInterval = (sub: Stripe.Subscription) => { let recuringItem = sub.items.data.find((i) => i.price.recurring != null); if (!recuringItem) { - return BillingInterval.OneOff; + return { + interval: BillingInterval.OneOff, + intervalCount: 1, + }; } - return stripeToAutumnInterval({ - interval: recuringItem.price.recurring!.interval, - intervalCount: recuringItem.price.recurring!.interval_count, - }); + return { + interval: recuringItem.price.recurring!.interval as BillingInterval, + intervalCount: recuringItem.price.recurring!.interval_count || 1, + }; + // return stripeToAutumnInterval({ + // interval: recuringItem.price.recurring!.interval, + // intervalCount: recuringItem.price.recurring!.interval_count, + // }); }; -export const stripeToAutumnInterval = ({ - interval, - intervalCount, -}: { - interval: string; - intervalCount: number; -}) => { - if (interval === "month" && intervalCount === 1) { - return BillingInterval.Month; - } +// export const stripeToAutumnInterval = ({ +// interval, +// intervalCount, +// }: { +// interval: string; +// intervalCount: number; +// }) => { +// if (interval === "month" && intervalCount === 1) { +// return BillingInterval.Month; +// } - if (interval === "month" && intervalCount === 3) { - return BillingInterval.Quarter; - } +// // if (interval === "month" && intervalCount === 3) { +// // return BillingInterval.Quarter; +// // } - if (interval === "month" && intervalCount === 6) { - return BillingInterval.SemiAnnual; - } +// // if (interval === "month" && intervalCount === 6) { +// // return BillingInterval.SemiAnnual; +// // } - if ( - (interval === "month" && intervalCount === 12) || - (interval === "year" && intervalCount === 1) - ) { - return BillingInterval.Year; - } -}; +// if ( +// (interval === "month" && intervalCount === 12) || +// (interval === "year" && intervalCount === 1) +// ) { +// return BillingInterval.Year; +// } +// }; export const subItemToAutumnInterval = (item: Stripe.SubscriptionItem) => { - return stripeToAutumnInterval({ - interval: item.price.recurring?.interval!, - intervalCount: item.price.recurring?.interval_count!, - }); + return { + interval: item.price.recurring?.interval as BillingInterval, + intervalCount: item.price.recurring?.interval_count || 1, + }; + // return stripeToAutumnInterval({ + // interval: item.price.recurring?.interval!, + // intervalCount: item.price.recurring?.interval_count!, + // }); }; diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts index a0ee36994..a7bc96461 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleCheckoutSub.ts @@ -97,8 +97,9 @@ export const handleCheckoutSub = async ({ getArrearItems({ prices: attachParams.prices, interval: attachParams.itemSets?.[0]?.interval, + intervalCount: attachParams.itemSets?.[0]?.intervalCount, org, - }), + }) ); } diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts index aaf20ec93..9e3f5c9ae 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted/handleRemainingSets.ts @@ -39,6 +39,7 @@ const filterUsagePrices = ({ prices: attachParams.prices, interval: itemSet.interval, org: attachParams.org, + intervalCount: itemSet.intervalCount, }); } @@ -89,17 +90,6 @@ export const handleRemainingSets = async ({ itemSet.items = filteredItems; - // itemSet.items = - // filteredItems.length == 0 - // ? attachParams.products.map((p: FullProduct) => - // getPlaceholderItem({ - // product: p, - // org, - // interval: itemSet.interval, - // }), - // ) - // : filteredItems; - const subscription = (await createStripeSub({ db, stripeCli, diff --git a/server/src/external/supabase/subscribeToOrgUpdates.ts b/server/src/external/supabase/subscribeToOrgUpdates.ts index 8170e4264..f0fa1524d 100644 --- a/server/src/external/supabase/subscribeToOrgUpdates.ts +++ b/server/src/external/supabase/subscribeToOrgUpdates.ts @@ -1,32 +1,21 @@ import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; -import { createSupabaseClient } from "../supabaseUtils.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { safeSb } from "./safeSb.js"; +import { DrizzleCli, client } from "@/db/initDrizzle.js"; -export const subscribeToOrgUpdates = safeSb({ - fn: ({ db }: { db: DrizzleCli }) => { - try { - const sb = createSupabaseClient(); - sb.channel("table-db-changes") - .on( - "postgres_changes", - { - event: "UPDATE", - schema: "public", - table: "organizations", - }, - async (payload) => { - try { - await clearOrgCache({ db, orgId: payload.new.id }); - } catch (error) { - console.warn("Error clearing org cache:", error); - } - }, - ) - .subscribe(); - } catch (error) { - console.warn("Error subscribing to org updates:", error); - } - }, - action: "subscribe to org updates", -}); +export const subscribeToOrgUpdates = async ({ db }: { db: DrizzleCli }) => { + try { + await client.listen("org_updates", async (payload) => { + try { + const data = JSON.parse(payload); + if (data.table === "organizations" && data.operation === "UPDATE") { + await clearOrgCache({ db, orgId: data.new.id }); + } + } catch (error) { + console.warn("Error processing org update notification:", error); + } + }); + + console.log("Successfully subscribed to organization updates via PostgreSQL LISTEN/NOTIFY"); + } catch (error) { + console.warn("Error subscribing to org updates:", error); + } +}; diff --git a/server/src/index.ts b/server/src/index.ts index 7df9d0490..9e6adcfe8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import { ClickHouseManager } from "./external/clickhouse/ClickHouseManager.js"; const tracer = trace.getTracer("express"); checkEnvVars(); +// subscribeToOrgUpdates({ db }); const init = async () => { const app = express(); @@ -45,8 +46,6 @@ const init = async () => { "https://staging.useautumn.com", "https://*.useautumn.com", "https://localhost:8080", - "https://app.aidvize.com", - "http://staging.aidvize.com", "https://www.alphalog.ai", "https://*.alphalog.ai", process.env.CLIENT_URL || "", @@ -84,8 +83,6 @@ const init = async () => { await CacheManager.getInstance(); await ClickHouseManager.getInstance(); - subscribeToOrgUpdates({ db }); - app.use(async (req: any, res: any, next: any) => { req.env = req.env = req.headers["app_env"] || AppEnv.Sandbox; req.db = db; @@ -180,7 +177,7 @@ if (process.env.NODE_ENV === "development") { console.log(`Master ${process.pid} is running`); console.log("Number of CPUs", numCPUs); - let numWorkers = 10; + let numWorkers = 7; for (let i = 0; i < numWorkers; i++) { cluster.fork(); diff --git a/server/src/internal/api/entities/getEntityUtils.ts b/server/src/internal/api/entities/getEntityUtils.ts index 595d75ed0..1834433f6 100644 --- a/server/src/internal/api/entities/getEntityUtils.ts +++ b/server/src/internal/api/entities/getEntityUtils.ts @@ -72,6 +72,14 @@ export const getEntityResponse = async ({ skipCache, }); + if (!customer) { + throw new RecaseError({ + message: `Customer ${customerId} not found`, + code: ErrCode.CustomerNotFound, + statusCode: 400, + }); + } + let entities = customer.entities.filter((e: Entity) => entityIds.includes(e.id) ); diff --git a/server/src/internal/api/entitled/checkUtils.ts b/server/src/internal/api/entitled/checkUtils.ts index 318a87b35..00b519df5 100644 --- a/server/src/internal/api/entitled/checkUtils.ts +++ b/server/src/internal/api/entitled/checkUtils.ts @@ -118,6 +118,7 @@ export const getOptions = ({ anchorToUnix, proration, interval: (i.interval || BillingInterval.OneOff) as BillingInterval, + intervalCount: i.interval_count || 1, now, }); @@ -139,7 +140,7 @@ export const getOptions = ({ } const currentOptions = cusProduct?.options.find( - (o) => o.feature_id == i.feature_id, + (o) => o.feature_id == i.feature_id ); let currentQuantity = currentOptions?.quantity; diff --git a/server/src/internal/api/events/usageRouter.ts b/server/src/internal/api/events/usageRouter.ts index f05f3a5b2..7af89e419 100644 --- a/server/src/internal/api/events/usageRouter.ts +++ b/server/src/internal/api/events/usageRouter.ts @@ -21,6 +21,7 @@ import { getEventTimestamp } from "./eventUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js"; import { logger } from "@/external/logtail/logtailUtils.js"; +import { isPaidContinuousUse } from "@/internal/features/featureUtils.js"; export const eventsRouter: Router = Router(); export const usageRouter: Router = Router(); @@ -201,9 +202,8 @@ export const handleUsageEvent = async ({ entityId: entity_id, }; - const featureUsageType = feature.config?.usage_type; - // console.log(`Feature Usage Type: ${featureUsageType}`); - if (featureUsageType === FeatureUsageType.Continuous) { + if (isPaidContinuousUse({ feature, fullCus: customer })) { + console.log(`Running update usage task synchronously`); await runUpdateUsageTask({ payload, logger: console, diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index 6900aa9d5..fe9c5f659 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -61,7 +61,12 @@ export const handleCreateCheckout = async ({ }); let billingCycleAnchorUnixSeconds = org.config.anchor_start_of_month - ? Math.floor(getNextStartOfMonthUnix(itemSets[0].interval) / 1000) + ? Math.floor( + getNextStartOfMonthUnix({ + interval: itemSets[0].interval, + intervalCount: itemSets[0].intervalCount, + }) / 1000 + ) : undefined; if (attachParams.billingAnchor) { diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index 96e4cb92a..f2ba40aa4 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -12,7 +12,10 @@ import { priceToProduct } from "@/internal/products/prices/priceUtils/findPriceU import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; import { AttachConfig } from "@autumn/shared"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; +import { + attachToInvoiceResponse, + insertInvoiceFromAttach, +} from "@/internal/invoices/invoiceUtils.js"; import { Decimal } from "decimal.js"; import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; @@ -160,7 +163,7 @@ export const handleOneOffFunction = async ({ attachParams: attachToInsertParams(attachParams, product), lastInvoiceId: stripeInvoice.id, logger, - }), + }) ); } await Promise.all(batchInsert); @@ -180,12 +183,14 @@ export const handleOneOffFunction = async ({ AttachResultSchema.parse({ success: true, message: `Successfully purchased ${productNames} and attached to ${customerName}`, - invoice: invoiceOnly ? stripeInvoice : undefined, + invoice: invoiceOnly + ? attachToInvoiceResponse({ invoice: stripeInvoice }) + : undefined, code: SuccessCode.OneOffProductAttached, product_ids: products.map((p) => p.id), customer_id: customer.id || customer.internal_id, scenario: AttachScenario.New, - }), + }) ); } }; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 938d04e12..ac5e55f7d 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -8,7 +8,10 @@ import { AttachParams, AttachResultSchema, } from "@/internal/customers/cusProducts/AttachParams.js"; -import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; +import { + attachToInvoiceResponse, + insertInvoiceFromAttach, +} from "@/internal/invoices/invoiceUtils.js"; import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; @@ -20,6 +23,7 @@ import { AttachScenario, BillingInterval, ErrCode, + intervalsDifferent, SuccessCode, } from "@autumn/shared"; import Stripe from "stripe"; @@ -78,15 +82,28 @@ export const handlePaidProduct = async ({ continue; } - let mergeWithSub = mergeSubs.find( - (sub) => subToAutumnInterval(sub) == itemSet.interval - ); + let mergeWithSub = mergeSubs.find((sub) => { + let subInterval = subToAutumnInterval(sub); + return !intervalsDifferent({ + intervalA: { + interval: subInterval.interval, + intervalCount: subInterval.intervalCount, + }, + intervalB: { + interval: itemSet.interval, + intervalCount: itemSet.intervalCount, + }, + }); + }); let subscription; try { let billingCycleAnchorUnix; if (org.config.anchor_start_of_month) { - billingCycleAnchorUnix = getNextStartOfMonthUnix(itemSet.interval); + billingCycleAnchorUnix = getNextStartOfMonthUnix({ + interval: itemSet.interval, + intervalCount: itemSet.intervalCount, + }); } if (attachParams.billingAnchor) { @@ -105,6 +122,7 @@ export const handlePaidProduct = async ({ freeTrial, invoiceOnly, itemSet, + finalizeInvoice: attachParams.finalizeInvoice, anchorToUnix: billingCycleAnchorUnix, reward: i == 0 ? reward : undefined, now: attachParams.now, @@ -179,7 +197,9 @@ export const handlePaidProduct = async ({ code: SuccessCode.NewProductAttached, product_ids: products.map((p) => p.id), customer_id: customer.id || customer.internal_id, - invoice: invoiceOnly ? invoices?.[0] : undefined, + invoice: invoiceOnly + ? attachToInvoiceResponse({ invoice: invoices?.[0] }) + : undefined, }) ); } else { diff --git a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts index c7b1d25d4..407f5e080 100644 --- a/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/scheduleFlow/handleScheduleFunction.ts @@ -29,7 +29,7 @@ export const handleScheduleFunction = async ({ }) => { const logger = req.logtail; const product = attachParams.products[0]; - const { stripeCli } = attachParams; + const { stripeCli, customer: fullCus } = attachParams; const { curMainProduct, curScheduledProduct } = attachParamToCusProducts({ attachParams, @@ -55,7 +55,9 @@ export const handleScheduleFunction = async ({ // 3. Get schedules for current cus products logger.info(`3. Getting schedules for current cus products`); let schedules = await cusProductsToSchedules({ - cusProducts: [curMainProduct, curScheduledProduct], + // cusProducts: [curMainProduct, curScheduledProduct], + // cusProducts: [curMainProduct, curScheduledProduct], + cusProducts: fullCus.customer_products, stripeCli, }); @@ -69,7 +71,9 @@ export const handleScheduleFunction = async ({ const stripeSchedules: Stripe.SubscriptionSchedule[] = []; for (const itemSet of itemSets) { let scheduleObj = schedules.find( - (schedule) => schedule.interval === itemSet.interval, + (schedule) => + schedule.interval === itemSet.interval && + schedule.intervalCount === itemSet.intervalCount ); // If schedule exists, update it @@ -110,7 +114,7 @@ export const handleScheduleFunction = async ({ cusProductId: curCusProduct.id, updates: { scheduled_ids: curCusProduct.scheduled_ids?.filter( - (id) => !newScheduledIds.includes(id), + (id) => !newScheduledIds.includes(id) ), }, }); @@ -147,7 +151,7 @@ export const handleScheduleFunction = async ({ product_ids: [product.id], customer_id: attachParams.customer.id || attachParams.customer.internal_id, - }), + }) ); } else { res.status(200).json({ diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts index e347396ce..bc7234914 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoiceItems.ts @@ -17,6 +17,7 @@ import { UsagePriceConfig, BillingType, BillingInterval, + intervalsDifferent, } from "@autumn/shared"; import Stripe from "stripe"; @@ -28,6 +29,7 @@ export const getUsageInvoiceItems = async ({ cusProduct, stripeSubs, interval, + intervalCount, }: { db: DrizzleCli; logger: any; @@ -35,6 +37,7 @@ export const getUsageInvoiceItems = async ({ cusProduct: FullCusProduct; stripeSubs: Stripe.Subscription[]; interval?: BillingInterval; + intervalCount?: number; }) => { const { stripeCli, org } = attachParams; @@ -72,7 +75,14 @@ export const getUsageInvoiceItems = async ({ }); if (!sub) continue; - if (interval && interval !== subToAutumnInterval(sub)) continue; + if ( + interval && + intervalsDifferent({ + intervalA: { interval, intervalCount }, + intervalB: subToAutumnInterval(sub), + }) + ) + continue; cusEntIds.push(cusEnt.id); @@ -106,6 +116,7 @@ export const createUsageInvoiceItems = async ({ invoiceId, logger, interval, + intervalCount, }: { db: DrizzleCli; attachParams: AttachParams; @@ -114,6 +125,7 @@ export const createUsageInvoiceItems = async ({ invoiceId?: string; logger: any; interval?: BillingInterval; + intervalCount?: number; }) => { const { stripeCli } = attachParams; @@ -123,6 +135,7 @@ export const createUsageInvoiceItems = async ({ cusProduct, stripeSubs, interval, + intervalCount, logger, }); @@ -131,7 +144,7 @@ export const createUsageInvoiceItems = async ({ const invoiceItem = invoiceItems[i]; const createInvoiceItem = async () => { logger.info( - `🌟 Creating usage invoice item: ${invoiceItem.description}, amount: ${invoiceItem.price_data.unit_amount}`, + `🌟 Creating usage invoice item: ${invoiceItem.description}, amount: ${invoiceItem.price_data.unit_amount}` ); await stripeCli.invoiceItems.create({ @@ -171,7 +184,7 @@ export const resetUsageBalances = async ({ }); let index = cusProduct.customer_entitlements.findIndex( - (ce) => ce.id === cusEntId, + (ce) => ce.id === cusEntId ); cusProduct.customer_entitlements[index] = { diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts index f1227675b..a6aecbb98 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.ts @@ -18,7 +18,10 @@ import { import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; +import { + attachToInvoiceResponse, + insertInvoiceFromAttach, +} from "@/internal/invoices/invoiceUtils.js"; import { updateSubsDiffInt } from "./updateSubsDiffInt.js"; export const handleUpgradeDiffInterval = async ({ @@ -69,7 +72,7 @@ export const handleUpgradeDiffInterval = async ({ cusProductId: curCusProduct.id, updates: { subscription_ids: curCusProduct.subscription_ids!.filter( - (subId) => subId !== newSubs[0].id, + (subId) => subId !== newSubs[0].id ), processor: { type: ProcessorType.Stripe, @@ -106,7 +109,7 @@ export const handleUpgradeDiffInterval = async ({ attachParams, invoiceId, logger, - }), + }) ); } @@ -124,8 +127,10 @@ export const handleUpgradeDiffInterval = async ({ product_ids: products.map((p) => p.id), code: SuccessCode.UpgradedToNewProduct, message: `Successfully upgraded from ${curProductName} to ${product.name}`, - invoice: config.invoiceOnly ? invoice : undefined, - }), + invoice: config.invoiceOnly + ? attachToInvoiceResponse({ invoice: invoice || undefined }) + : undefined, + }) ); } else { res.status(200).json({ diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateCurSchedules.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateCurSchedules.ts index aac780950..049f4ce6f 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateCurSchedules.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/updateCurSchedules.ts @@ -4,7 +4,11 @@ import { updateScheduledSubWithNewItems } from "@/internal/customers/change-prod import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { ItemSet } from "@/utils/models/ItemSet.js"; import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; -import { FullCusProduct } from "@autumn/shared"; +import { + FullCusProduct, + intervalsDifferent, + intervalsSame, +} from "@autumn/shared"; import Stripe from "stripe"; import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js"; @@ -39,7 +43,7 @@ export const updateCurSchedules = async ({ }); for (const scheduleObj of schedules) { - const { interval, schedule } = scheduleObj; + const { interval, intervalCount, schedule } = scheduleObj; // If schedule has passed, skip this step. let phase = schedule.phases.length > 0 ? schedule.phases[0] : null; @@ -51,7 +55,16 @@ export const updateCurSchedules = async ({ } // Get corresponding item set - const itemSet = itemSets.find((itemSet) => itemSet.interval === interval); + + const itemSet = itemSets.find((itemSet) => + intervalsSame({ + intervalA: { interval, intervalCount }, + intervalB: { + interval: itemSet.interval, + intervalCount: itemSet.intervalCount, + }, + }) + ); if (!itemSet) { continue; } diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.ts b/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.ts index 188676d9e..6a04c0187 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.ts @@ -11,6 +11,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { updateSubsByInt } from "./updateSubsSameInt.js"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { formatUnixToDate } from "@/utils/genUtils.js"; +import { attachToInvoiceResponse } from "@/internal/invoices/invoiceUtils.js"; export const handleUpgradeSameInterval = async ({ req, @@ -40,7 +41,7 @@ export const handleUpgradeSameInterval = async ({ } logger.info(`1. Updating subs by interval`); - const { replaceables } = await updateSubsByInt({ + const { replaceables, invoices } = await updateSubsByInt({ req, curCusProduct: curCusProduct!, attachParams, @@ -81,9 +82,15 @@ export const handleUpgradeSameInterval = async ({ AttachResultSchema.parse({ customer_id: attachParams.customer.id, product_ids: attachParams.products.map((p) => p.id), + // invoice: attachParams.invoiceOnly + // ? attachToInvoiceResponse({ invoice: invoices?.[0] }) + // : undefined, + invoice: attachParams.invoiceOnly + ? attachToInvoiceResponse({ invoice: invoices?.[0] || undefined }) + : undefined, code: "updated_product_successfully", message: `Successfully updated product`, - }), + }) ); } else { res.status(200).json({ diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts b/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts index 8f35a26b5..44dd4c087 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeSameIntFlow/updateSubsSameInt.ts @@ -3,7 +3,12 @@ import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSub import { subToAutumnInterval } from "@/external/stripe/utils.js"; import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; -import { AttachConfig, FullCusProduct, Replaceable } from "@autumn/shared"; +import { + AttachConfig, + FullCusProduct, + intervalsSame, + Replaceable, +} from "@autumn/shared"; import { addSubItemsToRemove } from "../attachFuncUtils.js"; import { updateStripeSub } from "../../attachUtils/updateStripeSub/updateStripeSub.js"; import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; @@ -51,9 +56,16 @@ export const updateSubsByInt = async ({ // const replaceables: Replaceable[] = []; for (const sub of stripeSubs) { - let interval = subToAutumnInterval(sub); + // let interval = subToAutumnInterval(sub); + + let subInterval = subToAutumnInterval(sub); + let itemSet = itemSets.find((itemSet) => { + return intervalsSame({ + intervalA: itemSet, + intervalB: subInterval, + }); + })!; - let itemSet = itemSets.find((itemSet) => itemSet.interval === interval)!; await addSubItemsToRemove({ sub, cusProduct: curCusProduct, @@ -67,14 +79,17 @@ export const updateSubsByInt = async ({ stripeSubs: [sub], itemSet, logger, - interval, + interval: itemSet.interval, + intervalCount: itemSet.intervalCount, }); if (latestInvoice) { invoices.push(latestInvoice); } - logger.info(`Updated sub ${sub.id}, interval ${interval}`); + logger.info( + `Updated sub ${sub.id}, interval ${itemSet.interval}, intervalCount ${itemSet.intervalCount}` + ); } const batchInvUpdate = []; @@ -85,9 +100,9 @@ export const updateSubsByInt = async ({ attachParams, stripeInvoice: invoice, logger, - }), + }) ); } - return { replaceables }; + return { replaceables, invoices }; }; diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts index ad1b16e3d..55e754e03 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts @@ -66,7 +66,8 @@ export const getAttachParams = async ({ // Others apiVersion, successUrl: attachBody.success_url, - invoiceOnly: attachBody.invoice_only, + invoiceOnly: attachBody.invoice_only || attachBody.invoice, + finalizeInvoice: attachBody.invoice, billingAnchor: attachBody.billing_cycle_anchor, metadata: attachBody.metadata, disableFreeTrial: attachBody.free_trial === false || false, diff --git a/server/src/internal/customers/attach/attachUtils/attachUtils.ts b/server/src/internal/customers/attach/attachUtils/attachUtils.ts index d2dc8999c..cdf0c631e 100644 --- a/server/src/internal/customers/attach/attachUtils/attachUtils.ts +++ b/server/src/internal/customers/attach/attachUtils/attachUtils.ts @@ -1,5 +1,8 @@ import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { getFirstInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { + getLargestInterval, + intervalsDifferent, +} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { subToAutumnInterval } from "@/external/stripe/utils.js"; import Stripe from "stripe"; import { attachParamsToProduct } from "./convertAttachParams.js"; @@ -12,7 +15,10 @@ export const getCycleWillReset = ({ stripeSubs: Stripe.Subscription[]; }) => { const product = attachParamsToProduct({ attachParams }); - const firstInterval = getFirstInterval({ prices: product.prices }); + const firstInterval = getLargestInterval({ prices: product.prices }); const prevInterval = subToAutumnInterval(stripeSubs[0]); - return prevInterval !== firstInterval; + return intervalsDifferent({ + intervalA: firstInterval, + intervalB: prevInterval, + }); }; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index d40ca6fe0..526745120 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -273,6 +273,7 @@ const getChangeProductBranch = async ({ // } let isUpgrade = isProductUpgrade({ prices1: curPrices, prices2: newPrices }); + if (isUpgrade) { if (isTrialing(curMainProduct!)) { return AttachBranch.MainIsTrial; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts index 26543571b..910228d92 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachConfig.ts @@ -1,6 +1,11 @@ import { AttachParams } from "../../cusProducts/AttachParams.js"; import { AttachFlags } from "../models/AttachFlags.js"; -import { AttachConfig, AttachBranch } from "@autumn/shared"; +import { + AttachConfig, + AttachBranch, + intervalsSame, + intervalToValue, +} from "@autumn/shared"; import { AttachBody } from "@autumn/shared"; import { isFreeProduct } from "@/internal/products/productUtils.js"; import { nullish } from "@/utils/genUtils.js"; @@ -27,12 +32,66 @@ export const intervalsAreSame = ({ let newProduct = attachParamsToProduct({ attachParams }); let curPrices = cusProductToPrices({ cusProduct: curCusProduct! }); - let curIntervals = new Set(curPrices.map((p) => p.config.interval)); - let newIntervals = new Set(newProduct.prices.map((p) => p.config.interval)); + const curIntervals = new Set( + curPrices.map((p) => + intervalToValue(p.config.interval, p.config.interval_count) + ) + ); + + const newIntervals = new Set( + newProduct.prices.map((p) => + intervalToValue(p.config.interval, p.config.interval_count) + ) + ); + return ( curIntervals.size === newIntervals.size && [...curIntervals].every((interval) => newIntervals.has(interval)) ); + + for (const price of curPrices) { + let hasSimilarInterval = newProduct.prices.some((p) => { + return intervalsSame({ + intervalA: price.config, + intervalB: p.config, + }); + }); + + if (!hasSimilarInterval) { + return false; + } + } + + for (const price of newProduct.prices) { + let hasSimilarInterval = curPrices.some((p) => { + return intervalsSame({ + intervalA: price.config, + intervalB: p.config, + }); + }); + + if (!hasSimilarInterval) { + return false; + } + } + + return true; + // let curIntervals = new Set( + // curPrices.map((p) => ({ + // interval: p.config.interval, + // intervalCount: p.config.interval_count, + // })) + // ); + // let newIntervals = new Set( + // newProduct.prices.map((p) => ({ + // interval: p.config.interval, + // intervalCount: p.config.interval_count, + // })) + // ); + // return ( + // curIntervals.size === newIntervals.size && + // [...curIntervals].every((interval) => newIntervals.has(interval)) + // ); }; export const getAttachConfig = async ({ @@ -51,7 +110,7 @@ export const getAttachConfig = async ({ let flags: AttachFlags = { isPublic: req.isPublic, forceCheckout: attachBody.force_checkout || false, - invoiceOnly: attachBody.invoice_only || false, + invoiceOnly: attachParams.invoiceOnly || false, isFree: isFreeProduct(prices), noPaymentMethod: nullish(paymentMethod) ? true : false, }; diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts index 5c33127f3..28b6c55e4 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts @@ -4,6 +4,8 @@ import { BillingType, FullCusProduct, FullProduct, + intervalsDifferent, + intervalsSame, } from "@autumn/shared"; import Stripe from "stripe"; import { getContUseInvoiceItems } from "./getContUseInvoiceItems.js"; @@ -35,7 +37,7 @@ export const filterContUsageProrations = async ({ subscription: sub.id, }); - const interval = subToAutumnInterval(sub); + const intervalSet = subToAutumnInterval(sub); for (const item of upcomingLines.data) { if (!item.proration) continue; @@ -48,12 +50,12 @@ export const filterContUsageProrations = async ({ if (!price) continue; logger.info( - `Deleting ii: ${item.description} - ${item.amount / 100} (${interval})`, + `Deleting ii: ${item.description} - ${item.amount / 100} (${intervalSet.interval}, ${intervalSet.intervalCount})` ); await stripeCli.invoiceItems.del( // @ts-ignore -- Stripe types are not correct - item.parent.subscription_item_details.invoice_item, + item.parent.subscription_item_details.invoice_item ); } }; @@ -64,12 +66,14 @@ export const createAndFilterContUseItems = async ({ stripeSubs, logger, interval, + intervalCount, }: { attachParams: AttachParams; curMainProduct: FullCusProduct; stripeSubs: Stripe.Subscription[]; logger: any; interval?: BillingInterval; + intervalCount?: number; }) => { const { stripeCli, customer, org } = attachParams; const product = attachParamsToProduct({ attachParams }); @@ -88,8 +92,13 @@ export const createAndFilterContUseItems = async ({ }); let sub = - stripeSubs.find((sub) => subToAutumnInterval(sub) == interval) || - stripeSubs[0]; + stripeSubs.find((sub) => { + return intervalsSame({ + intervalA: { interval: interval!, intervalCount: intervalCount! }, + intervalB: subToAutumnInterval(sub), + }); + // subToAutumnInterval(sub) == interval + }) || stripeSubs[0]; await filterContUsageProrations({ sub, @@ -113,12 +122,20 @@ export const createAndFilterContUseItems = async ({ product.prices.find((p) => p.id === item.price_id) || curPrices.find((p) => p.id === item.price_id); - if (interval && price?.config.interval !== interval) { + if ( + interval && + price?.config && + intervalsDifferent({ + // price?.config.interval !== interval + intervalA: price?.config, + intervalB: { interval, intervalCount }, + }) + ) { continue; } logger.info( - `Adding invoice item: ${item.description}, ${item.description}, interval: ${interval}`, + `Adding invoice item: ${item.description}, ${item.description}, interval: ${interval}` ); await stripeCli.invoiceItems.create({ diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts index 87cfa2b7b..b52756774 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts @@ -7,6 +7,7 @@ import { FullCustomerEntitlement, FullEntitlement, getFeatureInvoiceDescription, + intervalsSame, PreviewLineItem, Price, } from "@autumn/shared"; @@ -121,7 +122,7 @@ export const getContUseInvoiceItems = async ({ const cusEnts = cusProduct ? cusProduct.customer_entitlements : []; const product = attachParamsToProduct({ attachParams }); - const intervalsSame = intervalsAreSame({ attachParams }); + const allIntervalsSame = intervalsAreSame({ attachParams }); const curItems = stripeSubs ? await getCurContUseItems({ stripeSubs, @@ -150,7 +151,7 @@ export const getContUseInvoiceItems = async ({ ? getRelatedCusPrice(prevCusEnt, cusPrices)! : undefined; - if (!intervalsSame || !prevCusEnt || !stripeSubs) { + if (!allIntervalsSame || !prevCusEnt || !stripeSubs) { const newItem = await getContUseNewItems({ price, ent, @@ -159,7 +160,7 @@ export const getContUseInvoiceItems = async ({ }); const prevItem = curItems.find( - (item) => item.price_id === prevCusPrice?.price.id, + (item) => item.price_id === prevCusPrice?.price.id ); newItems.push(newItem); @@ -172,12 +173,16 @@ export const getContUseInvoiceItems = async ({ } const curItem = curItems.find( - (item) => item.price_id === prevCusPrice?.price.id, + (item) => item.price_id === prevCusPrice?.price.id ); - let sub = stripeSubs!.find( - (sub) => subToAutumnInterval(sub) === price.config.interval, - ); + let sub = stripeSubs!.find((sub) => { + let subInterval = subToAutumnInterval(sub); + return intervalsSame({ + intervalA: price.config, + intervalB: subInterval, + }); + }); let { oldItem, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index 7c61ae6af..396dabac5 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -213,7 +213,6 @@ export const handleAttachErrors = async ({ AttachBranch.NewVersion, AttachBranch.SameCustom, AttachBranch.UpdatePrepaidQuantity, - AttachBranch.Renew, ]; if (updateProductFlows.includes(branch)) { handleNonCheckoutErrors({ diff --git a/server/src/internal/customers/attach/attachUtils/updateStripeSub/updateStripeSub.ts b/server/src/internal/customers/attach/attachUtils/updateStripeSub/updateStripeSub.ts index 23819390f..c6f3aebbf 100644 --- a/server/src/internal/customers/attach/attachUtils/updateStripeSub/updateStripeSub.ts +++ b/server/src/internal/customers/attach/attachUtils/updateStripeSub/updateStripeSub.ts @@ -1,5 +1,5 @@ import Stripe from "stripe"; -import { BillingInterval, AttachConfig } from "@autumn/shared"; +import { BillingInterval, AttachConfig, intervalsSame } from "@autumn/shared"; import { ProrationBehavior } from "@autumn/shared"; import { SubService } from "@/internal/subscriptions/SubService.js"; import { ItemSet } from "@/utils/models/ItemSet.js"; @@ -23,7 +23,7 @@ export const getSubAndInvoiceItems = async ({ let subItems = items.filter( (i: any, index: number) => - i.deleted || prices[index].config!.interval !== BillingInterval.OneOff, + i.deleted || prices[index].config!.interval !== BillingInterval.OneOff ); let addInvoiceItems = items.filter((i: any, index: number) => { @@ -48,6 +48,7 @@ export const updateStripeSub = async ({ itemSet, logger, interval, + intervalCount, }: { db: DrizzleCli; attachParams: AttachParams; @@ -58,6 +59,7 @@ export const updateStripeSub = async ({ shouldPreview?: boolean; logger: any; interval?: BillingInterval; + intervalCount?: number; }) => { const { curMainProduct } = attachParamToCusProducts({ attachParams }); const { stripeCli, customer, org, paymentMethod } = attachParams; @@ -69,7 +71,13 @@ export const updateStripeSub = async ({ const curSub = (interval - ? stripeSubs.find((s) => subToAutumnInterval(s) === interval) + ? stripeSubs.find((s) => { + let subInterval = subToAutumnInterval(s); + return intervalsSame({ + intervalA: { interval, intervalCount }, + intervalB: subInterval, + }); + }) : stripeSubs[0]) || stripeSubs[0]; // 1. Update subscription @@ -102,6 +110,16 @@ export const updateStripeSub = async ({ }; } + // if (invoiceOnly && attachParams.finalizeInvoice) { + // try { + // await stripeCli.invoices.finalizeInvoice(latestInvoice?.id as string); + // } catch (error) { + // logger.error(`Failed to finalize invoice ${latestInvoice?.id}`, { + // error, + // }); + // } + // } + // 2. Create prorations for single use items let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({ db, @@ -110,6 +128,7 @@ export const updateStripeSub = async ({ stripeSubs, logger, interval: config.sameIntervals ? interval : undefined, + intervalCount: config.sameIntervals ? intervalCount : undefined, }); // 3. Create prorations for continuous use items @@ -118,6 +137,7 @@ export const updateStripeSub = async ({ curMainProduct: curMainProduct!, stripeSubs, interval, + intervalCount, logger, }); @@ -145,6 +165,19 @@ export const updateStripeSub = async ({ env: customer.env, }); + if (invoiceOnly && attachParams.finalizeInvoice) { + logger.info(`FINALIZING INVOICE ${latestInvoice?.id}`); + try { + latestInvoice = await stripeCli.invoices.finalizeInvoice( + latestInvoice?.id as string + ); + } catch (error) { + logger.error(`Failed to finalize invoice ${latestInvoice?.id}`, { + error, + }); + } + } + return { updatedSub, latestInvoice: latestInvoice, diff --git a/server/src/internal/customers/attach/checkout/handleCheckout.ts b/server/src/internal/customers/attach/checkout/handleCheckout.ts index a8f6da458..f92bdecb5 100644 --- a/server/src/internal/customers/attach/checkout/handleCheckout.ts +++ b/server/src/internal/customers/attach/checkout/handleCheckout.ts @@ -1,5 +1,4 @@ import { - AttachBranch, AttachFunction, AttachScenario, CheckoutResponseSchema, @@ -108,17 +107,18 @@ export const handleCheckout = (req: any, res: any) => handler: async (req: ExtendedRequest, res: ExtendedResponse) => { const { logger, features } = req; const attachBody = AttachBodySchema.parse(req.body); - // Pre-populate options... - const { attachParams, flags, branch, config, func } = await getAttachVars( - { req, attachBody } - ); + const { attachParams, branch, func } = await getAttachVars({ + req, + attachBody, + }); await getCheckoutOptions({ req, attachParams, }); + let checkoutUrl = null; if (func == AttachFunction.CreateCheckout) { await checkStripeConnections({ req, @@ -134,22 +134,24 @@ export const handleCheckout = (req: any, res: any) => returnCheckout: true, }); - const customer = attachParams.customer; - res.status(200).json( - CheckoutResponseSchema.parse({ - url: checkout?.url, - customer_id: customer.id || customer.internal_id, - scenario: AttachScenario.New, - lines: [], - product: await getProductResponse({ - product: attachParams.products[0], - features: features, - withDisplay: false, - options: attachParams.optionsList, - }), - }) - ); - return; + checkoutUrl = checkout?.url; + + // const customer = attachParams.customer; + // res.status(200).json( + // CheckoutResponseSchema.parse({ + // url: checkout?.url, + // customer_id: customer.id || customer.internal_id, + // scenario: AttachScenario.New, + // lines: [], + // product: await getProductResponse({ + // product: attachParams.products[0], + // features: features, + // withDisplay: false, + // options: attachParams.optionsList, + // }), + // }) + // ); + // return; } const preview = await attachParamsToPreview({ @@ -175,6 +177,7 @@ export const handleCheckout = (req: any, res: any) => res.status(200).json({ ...checkoutRes, + url: checkoutUrl, options: attachParams.optionsList.map((o) => ({ quantity: o.quantity, feature_id: o.feature_id, diff --git a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts index 322eb1a0a..f8747f73b 100644 --- a/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts +++ b/server/src/internal/customers/attach/checkout/previewToCheckoutRes.ts @@ -108,10 +108,6 @@ export const previewToCheckoutRes = async ({ return acc; } - // if (item.interval !== newProduct.properties?.interval_group) { - // return acc; - // } - if (isPriceItem(item)) { return acc.plus(item.price || 0); } diff --git a/server/src/internal/customers/attach/handleAttach.ts b/server/src/internal/customers/attach/handleAttach.ts index 64f6dcf6d..99e14fd98 100644 --- a/server/src/internal/customers/attach/handleAttach.ts +++ b/server/src/internal/customers/attach/handleAttach.ts @@ -6,7 +6,7 @@ import { getAttachParams } from "./attachUtils/attachParams/getAttachParams.js"; import { getAttachBranch } from "./attachUtils/getAttachBranch.js"; import { getAttachConfig } from "./attachUtils/getAttachConfig.js"; import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js"; -import { checkStripeConnections, createStripePrices } from "./attachRouter.js"; +import { checkStripeConnections } from "./attachRouter.js"; import { insertCustomItems } from "./attachUtils/insertCustomItems.js"; import { runAttachFunction } from "./attachUtils/getAttachFunction.js"; @@ -59,6 +59,28 @@ export const handleAttach = async (req: any, res: any) => customEnts: customEnts || [], }); + try { + req.logger.info(`Attach params: `, { + data: { + products: attachParams.products.map((p) => ({ + id: p.id, + name: p.name, + processor: p.processor, + version: p.version, + })), + prices: attachParams.prices.map((p) => ({ + id: p.id, + config: p.config, + })), + entitlements: attachParams.entitlements.map((e) => ({ + internal_feature_id: e.internal_feature_id, + feature_id: e.feature_id, + })), + freeTrial: attachParams.freeTrial, + }, + }); + } catch (error) {} + await runAttachFunction({ req, res, diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index c5745205a..42dc67ea7 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -10,15 +10,10 @@ import { getNextStartOfMonthUnix, } from "@/internal/products/prices/billingIntervalUtils.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; -import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; +import { getSmallestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js"; -import { - formatUnixToDate, - formatUnixToDateTime, - notNullish, - nullish, -} from "@/utils/genUtils.js"; +import { notNullish, nullish } from "@/utils/genUtils.js"; export const getNewProductPreview = async ({ branch, @@ -38,7 +33,10 @@ export const getNewProductPreview = async ({ let anchorToUnix = undefined; if (org.config.anchor_start_of_month) { - anchorToUnix = getNextStartOfMonthUnix(BillingInterval.Month); + anchorToUnix = getNextStartOfMonthUnix({ + interval: BillingInterval.Month, + intervalCount: 1, + }); } const { mergeCusProduct, mergeSubs } = await getMergeCusProduct({ @@ -77,12 +75,16 @@ export const getNewProductPreview = async ({ config, }); - let minInterval = getLastInterval({ + // let minInterval = getLastInterval({ + // prices: newProduct.prices, + // ents: newProduct.entitlements, + // }); + let min = getSmallestInterval({ prices: newProduct.prices, ents: newProduct.entitlements, }); - let getAligned = notNullish(anchorToUnix) && notNullish(minInterval); + let getAligned = notNullish(anchorToUnix) && notNullish(min); let dueAt = freeTrial ? freeTrialToStripeTimestamp({ @@ -92,11 +94,16 @@ export const getNewProductPreview = async ({ : getAligned ? getAlignedIntervalUnix({ alignWithUnix: anchorToUnix!, - interval: minInterval, + interval: min!.interval, + intervalCount: min!.intervalCount, now: attachParams.now, }) - : notNullish(minInterval) - ? addBillingIntervalUnix(attachParams.now || Date.now(), minInterval) + : notNullish(min) + ? addBillingIntervalUnix({ + unixTimestamp: attachParams.now || Date.now(), + interval: min!.interval, + intervalCount: min!.intervalCount, + }) : undefined; dueNextCycle = !nullish(dueAt) @@ -125,18 +132,25 @@ export const getNewProductPreview = async ({ // Next cycle at if (!dueNextCycle) { if (!isFreeProduct(newProduct.prices) && branch != AttachBranch.OneOff) { - let minInterval = getLastInterval({ prices: newProduct.prices }); + let min = getSmallestInterval({ + prices: newProduct.prices, + ents: newProduct.entitlements, + }); dueNextCycle = { line_items: items.filter((item) => { let price = newProduct.prices.find( (price) => price.id == item.price_id ); - return price?.config.interval == minInterval; + return ( + price?.config.interval == min!.interval && + (price?.config.interval_count || 1) == (min!.intervalCount || 1) + ); + }), + due_at: addBillingIntervalUnix({ + unixTimestamp: attachParams.now || Date.now(), + interval: min!.interval, + intervalCount: min!.intervalCount, }), - due_at: addBillingIntervalUnix( - attachParams.now || Date.now(), - minInterval - ), }; } } diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpdateQuantityPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpdateQuantityPreview.ts deleted file mode 100644 index f8f295bf9..000000000 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpdateQuantityPreview.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; -import { getOptions } from "@/internal/api/entitled/checkUtils.js"; -import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js"; -import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; -import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { - addBillingIntervalUnix, - getAlignedIntervalUnix, -} from "@/internal/products/prices/billingIntervalUtils.js"; -import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; -import { isFreeProduct } from "@/internal/products/productUtils.js"; -import { mapToProductItems } from "@/internal/products/productV2Utils.js"; -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { - AttachBranch, - BillingInterval, - FreeTrial, - PreviewLineItem, - Price, - UsageModel, -} from "@autumn/shared"; - -import Stripe from "stripe"; -import { - attachParamToCusProducts, - attachParamsToProduct, -} from "../attachUtils/convertAttachParams.js"; -import { intervalsAreSame } from "../attachUtils/getAttachConfig.js"; -import { AttachParams } from "../../cusProducts/AttachParams.js"; -import { Decimal } from "decimal.js"; - -const getNextCycleAt = ({ - prices, - stripeSubs, - willCycleReset, - interval, - now, - freeTrial, -}: { - prices: Price[]; - stripeSubs: Stripe.Subscription[]; - willCycleReset: boolean; - interval: BillingInterval; - now?: number; - freeTrial?: FreeTrial | null; -}) => { - now = now || Date.now(); - - if (freeTrial) { - return { - next_cycle_at: - freeTrialToStripeTimestamp({ - freeTrial, - now, - })! * 1000, - }; - } - - if (willCycleReset) { - const minInterval = getLastInterval({ prices }); - return { - next_cycle_at: addBillingIntervalUnix(now, minInterval), - }; - } - - const minInterval = getLastInterval({ prices }); - const nextCycleAt = getAlignedIntervalUnix({ - alignWithUnix: stripeSubs[0].current_period_end * 1000, - interval: minInterval, - alwaysReturn: true, - }); - - return { - next_cycle_at: nextCycleAt, - }; -}; - -export const getUpdateQuantityPreview = async ({ - req, - attachParams, - branch, - now, -}: { - req: ExtendedRequest; - attachParams: AttachParams; - branch: AttachBranch; - now: number; -}) => { - const { logtail: logger } = req; - - const { stripeCli } = attachParams; - - const { curMainProduct, curSameProduct } = attachParamToCusProducts({ - attachParams, - }); - const curCusProduct = curMainProduct!; - - const stripeSubs = await getStripeSubs({ - stripeCli, - subIds: curCusProduct?.subscription_ids || [], - expand: ["items.data.price.tiers"], - }); - - const curPreviewItems = await getItemsForCurProduct({ - stripeSubs, - attachParams, - now, - logger, - }); - - // Get prorated amounts for new product - const newProduct = attachParamsToProduct({ attachParams }); - const intervalsSame = intervalsAreSame({ attachParams }); - const anchorToUnix = - intervalsSame && stripeSubs.length > 0 - ? stripeSubs[0].current_period_end * 1000 - : undefined; - - const newPreviewItems = await getItemsForNewProduct({ - newProduct, - attachParams, - now, - anchorToUnix, - freeTrial: attachParams.freeTrial, - stripeSubs, - logger, - }); - - const lastInterval = getLastInterval({ prices: newProduct.prices }); - - let dueNextCycle = undefined; - if (!isFreeProduct(newProduct.prices)) { - const nextCycleAt = getNextCycleAt({ - prices: newProduct.prices, - stripeSubs, - willCycleReset: !intervalsSame, - interval: lastInterval, - now, - freeTrial: attachParams.freeTrial, - }); - - let nextCycleItems = await getItemsForNewProduct({ - newProduct, - attachParams, - interval: attachParams.freeTrial ? undefined : lastInterval, - logger, - }); - - dueNextCycle = { - line_items: nextCycleItems, - due_at: nextCycleAt.next_cycle_at, - }; - } - - let items = [...curPreviewItems, ...newPreviewItems]; - - for (const item of structuredClone(curPreviewItems)) { - let priceId = item.price_id; - let newItem = newPreviewItems.find((i) => i.price_id == priceId); - - if (!newItem) { - continue; - } - - let newItemAmount = new Decimal(newItem?.amount ?? 0).toDecimalPlaces(2); - let curItemAmount = new Decimal(item.amount ?? 0).toDecimalPlaces(2); - - if (newItemAmount.add(curItemAmount).eq(0)) { - items = items.filter((i) => i.price_id !== priceId); - } - } - - const dueTodayAmt = items - .reduce((acc, item) => acc.plus(item.amount ?? 0), new Decimal(0)) - .toDecimalPlaces(2) - .toNumber(); - - let options = getOptions({ - prodItems: mapToProductItems({ - prices: newProduct.prices, - entitlements: newProduct.entitlements, - features: attachParams.features, - }), - features: attachParams.features, - anchorToUnix, - now, - freeTrial: attachParams.freeTrial, - cusProduct: curSameProduct, - }); - - items = items.filter((item) => item.amount !== 0); - - if (branch == AttachBranch.UpdatePrepaidQuantity) { - items = items.filter((item) => item.usage_model == UsageModel.Prepaid); - dueNextCycle!.line_items = dueNextCycle!.line_items.filter( - (item) => item.usage_model == UsageModel.Prepaid, - ); - } - - let dueToday: - | { - line_items: PreviewLineItem[]; - total: number; - } - | undefined = { - line_items: items, - total: dueTodayAmt, - }; - - if (branch == AttachBranch.SameCustomEnts) { - dueToday = undefined; - } - - return { - currency: attachParams.org.default_currency, - due_today: dueToday, - due_next_cycle: dueNextCycle, - options, - }; -}; diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index e704578c0..62d5611ce 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -5,7 +5,7 @@ import { } from "../attachUtils/convertAttachParams.js"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; -import { getFirstInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; +import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js"; import { getOptions } from "@/internal/api/entitled/checkUtils.js"; @@ -29,13 +29,12 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free import { Decimal } from "decimal.js"; import { intervalsAreSame } from "../attachUtils/getAttachConfig.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; -import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js"; +import { formatUnixToDateTime, notNullish, nullish } from "@/utils/genUtils.js"; const getNextCycleAt = ({ prices, stripeSubs, willCycleReset, - interval, now, freeTrial, branch, @@ -44,7 +43,6 @@ const getNextCycleAt = ({ prices: Price[]; stripeSubs: Stripe.Subscription[]; willCycleReset: boolean; - interval: BillingInterval; now?: number; freeTrial?: FreeTrial | null; branch: AttachBranch; @@ -68,17 +66,28 @@ const getNextCycleAt = ({ }; } - if (willCycleReset) { - const firstInterval = getFirstInterval({ prices }); + const firstInterval = getLargestInterval({ prices }); + + if (nullish(firstInterval)) { return { - next_cycle_at: addBillingIntervalUnix(now, firstInterval), + next_cycle_at: now, + }; + } + + if (willCycleReset) { + return { + next_cycle_at: addBillingIntervalUnix({ + unixTimestamp: now, + interval: firstInterval!.interval, + intervalCount: firstInterval!.intervalCount, + }), }; } - const firstInterval = getFirstInterval({ prices }); const nextCycleAt = getAlignedIntervalUnix({ alignWithUnix: stripeSubs[0].current_period_end * 1000, - interval: firstInterval, + interval: firstInterval!.interval, + intervalCount: firstInterval!.intervalCount, alwaysReturn: true, now, }); @@ -154,7 +163,7 @@ export const getUpgradeProductPreview = async ({ config, }); - const lastInterval = getFirstInterval({ prices: newProduct.prices }); + const largestInterval = getLargestInterval({ prices: newProduct.prices }); let dueNextCycle = undefined; if (!isFreeProduct(newProduct.prices)) { @@ -162,7 +171,6 @@ export const getUpgradeProductPreview = async ({ prices: newProduct.prices, stripeSubs, willCycleReset: !intervalsSame, - interval: lastInterval, now, freeTrial: attachParams.freeTrial, branch, @@ -172,7 +180,10 @@ export const getUpgradeProductPreview = async ({ let nextCycleItems = await getItemsForNewProduct({ newProduct, attachParams, - interval: attachParams.freeTrial ? undefined : lastInterval, + interval: attachParams.freeTrial ? undefined : largestInterval?.interval, + intervalCount: attachParams.freeTrial + ? undefined + : largestInterval?.intervalCount, logger, withPrepaid, branch, diff --git a/server/src/internal/customers/change-product/billRemainingUsages.ts b/server/src/internal/customers/change-product/billRemainingUsages.ts deleted file mode 100644 index 89fa17441..000000000 --- a/server/src/internal/customers/change-product/billRemainingUsages.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { Stripe } from "stripe"; -import { AttachParams } from "../cusProducts/AttachParams.js"; -import { FullCusProduct, InvoiceItem } from "@autumn/shared"; -import { BillingInterval, BillingType, UsagePriceConfig } from "@autumn/shared"; - -import { - getBillingType, - getPriceEntitlement, - getPriceForOverage, -} from "@/internal/products/prices/priceUtils.js"; -import { - createStripeCli, - subToAutumnInterval, -} from "@/external/stripe/utils.js"; -import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js"; -import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; -import { getResetBalancesUpdate } from "../cusProducts/cusEnts/groupByUtils.js"; -import { - getCusPriceUsage, - getRelatedCusEnt, -} from "../cusProducts/cusPrices/cusPriceUtils.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; -import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; -import { - cusProductsToCusPrices, - cusProductToEnts, -} from "../cusProducts/cusProductUtils/convertCusProduct.js"; -import { getUsageBasedSub } from "@/external/stripe/stripeSubUtils.js"; - -// Add usage to end of cycle -const addUsageToNextInvoice = async ({ - db, - intervalToInvoiceItems, - intervalToSub, - customer, - org, - logger, - attachParams, -}: { - db: DrizzleCli; - intervalToInvoiceItems: any; - intervalToSub: any; - customer: any; - org: any; - logger: any; - attachParams: AttachParams; -}) => { - for (const interval in intervalToInvoiceItems) { - const itemsToInvoice = intervalToInvoiceItems[interval]; - - if (itemsToInvoice.length === 0) { - continue; - } - - // Add items to invoice - const stripeCli = createStripeCli({ - org: org, - env: customer.env, - }); - - for (const item of itemsToInvoice) { - const { amount, description } = item; - - logger.info( - ` feature: ${item.feature.id}, overage: ${item.overage}, amount: ${amount}`, - ); - - let relatedSub = intervalToSub[interval]; - if (!relatedSub) { - continue; - } - - // Create invoice item - let invoiceItem = { - customer: customer.processor.id, - currency: org.default_currency, - description, - price_data: { - product: (item.price.config! as UsagePriceConfig).stripe_product_id!, - unit_amount: Math.round(amount * 100), - currency: org.default_currency, - }, - subscription: relatedSub.id, - period: { - start: item.periodStart, - end: item.periodEnd, - }, - }; - - await stripeCli.invoiceItems.create(invoiceItem); - - // Update cus ent to 0 - await CusEntService.update({ - db, - id: item.relatedCusEnt!.id, - updates: getResetBalancesUpdate({ - cusEnt: item.relatedCusEnt!, - allowance: 0, - }), - }); - - // Update existing cusEnt in attachParams - let cusProducts = attachParams.cusProducts; - for (const cusProduct of cusProducts!) { - for (let i = 0; i < cusProduct.customer_entitlements.length; i++) { - let cusEnt = cusProduct.customer_entitlements[i]; - if (cusEnt.id === item.relatedCusEnt!.id) { - let balancesUpdate = getResetBalancesUpdate({ - cusEnt, - allowance: 0, - }); - cusProduct.customer_entitlements[i] = { - ...cusEnt, - ...balancesUpdate, - }; - } - } - } - } - } -}; - -const invoiceForUsageImmediately = async ({ - db, - intervalToInvoiceItems, - customer, - org, - logger, - curCusProduct, - attachParams, - newSubs, -}: { - db: DrizzleCli; - intervalToInvoiceItems: any; - customer: any; - org: any; - logger: any; - curCusProduct: FullCusProduct; - attachParams: AttachParams; - newSubs: Stripe.Subscription[]; -}) => { - // 1. Create invoice - const stripeCli = createStripeCli({ - org: org, - env: customer.env, - }); - const product = curCusProduct.product; - - let invoiceItems = Object.values(intervalToInvoiceItems).flat() as any[]; - if (invoiceItems.length === 0) { - return; - } - - let invoice: Stripe.Invoice; - let newInvoice = false; - - if (attachParams.invoiceOnly && newSubs.length > 0) { - invoice = await stripeCli.invoices.retrieve( - newSubs[0].latest_invoice as string, - ); - - if (invoice.status !== "draft") { - newInvoice = true; - invoice = await stripeCli.invoices.create({ - customer: customer.processor.id, - auto_advance: true, - }); - } - } else { - newInvoice = true; - - invoice = await stripeCli.invoices.create({ - customer: customer.processor.id, - auto_advance: true, - }); - } - - let autumnInvoiceItems: InvoiceItem[] = []; - - for (const item of invoiceItems) { - // const amount = getPriceForOverage(item.price, item.overage); - const { amount, description } = item; - let config = item.price.config! as UsagePriceConfig; - // let stripePrice = await stripeCli.prices.retrieve(config.stripe_price_id!); - let stripeProdId = config.stripe_product_id; - if (!stripeProdId) { - try { - let stripePrice = await stripeCli.prices.retrieve( - config.stripe_price_id!, - ); - stripeProdId = stripePrice.product as string; - } catch (error) {} - } - - if (!stripeProdId) { - stripeProdId = product.processor?.id; - } - - logger.info( - `🌟🌟🌟 (Bill remaining) created invoice item: ${description} -- ${amount}`, - ); - - let invoiceItem = { - customer: customer.processor.id, - invoice: invoice.id, - currency: org.default_currency, - description, - price_data: { - product: stripeProdId!, - unit_amount: Math.round(amount * 100), - currency: org.default_currency, - }, - period: { - start: item.periodStart, - end: item.periodEnd, - }, - }; - - let stripeInvoiceItem = await stripeCli.invoiceItems.create(invoiceItem); - - autumnInvoiceItems.push({ - price_id: item.price.id!, - internal_feature_id: item.feature.internal_id || null, - description: description, - period_start: item.periodStart * 1000, - period_end: item.periodEnd * 1000, - stripe_id: stripeInvoiceItem.id, - }); - - await CusEntService.update({ - db, - id: item.relatedCusEnt!.id, - updates: { - balance: 0, - }, - }); - let index = curCusProduct.customer_entitlements.findIndex( - (ce) => ce.id === item.relatedCusEnt!.id, - ); - - curCusProduct.customer_entitlements[index] = { - ...curCusProduct.customer_entitlements[index], - balance: 0, - }; - } - - if (newInvoice) { - await stripeCli.invoices.finalizeInvoice(invoice.id); - - const { paid, error } = await payForInvoice({ - stripeCli, - paymentMethod: null, - invoiceId: invoice.id, - logger, - }); - - if (!paid) { - logger.warn("Failed to pay invoice for remaining usages", { - stripeInvoice: newInvoice, - paymentError: error, - }); - } - } - - await insertInvoiceFromAttach({ - db, - attachParams, - invoiceId: invoice.id, - logger, - }); -}; - -const getRemainingUsagesPreview = async ({ - intervalToInvoiceItems, - curCusProduct, -}: { - intervalToInvoiceItems: any; - curCusProduct: FullCusProduct; -}) => { - let invoiceItems = Object.values(intervalToInvoiceItems).flat() as any[]; - if (invoiceItems.length === 0) { - return; - } - - let items = []; - for (const item of invoiceItems) { - const amount = getPriceForOverage(item.price, item.overage); - const description = `${curCusProduct.product.name} - ${ - item.feature.name - } x ${Math.round(item.usage)}`; - - items.push({ - amount, - description, - }); - } - - return items; -}; - -// export const billForRemainingUsages = async ({ -// db, -// logger, -// attachParams, -// curCusProduct, -// newSubs, -// shouldPreview = false, -// billImmediately = false, -// }: { -// db: DrizzleCli; -// logger: any; -// attachParams: AttachParams; -// curCusProduct: FullCusProduct; -// newSubs: Stripe.Subscription[]; -// shouldPreview?: boolean; -// billImmediately?: boolean; -// }) => { -// const { customer_prices, customer_entitlements } = curCusProduct; -// const { customer, org } = attachParams; - -// const intervalToSub: any = {}; - -// for (const sub of newSubs) { -// const interval = subToAutumnInterval(sub); -// if (interval) { -// intervalToSub[interval] = sub; -// } -// } - -// const intervalToInvoiceItems: any = {}; -// const stripeCli = createStripeCli({ -// org: org, -// env: customer.env, -// }); - -// for (const cp of customer_prices) { -// const config = cp.price.config! as UsagePriceConfig; -// const relatedCusEnt = getRelatedCusEnt({ -// cusPrice: cp, -// cusEnts: customer_entitlements, -// }); -// const billingType = getBillingType(config); - -// if (billingType !== BillingType.UsageInArrear) continue; - -// const { usage, overage, description, amount } = getCusPriceUsage({ -// cusPrice: cp, -// cusProduct: curCusProduct, -// logger, -// }); - -// if (overage <= 0) continue; // no overage, no need to bill... - -// let interval = config.interval as BillingInterval; -// if (!intervalToInvoiceItems[interval]) { -// intervalToInvoiceItems[interval] = []; -// } - -// let sub = intervalToSub[interval]; - -// const stripeNow = await getStripeNow({ -// stripeCli, -// stripeSub: sub, -// }); - -// intervalToInvoiceItems[interval].push({ -// overage, -// usage, -// description, -// amount, - -// feature: relatedCusEnt?.entitlement.feature, -// price: cp.price, -// relatedCusEnt, -// periodStart: sub?.current_period_start, -// periodEnd: stripeNow, -// }); -// } - -// if (shouldPreview) { -// return getRemainingUsagesPreview({ -// intervalToInvoiceItems, -// curCusProduct, -// }); -// } - -// if (billImmediately) { -// await invoiceForUsageImmediately({ -// db, -// intervalToInvoiceItems, -// customer, -// org, -// logger, -// curCusProduct, -// attachParams, -// newSubs, -// }); -// } else { -// await addUsageToNextInvoice({ -// db, -// intervalToInvoiceItems, -// intervalToSub, -// customer, -// org, -// logger, -// attachParams, -// }); -// } -// }; diff --git a/server/src/internal/customers/change-product/handleDowngrade/cancelCurSubs.ts b/server/src/internal/customers/change-product/handleDowngrade/cancelCurSubs.ts index 0cfd905c0..8ade0b0e0 100644 --- a/server/src/internal/customers/change-product/handleDowngrade/cancelCurSubs.ts +++ b/server/src/internal/customers/change-product/handleDowngrade/cancelCurSubs.ts @@ -1,4 +1,9 @@ import { getSubItemsForCusProduct } from "@/external/stripe/stripeSubUtils.js"; +import { subToAutumnInterval } from "@/external/stripe/utils.js"; +import { + priceToIntervalKey, + toIntervalKey, +} from "@/internal/products/prices/priceUtils/convertPrice.js"; import { notNullish } from "@/utils/genUtils.js"; import { FullCusProduct } from "@autumn/shared"; import { SupabaseClient } from "@supabase/supabase-js"; @@ -32,8 +37,10 @@ export const cancelCurSubs = async ({ cusProduct: curCusProduct, }); - let interval = sub.items.data[0].price.recurring!.interval; - intervalToOtherSubs[interval] = { + // let interval = sub.items.data[0].price.recurring!.interval; + let subInterval = subToAutumnInterval(sub); + let intervalKey = toIntervalKey(subInterval); + intervalToOtherSubs[intervalKey] = { otherSubItems, otherSub: sub, }; diff --git a/server/src/internal/customers/change-product/handleUpgrade.ts b/server/src/internal/customers/change-product/handleUpgrade.ts deleted file mode 100644 index fb818416b..000000000 --- a/server/src/internal/customers/change-product/handleUpgrade.ts +++ /dev/null @@ -1,569 +0,0 @@ -// import { getStripeExpandedInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; -// import { -// getStripeSchedules, -// getStripeSubs, -// } from "@/external/stripe/stripeSubUtils.js"; -// import { createStripeCli } from "@/external/stripe/utils.js"; - -// import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -// import { -// attachToInsertParams, -// isFreeProduct, -// } from "@/internal/products/productUtils.js"; -// import RecaseError from "@/utils/errorUtils.js"; -// import { -// FullCusProduct, -// ErrCode, -// FullProduct, -// CusProductStatus, -// APIVersion, -// UsagePriceConfig, -// AttachScenario, -// } from "@autumn/shared"; - -// import { StatusCodes } from "http-status-codes"; -// import Stripe from "stripe"; -// import { createFullCusProduct } from "../add-product/createFullCusProduct.js"; -// import { handleAddProduct } from "../attach/attachFunctions/addProductFlow/handleAddProduct.js"; -// import { -// AttachParams, -// AttachResultSchema, -// } from "../cusProducts/AttachParams.js"; -// import { CusProductService } from "../cusProducts/CusProductService.js"; -// import { -// getInvoiceItems, -// insertInvoiceFromAttach, -// } from "@/internal/invoices/invoiceUtils.js"; -// import { InvoiceService } from "@/internal/invoices/InvoiceService.js"; -// import { updateScheduledSubWithNewItems } from "./scheduleUtils/updateScheduleWithNewItems.js"; - -// // import { billForRemainingUsages } from "./billRemainingUsages.js"; -// import { updateStripeSubscription } from "@/external/stripe/stripeSubUtils/updateStripeSub.js"; -// import { createStripeSub } from "@/external/stripe/stripeSubUtils/createStripeSub.js"; - -// import { -// addBillingIntervalUnix, -// subtractBillingIntervalUnix, -// } from "@/internal/products/prices/billingIntervalUtils.js"; - -// import { differenceInSeconds } from "date-fns"; -// import { SuccessCode } from "@autumn/shared"; -// import { notNullish } from "@/utils/genUtils.js"; -// import { DrizzleCli } from "@/db/initDrizzle.js"; -// import { ExtendedRequest } from "@/utils/models/Request.js"; -// import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; - -// export enum ProrationBehavior { -// Immediately = "immediately", -// NextBilling = "next_billing", -// None = "none", -// } - -// // UPGRADE FUNCTIONS -// export const handleStripeSubUpdate = async ({ -// db, -// stripeCli, -// curCusProduct, -// attachParams, -// disableFreeTrial, -// stripeSubs, -// logger, -// carryExistingUsages = false, -// prorationBehavior = ProrationBehavior.Immediately, -// shouldPreview = false, -// }: { -// db: DrizzleCli; -// stripeCli: Stripe; -// curCusProduct: FullCusProduct; -// attachParams: AttachParams; -// disableFreeTrial?: boolean; -// stripeSubs: Stripe.Subscription[]; -// logger: any; -// carryExistingUsages?: boolean; -// prorationBehavior?: ProrationBehavior; -// shouldPreview?: boolean; -// }) => { -// // HANLDE UPGRADE - -// // 1. Get item sets -// const itemSets = await getStripeSubItems({ -// attachParams, -// carryExistingUsages, -// }); - -// const firstSub = stripeSubs[0]; -// const firstItemSet = itemSets[0]; -// let curPrices = curCusProduct.customer_prices.map((cp) => cp.price); - -// // 1. DELETE ITEMS FROM CURRENT SUB THAT CORRESPOND TO OLD PRODUCT -// for (const item of firstSub.items.data) { -// let stripePriceExists = curPrices.some( -// (p) => -// p.config!.stripe_price_id === item.price.id || -// (p.config as UsagePriceConfig).stripe_product_id === item.price.product, -// ); - -// let stripeProdExists = -// item.price.product == curCusProduct.product.processor?.id; - -// if (!stripePriceExists && !stripeProdExists) { -// continue; -// } - -// firstItemSet.items.push({ -// id: item.id, -// deleted: true, -// }); -// } - -// // 2. Add trial to new subscription? -// let trialEnd; -// if (!disableFreeTrial) { -// trialEnd = freeTrialToStripeTimestamp({ -// freeTrial: attachParams.freeTrial, -// now: attachParams.now, -// }); -// } - -// // 3. Update current subscription -// let newSubs = []; -// const subUpdateRes = await updateStripeSubscription({ -// db, -// stripeCli, -// subscriptionId: firstSub.id, -// trialEnd, -// org: attachParams.org, -// customer: attachParams.customer, -// invoiceOnly: attachParams.invoiceOnly || false, -// prorationBehavior, -// logger, -// itemSet: firstItemSet, -// shouldPreview, -// }); - -// if (shouldPreview) { -// return subUpdateRes; -// } - -// let subUpdate = subUpdateRes as Stripe.Subscription; - -// newSubs.push(subUpdate); - -// // 4. If scheduled_ids exist, need to update schedule too (BRUH)! -// if (curCusProduct.scheduled_ids && curCusProduct.scheduled_ids.length > 0) { -// let schedules = await getStripeSchedules({ -// stripeCli, -// scheduleIds: curCusProduct.scheduled_ids, -// }); - -// for (const scheduleObj of schedules) { -// const { interval, schedule } = scheduleObj; - -// // If schedule has passed, skip this step. -// let phase = schedule.phases.length > 0 ? schedule.phases[0] : null; -// let now = Date.now(); -// if (schedule.test_clock) { -// let testClock = await stripeCli.testHelpers.testClocks.retrieve( -// schedule.test_clock as string, -// ); -// now = testClock.frozen_time * 1000; -// } - -// if (phase && phase.start_date * 1000 < now) { -// logger.info("Note: Schedule has passed, skipping"); -// continue; -// } - -// // Get corresponding item set -// const itemSet = itemSets.find((itemSet) => itemSet.interval === interval); -// if (!itemSet) { -// continue; -// } - -// await updateScheduledSubWithNewItems({ -// scheduleObj, -// newItems: itemSet.items, -// stripeCli, -// cusProductsForGroup: [curCusProduct], -// itemSet, -// db, -// org: attachParams.org, -// env: attachParams.customer.env, -// }); -// } -// } - -// // 5. Insert invoice -// await insertInvoiceFromAttach({ -// db, -// attachParams, -// invoiceId: subUpdate.latest_invoice as string, -// logger, -// }); - -// // 2. Create new subscriptions -// let newSubIds = []; -// newSubIds.push(firstSub.id); -// const newItemSets = itemSets.slice(1); -// let invoiceIds = []; - -// // CREATE NEW SUBSCRIPTIONS -// for (const itemSet of newItemSets) { -// // 1. Next billing date for first sub -// // const nextCycleAnchor = firstSub.current_period_end * 1000; -// const nextCycleAnchor = subUpdate.current_period_end * 1000; -// let nextCycleAnchorUnix = nextCycleAnchor; -// const naturalBillingDate = addBillingIntervalUnix( -// Date.now(), -// itemSet.interval, -// ); - -// while (true) { -// const subtractedUnix = subtractBillingIntervalUnix( -// nextCycleAnchorUnix, -// itemSet.interval, -// ); - -// if (subtractedUnix < Date.now()) { -// break; -// } - -// nextCycleAnchorUnix = subtractedUnix; -// } - -// let billingCycleAnchorUnix: number | undefined = nextCycleAnchorUnix; -// if ( -// differenceInSeconds( -// new Date(naturalBillingDate), -// new Date(nextCycleAnchorUnix), -// ) < 60 -// ) { -// billingCycleAnchorUnix = undefined; -// } - -// const newSub = (await createStripeSub({ -// db, -// stripeCli, -// customer: attachParams.customer, -// org: attachParams.org, -// itemSet, -// invoiceOnly: attachParams.invoiceOnly || false, -// freeTrial: attachParams.freeTrial, -// anchorToUnix: billingCycleAnchorUnix, -// })) as Stripe.Subscription; - -// newSubs.push(newSub); -// newSubIds.push(newSub.id); -// invoiceIds.push(newSub.latest_invoice as string); -// } - -// // 3. Cancel old subscriptions -// let remainingExistingSubIds = stripeSubs.slice(1).map((sub) => sub.id); - -// return { -// subUpdate, -// newSubIds, -// invoiceIds, -// remainingExistingSubIds, -// newSubs, -// }; -// }; - -// const handleOnlyEntsChanged = async ({ -// req, -// res, -// attachParams, -// curCusProduct, -// carryExistingUsages = false, -// }: { -// req: any; -// res: any; -// attachParams: AttachParams; -// curCusProduct: FullCusProduct; -// carryExistingUsages?: boolean; -// }) => { -// const logger = req.logtail; -// logger.info("Only entitlements changed, no need to update prices"); - -// // Remove subscription from previous cus product -// await CusProductService.update({ -// db: req.db, -// cusProductId: curCusProduct.id, -// updates: { -// subscription_ids: [], -// }, -// }); - -// await createFullCusProduct({ -// db: req.db, -// attachParams: attachToInsertParams(attachParams, attachParams.products[0]), -// subscriptionIds: curCusProduct.subscription_ids || [], -// disableFreeTrial: false, -// keepResetIntervals: true, -// carryExistingUsages, -// logger, -// }); - -// logger.info("✅ Successfully updated entitlements for product"); - -// let org = attachParams.org; - -// let apiVersion = org.api_version || APIVersion.v1; -// if (apiVersion >= APIVersion.v1_1) { -// res.status(200).json( -// AttachResultSchema.parse({ -// customer_id: attachParams.customer.id, -// product_ids: attachParams.products.map((p) => p.id), -// code: SuccessCode.FeaturesUpdated, -// message: `Successfully updated features for customer ${attachParams.customer.id} on product ${attachParams.products[0].name}`, -// }), -// ); -// } else { -// res.status(200).json({ -// success: true, -// message: `Successfully updated entitlements for ${curCusProduct.product.name}`, -// }); -// } -// }; - -// export const handleUpgrade = async ({ -// req, -// res, -// attachParams, -// curCusProduct, -// curFullProduct, -// hasPricesChanged = true, -// fromReq = true, -// carryExistingUsages = false, -// prorationBehavior, -// newVersion = false, -// updateSameProduct = false, -// }: { -// req: ExtendedRequest; -// res: any; -// attachParams: AttachParams; -// curCusProduct: FullCusProduct; -// curFullProduct: FullProduct; -// hasPricesChanged?: boolean; -// fromReq?: boolean; -// carryExistingUsages?: boolean; -// prorationBehavior?: ProrationBehavior; -// newVersion?: boolean; -// updateSameProduct?: boolean; -// }) => { -// const logger = req.logtail; -// const { org, customer, products } = attachParams; -// let product = products[0]; - -// let disableFreeTrial = false; -// if (newVersion) { -// disableFreeTrial = true; -// } - -// if (!hasPricesChanged) { -// await handleOnlyEntsChanged({ -// req, -// res, -// attachParams, -// curCusProduct, -// carryExistingUsages, -// }); -// return; -// } - -// logger.info( -// `Upgrading ${curFullProduct.name} to ${product.name} for ${customer.id}`, -// ); - -// const stripeCli = createStripeCli({ org, env: customer.env }); -// const stripeSubs = await getStripeSubs({ -// stripeCli, -// subIds: curCusProduct.subscription_ids, -// }); - -// // 1. If current product has trial and new product has trial, cancel and start new subscription -// let trialToTrial = -// curCusProduct.trial_ends_at && -// curCusProduct.trial_ends_at > Date.now() && -// attachParams.freeTrial && -// !disableFreeTrial; - -// let trialToPaid = -// curCusProduct.trial_ends_at && -// curCusProduct.trial_ends_at > Date.now() && -// !attachParams.freeTrial && -// !newVersion; // If trial to paid and not migrating, cancel trial and start new sub immediately. - -// // 2. If upgrade is free to paid, or paid to free (migration / update) -// let toFreeProduct = isFreeProduct(attachParams.prices); -// let paidToFreeProduct = -// isFreeProduct(curCusProduct.customer_prices.map((cp) => cp.price)) && -// !isFreeProduct(attachParams.prices); - -// if (trialToTrial || trialToPaid || toFreeProduct || paidToFreeProduct) { -// if (trialToTrial) { -// logger.info( -// `Upgrading from trial to trial, cancelling and starting new subscription`, -// ); -// } else if (toFreeProduct) { -// logger.info( -// `switching to free product, cancelling (if needed) and adding free product`, -// ); -// } - -// await handleAddProduct({ -// req, -// res, -// attachParams, -// fromRequest: fromReq, -// carryExistingUsages, -// keepResetIntervals: newVersion, // keep reset intervals if upgrading version (migrations) -// disableMerge: true, -// }); - -// if (notNullish(curCusProduct.subscription_ids)) { -// for (const subId of curCusProduct.subscription_ids!) { -// try { -// await stripeCli.subscriptions.cancel(subId); -// } catch (error) { -// throw new RecaseError({ -// message: `Handling upgrade (cur product on trial): failed to cancel subscription ${subId}`, -// code: ErrCode.StripeCancelSubscriptionFailed, -// statusCode: StatusCodes.BAD_REQUEST, -// data: error, -// }); -// } -// } -// } -// return; -// } - -// logger.info("1. Updating current subscription to new product"); -// let { -// subUpdate, -// newSubIds, -// invoiceIds, -// remainingExistingSubIds, -// newSubs, -// }: any = await handleStripeSubUpdate({ -// db: req.db, -// curCusProduct, -// stripeCli, -// attachParams, -// disableFreeTrial, -// stripeSubs, -// logger, -// carryExistingUsages, -// prorationBehavior, -// }); - -// // logger.info("2. Bill for remaining usages"); -// // await billForRemainingUsages({ -// // db: req.db, -// // attachParams, -// // curCusProduct, -// // newSubs, -// // logger, -// // }); - -// logger.info( -// "2.1. Remove old subscription ID from old cus product and expire", -// ); -// await CusProductService.update({ -// db: req.db, -// cusProductId: curCusProduct.id, -// updates: { -// subscription_ids: curCusProduct.subscription_ids!.filter( -// (subId) => subId !== subUpdate.id, -// ), -// processor: { -// ...curCusProduct.processor, -// subscription_id: null, -// } as any, -// status: CusProductStatus.Expired, -// }, -// }); - -// if (remainingExistingSubIds && remainingExistingSubIds.length > 0) { -// logger.info("2.2. Canceling old subscriptions"); -// for (const subId of remainingExistingSubIds) { -// logger.info(" - Cancelling old subscription", subId); -// await stripeCli.subscriptions.cancel(subId); -// } -// } - -// // Handle backend -// logger.info("3. Creating new full cus product"); - -// await createFullCusProduct({ -// db: req.db, -// attachParams: attachToInsertParams(attachParams, products[0]), -// subscriptionIds: newSubIds, - -// anchorToUnix: -// newSubs.length > 0 ? newSubs[0].current_period_end * 1000 : undefined, - -// disableFreeTrial, -// carryExistingUsages, -// carryOverTrial: true, -// scenario: AttachScenario.Upgrade, -// logger, -// }); - -// // Create invoices -// logger.info("4. Creating invoices"); -// logger.info(`Invoice IDs: ${invoiceIds}`); -// const batchInsertInvoice = []; - -// for (const invoiceId of invoiceIds) { -// const insertInvoice = async () => { -// const stripeInvoice = await getStripeExpandedInvoice({ -// stripeCli, -// stripeInvoiceId: invoiceId, -// }); - -// let autumnInvoiceItems = await getInvoiceItems({ -// stripeInvoice, -// prices: attachParams.prices, -// logger, -// }); - -// await InvoiceService.createInvoiceFromStripe({ -// db: req.db, -// stripeInvoice, -// internalCustomerId: customer.internal_id, -// internalEntityId: attachParams.internalEntityId, -// org, -// productIds: products.map((p) => p.id), -// internalProductIds: products.map((p) => p.internal_id), -// items: autumnInvoiceItems, -// }); -// }; -// batchInsertInvoice.push(insertInvoice()); -// } - -// await Promise.all(batchInsertInvoice); -// logger.info("✅ Done!"); - -// if (fromReq) { -// if (org.api_version! >= APIVersion.v1_1) { -// res.status(200).json( -// AttachResultSchema.parse({ -// customer_id: customer.id, -// product_ids: products.map((p) => p.id), -// code: updateSameProduct -// ? SuccessCode.UpdatedSameProduct -// : newVersion -// ? SuccessCode.UpgradedToNewVersion -// : SuccessCode.UpgradedToNewProduct, -// message: `Successfully attached ${product.name} to ${customer.name} -- upgraded from ${curFullProduct.name}`, -// }), -// ); -// } else { -// res.status(200).json({ -// success: true, -// message: `Successfully attached ${product.name} to ${customer.name} -- upgraded from ${curFullProduct.name}`, -// }); -// } -// } -// }; diff --git a/server/src/internal/customers/change-product/scheduleUtils.ts b/server/src/internal/customers/change-product/scheduleUtils.ts index 148015c0a..b4126c78d 100644 --- a/server/src/internal/customers/change-product/scheduleUtils.ts +++ b/server/src/internal/customers/change-product/scheduleUtils.ts @@ -6,6 +6,7 @@ import { AppEnv, AttachScenario, FullCusProduct, + intervalsSame, Organization, Product, } from "@autumn/shared"; @@ -30,6 +31,7 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import { notNullish } from "@/utils/genUtils.js"; +import { subToAutumnInterval } from "@/external/stripe/utils.js"; export const getPricesForCusProduct = ({ cusProduct, @@ -134,7 +136,7 @@ export const cancelFutureProductSchedule = async ({ // Case where cur scheduled product is not free for (const scheduleObj of schedules) { - const { schedule, interval, prices } = scheduleObj; + const { schedule, interval, intervalCount, prices } = scheduleObj; if (inIntervals && !inIntervals.includes(interval!)) { continue; @@ -142,7 +144,7 @@ export const cancelFutureProductSchedule = async ({ // 1. Remove cur scheduled product items from schedule const activeCusProducts = cusProducts.filter((cusProduct) => - isActiveStatus(cusProduct?.status), + isActiveStatus(cusProduct?.status) ); const filteredScheduleItems = getFilteredScheduleItems({ @@ -152,8 +154,11 @@ export const cancelFutureProductSchedule = async ({ // 2. If any items left, update schedule with cur main product! if (filteredScheduleItems.length > 0) { - let oldItemSet = oldItemSets.find( - (itemSet) => itemSet.interval === interval, + let oldItemSet = oldItemSets.find((itemSet) => + intervalsSame({ + intervalA: { interval, intervalCount }, + intervalB: itemSet, + }) ); await updateScheduledSubWithNewItems({ @@ -186,7 +191,7 @@ export const cancelFutureProductSchedule = async ({ cusProductId: curMainProduct!.id, updates: { scheduled_ids: curMainProduct!.scheduled_ids?.filter( - (id) => id !== schedule.id, + (id) => id !== schedule.id ), }, }); @@ -202,15 +207,21 @@ export const cancelFutureProductSchedule = async ({ await stripeCli.subscriptionSchedules.cancel(schedule.id); } catch (error: any) { logger.warn( - `❌ Error cancelling schedule: ${schedule.id}, ${error.message}`, + `❌ Error cancelling schedule: ${schedule.id}, ${error.message}` ); } - const subWithSameInterval = curSubs.find( - (sub) => + const subWithSameInterval = curSubs.find((sub) => { + let subInterval = subToAutumnInterval(sub); + // sub.items.data[0]?.price?.recurring?.interval === interval + return ( sub.items.data.length > 0 && - sub.items.data[0]?.price?.recurring?.interval === interval, - ); + intervalsSame({ + intervalA: { interval, intervalCount }, + intervalB: subInterval, + }) + ); + }); if (subWithSameInterval && renewCurProduct) { await stripeCli.subscriptions.update(subWithSameInterval.id, { @@ -291,7 +302,7 @@ export const cancelFutureProductSchedule = async ({ } } catch (error) { logger.error( - `❌ Error sending products updated webhook from cancelFutureProductSchedule: ${error}`, + `❌ Error sending products updated webhook from cancelFutureProductSchedule: ${error}` ); } } @@ -310,7 +321,7 @@ export const cancelFutureProductSchedule = async ({ batchRenew.push( stripeCli.subscriptions.update(subId, { cancel_at: null, - }), + }) ); } diff --git a/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts b/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts index c5901d6f7..0dc863237 100644 --- a/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts +++ b/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts @@ -4,5 +4,6 @@ import Stripe from "stripe"; export interface ScheduleObj { schedule: Stripe.SubscriptionSchedule; interval: BillingInterval; + intervalCount: number; prices: Stripe.Price[]; } diff --git a/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts b/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts index 8d18832d3..ebba6d3c4 100644 --- a/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts +++ b/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts @@ -1,4 +1,9 @@ -import { AppEnv, FullCusProduct, Organization } from "@autumn/shared"; +import { + AppEnv, + FullCusProduct, + intervalsSame, + Organization, +} from "@autumn/shared"; import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js"; import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js"; import { getScheduleIdsFromCusProducts } from "../scheduleUtils.js"; @@ -23,7 +28,7 @@ export const getOtherCusProductsOnSub = async ({ if ( cusProduct.id === curMainProduct.id || !curMainSubIds?.some((subId) => - cusProduct?.subscription_ids?.includes(subId), + cusProduct?.subscription_ids?.includes(subId) ) ) { continue; @@ -72,10 +77,13 @@ export const addCurMainProductToSchedule = async ({ }); for (const scheduleObj of schedules) { - const { schedule, interval } = scheduleObj; + const { schedule, interval, intervalCount } = scheduleObj; - let oldItemSet = oldItemSets.find( - (itemSet) => itemSet.interval === interval, + let oldItemSet = oldItemSets.find((itemSet) => + intervalsSame({ + intervalA: { interval, intervalCount }, + intervalB: itemSet, + }) ); await updateScheduledSubWithNewItems({ @@ -99,7 +107,7 @@ export const addCurMainProductToSchedule = async ({ }); logger.info( - `✅ Added old items for product ${curMainProduct.product.name} to schedule: ${schedule.id}`, + `✅ Added old items for product ${curMainProduct.product.name} to schedule: ${schedule.id}` ); } }; diff --git a/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts b/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts index d8bfaa44a..3bf93aacd 100644 --- a/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts +++ b/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts @@ -10,7 +10,7 @@ export const getFilteredScheduleItems = ({ scheduleObj: ScheduleObj; cusProducts: (FullCusProduct | undefined)[]; }) => { - const { schedule, interval, prices } = scheduleObj; + const { schedule, prices } = scheduleObj; let scheduleItems = schedule.phases[0].items; let curPrices: any[] = []; @@ -31,10 +31,10 @@ export const getFilteredScheduleItems = ({ curPrices.some( (price) => price.config?.stripe_price_id === scheduleItem.price || - price.config?.stripe_product_id === stripePrice?.product, + price.config?.stripe_product_id === stripePrice?.product ) || products.some( - (product) => product.processor?.id === stripePrice?.product, + (product) => product.processor?.id === stripePrice?.product ); return !inCurProduct; diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index cce63ebf5..5ae21fdff 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -70,6 +70,7 @@ export type AttachParams = { scenario?: AttachScenario; fromMigration?: boolean; + finalizeInvoice?: boolean; req?: any; }; @@ -104,6 +105,7 @@ export type InsertCusProductParams = { internalEntityId?: string; fromMigration?: boolean; apiVersion?: APIVersion; + finalizeInvoice?: boolean; }; export const AttachResultSchema = z.object({ diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts index ef4659023..5497cbacb 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts @@ -5,6 +5,8 @@ import { CusProductStatus, Customer, EntInterval, + entIntervalsDifferent, + entIntervalToValue, Entitlement, EntitlementWithFeature, Entity, @@ -46,14 +48,14 @@ export const getCusEntMasterBalance = ({ (acc, curr) => { return acc + curr.balance; }, - 0, + 0 ); let totalAdjustment = Object.values(cusEnt.entities || {}).reduce( (acc, curr) => { return acc + curr.adjustment; }, - 0, + 0 ); return { @@ -69,7 +71,7 @@ export const getCusEntMasterBalance = ({ entities && entities.filter( (entity) => - entity.internal_feature_id == feature.internal_id && entity.deleted, + entity.internal_feature_id == feature.internal_id && entity.deleted ).length; return { @@ -120,7 +122,7 @@ export const sortCusEntsForDeduction = ( cusEnts: (FullCustomerEntitlement & { customer_product?: FullCusProduct; })[], - reverseOrder: boolean = false, + reverseOrder: boolean = false ) => { let intervalOrder: Record = { [EntInterval.Minute]: 0, // 1 minute @@ -206,11 +208,15 @@ export const sortCusEntsForDeduction = ( } // 3. Sort by interval - if (aEnt.interval && bEnt.interval && aEnt.interval != bEnt.interval) { + let aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count); + let bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count); + if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) { if (reverseOrder) { - return intervalOrder[bEnt.interval] - intervalOrder[aEnt.interval]; + return bVal.sub(aVal).toNumber(); + // return intervalOrder[bEnt.interval] - intervalOrder[aEnt.interval]; } else { - return intervalOrder[aEnt.interval] - intervalOrder[bEnt.interval]; + return aVal.sub(bVal).toNumber(); + // return intervalOrder[aEnt.interval] - intervalOrder[bEnt.interval]; } } @@ -241,7 +247,7 @@ export const sortCusEntsForDeduction = ( // Get related cusPrice export const getRelatedCusPrice = ( cusEnt: FullCustomerEntitlement, - cusPrices: FullCustomerPrice[], + cusPrices: FullCustomerPrice[] ) => { return cusPrices.find((cusPrice) => { let productMatch = @@ -328,7 +334,7 @@ export const getResetBalance = ({ return (entitlement.allowance || 0) + quantity! * billingUnits!; } catch (error) { console.log( - "WARNING: Failed to return quantity * billing units, returning allowance...", + "WARNING: Failed to return quantity * billing units, returning allowance..." ); return entitlement.allowance || 0; } @@ -347,14 +353,14 @@ export const getUnlimitedAndUsageAllowed = ({ (cusEnt) => cusEnt.internal_feature_id === internalFeatureId && (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited || - cusEnt.unlimited), + cusEnt.unlimited) ); const usageAllowed = cusEnts.some( (ent) => ent.internal_feature_id === internalFeatureId && ent.usage_allowed && - nullish(ent.entitlement.usage_limit), + nullish(ent.entitlement.usage_limit) ); return { unlimited, usageAllowed }; @@ -426,7 +432,7 @@ export const cusEntsContainFeature = ({ feature: Feature; }) => { return cusEnts.some( - (cusEnt) => cusEnt.internal_feature_id === feature.internal_id!, + (cusEnt) => cusEnt.internal_feature_id === feature.internal_id! ); }; @@ -516,7 +522,7 @@ export const getExistingUsageFromCusProducts = ({ !cp.product.is_add_on && (internalEntityId ? cp.internal_entity_id === internalEntityId - : nullish(cp.internal_entity_id)), + : nullish(cp.internal_entity_id)) ) .flatMap((cp) => cp.customer_entitlements) .find((ce) => ce.internal_feature_id === entitlement.internal_feature_id); @@ -537,15 +543,15 @@ export const getExistingUsageFromCusProducts = ({ // Get options let cusProduct = cusProducts?.find( - (cp) => cp.id === existingCusEnt.customer_product_id, + (cp) => cp.id === existingCusEnt.customer_product_id ); let options = getEntOptions( cusProduct?.options || [], - existingCusEnt.entitlement, + existingCusEnt.entitlement ); let price = getRelatedCusPrice( existingCusEnt, - cusProduct?.customer_prices || [], + cusProduct?.customer_prices || [] ); let existingAllowance = getResetBalance({ entitlement: existingCusEnt.entitlement, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts index d9079eff5..f80dd28e2 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts @@ -37,7 +37,7 @@ export const getExistingCusEntAndUsage = async ({ // 1. If there is only one cus ent, return it and usage let similarCusEnts = curCusProduct.customer_entitlements.filter( - (ce) => ce.internal_feature_id === entitlement.internal_feature_id, + (ce) => ce.internal_feature_id === entitlement.internal_feature_id // && // ce.entitlement.interval === entitlement.interval ); @@ -47,8 +47,8 @@ export const getExistingCusEntAndUsage = async ({ "Similar entitlements:", similarCusEnts.map( (ce) => - `${ce.entitlement.feature_id} (${ce.entitlement.interval}) (${ce.balance})`, - ), + `${ce.entitlement.feature_id} (${ce.entitlement.interval}) (${ce.balance})` + ) ); if (similarCusEnts.length === 1) { @@ -79,9 +79,9 @@ export const getExistingUsages = ({ // Get entityUsage for (const entity of entities) { let feature = features.find( - (f) => f.internal_id === entity.internal_feature_id, + (f) => f.internal_id === entity.internal_feature_id ); - let key = `${feature?.id}-${EntInterval.Lifetime}`; + let key = `${feature?.id}-${EntInterval.Lifetime}-1`; if (!usages[key]) { usages[key] = { @@ -96,7 +96,7 @@ export const getExistingUsages = ({ for (const cusEnt of curCusProduct?.customer_entitlements || []) { let ent = cusEnt.entitlement; - let key = `${ent.feature_id}-${ent.interval}`; + let key = `${ent.feature_id}-${ent.interval}-${ent.interval_count || 1}`; let feature = ent.feature; if (feature.type == FeatureType.Boolean) { continue; @@ -199,8 +199,8 @@ export const addExistingUsagesToCusEnts = ({ "Sorted cusEnts:", fullCusEnts.map( (ce) => - `${ce.entitlement.feature_id} (${ce.entitlement.interval}), balance: ${ce.balance}`, - ), + `${ce.entitlement.feature_id} (${ce.entitlement.interval}), balance: ${ce.balance}` + ) ); } @@ -212,7 +212,7 @@ export const addExistingUsagesToCusEnts = ({ for (const cusEnt of fullCusEnts) { let ent = cusEnt.entitlement; - let cusEntKey = `${ent.feature_id}-${ent.interval}`; + let cusEntKey = `${ent.feature_id}-${ent.interval}-${ent.interval_count || 1}`; let fromEntities = existingUsages[key].fromEntities; if (cusEntKey !== key) { diff --git a/server/src/internal/customers/cusProducts/cusProductUtils.ts b/server/src/internal/customers/cusProducts/cusProductUtils.ts index d3d76451e..b87b8b84c 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils.ts @@ -79,7 +79,7 @@ export const cancelCusProductSubscriptions = async ({ subIds: cusProduct.subscription_ids, }); - latestSubEnd = stripeSubs[0].current_period_end; + latestSubEnd = stripeSubs?.[0]?.current_period_end; } const cancelStripeSub = async (subId: string) => { diff --git a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts index 1744dd900..8687ad941 100644 --- a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts +++ b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts @@ -62,6 +62,7 @@ export const initNextResetAt = ({ nextResetAtCalculated = getNextEntitlementReset( nextResetAtCalculated || new UTCDate(now), resetInterval, + entitlement.interval_count || 1 ).getTime(); // If anchorToUnix, align next reset at to anchorToUnix... diff --git a/server/src/internal/customers/cusUtils/createNewCustomer.ts b/server/src/internal/customers/cusUtils/createNewCustomer.ts index 471c3ef83..537a17620 100644 --- a/server/src/internal/customers/cusUtils/createNewCustomer.ts +++ b/server/src/internal/customers/cusUtils/createNewCustomer.ts @@ -41,7 +41,7 @@ export const createNewCustomer = async ({ const { db, org, env, logger } = req; logger.info( - `Creating customer: ${customer.email || customer.id}, org: ${org.slug}`, + `Creating customer: ${customer.email || customer.id}, org: ${org.slug}` ); const defaultProds = await ProductService.listDefault({ @@ -149,7 +149,10 @@ export const createNewCustomer = async ({ }), nextResetAt, anchorToUnix: org.config.anchor_start_of_month - ? getNextStartOfMonthUnix(BillingInterval.Month) + ? getNextStartOfMonthUnix({ + interval: BillingInterval.Month, + intervalCount: 1, + }) : undefined, scenario: AttachScenario.New, logger, diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts index 35c8274ea..3d974736b 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/balancesToFeatureResponse.ts @@ -87,11 +87,14 @@ export const featuresToObject = ({ next_reset_at: getEarliestNextResetAt(relatedEnts), interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple", + interval_count: + relatedEnts.length == 1 ? relatedEnts[0].interval_count : null, overage_allowed: relatedEnts.some((e) => e.overage_allowed), breakdown: !unlimited && relatedEnts.length > 1 ? relatedEnts.map((e) => ({ interval: e.interval!, + interval_count: e.interval_count, balance: e.balance, usage: e.usage, included_usage: e.included_usage, diff --git a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts index cad1d573d..ca30d90d8 100644 --- a/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts +++ b/server/src/internal/customers/cusUtils/cusFeatureResponseUtils/getCusBalances.ts @@ -141,7 +141,7 @@ export const getRolloverFields = ({ ); } } else { - return cusEnt.rollovers.reduce( + return rollovers.reduce( (acc, rollover) => { return { balance: acc.balance + rollover.balance, @@ -204,7 +204,7 @@ export const getCusBalances = async ({ const cusProduct = cusEnt.customer_product; const feature = cusEnt.entitlement.feature; const ent: EntitlementWithFeature = cusEnt.entitlement; - let key = `${ent.interval || "no-interval"}-${feature.id}`; + let key = `${ent.interval || "no-interval"}-${ent.interval_count || 1}-${feature.id}`; // 1. Handle boolean let isBoolean = feature.type == FeatureType.Boolean; @@ -239,6 +239,11 @@ export const getCusBalances = async ({ unlimited: isBoolean ? undefined : unlimited, interval: isBoolean || unlimited ? undefined : ent.interval || undefined, + // interval_count: + // isBoolean || unlimited + // ? undefined + // : ent.interval_count || undefined, + balance: isBoolean ? undefined : unlimited ? null : 0, total: isBoolean || unlimited ? undefined : 0, adjustment: isBoolean || unlimited ? undefined : 0, @@ -252,6 +257,7 @@ export const getCusBalances = async ({ isBoolean || unlimited ? undefined : cusEnt.next_reset_at; data[key].allowance = isBoolean || unlimited ? undefined : 0; data[key].usage_limit = isBoolean || unlimited ? undefined : 0; + data[key].interval_count = ent.interval_count || 1; } } } diff --git a/server/src/internal/customers/expire/expireRouter.ts b/server/src/internal/customers/expire/expireRouter.ts index ad6a76ab7..4dd18dd61 100644 --- a/server/src/internal/customers/expire/expireRouter.ts +++ b/server/src/internal/customers/expire/expireRouter.ts @@ -65,31 +65,6 @@ expireRouter.post("", async (req, res) => prorate, }); - // console.log( - // "CUs products to expire", - // cusProductsToExpire.map((c) => c.product.id) - // ); - // throw new Error("test"); - - // if (!cusProductsToExpire) { - // throw new RecaseError({ - // code: ErrCode.ProductNotFound, - // message: `Product ${product_id} not found for customer ${customer_id}`, - // }); - // } - - // // Handle case if there are two products to expire... - - // for (const cusProduct of cusProductsToExpire) { - // await expireCusProduct({ - // req, - // cusProduct, - // fullCus, - // expireImmediately, - // prorate, - // }); - // } - res.status(200).json({ success: true, customer_id: customer_id, diff --git a/server/src/internal/customers/handlers/handleCusProductExpired.ts b/server/src/internal/customers/handlers/handleCusProductExpired.ts index 69c0372f2..c1c71039c 100644 --- a/server/src/internal/customers/handlers/handleCusProductExpired.ts +++ b/server/src/internal/customers/handlers/handleCusProductExpired.ts @@ -2,13 +2,16 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { cancelFutureProductSchedule } from "@/internal/customers/change-product/scheduleUtils.js"; -import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; +import { + ACTIVE_STATUSES, + CusProductService, +} from "@/internal/customers/cusProducts/CusProductService.js"; import { cancelCusProductSubscriptions, expireAndActivate, fullCusProductToProduct, } from "@/internal/customers/cusProducts/cusProductUtils.js"; -import { isOneOff } from "@/internal/products/productUtils.js"; +import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { @@ -21,6 +24,7 @@ import { } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { CusService } from "../CusService.js"; +import { cusProductToPrices } from "../cusProducts/cusProductUtils/convertCusProduct.js"; export const removeScheduledProduct = async ({ req, @@ -112,14 +116,29 @@ export const expireCusProduct = async ({ // 1. If main product, can't expire if there's scheduled product let isMain = !cusProduct.product.is_add_on; - if (isMain) { - let { curScheduledProduct: futureProduct } = getExistingCusProducts({ - product: cusProduct.product, - cusProducts: fullCus.customer_products, - internalEntityId: cusProduct.internal_entity_id, - }); + let { curScheduledProduct: futureProduct } = getExistingCusProducts({ + product: cusProduct.product, + cusProducts: fullCus.customer_products, + internalEntityId: cusProduct.internal_entity_id, + }); - if (futureProduct) { + if (isMain) { + if ( + cusProduct.canceled_at && + ACTIVE_STATUSES.includes(cusProduct.status) && + !expireImmediately + ) { + throw new RecaseError({ + message: `Product ${cusProduct.product.name} is already about to cancel at the end of cycle.`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + if ( + futureProduct && + !isFreeProduct(cusProductToPrices({ cusProduct: futureProduct })) + ) { throw new RecaseError({ message: `Please delete scheduled product ${futureProduct.product.name} first`, code: ErrCode.InvalidRequest, @@ -130,6 +149,7 @@ export const expireCusProduct = async ({ // 2. If expire at cycle end, just cancel subscriptions if (!expireImmediately) { + // 1. Check if already canceled await cancelCusProductSubscriptions({ cusProduct, org, @@ -175,6 +195,14 @@ export const expireCusProduct = async ({ return; } + // Remove scheduled products first... + if (futureProduct) { + await CusProductService.delete({ + db, + cusProductId: futureProduct.id, + }); + } + logger.info(`Expiring current product: ${cusProduct.product.name}`); await expireAndActivate({ req, diff --git a/server/src/internal/customers/handlers/handleUpdateBalances.ts b/server/src/internal/customers/handlers/handleUpdateBalances.ts index 278194824..bef4248e4 100644 --- a/server/src/internal/customers/handlers/handleUpdateBalances.ts +++ b/server/src/internal/customers/handlers/handleUpdateBalances.ts @@ -143,13 +143,21 @@ export const handleUpdateBalances = async (req: any, res: any) => { delete properties.balance; for (const cusEnt of cusEnts) { - if (cusEnt.internal_feature_id !== feature!.internal_id!) { - continue; - } + let cusEntIntCount = cusEnt.entitlement.interval_count || 1; + let deductionIntCount = balance.interval_count || 1; + + let intCountMatch = notNullish(balance.interval_count) + ? cusEntIntCount === deductionIntCount + : true; + + let intMatch = notNullish(balance.interval) + ? balance.interval === cusEnt.entitlement.interval + : true; if ( - notNullish(balance.interval) && - balance.interval !== cusEnt.entitlement.interval + cusEnt.internal_feature_id !== feature!.internal_id! || + !intMatch || + !intCountMatch ) { continue; } @@ -173,6 +181,7 @@ export const handleUpdateBalances = async (req: any, res: any) => { toDeduct, properties, interval: balance.interval, + intervalCount: balance.interval_count, }); } @@ -186,12 +195,18 @@ export const handleUpdateBalances = async (req: any, res: any) => { // Handle unlimited if (featureDeduction.unlimited) { // Get one active cusEnt and set unlimited to true + const cusEnt = notNullish(interval) - ? cusEnts.find( - (cusEnt) => + ? cusEnts.find((cusEnt) => { + let cusEntIntCount = cusEnt.entitlement.interval_count || 1; + let deductionIntCount = featureDeduction.intervalCount || 1; + + return ( cusEnt.internal_feature_id === feature!.internal_id! && - cusEnt.entitlement.interval === interval - ) + cusEnt.entitlement.interval === interval && + cusEntIntCount === deductionIntCount + ); + }) : cusEnts.find( (cusEnt) => cusEnt.internal_feature_id === feature!.internal_id! ); @@ -218,9 +233,22 @@ export const handleUpdateBalances = async (req: any, res: any) => { } for (const cusEnt of cusEnts) { + let cusEntIntCount = cusEnt.entitlement.interval_count || 1; + let deductionIntCount = featureDeduction.intervalCount || 1; + + let intCountMatch = notNullish(featureDeduction.intervalCount) + ? cusEntIntCount === deductionIntCount + : true; + + let intMatch = notNullish(featureDeduction.interval) + ? featureDeduction.interval === cusEnt.entitlement.interval + : true; + if ( cusEnt.internal_feature_id !== - featureDeduction.feature!.internal_id! + featureDeduction.feature!.internal_id! || + !intMatch || + !intCountMatch ) { continue; } diff --git a/server/src/internal/dev/devRouter.ts b/server/src/internal/dev/devRouter.ts index 44502dc21..c5f827253 100644 --- a/server/src/internal/dev/devRouter.ts +++ b/server/src/internal/dev/devRouter.ts @@ -216,7 +216,7 @@ export const handleGetOtp = async (req: any, res: any) => // Generate API key for the OTP const sandboxKey = await createKey({ db, - env: env || AppEnv.Sandbox, + env: AppEnv.Sandbox, name: `Autumn Key CLI`, orgId: cacheData.orgId, prefix: "am_sk_test", @@ -229,7 +229,7 @@ export const handleGetOtp = async (req: any, res: any) => const prodKey = await createKey({ db, - env: env || AppEnv.Live, + env: AppEnv.Live, name: `Autumn Key CLI`, orgId: cacheData.orgId, prefix: "am_sk_live", diff --git a/server/src/internal/emails/sendOnboardingEmail.ts b/server/src/internal/emails/sendOnboardingEmail.ts index 0bd5dc5a1..955990fa0 100644 --- a/server/src/internal/emails/sendOnboardingEmail.ts +++ b/server/src/internal/emails/sendOnboardingEmail.ts @@ -27,6 +27,7 @@ export const sendOnboardingEmail = safeResend({ to: email, subject: "Anything I can help with?", body: getWelcomeEmailBody(firstName), + replyTo: "ayush@useautumn.com", }); }, action: "send onboarding email", diff --git a/server/src/internal/features/FeatureService.ts b/server/src/internal/features/FeatureService.ts index d884b0425..39e603a77 100644 --- a/server/src/internal/features/FeatureService.ts +++ b/server/src/internal/features/FeatureService.ts @@ -4,25 +4,26 @@ import { ErrCode } from "@/errors/errCodes.js"; import { clearOrgCache } from "../orgs/orgUtils/clearOrgCache.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { and, eq } from "drizzle-orm"; +import { notNullish } from "@/utils/genUtils.js"; export class FeatureService { static async list({ db, orgId, env, - showOnlyArchived = false, + archived, }: { db: DrizzleCli; orgId: string; env: AppEnv; - showOnlyArchived?: boolean; + archived?: boolean; }) { const features = await db.query.features.findMany({ where: (features, { eq, and }) => and( eq(features.org_id, orgId), eq(features.env, env), - eq(features.archived, showOnlyArchived) + notNullish(archived) ? eq(features.archived, archived!) : undefined ), orderBy: (features, { desc }) => [desc(features.internal_id)], @@ -208,5 +209,4 @@ export class FeatureService { .delete(features) .where(and(eq(features.org_id, orgId), eq(features.env, env))); } - } diff --git a/server/src/internal/features/featureRouter.ts b/server/src/internal/features/featureRouter.ts index 693396a90..4b8ef9fb9 100644 --- a/server/src/internal/features/featureRouter.ts +++ b/server/src/internal/features/featureRouter.ts @@ -27,12 +27,15 @@ featureRouter.get("", async (req: any, res: any) => res, action: "list features", handler: async () => { + const includeArchived = req.query.include_archived === "true"; let features = await FeatureService.list({ db: req.db, orgId: req.orgId, env: req.env, - showOnlyArchived: false, + archived: includeArchived ? undefined : false, + // showOnlyArchived: includeArchived ? undefined : false, }); + res .status(200) .json({ list: features.map((feature) => toAPIFeature({ feature })) }); @@ -119,7 +122,7 @@ featureRouter.post("/:feature_id", async (req: any, res: any) => } if (apiFeature.credit_schema) { - newConfig.credit_schema = apiFeature.credit_schema.map((credit) => ({ + newConfig.schema = apiFeature.credit_schema.map((credit) => ({ metered_feature_id: credit.metered_feature_id, credit_amount: credit.credit_cost, })); diff --git a/server/src/internal/features/featureUtils.ts b/server/src/internal/features/featureUtils.ts index ede7e65f1..b9d6aee1d 100644 --- a/server/src/internal/features/featureUtils.ts +++ b/server/src/internal/features/featureUtils.ts @@ -9,6 +9,8 @@ import { Organization, ProductItemFeatureType, FeatureType, + FullCustomer, + UsagePriceConfig, } from "@autumn/shared"; import { FeatureService } from "./FeatureService.js"; import { StatusCodes } from "http-status-codes"; @@ -16,6 +18,11 @@ import { generateFeatureDisplay } from "@/external/llm/llmUtils.js"; import { ProductService } from "../products/ProductService.js"; import { getCreditSystemsFromFeature } from "./creditSystemUtils.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import { + cusProductsToCusPrices, + cusProductToPrices, +} from "../customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { priceToFeature } from "../products/prices/priceUtils/convertPrice.js"; export const validateFeatureId = (featureId: string) => { if (!featureId.match(/^[a-zA-Z0-9_-]+$/)) { @@ -77,9 +84,9 @@ export const validateCreditSystem = (config: CreditSystemConfig) => { // Check if multiple of the same feature const meteredFeatureIds = schema.map( - (schemaItem) => schemaItem.metered_feature_id, + (schemaItem) => schemaItem.metered_feature_id ); - console.log("Metered feature ids:", meteredFeatureIds); + // console.log("Metered feature ids:", meteredFeatureIds); const uniqueMeteredFeatureIds = Array.from(new Set(meteredFeatureIds)); if (meteredFeatureIds.length !== uniqueMeteredFeatureIds.length) { throw new RecaseError({ @@ -135,14 +142,14 @@ export const getObjectsUsingFeature = async ({ }); let entitlements = allEnts.filter( - (entitlement) => entitlement.internal_feature_id == feature.internal_id, + (entitlement) => entitlement.internal_feature_id == feature.internal_id ); let linkedEntitlements = allEnts.filter( - (entitlement) => entitlement.entity_feature_id == feature.id, + (entitlement) => entitlement.entity_feature_id == feature.id ); let prices = allPrices.filter( - (price) => (price.config as any).internal_feature_id == feature.internal_id, + (price) => (price.config as any).internal_feature_id == feature.internal_id ); return { entitlements, prices, creditSystems, linkedEntitlements }; @@ -163,7 +170,7 @@ export const runSaveFeatureDisplayTask = async ({ try { if (!process.env.ANTHROPIC_API_KEY) { logger.warn( - "ANTHROPIC_API_KEY is not set, skipping feature display generation", + "ANTHROPIC_API_KEY is not set, skipping feature display generation" ); return; } @@ -204,3 +211,29 @@ export const getCusFeatureType = ({ feature }: { feature: Feature }) => { export const isCreditSystem = ({ feature }: { feature: Feature }) => { return feature.type == FeatureType.CreditSystem; }; + +export const isPaidContinuousUse = ({ + feature, + fullCus, +}: { + feature: Feature; + fullCus: FullCustomer; +}) => { + let isContinuous = feature.config?.usage_type == FeatureUsageType.Continuous; + + if (!isContinuous) { + return false; + } + + let cusPrices = cusProductsToCusPrices({ + cusProducts: fullCus.customer_products, + }); + let hasPaid = cusPrices.some((cp) => { + let config = cp.price.config as UsagePriceConfig; + if (config.internal_feature_id == feature.internal_id) { + return true; + } + }); + + return hasPaid; +}; diff --git a/server/src/internal/features/handlers/handleCreateFeature.ts b/server/src/internal/features/handlers/handleCreateFeature.ts index dc200a4d1..1e6344b80 100644 --- a/server/src/internal/features/handlers/handleCreateFeature.ts +++ b/server/src/internal/features/handlers/handleCreateFeature.ts @@ -9,11 +9,13 @@ import { handleFrontendReqError } from "@/utils/errorUtils.js"; export const handleCreateFeature = async (req: any, res: any) => { try { + console.log("Trying to create feature"); const data = req.body; let { db, orgId, env, logtail: logger } = req; let parsedFeature = validateFeature(data); - let feature: Feature = { + const feature: Feature = { + archived: false, internal_id: generateId("fe"), org_id: orgId, created_at: Date.now(), diff --git a/server/src/internal/features/handlers/handleUpdateFeature.ts b/server/src/internal/features/handlers/handleUpdateFeature.ts index 3a6e65a52..d4a51fdff 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature.ts @@ -350,6 +350,17 @@ export const handleUpdateFeature = async (req: any, res: any) => } } + const newConfig = + data.config !== undefined + ? feature.type == FeatureType.CreditSystem + ? validateCreditSystem(data.config) + : feature.type == FeatureType.Metered + ? validateMeteredConfig(data.config) + : data.config + : feature.config; + + console.log(`feature: ${feature.id}, new config:`, newConfig); + let updatedFeature = await FeatureService.update({ db: req.db, id: featureId, @@ -359,15 +370,10 @@ export const handleUpdateFeature = async (req: any, res: any) => id: data.id !== undefined ? data.id : feature.id, name: data.name !== undefined ? data.name : feature.name, type: data.type !== undefined ? data.type : feature.type, - archived: data.archived !== undefined ? data.archived : feature.archived, + archived: + data.archived !== undefined ? data.archived : feature.archived, - config: data.config !== undefined - ? (feature.type == FeatureType.CreditSystem - ? validateCreditSystem(data.config) - : feature.type == FeatureType.Metered - ? validateMeteredConfig(data.config) - : data.config) - : feature.config, + config: newConfig, }, }); diff --git a/server/src/internal/features/internalFeatureRouter.ts b/server/src/internal/features/internalFeatureRouter.ts index fb2c3eb2c..e40b103d1 100644 --- a/server/src/internal/features/internalFeatureRouter.ts +++ b/server/src/internal/features/internalFeatureRouter.ts @@ -29,14 +29,14 @@ export const internalFeatureRouter: Router = express.Router(); internalFeatureRouter.get("", async (req: any, res: any) => { try { let { showArchived } = req.query; - + if (showArchived !== undefined) { // If showArchived is specified, use FeatureService.list with the parameter let features = await FeatureService.list({ db: req.db, orgId: req.orgId, env: req.env, - showOnlyArchived: showArchived === 'true', + archived: showArchived === "true" ? true : false, }); res.status(200).json({ features }); } else { @@ -50,8 +50,6 @@ internalFeatureRouter.get("", async (req: any, res: any) => { } }); - - export const validateFeature = (data: any) => { let featureType = data.type; @@ -96,26 +94,23 @@ export const initNewFeature = ({ internalFeatureRouter.post("", async (req: any, res) => { let data = req.body; - try { let { db, orgId, env, logtail: logger } = req; let parsedFeature = validateFeature(data); - - let feature: Feature = { + const feature: Feature = { + archived: false, internal_id: generateId("fe"), org_id: orgId, created_at: Date.now(), env: env, ...parsedFeature, }; - let org = await OrgService.getFromReq(req); let insertedData = await FeatureService.insert({ db, data: feature, logger, }); - await addTaskToQueue({ jobName: JobName.GenerateFeatureDisplay, payload: { @@ -123,7 +118,6 @@ internalFeatureRouter.post("", async (req: any, res) => { org: org, }, }); - let insertedFeature = insertedData && insertedData.length > 0 ? insertedData[0] : null; res.status(200).json(insertedFeature); @@ -132,60 +126,63 @@ internalFeatureRouter.post("", async (req: any, res) => { } }); -internalFeatureRouter.get("/data/deletion_text/:feature_id", async (req: any, res) => { - try { - let { db } = req; - let { feature_id } = req.params; - - // Get the feature first - let feature = await FeatureService.get({ - db, - id: feature_id, - orgId: req.orgId, - env: req.env, - }); +internalFeatureRouter.get( + "/data/deletion_text/:feature_id", + async (req: any, res) => { + try { + let { db } = req; + let { feature_id } = req.params; - if (!feature) { - return res.status(404).json({ error: "Feature not found" }); - } + // Get the feature first + let feature = await FeatureService.get({ + db, + id: feature_id, + orgId: req.orgId, + env: req.env, + }); - // Use Drizzle query similar to ProductService.getDeletionText - let res_data = await db - .select({ - productName: sql`CASE WHEN ROW_NUMBER() OVER (ORDER BY ${products.created_at}) = 1 THEN ${products.name ?? "Product name not found"} ELSE NULL END`, - totalCount: sql`COUNT(*) OVER ()`, - }) - .from(products) - .innerJoin( - entitlements, - eq(products.internal_id, entitlements.internal_product_id) - ) - .where( - and( - eq(entitlements.internal_feature_id, feature.internal_id!), - eq(products.env, req.env), - eq(products.org_id, req.orgId) + if (!feature) { + return res.status(404).json({ error: "Feature not found" }); + } + + // Use Drizzle query similar to ProductService.getDeletionText + let res_data = await db + .select({ + productName: sql`CASE WHEN ROW_NUMBER() OVER (ORDER BY ${products.created_at}) = 1 THEN ${products.name ?? "Product name not found"} ELSE NULL END`, + totalCount: sql`COUNT(*) OVER ()`, + }) + .from(products) + .innerJoin( + entitlements, + eq(products.internal_id, entitlements.internal_product_id) ) - ) - .limit(1); + .where( + and( + eq(entitlements.internal_feature_id, feature.internal_id!), + eq(products.env, req.env), + eq(products.org_id, req.orgId) + ) + ) + .limit(1); - // If no products found, return explicit zero count - if (!res_data || res_data.length === 0) { - res.status(200).json({ - productName: null, - totalCount: 0, - }); - } else { - res.status(200).json({ - productName: res_data[0]?.productName || null, - totalCount: Number(res_data[0]?.totalCount) || 0, - }); + // If no products found, return explicit zero count + if (!res_data || res_data.length === 0) { + res.status(200).json({ + productName: null, + totalCount: 0, + }); + } else { + res.status(200).json({ + productName: res_data[0]?.productName || null, + totalCount: Number(res_data[0]?.totalCount) || 0, + }); + } + } catch (error) { + console.error("Failed to get feature deletion text", error); + res.status(500).send(error); } - } catch (error) { - console.error("Failed to get feature deletion text", error); - res.status(500).send(error); } -}); +); internalFeatureRouter.post("/:feature_id", handleUpdateFeature); internalFeatureRouter.delete("/:featureId", handleDeleteFeature); diff --git a/server/src/internal/invoices/invoiceFormatUtils.ts b/server/src/internal/invoices/invoiceFormatUtils.ts index 646f06dcc..72f320095 100644 --- a/server/src/internal/invoices/invoiceFormatUtils.ts +++ b/server/src/internal/invoices/invoiceFormatUtils.ts @@ -11,6 +11,7 @@ import { getFeatureNameWithCapital, Organization, Price, + ProductItemInterval, UsagePriceConfig, } from "@autumn/shared"; import { @@ -24,6 +25,8 @@ import { } from "../customers/cusProducts/cusPrices/cusPriceUtils.js"; import { getFeatureQuantity } from "../customers/cusProducts/cusProductUtils.js"; import { formatAmount } from "@/utils/formatUtils.js"; +import { getIntervalString } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js"; +import { billingToItemInterval } from "../products/product-items/itemIntervalUtils.js"; const getSingularAndPlural = (feature: Feature) => { const singular = getFeatureName({ @@ -72,10 +75,16 @@ export const formatFixedPrice = ({ const config = price.config as FixedPriceConfig; const amount = formatAmount({ org, amount: config.amount }); + const intervalStr = getIntervalString({ + interval: billingToItemInterval(config.interval) as ProductItemInterval, + intervalCount: config.interval_count || 1, + prefix: "", + }); + if (config.interval == BillingInterval.OneOff) { return `${amount}`; } else { - return `${amount} / ${config.interval}`; + return `${amount} / ${intervalStr}`; } }; diff --git a/server/src/internal/invoices/invoiceUtils.ts b/server/src/internal/invoices/invoiceUtils.ts index 4f74ab118..22ce2f3b9 100644 --- a/server/src/internal/invoices/invoiceUtils.ts +++ b/server/src/internal/invoices/invoiceUtils.ts @@ -80,7 +80,7 @@ export const invoicesToResponse = ({ invoice: i, withItems: false, features: [], - }), + }) ); }; @@ -119,10 +119,33 @@ export const getInvoiceItems = async ({ } catch (error) { logger.error( `Failed to get invoice items for invoice ${stripeInvoice.id}`, - error, + error ); return []; } return invoiceItems; }; + +export const attachToInvoiceResponse = ({ + invoice, +}: { + invoice?: Stripe.Invoice; +}) => { + if (!invoice) { + return undefined; + } + + return { + status: invoice.status, + stripe_id: invoice.id, + hosted_invoice_url: invoice.hosted_invoice_url, + total: invoice.total, + currency: invoice.currency, + // id: invoice.id, + // stripe_id: invoice.stripe_id, + // status: invoice.status, + // created_at: invoice.created_at, + // updated_at: invoice.updated_at, + }; +}; diff --git a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts index 64a5451ae..cc69007a6 100644 --- a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts +++ b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts @@ -63,6 +63,7 @@ export const getCurContUseItems = async ({ const finalProration = getProration({ now, interval: price.config.interval!, + intervalCount: price.config.interval_count || 1, anchorToUnix: sub.current_period_end * 1000, })!; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts index 43d678914..565637e4e 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts @@ -86,6 +86,7 @@ export const getItemsForCurProduct = async ({ const finalProration = getProration({ now, interval: price.config.interval!, + intervalCount: price.config.interval_count || 1, anchorToUnix: sub.current_period_end * 1000, })!; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index de8bee69f..dd1112f0a 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -51,6 +51,7 @@ import { import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js"; import { getMergeCusProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/getMergeCusProduct.js"; import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; +import { intervalsDifferent } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; export const getDefaultPriceStr = ({ org, @@ -83,6 +84,7 @@ export const getProration = ({ anchorToUnix, now, interval, + intervalCount, }: { proration?: { start: number; @@ -90,6 +92,7 @@ export const getProration = ({ }; anchorToUnix?: number; interval: BillingInterval; + intervalCount: number; now: number; }) => { if (!proration && !anchorToUnix) return undefined; @@ -103,11 +106,16 @@ export const getProration = ({ let end = getAlignedIntervalUnix({ alignWithUnix: anchorToUnix!, interval, + intervalCount, now, alwaysReturn: true, }); - let start = subtractBillingIntervalUnix(end!, interval); + let start = subtractBillingIntervalUnix({ + unixTimestamp: end!, + interval, + intervalCount, + }); return { start, @@ -121,6 +129,7 @@ export const getItemsForNewProduct = async ({ now, proration, interval, + intervalCount, anchorToUnix, freeTrial, stripeSubs, @@ -137,6 +146,7 @@ export const getItemsForNewProduct = async ({ end: number; }; interval?: BillingInterval; + intervalCount?: number; anchorToUnix?: number; freeTrial?: FreeTrial | null; stripeSubs?: Stripe.Subscription[]; @@ -156,13 +166,27 @@ export const getItemsForNewProduct = async ({ const ent = getPriceEntitlement(price, newProduct.entitlements); const billingType = getBillingType(price.config); - if (interval && price.config.interval !== interval) continue; + if ( + interval && + intervalsDifferent({ + intervalA: { + interval: interval, + intervalCount: intervalCount, + }, + intervalB: { + interval: price.config.interval, + intervalCount: price.config.interval_count, + }, + }) + ) + continue; const finalProration = getProration({ proration, anchorToUnix, now, interval: price.config.interval!, + intervalCount: price.config.interval_count!, }); if (isFixedPrice({ price })) { diff --git a/server/src/internal/mainRouter.ts b/server/src/internal/mainRouter.ts index f7f80aee7..506d7eaf8 100644 --- a/server/src/internal/mainRouter.ts +++ b/server/src/internal/mainRouter.ts @@ -116,7 +116,7 @@ mainRouter.use( }, identify: async (req: any) => { return { - customerId: "user_123", + customerId: "onboarding_demo_user", customerData: { name: "Demo User", email: "demo@useautumn.com", diff --git a/server/src/internal/orgs/handlers/handleConnectStripe.ts b/server/src/internal/orgs/handlers/handleConnectStripe.ts index 4d46b8494..7369860b5 100644 --- a/server/src/internal/orgs/handlers/handleConnectStripe.ts +++ b/server/src/internal/orgs/handlers/handleConnectStripe.ts @@ -32,9 +32,11 @@ export const connectStripe = async ({ env: AppEnv; }) => { // 1. Check if key is valid + await checkKeyValid(apiKey); let stripe = new Stripe(apiKey); + let account = await stripe.accounts.retrieve(); // 2. Disconnect existing webhook endpoints @@ -47,6 +49,7 @@ export const connectStripe = async ({ // 3. Create webhook endpoint let webhook = await createWebhookEndpoint(apiKey, env, orgId); + console.log("MADE IT HERE"); // 3. Return encrypted if (env === AppEnv.Sandbox) { @@ -96,7 +99,9 @@ export const connectAllStripe = async ({ // Get default currency from Stripe let stripe = new Stripe(testApiKey); + let account = await stripe.accounts.retrieve(); + if (nullish(defaultCurrency) && nullish(account.default_currency)) { throw new RecaseError({ message: "Default currency not set", @@ -107,7 +112,7 @@ export const connectAllStripe = async ({ defaultCurrency = account.default_currency; } } catch (error: any) { - console.error("Error checking stripe keys", error); + // console.error("Error checking stripe keys", error); throw new RecaseError({ message: error.message || "Invalid Stripe API keys", code: ErrCode.StripeKeyInvalid, diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index 18a6ebef8..37bfc73ab 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -77,12 +77,14 @@ orgRouter.post("/stripe", async (req: any, res) => { logger, }); + console.log("Connecting Stripe"); await checkKeyValid(testApiKey); await checkKeyValid(liveApiKey); // Get default currency from Stripe let stripe = new Stripe(testApiKey); let account = await stripe.accounts.retrieve(); + if (nullish(defaultCurrency) && nullish(account.default_currency)) { throw new RecaseError({ message: "Default currency not set", @@ -93,6 +95,14 @@ orgRouter.post("/stripe", async (req: any, res) => { defaultCurrency = account.default_currency; } } catch (error: any) { + // if (error.message.includes("rk_***")) { + // throw new RecaseError({ + // message: "Invalid Stripe restricted key. Please add the ", + // code: ErrCode.StripeKeyInvalid, + // statusCode: 500, + // }); + // } + throw new RecaseError({ message: error.message || "Invalid Stripe API keys", code: ErrCode.StripeKeyInvalid, diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 7c6a3418c..eb196d3af 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -265,12 +265,14 @@ export class ProductService { orgId, env, version, + allowNotFound = false, }: { db: DrizzleCli; idOrInternalId: string; orgId: string; env: AppEnv; version?: number; + allowNotFound?: boolean; }) { let data = (await db.query.products.findFirst({ where: and( @@ -298,7 +300,7 @@ export class ProductService { parseFreeTrials({ product: data }); if (!data) { - // return null; + if (allowNotFound) return null; throw new RecaseError({ message: `Product ${idOrInternalId} not found`, code: ErrCode.ProductNotFound, diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index d7caa27c8..6d219532f 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -23,28 +23,34 @@ import { addDays } from "date-fns"; import { getBillingType } from "@/internal/products/prices/priceUtils.js"; import { features } from "process"; -export const entIntervalToTrialDuration = (interval: EntInterval) => { +export const entIntervalToTrialDuration = ({ + interval, + intervalCount, +}: { + interval: EntInterval; + intervalCount: number; +}) => { switch (interval) { case EntInterval.Day: - return 1; + return intervalCount; case EntInterval.Week: - return 7; + return intervalCount * 7; case EntInterval.Month: - return 30; + return intervalCount * 30; case EntInterval.Quarter: - return 90; + return intervalCount * 90; case EntInterval.SemiAnnual: - return 180; + return intervalCount * 180; case EntInterval.Year: - return 365; + return intervalCount * 365; case EntInterval.Lifetime: - return 1000; + return intervalCount * 1000; } }; export const applyTrialToEntitlement = ( entitlement: EntitlementWithFeature, - freeTrial: FreeTrial | null, + freeTrial: FreeTrial | null ) => { if (!freeTrial) return false; @@ -54,7 +60,10 @@ export const applyTrialToEntitlement = ( if (entitlement.allowance_type === AllowanceType.Unlimited) return false; const trialDays = freeTrial.length; - const entDays = entIntervalToTrialDuration(entitlement.interval!); + const entDays = entIntervalToTrialDuration({ + interval: entitlement.interval!, + intervalCount: entitlement.interval_count || 1, + }); if (entDays && entDays > trialDays) { return true; @@ -65,7 +74,7 @@ export const applyTrialToEntitlement = ( export const addTrialToNextResetAt = ( nextResetAt: number, - freeTrial: FreeTrial | null, + freeTrial: FreeTrial | null ) => { if (!freeTrial) return nextResetAt; @@ -76,7 +85,7 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { // 1. Check if they have same internal_feature_id if (ent1.internal_feature_id !== ent2.internal_feature_id) { console.log( - `Internal feature ID different: ${ent1.internal_feature_id} !== ${ent2.internal_feature_id}`, + `Internal feature ID different: ${ent1.internal_feature_id} !== ${ent2.internal_feature_id}` ); return false; } @@ -84,7 +93,7 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { // 2. Check if they have same allowance type if (ent1.allowance_type !== ent2.allowance_type) { console.log( - `Allowance type different: ${ent1.allowance_type} !== ${ent2.allowance_type}`, + `Allowance type different: ${ent1.allowance_type} !== ${ent2.allowance_type}` ); return false; } @@ -94,6 +103,10 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { condition: ent1.interval !== ent2.interval, message: `Interval different: ${ent1.interval} !== ${ent2.interval}`, }, + intervalCount: { + condition: ent1.interval_count !== ent2.interval_count, + message: `Interval count different: ${ent1.interval_count} !== ${ent2.interval_count}`, + }, allowance: { condition: ent1.allowance_type !== AllowanceType.Unlimited && @@ -113,7 +126,8 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { message: `Usage limit different: ${ent1.usage_limit} !== ${ent2.usage_limit}`, }, rollover: { - condition: JSON.stringify(ent1.rollover) !== JSON.stringify(ent2.rollover), + condition: + JSON.stringify(ent1.rollover) !== JSON.stringify(ent2.rollover), message: `Rollover different: ${ent1.rollover} !== ${ent2.rollover}`, }, }; @@ -136,7 +150,7 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { export const getEntRelatedPrice = ( entitlement: Entitlement, prices: Price[], - allowFeatureMatch = false, + allowFeatureMatch = false ) => { return prices.find((price) => { if (price.config?.type === PriceType.Fixed) { @@ -158,10 +172,10 @@ export const getEntRelatedPrice = ( export const getEntitlementsForProduct = ( product: FullProduct, - entitlements: EntitlementWithFeature[], + entitlements: EntitlementWithFeature[] ) => { return entitlements.filter( - (ent) => ent.internal_product_id === product.internal_id, + (ent) => ent.internal_product_id === product.internal_id ); }; @@ -174,7 +188,7 @@ export const getEntsWithFeature = ({ }) => { return ents.map((ent) => { let feature = features.find( - (f) => f.internal_id === ent.internal_feature_id, + (f) => f.internal_id === ent.internal_feature_id ); if (!feature) { throw new RecaseError({ diff --git a/server/src/internal/products/handlers/handleListProductsBeta.ts b/server/src/internal/products/handlers/handleListProductsBeta.ts index 02759bca7..00e9f4af0 100644 --- a/server/src/internal/products/handlers/handleListProductsBeta.ts +++ b/server/src/internal/products/handlers/handleListProductsBeta.ts @@ -1,9 +1,7 @@ import { routeHandler } from "@/utils/routerUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; -import { CusService } from "@/internal/customers/CusService.js"; import { sortFullProducts } from "../productUtils/sortProductUtils.js"; -import { toPricecnProduct } from "../pricecn/pricecnUtils.js"; import { getProductResponse } from "../productUtils/productResponseUtils/getProductResponse.js"; import { getCusWithCache } from "@/internal/customers/cusCache/getCusWithCache.js"; @@ -38,14 +36,6 @@ export const handleListProductsBeta = async (req: any, res: any) => env, logger: req.logger, }); - // return await CusService.getFull({ - // db, - // orgId: org.id, - // env, - // idOrInternalId: customerId as string, - // allowNotFound: true, - // entityId, - // }); })(), ]); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index bcf276daa..a19b6c53e 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -17,6 +17,7 @@ import { addTaskToQueue } from "@/queue/queueUtils.js"; import { JobName } from "@/queue/JobName.js"; import { productsAreSame } from "../../productUtils/compareProductUtils.js"; import { initProductInStripe } from "../../productUtils.js"; +import { handleCreateProduct } from "../handleCreateProduct.js"; export const handleUpdateProductV2 = async (req: any, res: any) => routeHandler({ @@ -25,7 +26,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => action: "Update product", handler: async () => { const { productId } = req.params; - const { version } = req.query; + const { version, upsert, disable_version } = req.query; const { orgId, env, logger, db } = req; const [features, org, fullProduct, rewardPrograms] = await Promise.all([ @@ -37,6 +38,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => orgId, env, version: version ? parseInt(version) : undefined, + allowNotFound: upsert == "true", }), RewardProgramService.getByProductId({ db, @@ -47,6 +49,12 @@ export const handleUpdateProductV2 = async (req: any, res: any) => ]); if (!fullProduct) { + console.log("Upserting:", upsert); + if (upsert == "true") { + await handleCreateProduct(req, res); + return; + } + throw new RecaseError({ message: "Product not found", code: ErrCode.ProductNotFound, @@ -75,6 +83,15 @@ export const handleUpdateProductV2 = async (req: any, res: any) => let 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, + }); + return; + } + const { itemsSame, freeTrialsSame } = productsAreSame({ newProductV2: req.body, curProductV1: fullProduct, diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index bab6c4f14..e24c91156 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -42,7 +42,13 @@ const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => { if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) { return false; } - console.log("prod1.archived", prod1.archived, prod2.archived, notNullish(prod2.archived), prod1.archived !== prod2.archived); + // console.log( + // "prod1.archived", + // prod1.archived, + // prod2.archived, + // notNullish(prod2.archived), + // prod1.archived !== prod2.archived + // ); if (notNullish(prod2.archived) && prod1.archived !== prod2.archived) { return false; } @@ -71,7 +77,7 @@ const updateStripeProductNames = async ({ }); let stripeProdId = curProduct.processor?.id; - if (!stripeProdId) { + if (!stripeProdId || !newName) { return; } diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 6942ce124..1747eded1 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -59,6 +59,7 @@ export const handleVersionProductV2 = async ({ orgId: org.id, env: latestProduct.env as AppEnv, processor: latestProduct.processor, + baseVariantId: latestProduct.base_variant_id, }); // Validate product items... @@ -114,12 +115,16 @@ export const handleVersionProductV2 = async ({ }); } - await addTaskToQueue({ - jobName: JobName.DetectBaseVariant, - payload: { - curProduct: newProduct, - }, - }); + // await addTaskToQueue({ + // jobName: JobName.DetectBaseVariant, + // payload: { + // curProduct: { + // ...newProduct, + // // prices: customPrices, + // // entitlements: getEntsWithFeature({ ents: customEnts, features }), + // }, + // }, + // }); await initProductInStripe({ db, diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 20f1bd51b..b817f6aa4 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -27,14 +27,16 @@ productRouter.get("/data", async (req: any, res) => { try { let { db } = req; + const allVersions = req.query.all_versions === "true"; + const [products, features, org, coupons, rewardPrograms] = await Promise.all([ ProductService.listFull({ db, orgId: req.orgId, env: req.env, - returnAll: true, archived: false, + returnAll: allVersions, }), FeatureService.getFromReq(req), OrgService.getFromReq(req), @@ -77,7 +79,7 @@ productRouter.post("/data", async (req: any, res) => { db, orgId: req.orgId, env: req.env, - returnAll: true, + // returnAll: true, archived: showArchived, }), FeatureService.getFromReq(req), @@ -116,8 +118,17 @@ productRouter.get("/counts", async (req: any, res) => { // returnAll: true, }); + const latestVersion = req.query.latest_version === "true"; + let counts = await Promise.all( products.map(async (product) => { + if (latestVersion) { + return CusProdReadService.getCounts({ + db, + internalProductId: product.internal_id, + }); + } + return CusProdReadService.getCountsForAllVersions({ db, productId: product.id, diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index e561bd32a..371db3623 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -22,7 +22,7 @@ import { isPriceItem } from "../product-items/productItemUtils/getItemType.js"; import { isFeaturePriceItem } from "../product-items/productItemUtils/getItemType.js"; import { cusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { isProductUpgrade } from "../productUtils.js"; -import { getFirstInterval } from "../prices/priceUtils/priceIntervalUtils.js"; +import { getLargestInterval } from "../prices/priceUtils/priceIntervalUtils.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { getFreeTrialAfterFingerprint } from "../free-trials/freeTrialUtils.js"; @@ -388,7 +388,7 @@ export const toPricecnProduct = async ({ let baseVariant = null; if (fullProduct.base_variant_id) { baseVariant = otherProducts.find( - (p) => p.id == fullProduct.base_variant_id, + (p) => p.id == fullProduct.base_variant_id ); } @@ -402,7 +402,8 @@ export const toPricecnProduct = async ({ baseVariant || otherProducts.some((p) => p.base_variant_id == product.id) ) { - intervalGroup = getFirstInterval({ prices: fullProduct.prices }); + let intervalSet = getLargestInterval({ prices: fullProduct.prices }); + intervalGroup = intervalSet?.interval; } let trialAvailable = false; diff --git a/server/src/internal/products/prices/billingIntervalUtils.ts b/server/src/internal/products/prices/billingIntervalUtils.ts index a46685e8c..115412d9b 100644 --- a/server/src/internal/products/prices/billingIntervalUtils.ts +++ b/server/src/internal/products/prices/billingIntervalUtils.ts @@ -3,6 +3,7 @@ import { addMinutes, addMonths, addSeconds, + addWeeks, addYears, differenceInSeconds, getDate, @@ -16,29 +17,38 @@ import { setSeconds, startOfMonth, subMonths, + subWeeks, subYears, } from "date-fns"; import { UTCDate } from "@date-fns/utc"; import { formatUnixToDate, formatUnixToDateTime } from "@/utils/genUtils.js"; -export const subtractBillingIntervalUnix = ( - unixTimestamp: number, - interval: BillingInterval, -) => { +export const subtractBillingIntervalUnix = ({ + unixTimestamp, + interval, + intervalCount, +}: { + unixTimestamp: number; + interval: BillingInterval; + intervalCount: number; +}) => { const date = new UTCDate(unixTimestamp); let subtractedDate = date; switch (interval) { + case BillingInterval.Week: + subtractedDate = subWeeks(date, 1 * intervalCount); + break; case BillingInterval.Month: - subtractedDate = subMonths(date, 1); + subtractedDate = subMonths(date, 1 * intervalCount); break; case BillingInterval.Quarter: - subtractedDate = subMonths(date, 3); + subtractedDate = subMonths(date, 3 * intervalCount); break; case BillingInterval.SemiAnnual: - subtractedDate = subMonths(date, 6); + subtractedDate = subMonths(date, 6 * intervalCount); break; case BillingInterval.Year: - subtractedDate = subYears(date, 1); + subtractedDate = subYears(date, 1 * intervalCount); break; default: throw new Error(`Invalid billing interval: ${interval}`); @@ -46,24 +56,32 @@ export const subtractBillingIntervalUnix = ( return subtractedDate.getTime(); }; -export const addBillingIntervalUnix = ( - unixTimestamp: number, - interval: BillingInterval, -) => { +export const addBillingIntervalUnix = ({ + unixTimestamp, + interval, + intervalCount, +}: { + unixTimestamp: number; + interval: BillingInterval; + intervalCount: number; +}) => { const date = new UTCDate(unixTimestamp); let addedDate = date; switch (interval) { + case BillingInterval.Week: + addedDate = addWeeks(date, 1 * intervalCount); + break; case BillingInterval.Month: - addedDate = addMonths(date, 1); + addedDate = addMonths(date, intervalCount); break; case BillingInterval.Quarter: - addedDate = addMonths(date, 3); + addedDate = addMonths(date, 3 * intervalCount); break; case BillingInterval.SemiAnnual: - addedDate = addMonths(date, 6); + addedDate = addMonths(date, 6 * intervalCount); break; case BillingInterval.Year: - addedDate = addYears(date, 1); + addedDate = addYears(date, 1 * intervalCount); break; default: throw new Error(`Invalid billing interval: ${interval}`); @@ -71,8 +89,18 @@ export const addBillingIntervalUnix = ( return addedDate.getTime(); }; -export const getNextStartOfMonthUnix = (interval: BillingInterval) => { - const nextBillingCycle = addBillingIntervalUnix(Date.now(), interval); +export const getNextStartOfMonthUnix = ({ + interval, + intervalCount, +}: { + interval: BillingInterval; + intervalCount: number; +}) => { + const nextBillingCycle = addBillingIntervalUnix({ + unixTimestamp: Date.now(), + interval, + intervalCount, + }); // Subtract till it hits first const date = new UTCDate(nextBillingCycle); @@ -85,11 +113,13 @@ export const getNextStartOfMonthUnix = (interval: BillingInterval) => { export const getAlignedIntervalUnix = ({ alignWithUnix, interval, + intervalCount, now, alwaysReturn, }: { alignWithUnix: number; interval: BillingInterval; + intervalCount: number; now?: number; alwaysReturn?: boolean; }) => { @@ -99,7 +129,11 @@ export const getAlignedIntervalUnix = ({ now = now || Date.now(); - const naturalBillingDate = addBillingIntervalUnix(now, interval); + const naturalBillingDate = addBillingIntervalUnix({ + unixTimestamp: now, + interval, + intervalCount, + }); // console.log("Now:", formatUnixToDateTime(now)); // console.log("Anchoring to:", formatUnixToDateTime(alignWithUnix)); @@ -108,10 +142,11 @@ export const getAlignedIntervalUnix = ({ const maxIterations = 10000; let iterations = 0; while (true) { - const subtractedUnix = subtractBillingIntervalUnix( - nextCycleAnchorUnix, + const subtractedUnix = subtractBillingIntervalUnix({ + unixTimestamp: nextCycleAnchorUnix, interval, - ); + intervalCount, + }); // console.log("Subtracted unix:", formatUnixToDateTime(subtractedUnix)); @@ -134,12 +169,12 @@ export const getAlignedIntervalUnix = ({ let anchorAndNaturalDiff = differenceInSeconds( naturalBillingDate, - nextCycleAnchorUnix, + nextCycleAnchorUnix ); // For insurance, also means you can't set billing cycle anchor to a minute in the future... let anchorAndNowDiff = Math.abs( - differenceInSeconds(now, nextCycleAnchorUnix), + differenceInSeconds(now, nextCycleAnchorUnix) ); if (anchorAndNaturalDiff < 60 || anchorAndNowDiff < 20) { @@ -182,7 +217,7 @@ export const subtractFromUnixTillAligned = ({ const lastDayOfMonth = new UTCDate( alignedDate.getFullYear(), alignedDate.getMonth() + 1, - 0, + 0 ).getDate(); // Apply target day (capped to last day of month) and time components diff --git a/server/src/internal/products/prices/priceInitUtils.ts b/server/src/internal/products/prices/priceInitUtils.ts index fa616952a..b8715cac9 100644 --- a/server/src/internal/products/prices/priceInitUtils.ts +++ b/server/src/internal/products/prices/priceInitUtils.ts @@ -44,7 +44,7 @@ export const constructPrice = ({ // GET PRICES const validatePrice = ( price: Price, - relatedEnt?: Entitlement | undefined | null, + relatedEnt?: Entitlement | undefined | null ) => { if (!price.config?.type) { throw new RecaseError({ @@ -117,7 +117,7 @@ export const tiersAreSame = (tiers1: any[], tiers2: any[]) => { export const pricesAreSame = ( price1: Price, price2: Price, - logDifferences = false, + logDifferences = false ) => { // if (price1.name !== price2.name) return false; @@ -139,6 +139,10 @@ export const pricesAreSame = ( condition: fixedConfig1.interval !== fixedConfig2.interval, message: `Interval different: ${fixedConfig1.interval} !== ${fixedConfig2.interval}`, }, + interval_count: { + condition: fixedConfig1.interval_count !== fixedConfig2.interval_count, + message: `Interval count different: ${fixedConfig1.interval_count} !== ${fixedConfig2.interval_count}`, + }, }; let pricesAreDiff = Object.values(diffs).some((d) => d.condition); @@ -149,7 +153,7 @@ export const pricesAreSame = ( "Differences:", Object.values(diffs) .filter((d) => d.condition) - .map((d) => d.message), + .map((d) => d.message) ); } @@ -175,6 +179,10 @@ export const pricesAreSame = ( condition: usageConfig1.interval !== usageConfig2.interval, message: `Interval different: ${usageConfig1.interval} !== ${usageConfig2.interval}`, }, + interval_count: { + condition: usageConfig1.interval_count !== usageConfig2.interval_count, + message: `Interval count different: ${usageConfig1.interval_count} !== ${usageConfig2.interval_count}`, + }, internal_feature_id: { condition: usageConfig1.internal_feature_id !== usageConfig2.internal_feature_id, @@ -187,10 +195,10 @@ export const pricesAreSame = ( usage_tiers: { condition: !tiersAreSame( usageConfig1.usage_tiers, - usageConfig2.usage_tiers, + usageConfig2.usage_tiers ), message: `Usage tiers different: ${usageConfig1.usage_tiers.map( - (t) => `${t.to} (${t.amount})`, + (t) => `${t.to} (${t.amount})` )} !== ${usageConfig2.usage_tiers.map((t) => `${t.to} (${t.amount})`)}`, }, }; @@ -224,7 +232,7 @@ export const pricesAreSame = ( .map((d) => d.message), Object.values(prorationConfigDiff) .filter((d) => d.condition) - .map((d) => d.message), + .map((d) => d.message) ); } diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index cd6875cc0..d0b902b04 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -20,6 +20,7 @@ import { import RecaseError from "@/utils/errorUtils.js"; import { StatusCodes } from "http-status-codes"; import { Decimal } from "decimal.js"; +import { compareBillingIntervals } from "./priceUtils/priceIntervalUtils.js"; const BillingIntervalOrder = [ BillingInterval.Year, @@ -100,23 +101,44 @@ export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => { export const getBillingInterval = (prices: Price[]) => { if (prices.length === 0) { - return BillingInterval.OneOff; + return { + interval: BillingInterval.OneOff, + intervalCount: 1, + }; } const pricesCopy = structuredClone(prices); try { pricesCopy.sort((a, b) => { - return ( - BillingIntervalOrder.indexOf(b.config!.interval!) - - BillingIntervalOrder.indexOf(a.config!.interval!) - ); + return compareBillingIntervals({ + configA: { + interval: a.config!.interval as BillingInterval, + intervalCount: a.config!.interval_count || 1, + }, + configB: { + interval: b.config!.interval as BillingInterval, + intervalCount: b.config!.interval_count || 1, + }, + }); + // return ( + // BillingIntervalOrder.indexOf(b.config!.interval!) - + // BillingIntervalOrder.indexOf(a.config!.interval!) + // ); }); } catch (error) { console.log("Error sorting prices:", error); throw error; } + // console.log( + // "pricesCopy", + // pricesCopy.map((p) => ({ + // interval: p.config!.interval, + // intervalCount: p.config!.interval_count, + // })) + // ); + if (pricesCopy.length == 0) { throw new RecaseError({ message: "No prices found, can't get billing interval", @@ -125,7 +147,11 @@ export const getBillingInterval = (prices: Price[]) => { }); } - return pricesCopy[pricesCopy.length - 1].config!.interval as BillingInterval; + return { + interval: pricesCopy[0].config!.interval as BillingInterval, + intervalCount: pricesCopy[0].config!.interval_count || 1, + }; + // return pricesCopy[pricesCopy.length - 1].config!.interval as BillingInterval; }; export const pricesOnlyOneOff = (prices: Price[]) => { @@ -154,36 +180,16 @@ export const pricesContainRecurring = (prices: Price[]) => { }); }; -export const haveDifferentRecurringIntervals = (prices: Price[]) => { - let interval = null; - - for (const price of prices) { - const newInterval = price.config?.interval; - - if (newInterval == BillingInterval.OneOff) { - continue; - } - - if (interval !== null && newInterval !== null && newInterval !== interval) { - return true; - } - - interval = newInterval; - } - return false; -}; - // Get price options export const getEntOptions = ( optionsList: FeatureOptions[], - entitlement: Entitlement | EntitlementWithFeature, + entitlement: Entitlement | EntitlementWithFeature ) => { if (!entitlement) { return null; } const options = optionsList.find( - (options) => - options.internal_feature_id === entitlement.internal_feature_id, + (options) => options.internal_feature_id === entitlement.internal_feature_id ); return options; }; @@ -191,7 +197,7 @@ export const getEntOptions = ( export const getPriceEntitlement = ( price: Price, entitlements: EntitlementWithFeature[], - allowFeatureMatch = false, + allowFeatureMatch = false ) => { let config = price.config as UsagePriceConfig; @@ -217,12 +223,12 @@ export const getPriceEntitlement = ( export const getPriceOptions = ( price: Price, - optionsList: FeatureOptions[], + optionsList: FeatureOptions[] ) => { let config = price.config as UsagePriceConfig; const options = optionsList.find( - (options) => options.internal_feature_id === config.internal_feature_id, + (options) => options.internal_feature_id === config.internal_feature_id ); return options; @@ -307,7 +313,7 @@ export const getPriceForOverage = (price: Price, overage?: number) => { let amount = 0; let billingUnits = usageConfig.billing_units || 1; let remainingUsage = new Decimal( - Math.ceil(new Decimal(overage!).div(billingUnits).toNumber()), + Math.ceil(new Decimal(overage!).div(billingUnits).toNumber()) ) .mul(billingUnits) .toNumber(); @@ -353,7 +359,7 @@ export const roundPriceAmounts = (price: Price) => { const config = price.config as UsagePriceConfig; for (let i = 0; i < config.usage_tiers.length; i++) { config.usage_tiers[i].amount = Number( - config.usage_tiers[i].amount.toFixed(10), + config.usage_tiers[i].amount.toFixed(10) ); } @@ -363,7 +369,7 @@ export const roundPriceAmounts = (price: Price) => { export const priceIsOneOffAndTiered = ( price: Price, - relatedEnt: EntitlementWithFeature, + relatedEnt: EntitlementWithFeature ) => { let config = price.config as UsagePriceConfig; if (config.type == PriceType.Fixed) { @@ -371,17 +377,13 @@ export const priceIsOneOffAndTiered = ( } return ( - // (config.interval == BillingInterval.OneOff && - // config.usage_tiers.length > 0 && - // relatedEnt.allowance && - // relatedEnt.allowance > 0) || config.interval == BillingInterval.OneOff && config.usage_tiers.length > 1 ); }; export const getProductForPrice = (price: Price, products: FullProduct[]) => { return products.find( - (product) => product.internal_id === price.internal_product_id, + (product) => product.internal_id === price.internal_product_id ); }; diff --git a/server/src/internal/products/prices/priceUtils/constructPriceUtils.ts b/server/src/internal/products/prices/priceUtils/constructPriceUtils.ts index 9e653be3c..cd29ac334 100644 --- a/server/src/internal/products/prices/priceUtils/constructPriceUtils.ts +++ b/server/src/internal/products/prices/priceUtils/constructPriceUtils.ts @@ -15,6 +15,7 @@ export const subItemToFixedPrice = ({ }) => { const { price } = subItem; + const { interval, intervalCount } = subItemToAutumnInterval(subItem); return constructPrice({ internalProductId: product.internal_id, isCustom: true, @@ -22,7 +23,8 @@ export const subItemToFixedPrice = ({ fixedConfig: { type: PriceType.Fixed, amount: basePrice || (price.unit_amount || 0) / 100, - interval: subItemToAutumnInterval(subItem)!, + interval, + interval_count: intervalCount, stripe_price_id: price.id, }, }); diff --git a/server/src/internal/products/prices/priceUtils/convertPrice.ts b/server/src/internal/products/prices/priceUtils/convertPrice.ts index 089e403c3..e40104b9d 100644 --- a/server/src/internal/products/prices/priceUtils/convertPrice.ts +++ b/server/src/internal/products/prices/priceUtils/convertPrice.ts @@ -4,12 +4,53 @@ import { Feature, UsageModel, UsagePriceConfig, + BillingInterval, } from "@autumn/shared"; import { Price } from "@autumn/shared"; import { getBillingType, getPriceEntitlement } from "../priceUtils.js"; import { isFixedPrice } from "./usagePriceUtils/classifyUsagePrice.js"; +export const priceToIntervalKey = (price: Price) => { + return toIntervalKey({ + interval: price.config?.interval, + intervalCount: price.config?.interval_count ?? 1, + }); +}; + +export const toIntervalKey = ({ + interval, + intervalCount, +}: { + interval: BillingInterval; + intervalCount: number; +}) => { + if (interval == BillingInterval.OneOff) { + return BillingInterval.OneOff; + } else if (interval == BillingInterval.Quarter) { + let finalCount = (intervalCount ?? 1) * 3; + return `${BillingInterval.Month}-${finalCount}`; + } else if (interval == BillingInterval.SemiAnnual) { + let finalCount = (intervalCount ?? 1) * 6; + return `${BillingInterval.Month}-${finalCount}`; + } + + if (interval == BillingInterval.Week) { + return `${BillingInterval.Week}-${intervalCount}`; + } else if (interval == BillingInterval.Year) { + return `${BillingInterval.Year}-${intervalCount}`; + } + return `${interval}-${intervalCount}`; +}; + +export const intervalKeyToPrice = (intervalKey: string) => { + const [interval, intervalCount] = intervalKey.split("-"); + return { + interval: interval as BillingInterval, + intervalCount: intervalCount ? parseInt(intervalCount) : 1, + }; +}; + export const priceToFeature = ({ price, ents, @@ -26,7 +67,7 @@ export const priceToFeature = ({ if (features) { return features.find( (f) => - f.internal_id == (price.config as UsagePriceConfig).internal_feature_id, + f.internal_id == (price.config as UsagePriceConfig).internal_feature_id ); } diff --git a/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts b/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts index 02758ef38..bd42e19b7 100644 --- a/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts +++ b/server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts @@ -2,7 +2,9 @@ import { BillingInterval, EntInterval, Entitlement, + FixedPriceConfig, Price, + UsagePriceConfig, } from "@autumn/shared"; import { nullish } from "@/utils/genUtils.js"; @@ -28,60 +30,170 @@ const entToBillingInterval = (entInterval: EntInterval | null | undefined) => { } else return entInterval as unknown as BillingInterval; }; -export function compareBillingIntervals( - a: BillingInterval | undefined, - b: BillingInterval | undefined, -): number { - if (nullish(a)) { - return 1; - } else if (nullish(b)) { - return -1; - } +const intervalToValue = ( + interval: BillingInterval, + intervalCount?: number | null +) => { + const intervalToBaseVal: Record = { + [BillingInterval.OneOff]: 0, + [BillingInterval.Week]: 0.25, + [BillingInterval.Month]: 1, + [BillingInterval.Quarter]: 3, + [BillingInterval.SemiAnnual]: 6, + [BillingInterval.Year]: 12, + }; - return BillingIntervalOrder.indexOf(a!) - BillingIntervalOrder.indexOf(b!); + return intervalToBaseVal[interval] * (intervalCount ?? 1); +}; + +type IntervalConfig = { + interval: BillingInterval; + intervalCount?: number | null; +}; + +export function compareBillingIntervals({ + configA, + configB, +}: { + configA: IntervalConfig; + configB: IntervalConfig; +}): number { + // if (nullish(a)) { + // return 1; + // } else if (nullish(b)) { + // return -1; + // } + + // how to compare these... convert to months? + + const a = intervalToValue(configA.interval, configA.intervalCount); + const b = intervalToValue(configB.interval, configB.intervalCount); + + return b - a; } -export const getFirstInterval = ({ +export const getLargestInterval = ({ prices, excludeOneOff = false, }: { prices: Price[]; excludeOneOff?: boolean; }) => { - return BillingIntervalOrder.find((interval) => - prices.some((price) => { - let intervalMatch = price.config.interval === interval; - let oneOffMatch = excludeOneOff - ? price.config.interval !== BillingInterval.OneOff - : true; - return intervalMatch && oneOffMatch; - }), - )!; + let sortedPrices = structuredClone(prices); + sortPricesByInterval(sortedPrices); + + if (excludeOneOff) { + sortedPrices = sortedPrices.filter( + (price) => price.config.interval !== BillingInterval.OneOff + ); + } + + if (sortedPrices.length === 0) { + return null; + } + + return { + interval: sortedPrices[0].config.interval, + intervalCount: sortedPrices[0].config.interval_count ?? 1, + }; + + // return BillingIntervalOrder.find((interval) => + // prices.some((price) => { + // let intervalMatch = price.config.interval === interval; + // let oneOffMatch = excludeOneOff + // ? price.config.interval !== BillingInterval.OneOff + // : true; + // return intervalMatch && oneOffMatch; + // }) + // )!; }; -export const getLastInterval = ({ +export const getSmallestInterval = ({ prices, ents, }: { prices: Price[]; ents?: Entitlement[]; }) => { - return ReversedBillingIntervalOrder.find( - (interval) => - prices.some((price) => price.config.interval === interval) || - (ents && - ents?.some((ent) => entToBillingInterval(ent.interval) === interval)), - )!; -}; - -export const sortBillingIntervals = (intervals: BillingInterval[]) => { - return intervals.sort((a, b) => { - return BillingIntervalOrder.indexOf(a) - BillingIntervalOrder.indexOf(b); + // let sortedPrices = structuredClone(prices); + // sortPricesByInterval(sortedPrices); + const allPriceIntervals = prices.map((p) => { + return { + interval: p.config.interval, + intervalCount: p.config.interval_count ?? 1, + }; }); + + const allEntIntervals = ents?.map((e) => { + return { + interval: entToBillingInterval(e.interval), + intervalCount: e.interval_count ?? 1, + }; + }); + + const allIntervals = [...allPriceIntervals, ...(allEntIntervals || [])]; + + if (allIntervals.length === 0) { + return null; + } + + allIntervals.sort((a, b) => { + return compareBillingIntervals({ configA: a, configB: b }); + }); + + const smallestInterval = allIntervals?.[0]; + + return { + interval: smallestInterval.interval, + intervalCount: smallestInterval.intervalCount, + }; + + // if (!smallestIntervalPrice) { + // return null; + // } + + // return { + // interval: smallestIntervalPrice.config.interval, + // intervalCount: smallestIntervalPrice.config.interval_count ?? 1, + // }; + + // return ReversedBillingIntervalOrder.find( + // (interval) => + // prices.some((price) => price.config.interval === interval) || + // (ents && + // ents?.some((ent) => entToBillingInterval(ent.interval) === interval)) + // )!; }; export const sortPricesByInterval = (prices: Price[]) => { return prices.sort((a, b) => { - return compareBillingIntervals(a.config.interval, b.config.interval); + return compareBillingIntervals({ configA: a.config, configB: b.config }); }); }; + +export const intervalsDifferent = ({ + intervalA, + intervalB, +}: { + intervalA: IntervalConfig | null; + intervalB: IntervalConfig | null; +}) => { + // return compareBillingIntervals({ configA: intervalA, configB: intervalB }) !== 0; + if (nullish(intervalA) && nullish(intervalB)) { + return false; + } + + if (nullish(intervalA) || nullish(intervalB)) { + return true; + } + + const intervalCountA = intervalToValue( + intervalA!.interval, + intervalA!.intervalCount + ); + const intervalCountB = intervalToValue( + intervalB!.interval, + intervalB!.intervalCount + ); + return intervalCountA !== intervalCountB; +}; diff --git a/server/src/internal/products/product-items/compareItemUtils.ts b/server/src/internal/products/product-items/compareItemUtils.ts index c2c8129f3..67dfc8cab 100644 --- a/server/src/internal/products/product-items/compareItemUtils.ts +++ b/server/src/internal/products/product-items/compareItemUtils.ts @@ -33,7 +33,10 @@ export const findSimilarItem = ({ if (isPriceItem(item)) { return items.find((i) => { return ( - isPriceItem(i) && i.price === item.price && i.interval === item.interval + isPriceItem(i) && + i.price === item.price && + i.interval === item.interval && + (i.interval_count || 1) == (item.interval_count || 1) ); }); } @@ -68,16 +71,17 @@ export const featureItemsAreSame = ({ item2: FeatureItem; }) => { // Compare config objects (including rollover) - const configsAreSame = JSON.stringify(item1.config) === JSON.stringify(item2.config); - - const same = ( + const configsAreSame = + JSON.stringify(item1.config) === JSON.stringify(item2.config); + + const same = item1.feature_id === item2.feature_id && item1.included_usage == item2.included_usage && item1.interval == item2.interval && + (item1.interval_count || 1) == (item2.interval_count || 1) && item1.entity_feature_id == item2.entity_feature_id && item1.reset_usage_when_enabled == item2.reset_usage_when_enabled && - configsAreSame - ); + configsAreSame; return same; }; @@ -89,7 +93,10 @@ export const priceItemsAreSame = ({ item1: PriceItem; item2: PriceItem; }) => { - const same = item1.price === item2.price && item1.interval == item2.interval; + const same = + item1.price === item2.price && + item1.interval == item2.interval && + (item1.interval_count || 1) == (item2.interval_count || 1); if (!same) { console.log(`Price items different: ${item1.price}`); @@ -134,6 +141,10 @@ export const featurePriceItemsAreSame = ({ condition: item1.interval == item2.interval, message: `Interval different: ${item1.interval} != ${item2.interval}`, }, + interval_count: { + condition: (item1.interval_count || 1) == (item2.interval_count || 1), + message: `Interval count different: ${item1.interval_count} != ${item2.interval_count}`, + }, usage_model: { condition: item1.usage_model === item2.usage_model, message: `Usage model different: ${item1.usage_model} != ${item2.usage_model}`, @@ -194,8 +205,6 @@ export const itemsAreSame = ({ let same = false; let pricesChanged = false; - - if (isFeatureItem(item1)) { if (!isFeatureItem(item2)) { return { @@ -246,7 +255,9 @@ export const itemsAreSame = ({ item1: PriceItemSchema.parse(item1), item2: PriceItemSchema.parse(item2), }); - pricesChanged = false; + if (!same) { + pricesChanged = true; + } } return { diff --git a/server/src/internal/products/product-items/mapToItem.ts b/server/src/internal/products/product-items/mapToItem.ts index 9f439e8b7..a4a46cc0b 100644 --- a/server/src/internal/products/product-items/mapToItem.ts +++ b/server/src/internal/products/product-items/mapToItem.ts @@ -48,6 +48,7 @@ export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { included_usage: ent.allowance_type == AllowanceType.Unlimited ? Infinite : ent.allowance, interval: entToItemInterval(ent.interval!), + interval_count: ent.interval_count ?? 1, entity_feature_id: ent.entity_feature_id, reset_usage_when_enabled: !ent.carry_from_previous, @@ -95,6 +96,7 @@ export const toFeaturePriceItem = ({ included_usage: ent.allowance, interval: billingToItemInterval(config.interval!), + interval_count: config.interval_count ?? 1, price: null, tiers, @@ -127,6 +129,7 @@ export const toPriceItem = ({ price }: { price: Price }) => { feature_id: null, interval: billingToItemInterval(config.interval!), + interval_count: config.interval_count ?? 1, price: config.amount, price_id: price.id, diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index 00e83f41e..edd44f375 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -124,13 +124,16 @@ export const constructFeatureItem = ({ export const constructPriceItem = ({ price, interval, + intervalCount, }: { price: number; interval: BillingInterval | null; + intervalCount?: number; }) => { let item: ProductItem = { price: price, interval: interval as any, + interval_count: intervalCount || 1, }; return item; diff --git a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts index 9c3e73f7a..271829697 100644 --- a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts @@ -32,6 +32,7 @@ const updateDbPricesAndEnts = async ({ deletedEnts: Entitlement[]; }) => { // 1. Create new ents + await Promise.all([ EntitlementService.insert({ db, @@ -76,7 +77,7 @@ const updateDbPricesAndEnts = async ({ let updateOrDelete: any = []; for (const ent of deletedEnts) { let hasCustomPrice = customPrices.some( - (price) => price.entitlement_id == ent.id, + (price) => price.entitlement_id == ent.id ); if (hasCustomPrice) { @@ -87,14 +88,14 @@ const updateDbPricesAndEnts = async ({ updates: { is_custom: true, }, - }), + }) ); } else { updateOrDelete.push( EntitlementService.deleteInIds({ db, ids: [ent.id!], - }), + }) ); } } @@ -202,7 +203,7 @@ export const handleNewProductItems = async ({ }); let deletedEnts: Entitlement[] = curEnts.filter( - (ent) => !newItems.some((item) => item.entitlement_id == ent.id), + (ent) => !newItems.some((item) => item.entitlement_id == ent.id) ); let samePrices: Price[] = []; diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index 313c98cc7..733f0fe09 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -75,6 +75,7 @@ export const toPrice = ({ type: PriceType.Fixed, amount: notNullish(item.price) ? item.price! : item.tiers![0].amount!, interval: itemToBillingInterval(item) as BillingInterval, + interval_count: item.interval_count || 1, }; let price: Price = { @@ -137,6 +138,7 @@ export const toFeature = ({ : AllowanceType.Fixed, interval: isBoolean ? null : (itemToEntInterval(item) as EntInterval), + interval_count: item.interval_count || 1, carry_from_previous: !resetUsage, entity_feature_id: item.entity_feature_id, @@ -194,6 +196,7 @@ export const toFeatureAndPrice = ({ allowance: (item.included_usage as number) || 0, allowance_type: AllowanceType.Fixed, interval: itemToEntInterval(item) as EntInterval, + interval_count: item.interval_count || 1, carry_from_previous: !resetUsage, entity_feature_id: item.entity_feature_id, @@ -236,6 +239,7 @@ export const toFeatureAndPrice = ({ ] : (item.tiers as any), interval: itemToBillingInterval(item) as BillingInterval, + interval_count: item.interval_count || 1, }; let prorationConfig = null; diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 13d9214e8..e84c376bc 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -202,6 +202,7 @@ export const validateProductItems = ({ for (let index = 0; index < newItems.length; index++) { let item = newItems[index]; let entInterval = itemToEntInterval(item); + const intervalCount = item.interval_count || 1; if (isFeaturePriceItem(item) && entInterval == EntInterval.Lifetime) { let otherItem = newItems.find((i: any, index2: any) => { @@ -241,6 +242,7 @@ export const validateProductItems = ({ i.feature_id == item.feature_id && index2 != index && itemToEntInterval(i) == entInterval && + (i.interval_count || 1) == intervalCount && i.entity_feature_id == item.entity_feature_id ); }); diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 1467eed54..851ef41b4 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -8,6 +8,7 @@ import { ErrCode, Feature, FixedPriceConfig, + intervalsSame, Organization, Price, PriceSchema, @@ -75,11 +76,13 @@ export const constructProduct = ({ orgId, env, processor, + baseVariantId, }: { productData: CreateProduct; orgId: string; env: AppEnv; processor?: any; + baseVariantId?: string | null; }) => { let newProduct: Product = { ...productData, @@ -124,8 +127,8 @@ export const isProductUpgrade = ({ return true; } - let billingInterval1 = getBillingInterval(prices1); - let billingInterval2 = getBillingInterval(prices2); + let billingInterval1 = getBillingInterval(prices1); // pro quarter + let billingInterval2 = getBillingInterval(prices2); // premium // 2. Get total price for each product const getTotalPrice = (prices: Price[]) => { @@ -141,10 +144,21 @@ export const isProductUpgrade = ({ }; // 3. Compare prices - if (billingInterval1 == billingInterval2) { + + if ( + intervalsSame({ + intervalA: billingInterval1, + intervalB: billingInterval2, + }) + ) { return getTotalPrice(prices1) < getTotalPrice(prices2); } else { - return compareBillingIntervals(billingInterval1, billingInterval2) > 0; + return ( + compareBillingIntervals({ + configA: billingInterval1, + configB: billingInterval2, + }) > 0 + ); } }; @@ -338,6 +352,7 @@ export const copyProduct = async ({ newEntitlements.push( EntitlementSchema.parse({ ...entitlement, + interval_count: entitlement.interval_count ?? 1, id: newId, org_id: toOrgId, created_at: Date.now(), diff --git a/server/src/internal/products/productUtils/detectProductVariant.ts b/server/src/internal/products/productUtils/detectProductVariant.ts index 6ec596ee0..62f9b7788 100644 --- a/server/src/internal/products/productUtils/detectProductVariant.ts +++ b/server/src/internal/products/productUtils/detectProductVariant.ts @@ -18,9 +18,7 @@ To determine if a product is an interval variant, please follow these guidelines 1. Look at the name of the product. If it contains a word like "annual", "yearly", etc. and the name resembles another product, it's a variant. - Example of this: "Pro (Annual)" is a variant of "Pro". -2. - - +2. If the product has a similar name to another product, but interval is different, it's probably a variant. 4. If the current product is not a variant of any existing product, return null. `; @@ -34,6 +32,7 @@ export const detectBaseVariant = async ({ curProduct: FullProduct; logger: Logger; }) => { + logger.info(`Detecting base variant for ${curProduct.id}`); if (!process.env.ANTHROPIC_API_KEY) return; let existingProducts = (await ProductService.listFull({ @@ -50,10 +49,12 @@ export const detectBaseVariant = async ({ // 1. Return null if add on if (curProduct.is_add_on) return null; - // // 2. Return null if only one off or monthly price + // 2. Return null if only one off or monthly price const oneOffOrMonthly = [BillingInterval.OneOff, BillingInterval.Month]; - if (intervals.every((i: BillingInterval) => oneOffOrMonthly.includes(i))) + if (intervals.every((i: BillingInterval) => oneOffOrMonthly.includes(i))) { + logger.info(`Is one off or monthly, skipping`); return null; + } const filteredExistingProducts = existingProducts.filter( (p) => @@ -62,12 +63,15 @@ export const detectBaseVariant = async ({ !p.is_add_on && p.prices.length > 0 && p.prices.every( - (price) => price.config.interval == BillingInterval.Month, + (price) => price.config.interval == BillingInterval.Month ) && - p.group == curProduct.group, + p.group == curProduct.group ); - if (filteredExistingProducts.length == 0) return null; + if (filteredExistingProducts.length == 0) { + logger.info(`No base product to search for`); + return null; + } const variables = ` @@ -85,7 +89,7 @@ export const detectBaseVariant = async ({ id: p.id, name: p.name, prices: p.prices, - }), + }) ) .join("\n")} @@ -100,7 +104,7 @@ export const detectBaseVariant = async ({ let baseVariantId = object.base_variant_id; logger.info( - `llm response for base variant of ${curProduct.id}: ${baseVariantId}`, + `llm response for base variant of ${curProduct.id}: ${baseVariantId}` ); if (baseVariantId) { diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts index 0dc741e7c..8da066d96 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts @@ -10,6 +10,7 @@ import { Organization, ProductItem, ProductItemFeatureType, + ProductItemInterval, } from "@autumn/shared"; import { isFeatureItem, @@ -17,6 +18,22 @@ import { isPriceItem, } from "../../product-items/productItemUtils/getItemType.js"; +export const getIntervalString = ({ + interval, + intervalCount, + prefix = "per ", +}: { + interval: ProductItemInterval; + intervalCount?: number | null; + prefix?: string; +}) => { + if (!interval) return ""; + if (intervalCount == 1) { + return `${prefix}${interval}`; + } + return `${prefix}${intervalCount} ${interval}s`; +}; + export const formatTiers = ({ item, currency, @@ -112,7 +129,12 @@ export const getPriceItemDisplay = ({ currency, amount: item.price as number, }); - let secondaryText = item.interval ? `per ${item.interval}` : undefined; + let intervalStr = getIntervalString({ + interval: item.interval!, + intervalCount: item.interval_count, + }); + + let secondaryText = intervalStr || undefined; return { primary_text: primaryText, @@ -170,7 +192,16 @@ export const getFeaturePriceItemDisplay = ({ priceStr2 = `${billingFeatureName}`; } - let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : ""; + // let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : ""; + let intervalStr = isMainPrice + ? getIntervalString({ + interval: item.interval!, + intervalCount: item.interval_count, + }) + : ""; + + // console.log("isMainPrice", isMainPrice); + // console.log("intervalStr", intervalStr); if (includedUsageStr) { return { @@ -179,8 +210,16 @@ export const getFeaturePriceItemDisplay = ({ }; } + if (isMainPrice) { + return { + primary_text: priceStr + ` per ${priceStr2}`, + secondary_text: `${intervalStr}`, + }; + } + + // ${intervalStr} return { - primary_text: priceStr + ` per ${priceStr2}${intervalStr}`, + primary_text: priceStr + ` per ${priceStr2}`, // secondary_text: `per ${priceStr2}${intervalStr}`, secondary_text: "", }; @@ -190,10 +229,12 @@ export const getProductItemDisplay = ({ item, features, currency = "usd", + isMainPrice = false, }: { item: ProductItem; features: Feature[]; currency?: string | null; + isMainPrice?: boolean; }) => { if (isFeatureItem(item)) { return getFeatureItemDisplay({ @@ -214,6 +255,7 @@ export const getProductItemDisplay = ({ item, feature: features.find((f) => f.id === item.feature_id), currency, + isMainPrice, }); } diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index 9f1df6f68..2ac56e5f7 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -24,7 +24,7 @@ import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.j import { DrizzleCli } from "@/db/initDrizzle.js"; import { notNullish } from "@/utils/genUtils.js"; import { isFreeProduct, isOneOff } from "../../productUtils.js"; -import { getFirstInterval } from "../../prices/priceUtils/priceIntervalUtils.js"; +import { getLargestInterval } from "../../prices/priceUtils/priceIntervalUtils.js"; import { itemToPriceOrTiers } from "../../product-items/productItemUtils.js"; import { toAPIFeature } from "@/internal/features/utils/mapFeatureUtils.js"; import { isPrepaidPrice } from "../../prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; @@ -35,12 +35,14 @@ export const getProductItemResponse = ({ currency, withDisplay = true, options, + isMainPrice = false, }: { item: ProductItem; features: Feature[]; currency?: string | null; withDisplay?: boolean; options?: FeatureOptions[]; + isMainPrice?: boolean; }) => { // 1. Get item type let type = getItemType(item); @@ -50,6 +52,7 @@ export const getProductItemResponse = ({ item, features, currency, + isMainPrice, }); let priceData = itemToPriceOrTiers({ item }); @@ -130,10 +133,10 @@ export const getProductProperties = ({ product: FullProduct; freeTrial?: FreeTrialResponse | null; }) => { - let firstInterval: any = getFirstInterval({ prices: product.prices }); - if (firstInterval == BillingInterval.OneOff) { - firstInterval = null; - } + const largestInterval = getLargestInterval({ + prices: product.prices, + excludeOneOff: true, + }); let hasFreeTrial = notNullish(freeTrial) && freeTrial?.trial_available !== false; @@ -141,7 +144,7 @@ export const getProductProperties = ({ return ProductPropertiesSchema.parse({ is_free: isFreeProduct(product.prices) || false, is_one_off: isOneOff(product.prices) || false, - interval_group: firstInterval, + interval_group: largestInterval?.interval, has_trial: hasFreeTrial, updateable: product.prices.some( (p: Price) => @@ -179,13 +182,14 @@ export const getProductResponse = async ({ let sortedItems = sortProductItems(rawItems, features); // Transform sorted items - let items = sortedItems.map((item) => { + let items = sortedItems.map((item, index) => { return getProductItemResponse({ item, features, currency, withDisplay, options, + isMainPrice: index == 0, }); }); diff --git a/server/src/queue/workersInit.ts b/server/src/queue/workersInit.ts index 80cc2b670..9cd3dfc27 100644 --- a/server/src/queue/workersInit.ts +++ b/server/src/queue/workersInit.ts @@ -15,6 +15,7 @@ import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask. import { logger } from "@/external/logtail/logtailUtils.js"; import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js"; import { Logger } from "pino"; +import { generateId } from "@/utils/genUtils.js"; const NUM_WORKERS = 10; @@ -44,47 +45,58 @@ const initWorker = ({ worker: { task: job.name, data: job.data, + jobId: generateId("job"), workerId: id, }, }, }); - if (job.name == JobName.DetectBaseVariant) { - await detectBaseVariant({ - db, - curProduct: job.data.curProduct, - logger: logtail as Logger, - }); - return; - } + try { + if (job.name == JobName.DetectBaseVariant) { + await detectBaseVariant({ + db, + curProduct: job.data.curProduct, + logger: logtail as Logger, + }); + return; + } - if (job.name == JobName.GenerateFeatureDisplay) { - await runSaveFeatureDisplayTask({ - db, - feature: job.data.feature, - logger: logtail, - }); - return; - } + if (job.name == JobName.GenerateFeatureDisplay) { + await runSaveFeatureDisplayTask({ + db, + feature: job.data.feature, + logger: logtail, + }); + return; + } - if (job.name == JobName.Migration) { - await runMigrationTask({ - db, - payload: job.data, - logger: logtail, - }); - return; - } + if (job.name == JobName.Migration) { + await runMigrationTask({ + db, + payload: job.data, + logger: logtail, + }); + return; + } - if (actionHandlers.includes(job.name as JobName)) { - await runActionHandlerTask({ - queue, - job, - logger: logtail, - db, - useBackup, + if (actionHandlers.includes(job.name as JobName)) { + await runActionHandlerTask({ + queue, + job, + logger: logtail, + db, + useBackup, + }); + return; + } + } catch (error: any) { + logtail.error(`Failed to process bullmq job: ${job.name}`, { + jobName: job.name, + error: { + message: error.message, + stack: error.stack, + }, }); - return; } // TRIGGER CHECKOUT REWARD @@ -180,8 +192,6 @@ const initWorker = ({ console.log("JOB ID:", jobId); }); - // Check jobs left in queue - worker.on("error", async (error: any) => { if (error.code !== "ECONNREFUSED") { console.log("WORKER ERROR:", error.message); diff --git a/server/src/utils/errorUtils.ts b/server/src/utils/errorUtils.ts index cfb35edfc..1177f8877 100644 --- a/server/src/utils/errorUtils.ts +++ b/server/src/utils/errorUtils.ts @@ -191,15 +191,13 @@ export const handleFrontendReqError = ({ action: string; }) => { try { - let logger = req.logtail; - + const logger = req.logger; if ( error instanceof RecaseError && error.statusCode == StatusCodes.NOT_FOUND ) { - req.logtail.warn( - `(frontend) ${req.method} ${req.originalUrl}: not found` - ); + // Temporarily disable logger to prevent thread-stream crashes + console.log(`(frontend) ${req.method} ${req.originalUrl}: not found`); res.status(404).json({ message: error.message, code: error.code, @@ -207,21 +205,13 @@ export const handleFrontendReqError = ({ return; } - logger.warn(`FRONTEND REQUEST WARNING: ${error.message}`, { - type: "frontend_request", - error: { - message: error.message, - stack: error.stack, - code: error.code, - action, - }, - }); + logger.error( + `(frontend) ${req.method} ${req.originalUrl}: ${error.message}`, + { + error, + } + ); - // logger.warn(`${req.method} ${req.originalUrl}`, { - // type: "frontend_request", - // }); - // logger.warn(`${action}`); - // logger.warn(error); res.status(400).json({ message: error.message || "Unknown error", code: error.code || "unknown_error", diff --git a/server/src/utils/importUtils/addProductFromSubs.ts b/server/src/utils/importUtils/addProductFromSubs.ts index 56e58fa03..fb38378f9 100644 --- a/server/src/utils/importUtils/addProductFromSubs.ts +++ b/server/src/utils/importUtils/addProductFromSubs.ts @@ -51,9 +51,7 @@ export const addProductFromSubs = async ({ (cp) => !cp.product.is_add_on && cp.product_id == autumnProduct.id && - (notNullish(entity) - ? cp.internal_entity_id == entity!.internal_id - : true), + (notNullish(entity) ? cp.internal_entity_id == entity!.internal_id : true) ); if (mainCusProduct && !force) { @@ -66,7 +64,7 @@ export const addProductFromSubs = async ({ autumnCus.id || autumnCus.email } already has non-free free product: ${ mainCusProduct.product.name - }, skipping...`, + }, skipping...` ); return mainCusProduct; } @@ -112,7 +110,7 @@ export const addProductFromSubs = async ({ anchorToUnix: anchorToUnix || stripeSubs[0].current_period_end * 1000, subscriptionStatus: stripeToAutumnSubStatus( - stripeSubs[0].status, + stripeSubs[0].status ) as CusProductStatus, canceledAt: stripeSubs[0].canceled_at @@ -124,7 +122,7 @@ export const addProductFromSubs = async ({ }); logger.info( - `Added product ${autumnProduct.name} to customer ${autumnCus.name}`, + `Added product ${autumnProduct.name} to customer ${autumnCus.name}` ); // Create sub @@ -146,7 +144,7 @@ export const addProductFromSubs = async ({ sub: constructSub({ stripeId: sub.id, usageFeatures: - subInterval == BillingInterval.Month ? usageFeatures : [], + subInterval.interval == BillingInterval.Month ? usageFeatures : [], orgId: org.id, env, currentPeriodStart: sub.current_period_start, diff --git a/server/src/utils/models/ItemSet.ts b/server/src/utils/models/ItemSet.ts index 041fdbd94..99895b8ea 100644 --- a/server/src/utils/models/ItemSet.ts +++ b/server/src/utils/models/ItemSet.ts @@ -5,6 +5,7 @@ export const ItemSetSchema = z.object({ items: z.array(z.any()), prices: z.array(z.any()), interval: z.nativeEnum(BillingInterval), + intervalCount: z.number(), subMeta: z.record(z.string(), z.any()), usageFeatures: z.array(z.string()), }); diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index 199fad396..e02b06f10 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -13,6 +13,7 @@ export const constructFeatureItem = ({ featureId, includedUsage = 150, interval = ProductItemInterval.Month, + intervalCount = 1, entityFeatureId, isBoolean = false, rolloverConfig, @@ -20,6 +21,7 @@ export const constructFeatureItem = ({ featureId: string; includedUsage?: number; interval?: ProductItemInterval | null; + intervalCount?: number; entityFeatureId?: string; isBoolean?: boolean; rolloverConfig?: RolloverConfig; @@ -35,6 +37,7 @@ export const constructFeatureItem = ({ included_usage: includedUsage, entity_feature_id: entityFeatureId, interval: interval, + interval_count: intervalCount, }; if (rolloverConfig) { @@ -58,6 +61,7 @@ export const constructPrepaidItem = ({ }, rolloverConfig, usageLimit, + intervalCount = 1, }: { featureId: string; price?: number; @@ -67,6 +71,7 @@ export const constructPrepaidItem = ({ config?: ProductItemConfig; rolloverConfig?: RolloverConfig; usageLimit?: number; + intervalCount?: number; }) => { let item: ProductItem = { feature_id: featureId, @@ -74,8 +79,8 @@ export const constructPrepaidItem = ({ price: price, billing_units: billingUnits || 100, - interval: isOneOff ? null : ProductItemInterval.Month, + interval_count: intervalCount, included_usage: includedUsage, config: { @@ -99,6 +104,7 @@ export const constructArrearItem = ({ }, entityFeatureId, usageLimit, + intervalCount = 1, }: { featureId: string; includedUsage?: number; @@ -107,6 +113,7 @@ export const constructArrearItem = ({ config?: ProductItemConfig; entityFeatureId?: string; usageLimit?: number; + intervalCount?: number; }) => { let item: ProductItem = { feature_id: featureId, @@ -115,6 +122,7 @@ export const constructArrearItem = ({ price: price, billing_units: billingUnits, interval: ProductItemInterval.Month, + interval_count: intervalCount, reset_usage_when_enabled: true, config, entity_feature_id: entityFeatureId, diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 268ab394c..fb1bd26cf 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -74,13 +74,16 @@ export const constructRawProduct = ({ is_default: false, version: 1, group: "", + created_at: Date.now(), }; }; + export const constructProduct = ({ id, items, type, interval, + intervalCount, isAnnual = false, trial = false, excludeBase = false, @@ -92,6 +95,7 @@ export const constructProduct = ({ items: ProductItem[]; type: "free" | "pro" | "premium" | "growth" | "one_off"; interval?: BillingInterval; + intervalCount?: number; isAnnual?: boolean; trial?: boolean; excludeBase?: boolean; @@ -117,6 +121,7 @@ export const constructProduct = ({ : interval ? interval : BillingInterval.Month, + intervalCount: intervalCount || 1, }) ); } diff --git a/server/src/utils/scriptUtils/logUtils/logSubItems.ts b/server/src/utils/scriptUtils/logUtils/logSubItems.ts index 6b410a32a..f5d443661 100644 --- a/server/src/utils/scriptUtils/logUtils/logSubItems.ts +++ b/server/src/utils/scriptUtils/logUtils/logSubItems.ts @@ -1,8 +1,15 @@ import { subItemToAutumnInterval } from "@/external/stripe/utils.js"; import Stripe from "stripe"; -export const logSubItems = (sub: Stripe.Subscription) => { - for (const item of sub.items.data) { +export const logSubItems = ({ + sub, + subItems, +}: { + sub?: Stripe.Subscription; + subItems?: Stripe.SubscriptionItem[]; +}) => { + let finalSubItems = subItems || sub!.items.data; + for (const item of finalSubItems) { let isMetered = item.price.recurring?.usage_type === "metered"; let isTiered = item.price.billing_scheme === "tiered"; @@ -10,8 +17,10 @@ export const logSubItems = (sub: Stripe.Subscription) => { console.log(`Usage price`); } else { let price = item.price.unit_amount! / 100; - let interval = subItemToAutumnInterval(item); - console.log(`${price} ${item.price.currency} / ${interval}`); + let subInterval = subItemToAutumnInterval(item); + console.log( + `${price} ${item.price.currency} / ${subInterval?.interval} (${subInterval?.intervalCount})` + ); } } }; diff --git a/server/src/utils/timeUtils.ts b/server/src/utils/timeUtils.ts index 399d29dcc..81bbb7c74 100644 --- a/server/src/utils/timeUtils.ts +++ b/server/src/utils/timeUtils.ts @@ -7,7 +7,8 @@ import { UTCDate } from "@date-fns/utc"; // 1. Get next entitlement reset export const getNextEntitlementReset = ( prevReset: UTCDate | null, - interval: EntInterval + interval: EntInterval, + intervalCount: number ) => { if (!prevReset) { prevReset = new UTCDate(); @@ -15,32 +16,41 @@ export const getNextEntitlementReset = ( switch (interval) { case EntInterval.Minute: - return add(prevReset, { minutes: 1 }); + return add(prevReset, { minutes: intervalCount }); case EntInterval.Hour: - return add(prevReset, { hours: 1 }); + return add(prevReset, { hours: intervalCount }); case EntInterval.Day: - return add(prevReset, { days: 1 }); + return add(prevReset, { days: intervalCount }); case EntInterval.Week: - return add(prevReset, { weeks: 1 }); + return add(prevReset, { weeks: intervalCount }); case EntInterval.Month: - return add(prevReset, { months: 1 }); + return add(prevReset, { months: intervalCount }); case EntInterval.Quarter: - return add(prevReset, { months: 3 }); + return add(prevReset, { months: intervalCount * 3 }); case EntInterval.SemiAnnual: - return add(prevReset, { months: 6 }); + return add(prevReset, { months: intervalCount * 6 }); case EntInterval.Year: - return add(prevReset, { years: 1 }); + return add(prevReset, { years: intervalCount }); default: throw new Error("Invalid duration"); } }; -export const getNextResetAt = ( - curReset: UTCDate | null, - interval: EntInterval -) => { +export const getNextResetAt = ({ + curReset, + interval, + intervalCount = 1, +}: { + curReset: UTCDate | null; + interval: EntInterval; + intervalCount?: number; +}) => { while (true) { - const nextReset = getNextEntitlementReset(curReset, interval); + const nextReset = getNextEntitlementReset( + curReset, + interval, + intervalCount + ); if (nextReset.getTime() > Date.now()) { return nextReset.getTime(); } diff --git a/server/tests/advanced/customInterval/customInterval1.ts b/server/tests/advanced/customInterval/customInterval1.ts new file mode 100644 index 000000000..8cf7c3f01 --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval1.ts @@ -0,0 +1,146 @@ +import chalk from "chalk"; +import Stripe from "stripe"; + +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addHours, addMonths } from "date-fns"; +import { expect } from "chai"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; + +const testCase = "customInterval1"; + +export let pro = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + intervalCount: 2, + includedUsage: 500, + }), + ], + intervalCount: 2, + type: "pro", +}); + +export let premium = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + intervalCount: 2, + }), + // constructArrearItem({ featureId: TestFeature.Words }), + // constructArrearProratedItem({ + // featureId: TestFeature.Users, + // pricePerUnit: 30, + // }), + ], + intervalCount: 2, + type: "premium", +}); + +describe(`${chalk.yellowBright(`${testCase}: Testing custom interval and interval count`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + 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; + + stripeCli = this.stripeCli; + + const { testClockId: testClockId1 } = 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, + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product", async function () { + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + let usage = 100012; + it("should upgrade to premium product and have correct invoice next cycle", async function () { + const curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 15, + }); + + await runAttachTest({ + autumn, + customerId, + product: premium, + stripeCli, + db, + org, + env, + }); + + const customer = await autumn.customers.get(customerId); + expect(customer.invoices.length).to.equal(2); + + const nextUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(new Date(curUnix), 1), + hoursToFinalizeInvoice + ).getTime(), + waitForSeconds: 30, + }); + + const customer2 = await autumn.customers.get(customerId); + const invoices = customer2.invoices; + expect(invoices.length).to.equal(3); + expect(invoices[0].product_ids).to.include(premium.id); + expect(invoices[0].total).to.equal(getBasePrice({ product: premium })); + + const wordsFeature = customer2.features[TestFeature.Words]; + // @ts-ignore + expect(wordsFeature.interval_count).to.equal(2); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval2.ts b/server/tests/advanced/customInterval/customInterval2.ts new file mode 100644 index 000000000..9b39c0d9f --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval2.ts @@ -0,0 +1,120 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, Organization } from "@autumn/shared"; +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 { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js"; + +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addHours, addMonths } from "date-fns"; +import { expect } from "chai"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; + +const testCase = "customInterval2"; + +export let pro = constructRawProduct({ + id: "pro", + items: [ + constructArrearItem({ + includedUsage: 0, + featureId: TestFeature.Words, + intervalCount: 2, + }), + ], +}); + +describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear prorated price`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + 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; + + stripeCli = this.stripeCli; + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + db, + orgId: org.id, + env, + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product", async function () { + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + let usage = 100012; + it("should upgrade to premium product and have correct invoice next cycle", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: usage, + }); + + const curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(new Date(), 2), + hoursToFinalizeInvoice + ).getTime(), + waitForSeconds: 30, + }); + + const invoiceAmount = await getExpectedInvoiceTotal({ + customerId, + productId: pro.id, + usage: [{ featureId: TestFeature.Words, value: usage }], + stripeCli, + db, + org, + env, + }); + + const customer = await autumn.customers.get(customerId); + expect(customer.invoices.length).to.equal(2); + expect(invoiceAmount).to.equal(customer.invoices[0].total); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval3.ts b/server/tests/advanced/customInterval/customInterval3.ts new file mode 100644 index 000000000..b7f4c2148 --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval3.ts @@ -0,0 +1,173 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + BillingInterval, + LimitedItem, + Organization, + Product, +} from "@autumn/shared"; +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 { + constructArrearItem, + constructFeatureItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { + constructProduct, + constructRawProduct, +} from "@/utils/scriptUtils/createTestProducts.js"; + +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addDays, addHours, addMonths } from "date-fns"; +import { expect } from "chai"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; +import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; +import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js"; + +const testCase = "customInterval3"; + +export let pro = constructProduct({ + type: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + intervalCount: 2, + }), + ], + intervalCount: 2, +}); + +const prepaidWordsItem = constructPrepaidItem({ + featureId: TestFeature.Words, + price: 10, + billingUnits: 1, + includedUsage: 0, + intervalCount: 2, +}); + +export const addOn = constructRawProduct({ + id: "addOn", + items: [prepaidWordsItem], + isAddOn: true, +}); + +describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on add on merged product`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + 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; + + stripeCli = this.stripeCli; + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [pro, addOn], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro, addOn], + db, + orgId: org.id, + env, + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product", async function () { + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + it("should upgrade to attached add on and have correct invoice next cycle", async function () { + const curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addDays(new Date(), 20).getTime(), + waitForSeconds: 15, + }); + + const wordBillingSets = 2; + const wordsBillingUnits = prepaidWordsItem.billing_units! * wordBillingSets; + await autumn.attach({ + customer_id: customerId, + product_id: addOn.id, + options: [ + { + feature_id: TestFeature.Words, + quantity: wordsBillingUnits, + }, + ], + }); + + const customer = await autumn.customers.get(customerId); + const proProduct = customer.products.find((p) => p.id === pro.id); + const invoices = customer.invoices; + expectProductAttached({ + customer, + product: pro, + }); + + expectProductAttached({ + customer, + product: addOn, + }); + + let expectedPrice = wordsBillingUnits * prepaidWordsItem.price!; + const proratedPrice = calculateProrationAmount({ + amount: expectedPrice, + periodStart: new Date().getTime(), + periodEnd: addMonths(new Date(), 2).getTime(), + now: curUnix!, + }); + + expect(invoices[0].product_ids).to.include(addOn.id); + expect(invoices[0].total).to.approximately(proratedPrice, 0.1); + + const expectedAddonEnd = addMonths(new Date(), 2); + const approximate = 1000 * 60 * 60 * 24; // +- 1 day + const addOnProduct = customer.products.find((p) => p.id === addOn.id); + + expect(addOnProduct?.current_period_end).to.be.approximately( + expectedAddonEnd.getTime(), + approximate + ); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval4.ts b/server/tests/advanced/customInterval/customInterval4.ts new file mode 100644 index 000000000..056ee8893 --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval4.ts @@ -0,0 +1,150 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, Organization } from "@autumn/shared"; +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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; + +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { addMonths } from "date-fns"; +import { expect } from "chai"; +import { + expectDowngradeCorrect, + expectNextCycleCorrect, +} from "tests/utils/expectUtils/expectScheduleUtils.js"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; + +const testCase = "customInterval4"; + +export let pro = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 500, + }), + ], + intervalCount: 2, + type: "pro", +}); + +export let premium = constructProduct({ + id: "premium", + items: [ + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 500, + }), + ], + intervalCount: 2, + type: "premium", +}); + +describe(`${chalk.yellowBright(`${testCase}: Testing downgrades for custom intervals`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + 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; + + stripeCli = this.stripeCli; + + const { testClockId: testClockId1 } = 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, + }); + + testClockId = testClockId1!; + }); + + it("should attach premium product", async function () { + await runAttachTest({ + autumn, + customerId, + product: premium, + stripeCli, + db, + org, + env, + }); + }); + + it("should have correct next cycle at on checkout", async function () { + const checkout = await autumn.checkout({ + customer_id: customerId, + product_id: pro.id, + }); + + let expectedNextCycle = addMonths(new Date(), 2); + expect(checkout.next_cycle?.starts_at).to.be.approximately( + expectedNextCycle.getTime(), + 1000 * 60 * 60 * 24 + ); + + expect(checkout.total).to.equal(0); + }); + + let preview: any; + it("should downgrade to pro", async function () { + const { preview: preview_ } = await expectDowngradeCorrect({ + autumn, + customerId, + curProduct: premium, + newProduct: pro, + stripeCli, + db, + org, + env, + }); + + preview = preview_; + }); + + it("should have pro attached on next cycle", async function () { + await expectNextCycleCorrect({ + preview: preview!, + autumn, + stripeCli, + customerId, + testClockId, + product: pro, + db, + org, + env, + }); + + const customer = await autumn.customers.get(customerId); + const invoices = customer.invoices; + expect(invoices.length).to.equal(2); + expect(invoices[0].total).to.equal(getBasePrice({ product: pro })); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval5.ts b/server/tests/advanced/customInterval/customInterval5.ts new file mode 100644 index 000000000..5fab46f4f --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval5.ts @@ -0,0 +1,163 @@ +import chalk from "chalk"; +import Stripe from "stripe"; + +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, FullCustomer, Organization } from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { expect } from "chai"; +import { Customer } from "autumn-js"; +import { timeout } from "@/utils/genUtils.js"; + +const testCase = "customInterval5"; + +const includedUsage = 500; +const monthlyWords = constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage, +}); + +const biMonthlyWords = constructFeatureItem({ + featureId: TestFeature.Words, + intervalCount: 2, + includedUsage, +}); + +export let pro = constructProduct({ + items: [monthlyWords, biMonthlyWords], + intervalCount: 2, + type: "pro", +}); + +const getBreakdown = ({ + customer, + intervalCount, +}: { + customer: Customer; + intervalCount: number; +}) => { + const wordsFeature = customer.features[TestFeature.Words]; + // @ts-ignore + return wordsFeature.breakdown?.find( + (b: any) => b.interval_count == intervalCount + ); +}; + +describe(`${chalk.yellowBright(`${testCase}: Testing multi interval features with custom intervals`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + 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; + + stripeCli = this.stripeCli; + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + db, + orgId: org.id, + env, + }); + + testClockId = testClockId1!; + }); + + it("should attach pro product", async function () { + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + + const customer = await autumn.customers.get(customerId); + const wordsFeature = customer.features[TestFeature.Words]; + // @ts-ignore + expect(wordsFeature.interval_count).to.equal(null); + expect(wordsFeature.breakdown?.length).to.equal(2); + + expect( + wordsFeature.breakdown?.some( + (b: any) => b.interval_count == 1 && b.interval == "month" + ) + ).to.equal(true); + expect( + wordsFeature.breakdown?.some( + (b: any) => b.interval_count == 2 && b.interval == "month" + ) + ).to.equal(true); + }); + + const trackVal = 300; + it("should have correct breakdown after usage", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: trackVal, + }); + + await timeout(3000); + + const customer = await autumn.customers.get(customerId); + + // Should deduct + const monthlyBreakdown = getBreakdown({ customer, intervalCount: 1 }); + const biMonthlyBreakdown = getBreakdown({ customer, intervalCount: 2 }); + + expect(monthlyBreakdown?.balance).to.equal(includedUsage - trackVal); + expect(biMonthlyBreakdown?.balance).to.equal(includedUsage); + + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: trackVal, + }); + + await timeout(3000); + + const customer2 = await autumn.customers.get(customerId); + const monthlyBreakdown2 = getBreakdown({ + customer: customer2, + intervalCount: 1, + }); + const biMonthlyBreakdown2 = getBreakdown({ + customer: customer2, + intervalCount: 2, + }); + + expect(monthlyBreakdown2?.balance).to.equal(0); + expect(biMonthlyBreakdown2?.balance).to.equal(includedUsage - 100); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval6.ts b/server/tests/advanced/customInterval/customInterval6.ts new file mode 100644 index 000000000..5f0899982 --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval6.ts @@ -0,0 +1,165 @@ +import chalk from "chalk"; +import Stripe from "stripe"; + +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { APIVersion, AppEnv, FullCustomer, Organization } from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js"; +import { expect } from "chai"; +import { Customer } from "autumn-js"; +import { timeout } from "@/utils/genUtils.js"; + +const testCase = "customInterval6"; + +// Update balances! + +// const includedUsage = 500; +// const monthlyWords = constructFeatureItem({ +// featureId: TestFeature.Words, +// includedUsage, +// }); + +// const biMonthlyWords = constructFeatureItem({ +// featureId: TestFeature.Words, +// intervalCount: 2, +// includedUsage, +// }); + +// export let pro = constructProduct({ +// items: [monthlyWords, biMonthlyWords], +// intervalCount: 2, +// type: "pro", +// }); + +// const getBreakdown = ({ +// customer, +// intervalCount, +// }: { +// customer: Customer; +// intervalCount: number; +// }) => { +// const wordsFeature = customer.features[TestFeature.Words]; +// // @ts-ignore +// return wordsFeature.breakdown?.find( +// (b: any) => b.interval_count == intervalCount +// ); +// }; + +// describe(`${chalk.yellowBright(`${testCase}: Testing multi interval features with custom intervals`)}`, () => { +// let customerId = testCase; +// let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); +// let testClockId: string; +// 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; + +// stripeCli = this.stripeCli; + +// const { testClockId: testClockId1 } = await initCustomer({ +// autumn: autumnJs, +// customerId, +// db, +// org, +// env, +// attachPm: "success", +// }); + +// addPrefixToProducts({ +// products: [pro], +// prefix: testCase, +// }); + +// await createProducts({ +// autumn, +// products: [pro], +// db, +// orgId: org.id, +// env, +// }); + +// testClockId = testClockId1!; +// }); + +// it("should attach pro product", async function () { +// await runAttachTest({ +// autumn, +// customerId, +// product: pro, +// stripeCli, +// db, +// org, +// env, +// }); + +// const customer = await autumn.customers.get(customerId); +// const wordsFeature = customer.features[TestFeature.Words]; +// // @ts-ignore +// expect(wordsFeature.interval_count).to.equal(null); +// expect(wordsFeature.breakdown?.length).to.equal(2); + +// expect( +// wordsFeature.breakdown?.some( +// (b: any) => b.interval_count == 1 && b.interval == "month" +// ) +// ).to.equal(true); +// expect( +// wordsFeature.breakdown?.some( +// (b: any) => b.interval_count == 2 && b.interval == "month" +// ) +// ).to.equal(true); +// }); + +// const trackVal = 300; +// it("should have correct breakdown after usage", async function () { +// await autumn.track({ +// customer_id: customerId, +// feature_id: TestFeature.Words, +// value: trackVal, +// }); + +// await timeout(3000); + +// const customer = await autumn.customers.get(customerId); + +// // Should deduct +// const monthlyBreakdown = getBreakdown({ customer, intervalCount: 1 }); +// const biMonthlyBreakdown = getBreakdown({ customer, intervalCount: 2 }); + +// expect(monthlyBreakdown?.balance).to.equal(includedUsage - trackVal); +// expect(biMonthlyBreakdown?.balance).to.equal(includedUsage); + +// await autumn.track({ +// customer_id: customerId, +// feature_id: TestFeature.Words, +// value: trackVal, +// }); + +// await timeout(3000); + +// const customer2 = await autumn.customers.get(customerId); +// const monthlyBreakdown2 = getBreakdown({ +// customer: customer2, +// intervalCount: 1, +// }); +// const biMonthlyBreakdown2 = getBreakdown({ +// customer: customer2, +// intervalCount: 2, +// }); + +// expect(monthlyBreakdown2?.balance).to.equal(0); +// expect(biMonthlyBreakdown2?.balance).to.equal(includedUsage - 100); +// }); +// }); diff --git a/server/tests/advanced/rollovers/rollover1.ts b/server/tests/advanced/rollovers/rollover1.ts index 09cfaaece..6f727a49f 100644 --- a/server/tests/advanced/rollovers/rollover1.ts +++ b/server/tests/advanced/rollovers/rollover1.ts @@ -190,8 +190,8 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item` // @ts-ignore let rollovers = msgesFeature.rollovers; - expect(rollovers[0].balance).to.equal(0); - expect(rollovers[1].balance).to.equal(0); + expect(rollovers![0].balance).to.equal(0); + expect(rollovers![1].balance).to.equal(0); expect(msgesFeature.balance).to.equal(messagesItem.included_usage - 50); }); }); diff --git a/server/tests/advanced/rollovers/rolloverTestUtils.ts b/server/tests/advanced/rollovers/rolloverTestUtils.ts index 1e49716d1..2a9ce061e 100644 --- a/server/tests/advanced/rollovers/rolloverTestUtils.ts +++ b/server/tests/advanced/rollovers/rolloverTestUtils.ts @@ -1,5 +1,6 @@ import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { Customer } from "@autumn/shared"; @@ -28,7 +29,7 @@ export const resetAndGetCusEnt = async ({ featureId, }); - await resetCustomerEntitlement({ + const updatedCusEnt = await resetCustomerEntitlement({ db, cusEnt: { ...cusEnt!, @@ -37,6 +38,13 @@ export const resetAndGetCusEnt = async ({ cacheEnabledOrgs: [], }); + if (updatedCusEnt) { + await CusEntService.upsert({ + db, + data: [updatedCusEnt], + }); + } + mainCusProduct = await getMainCusProduct({ db, internalCustomerId: customer.internal_id, diff --git a/server/tests/attach/basic/basic2.ts b/server/tests/attach/basic/basic2.ts index 9ae9f6068..6e99007f8 100644 --- a/server/tests/attach/basic/basic2.ts +++ b/server/tests/attach/basic/basic2.ts @@ -23,6 +23,7 @@ describe(`${chalk.yellowBright("basic2: Testing attach pro")}`, () => { before(async function () { await setupBefore(this); + db = this.db; org = this.org; env = this.env; @@ -44,7 +45,7 @@ describe(`${chalk.yellowBright("basic2: Testing attach pro")}`, () => { }); await completeCheckoutForm(checkout_url); - await timeout(10000); + await timeout(12000); }); it("should have correct product & entitlements", async function () { diff --git a/server/tests/attach/basic/basic4.ts b/server/tests/attach/basic/basic4.ts index f2b1cd9ce..508892cf3 100644 --- a/server/tests/attach/basic/basic4.ts +++ b/server/tests/attach/basic/basic4.ts @@ -88,16 +88,16 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => (e: any) => e.feature_id === features.metered1.id && e.interval == - products.monthlyAddOnMetered1.entitlements.metered1.interval, + products.monthlyAddOnMetered1.entitlements.metered1.interval ); expect(monthlyMetered1Balance!.balance).to.equal( - proMetered1! + monthlyQuantity, + proMetered1! + monthlyQuantity ); expect(cusRes.add_ons).to.have.lengthOf(1); const monthlyAddOnId = cusRes.add_ons.find( - (a: any) => a.id === products.monthlyAddOnMetered1.id, + (a: any) => a.id === products.monthlyAddOnMetered1.id ); expect(monthlyAddOnId).to.exist; @@ -108,14 +108,14 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => const res: any = await AutumnCli.entitled(customerId, features.metered1.id); const metered1Balance = res!.balances.find( - (b: any) => b.feature_id === features.metered1.id, + (b: any) => b.feature_id === features.metered1.id ); const proMetered1Amt = products.pro.entitlements.metered1.allowance; const monthlyAddOnMetered1Amt = monthlyQuantity; expect(metered1Balance!.balance).to.equal( - proMetered1Amt! + monthlyAddOnMetered1Amt, + proMetered1Amt! + monthlyAddOnMetered1Amt ); }); }); diff --git a/server/tests/attach/basic/basic9.ts b/server/tests/attach/basic/basic9.ts index 826c9553a..d9e24ea1a 100644 --- a/server/tests/attach/basic/basic9.ts +++ b/server/tests/attach/basic/basic9.ts @@ -9,7 +9,7 @@ import { compareMainProduct } from "tests/utils/compare.js"; const testCase = "basic9"; describe(`${chalk.yellowBright( - "basic9: attach monthly with one time prepaid, and quantity = 0", + "basic9: attach monthly with one time prepaid, and quantity = 0" )}`, () => { let customerId = testCase; @@ -40,7 +40,7 @@ describe(`${chalk.yellowBright( }); await completeCheckoutForm(res.checkout_url); - await timeout(10000); + await timeout(12000); }); it("should have correct main product and entitlements", async function () { diff --git a/server/tests/attach/renew/renew1.ts b/server/tests/attach/renew/renew1.ts new file mode 100644 index 000000000..540e16230 --- /dev/null +++ b/server/tests/attach/renew/renew1.ts @@ -0,0 +1,131 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + AttachBranch, + CreateFreeTrialSchema, + CusProductStatus, + FreeTrialDuration, + Organization, + organizations, +} from "@autumn/shared"; +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, runAttachTest } from "../utils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { addDays } from "date-fns"; +import { expect } from "chai"; +import { eq } from "drizzle-orm"; +import { CacheManager } from "@/external/caching/CacheManager.js"; +import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; + +const testCase = "renew1"; + +export let free = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], + isDefault: false, + type: "free", +}); + +export let pro = constructProduct({ + items: [ + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 1000, + }), + ], + + type: "pro", +}); + +describe(`${chalk.yellowBright(`${testCase}: Testing renew pro, force checkout`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + 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; + + stripeCli = this.stripeCli; + + const { testClockId } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + addPrefixToProducts({ + products: [free, pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [free, pro], + db, + orgId: org.id, + env, + }); + }); + + it("should attach pro product", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + it("should attach free, then pro with force checkout and renew", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: free.id, + }); + + // 1. Get attach preview + let attachPreview = await autumn.attachPreview({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(attachPreview.branch).to.equal(AttachBranch.Renew); + + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + force_checkout: true, + }); + + let customer = await autumn.customers.get(customerId); + + expectProductAttached({ + customer, + product: pro, + }); + }); +}); diff --git a/server/tests/before.ts b/server/tests/before.ts index 931f206ec..3562c10b5 100644 --- a/server/tests/before.ts +++ b/server/tests/before.ts @@ -19,34 +19,34 @@ const hyperbrowser = new Hyperbrowser({ }); export const setupBefore = async (instance: any) => { - const { db, client } = initDrizzle(); + try { + const { db, client } = initDrizzle(); - const org = await OrgService.getBySlug({ db, slug: ORG_SLUG }); - if (!org) { - throw new Error("Org not found"); + const org = await OrgService.getBySlug({ db, slug: ORG_SLUG }); + if (!org) { + throw new Error("Org not found"); + } + const env = DEFAULT_ENV; + const autumnSecretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!; + const autumn = new AutumnInt({ apiKey: autumnSecretKey }); + + const autumnJs = new AutumnJS({ + secretKey: autumnSecretKey, + url: "http://localhost:8080/v1", + }); + + const stripeCli = createStripeCli({ org, env }); + instance.org = org; + instance.env = env; + instance.autumn = autumn; + instance.stripeCli = stripeCli; + instance.autumnJs = autumnJs; + instance.db = db; + instance.client = client; + } catch (error) { + console.log("Error setting up before", error); + throw error; } - const env = DEFAULT_ENV; - const autumnSecretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!; - const autumn = new AutumnInt({ apiKey: autumnSecretKey }); - - // console.log("instance.browserSession", instance.browserSession); - // if (!instance.browserSession) { - // instance.browserSession = await hyperbrowser.sessions.create(); - // } - - const autumnJs = new AutumnJS({ - secretKey: autumnSecretKey, - url: "http://localhost:8080/v1", - }); - - const stripeCli = createStripeCli({ org, env }); - instance.org = org; - instance.env = env; - instance.autumn = autumn; - instance.stripeCli = stripeCli; - instance.autumnJs = autumnJs; - instance.db = db; - instance.client = client; // Return a cleanup function after(async () => { diff --git a/server/tests/core/reset1.ts b/server/tests/core/reset1.ts new file mode 100644 index 000000000..e86609172 --- /dev/null +++ b/server/tests/core/reset1.ts @@ -0,0 +1,148 @@ +import chalk from "chalk"; +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; + +import { + APIVersion, + AppEnv, + Customer, + LimitedItem, + Organization, + ProductItemInterval, + RolloverDuration, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; + +import { expect } from "chai"; +import { timeout } from "@/utils/genUtils.js"; +import { resetAndGetCusEnt } from "tests/advanced/rollovers/rolloverTestUtils.js"; +import { UTCDate } from "@date-fns/utc"; +import { addDays, addMonths } from "date-fns"; + +const messagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 400, + interval: ProductItemInterval.Day, + intervalCount: 3, +}) as LimitedItem; + +const wordsItem = constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage: 100, + interval: ProductItemInterval.Month, + intervalCount: 4, +}) as LimitedItem; + +export let free = constructProduct({ + items: [messagesItem, wordsItem], + type: "free", + isDefault: false, +}); + +const testCase = "reset1"; + +describe(`${chalk.yellowBright(`${testCase}: Testing custom reset intervals`)}`, () => { + let customerId = testCase; + let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); + let testClockId: string; + let db: DrizzleCli, org: Organization, env: AppEnv; + let customer: Customer; + 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; + + addPrefixToProducts({ + products: [free], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [free], + customerId, + db, + orgId: org.id, + env, + }); + + const res = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + it("should attach free product", async function () { + await autumn.attach({ + customer_id: customerId, + product_id: free.id, + }); + }); + + let messageUsage = 250; + let curBalance = messagesItem.included_usage; + + it("should reset messages feature and have correct next reset at", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messageUsage, + }); + + await timeout(3000); + + await resetAndGetCusEnt({ + db, + customer, + productGroup: free.group, + featureId: TestFeature.Messages, + }); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + expect(msgesFeature.next_reset_at).to.exist; + expect(msgesFeature.next_reset_at).to.approximately( + addDays(new Date(), 3).getTime(), + 1000 * 30 + ); + }); + + it("should reset words feature and have correct next reset at", async function () { + await resetAndGetCusEnt({ + db, + customer, + productGroup: free.group, + featureId: TestFeature.Words, + }); + + const cus = await autumn.customers.get(customerId); + const wordsFeature = cus.features[TestFeature.Words]; + expect(wordsFeature.next_reset_at).to.exist; + expect(wordsFeature.next_reset_at).to.approximately( + addMonths(new Date(), 4).getTime(), + 1000 * 30 * 60 // account for timezone differences + ); + }); +}); diff --git a/server/tests/utils/productUtils.ts b/server/tests/utils/productUtils.ts index 2a81a92a1..eab69f507 100644 --- a/server/tests/utils/productUtils.ts +++ b/server/tests/utils/productUtils.ts @@ -36,7 +36,7 @@ export const createProduct = async ({ internalId: prod.internal_id, orgId, env, - }), + }) ); } @@ -82,7 +82,7 @@ export const createProducts = async ({ const batchCreate = []; for (const product of products) { batchCreate.push( - createProduct({ db, orgId, env, autumn, product, prefix }), + createProduct({ db, orgId, env, autumn, product, prefix }) ); } @@ -113,12 +113,12 @@ export const createReward = async ({ idOrInternalId: productId!, }); - let usagePrices = fullProduct.prices.filter((price) => - isUsagePrice({ price }), + let usagePrices = fullProduct?.prices.filter((price) => + isUsagePrice({ price }) ); if (onlyUsage) { - reward.discount_config!.price_ids = usagePrices.map((price) => price.id); + reward.discount_config!.price_ids = usagePrices?.map((price) => price.id); } try { diff --git a/server/tests/utils/setup.ts b/server/tests/utils/setup.ts index ca1b7fb6d..d625ec433 100644 --- a/server/tests/utils/setup.ts +++ b/server/tests/utils/setup.ts @@ -294,67 +294,79 @@ export const setupOrg = async ({ // 2. Create products let insertProducts = []; - for (let i = 0; i < Object.values(products).length; i++) { - const product = Object.values(products)[i]; - 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 productValues = Object.values(products); + const batchSize = 5; - 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, - }, - })); + for ( + let batchStart = 0; + batchStart < productValues.length; + batchStart += batchSize + ) { + const batch = productValues.slice(batchStart, batchStart + batchSize); + const batchPromises = []; - 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), - })); - - let 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, + 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, }); - } catch (error) { - console.log("Product:", product.name); - console.error("Error creating product prices / ents"); - console.log("Items", items); - } - return; - }; - insertProducts.push(insertProduct()); + 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, + }, + })); - // if (i > 1) { - // break; - // } + 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), + })); + + let 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); diff --git a/server/tests/utils/stripeUtils.ts b/server/tests/utils/stripeUtils.ts index 27a4f9c1d..825106cad 100644 --- a/server/tests/utils/stripeUtils.ts +++ b/server/tests/utils/stripeUtils.ts @@ -447,8 +447,12 @@ export const stripeToAutumnInterval = ({ }; export const subItemToAutumnInterval = (item: Stripe.SubscriptionItem) => { - return stripeToAutumnInterval({ - interval: item.price.recurring?.interval!, - intervalCount: item.price.recurring?.interval_count!, - }); + return { + interval: item.price.recurring?.interval as BillingInterval, + intervalCount: item.price.recurring?.interval_count || 1, + }; + // return stripeToAutumnInterval({ + // interval: item.price.recurring?.interval!, + // intervalCount: item.price.recurring?.interval_count!, + // }); }; diff --git a/server/tests/utils/testProductUtils/testProductUtils.ts b/server/tests/utils/testProductUtils/testProductUtils.ts index 3b7fb33a8..1ac865a53 100644 --- a/server/tests/utils/testProductUtils/testProductUtils.ts +++ b/server/tests/utils/testProductUtils/testProductUtils.ts @@ -29,11 +29,13 @@ export const addPrefixToProducts = ({ export const replaceItems = ({ featureId, interval, + intervalCount, newItem, items, }: { featureId?: string; interval?: BillingInterval; + intervalCount?: number; newItem: ProductItem; items: ProductItem[]; }) => { @@ -46,7 +48,10 @@ export const replaceItems = ({ if (interval) { index = newItems.findIndex( - (item) => item.interval == (interval as any) && nullish(item.feature_id), + (item) => + item.interval == (interval as any) && + (intervalCount ? item.interval_count == intervalCount : true) && + nullish(item.feature_id) ); } diff --git a/shared/index.ts b/shared/index.ts index 33fc04803..69b259c29 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -140,6 +140,10 @@ export * from "./models/attachModels/attachBody.js"; export * from "./utils/displayUtils.js"; export * from "./models/checkModels/checkPreviewModels.js"; export * from "./models/chatResultModels/chatResultFeature.js"; +export * from "./utils/productDisplayUtils/getProductItemRes.js"; +export * from "./utils/productUtils.js"; +export * from "./utils/productDisplayUtils/sortProductItems.js"; +export * from "./utils/intervalUtils.js"; // ENUMS export * from "./enums/SuccessCode.js"; diff --git a/shared/models/attachModels/attachBody.ts b/shared/models/attachModels/attachBody.ts index 1935f3b54..6d14dfd3c 100644 --- a/shared/models/attachModels/attachBody.ts +++ b/shared/models/attachModels/attachBody.ts @@ -43,6 +43,7 @@ export const AttachBodySchema = z billing_cycle_anchor: z.number().optional(), checkout_session_params: z.any().optional(), reward: z.string().optional(), + invoice: z.boolean().optional(), }) .refine( (data) => { diff --git a/shared/models/cusModels/cusResModels/cusFeatureResponse.ts b/shared/models/cusModels/cusResModels/cusFeatureResponse.ts index 1caae1a8c..d571e540e 100644 --- a/shared/models/cusModels/cusResModels/cusFeatureResponse.ts +++ b/shared/models/cusModels/cusResModels/cusFeatureResponse.ts @@ -10,6 +10,7 @@ export const CusRolloverSchema = z.object({ export const CusEntResponseSchema = z.object({ feature_id: z.string(), interval: z.nativeEnum(EntInterval).nullish(), + interval_count: z.number().nullish(), unlimited: z.boolean().nullish(), balance: z.number().nullish(), // usage: z.number().nullish(), @@ -22,6 +23,7 @@ export const CusEntResponseSchema = z.object({ export const CoreCusFeatureResponseSchema = z.object({ interval: z.nativeEnum(EntInterval).or(z.literal("multiple")).nullish(), + interval_count: z.number().nullish(), unlimited: z.boolean().nullish(), balance: z.number().nullish(), usage: z.number().nullish(), @@ -33,6 +35,7 @@ export const CoreCusFeatureResponseSchema = z.object({ .array( z.object({ interval: z.nativeEnum(EntInterval), + interval_count: z.number().nullish(), balance: z.number().nullish(), usage: z.number().nullish(), included_usage: z.number().nullish(), diff --git a/shared/models/productModels/entModels/entModels.ts b/shared/models/productModels/entModels/entModels.ts index 383e85fd1..91e2cd5e3 100644 --- a/shared/models/productModels/entModels/entModels.ts +++ b/shared/models/productModels/entModels/entModels.ts @@ -20,6 +20,7 @@ export const EntitlementSchema = z.object({ allowance_type: z.nativeEnum(AllowanceType).optional().nullable(), allowance: z.number().nullish(), interval: z.nativeEnum(EntInterval).optional().nullable(), + interval_count: z.number().default(1), carry_from_previous: z.boolean().default(false).optional(), entity_feature_id: z.string().nullish(), @@ -39,6 +40,7 @@ export const CreateEntitlementSchema = z.object({ allowance_type: z.nativeEnum(AllowanceType).nullish(), allowance: z.number().nullish(), interval: z.nativeEnum(EntInterval).nullish(), + interval_count: z.number().nullish(), carry_from_previous: z.boolean().default(false), entity_feature_id: z.string().nullish(), usage_limit: z.number().nullish().default(null), diff --git a/shared/models/productModels/entModels/entTable.ts b/shared/models/productModels/entModels/entTable.ts index 26718ba01..1f1919e7b 100644 --- a/shared/models/productModels/entModels/entTable.ts +++ b/shared/models/productModels/entModels/entTable.ts @@ -28,6 +28,7 @@ export const entitlements = pgTable( allowance_type: text(), allowance: numeric({ mode: "number" }), interval: text(), + interval_count: numeric({ mode: "number" }).default(1), carry_from_previous: boolean("carry_from_previous").default(false), entity_feature_id: text("entity_feature_id").default(sql`null`), diff --git a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts index b97e818e5..7f88d631d 100644 --- a/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/fixedPriceConfig.ts @@ -5,6 +5,7 @@ export const FixedPriceConfigSchema = z.object({ type: z.string(), amount: z.number().min(0), interval: z.nativeEnum(BillingInterval), + interval_count: z.number().nullish(), stripe_price_id: z.string().nullish(), stripe_product_id: z.null().or(z.undefined()), }); diff --git a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts index 505bd3c42..348f6e49a 100644 --- a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts @@ -22,6 +22,7 @@ export const UsagePriceConfigSchema = z.object({ feature_id: z.string(), usage_tiers: z.array(UsageTierSchema), interval: z.nativeEnum(BillingInterval), + interval_count: z.number().nullish(), // For usage in arrear stripe_meter_id: z.string().nullish(), diff --git a/shared/models/productModels/priceModels/priceEnums.ts b/shared/models/productModels/priceModels/priceEnums.ts index fbc36b181..e9b7b1a2d 100644 --- a/shared/models/productModels/priceModels/priceEnums.ts +++ b/shared/models/productModels/priceModels/priceEnums.ts @@ -1,5 +1,6 @@ export enum BillingInterval { OneOff = "one_off", + Week = "week", Month = "month", Quarter = "quarter", SemiAnnual = "semi_annual", diff --git a/shared/models/productV2Models/productItemModels/featureItem.ts b/shared/models/productV2Models/productItemModels/featureItem.ts index 3ce838cba..1ca38afdd 100644 --- a/shared/models/productV2Models/productItemModels/featureItem.ts +++ b/shared/models/productV2Models/productItemModels/featureItem.ts @@ -7,6 +7,7 @@ export const FeatureItemSchema = ProductItemSchema.pick({ feature_type: true, included_usage: true, interval: true, + interval_count: true, entity_feature_id: true, reset_usage_when_enabled: true, config: true, diff --git a/shared/models/productV2Models/productItemModels/featurePriceItem.ts b/shared/models/productV2Models/productItemModels/featurePriceItem.ts index 63eb4a780..93391f973 100644 --- a/shared/models/productV2Models/productItemModels/featurePriceItem.ts +++ b/shared/models/productV2Models/productItemModels/featurePriceItem.ts @@ -6,6 +6,7 @@ export const FeaturePriceItemSchema = ProductItemSchema.pick({ feature_type: true, included_usage: true, interval: true, + interval_count: true, usage_model: true, price: true, diff --git a/shared/models/productV2Models/productItemModels/priceItem.ts b/shared/models/productV2Models/productItemModels/priceItem.ts index a5bf44199..db075b3b2 100644 --- a/shared/models/productV2Models/productItemModels/priceItem.ts +++ b/shared/models/productV2Models/productItemModels/priceItem.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const PriceItemSchema = ProductItemSchema.pick({ price: true, interval: true, + interval_count: true, }).extend({ price: z.number().nonnegative(), }); diff --git a/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts b/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts index fea374680..59919e511 100644 --- a/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts +++ b/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts @@ -20,6 +20,7 @@ export const ProductItemResponseSchema = z.object({ included_usage: z.number().or(z.literal(Infinite)).nullish(), interval: z.nativeEnum(ProductItemInterval).nullish(), + interval_count: z.number().nullish(), // Price config price: z.number().nullish(), diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index 910cfe787..ed167c2ca 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -66,6 +66,7 @@ export const ProductItemSchema = z.object({ feature_type: z.nativeEnum(ProductItemFeatureType).nullish(), included_usage: z.union([z.number(), z.literal(Infinite)]).nullish(), interval: z.nativeEnum(ProductItemInterval).nullish(), + interval_count: z.number().nullish(), entity_feature_id: z.string().nullish(), // Price config diff --git a/shared/models/productV2Models/productResponseModels.ts b/shared/models/productV2Models/productResponseModels.ts index 375066017..401783454 100644 --- a/shared/models/productV2Models/productResponseModels.ts +++ b/shared/models/productV2Models/productResponseModels.ts @@ -31,3 +31,4 @@ export const ProductResponseSchema = z.object({ }); export type ProductResponse = z.infer; +export type ProductProperties = z.infer; diff --git a/shared/package.json b/shared/package.json index 41cac0732..05a3b7a04 100644 --- a/shared/package.json +++ b/shared/package.json @@ -19,7 +19,8 @@ "dev:bun": "bun ./index.ts --outdir dist --target bun --external zod --watch", "db:push": "bun db:generate && bun db:migrate", "db:generate": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit generate --config drizzle.config.ts", - "db:migrate": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit migrate --config drizzle.config.ts" + "db:migrate": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit migrate --config drizzle.config.ts", + "db:studio": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit studio --config drizzle.config.ts" }, "dependencies": { "date-fns": "^4.1.0", diff --git a/shared/utils/displayUtils.ts b/shared/utils/displayUtils.ts index b0b4713d1..8919b7cf3 100644 --- a/shared/utils/displayUtils.ts +++ b/shared/utils/displayUtils.ts @@ -1,6 +1,7 @@ import { Feature } from "../models/featureModels/featureModels.js"; -import { Decimal } from "decimal.js"; +import { Organization } from "../models/orgModels/orgTable.js"; import { format } from "date-fns"; +import { notNullish, nullish } from "./utils.js"; export const getFeatureName = ({ feature, plural, @@ -19,18 +20,28 @@ export const getFeatureName = ({ let featureName = feature.name || ""; if (feature.display) { - if (units && units === 1) { - plural = false; + let finalPlural; + // Case 1: If units and nullish plural + if (notNullish(units) && nullish(plural)) { + finalPlural = units !== 1; + } else if (nullish(units) && notNullish(plural)) { + finalPlural = plural; + } else { + finalPlural = plural || false; } - // if (!plural) { - // if (units && units === 1) { - // plural = false; - // } else { - // plural = true; - // } - // } - if (plural) { + // if (units && units === 1) { + // plural = false; + // } + // // if (!plural) { + // // if (units && units === 1) { + // // plural = false; + // // } else { + // // plural = true; + // // } + // // } + + if (finalPlural) { featureName = feature.display.plural || featureName; } else { featureName = feature.display.singular || featureName; @@ -130,3 +141,24 @@ export const getFeatureInvoiceDescription = ({ return result; }; + +export const formatAmount = ({ + org, + currency, + amount, + maxFractionDigits = 2, + minFractionDigits = 0, +}: { + org?: Organization; + currency?: string | null; + amount: number; + maxFractionDigits?: number; + minFractionDigits?: number; +}) => { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || org?.default_currency || "USD", + minimumFractionDigits: minFractionDigits || 0, + maximumFractionDigits: maxFractionDigits || 2, + }).format(amount); +}; diff --git a/shared/utils/featureUtils.ts b/shared/utils/featureUtils.ts new file mode 100644 index 000000000..0be2058fc --- /dev/null +++ b/shared/utils/featureUtils.ts @@ -0,0 +1,32 @@ +import { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js"; +import { FeatureType } from "../models/featureModels/featureEnums.js"; +import { Feature } from "../models/featureModels/featureModels.js"; +import { APIFeatureSchema } from "../models/featureModels/featureResModels.js"; + +export const toAPIFeature = ({ feature }: { feature: Feature }) => { + // return FeatureResponseSchema.parse(feature); + // 1. Get feature type + let featureType = feature.type; + if (feature.type == FeatureType.Metered) { + featureType = feature.config.usage_type; + } + + let creditSchema = undefined; + if (feature.type == FeatureType.CreditSystem) { + creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({ + metered_feature_id: s.metered_feature_id, + credit_cost: s.credit_amount, + })); + } + + return APIFeatureSchema.parse({ + id: feature.id, + name: feature.name, + type: featureType, + display: { + singular: feature.display?.singular || feature.name, + plural: feature.display?.plural || feature.name, + }, + credit_schema: creditSchema, + }); +}; diff --git a/shared/utils/intervalUtils.ts b/shared/utils/intervalUtils.ts new file mode 100644 index 000000000..c029082e5 --- /dev/null +++ b/shared/utils/intervalUtils.ts @@ -0,0 +1,97 @@ +import { Decimal } from "decimal.js"; +import { EntInterval } from "../models/productModels/entModels/entEnums.js"; +import { BillingInterval } from "../models/productModels/priceModels/priceEnums.js"; + +export const intervalToValue = ( + interval: BillingInterval, + intervalCount?: number | null +) => { + const intervalToBaseVal: Record = { + [BillingInterval.OneOff]: 0, + [BillingInterval.Week]: 0.25, + [BillingInterval.Month]: 1, + [BillingInterval.Quarter]: 3, + [BillingInterval.SemiAnnual]: 6, + [BillingInterval.Year]: 12, + }; + + return intervalToBaseVal[interval] * (intervalCount ?? 1); +}; + +export type IntervalConfig = { + interval: BillingInterval; + intervalCount?: number | null; +}; + +export const intervalsDifferent = ({ + intervalA, + intervalB, +}: { + intervalA: IntervalConfig; + intervalB: IntervalConfig; +}) => { + let valA = intervalToValue(intervalA.interval, intervalA.intervalCount); + let valB = intervalToValue(intervalB.interval, intervalB.intervalCount); + return valA != valB; +}; + +export const intervalsSame = ({ + intervalA, + intervalB, +}: { + intervalA: IntervalConfig; + intervalB: IntervalConfig; +}) => { + return !intervalsDifferent({ intervalA, intervalB }); +}; + +type EntIntervalConfig = { + interval: EntInterval; + intervalCount?: number | null; +}; + +export const entIntervalToValue = ( + interval?: EntInterval | null, + intervalCount?: number | null +) => { + if (!interval) { + return new Decimal(10000000); + } + + const intervalToBaseVal: Record = { + [EntInterval.Minute]: 1, + [EntInterval.Hour]: 60, + [EntInterval.Day]: 1 * 60 * 24, + [EntInterval.Week]: 1 * 60 * 24 * 7, + [EntInterval.Month]: 1 * 60 * 24 * 30, + [EntInterval.Quarter]: 1 * 60 * 24 * 90, + [EntInterval.SemiAnnual]: 1 * 60 * 24 * 180, + [EntInterval.Year]: 1 * 60 * 24 * 365, + [EntInterval.Lifetime]: 1000000000, + }; + + const baseValue = intervalToBaseVal[interval]; + return new Decimal(baseValue).mul(intervalCount ?? 1); +}; + +export const entIntervalsSame = ({ + intervalA, + intervalB, +}: { + intervalA: EntIntervalConfig; + intervalB: EntIntervalConfig; +}) => { + const valA = entIntervalToValue(intervalA.interval, intervalA.intervalCount); + const valB = entIntervalToValue(intervalB.interval, intervalB.intervalCount); + return valA.eq(valB); +}; + +export const entIntervalsDifferent = ({ + intervalA, + intervalB, +}: { + intervalA: EntIntervalConfig; + intervalB: EntIntervalConfig; +}) => { + return !entIntervalsSame({ intervalA, intervalB }); +}; diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts new file mode 100644 index 000000000..ef1d1f2f1 --- /dev/null +++ b/shared/utils/productDisplayUtils.ts @@ -0,0 +1,235 @@ +import { Feature } from "../models/featureModels/featureModels.js"; +import { Infinite } from "../models/productModels/productEnums.js"; +import { + ProductItem, + ProductItemFeatureType, + ProductItemInterval, +} from "../models/productV2Models/productItemModels/productItemModels.js"; +import { + getFeatureName, + numberWithCommas, + formatAmount, +} from "./displayUtils.js"; +import { + isFeatureItem, + isFeaturePriceItem, + isPriceItem, +} from "./productDisplayUtils/getItemType.js"; +import { notNullish, nullish } from "./utils.js"; + +export const formatTiers = ({ + item, + currency, +}: { + item: ProductItem; + currency?: string | null; +}) => { + let tiers = item.tiers; + if (tiers) { + if (tiers.length == 1) { + return formatAmount({ + currency, + amount: tiers[0].amount, + }); + } + + let firstPrice = tiers[0].amount; + let lastPrice = tiers[tiers.length - 1].amount; + + return `${formatAmount({ + currency, + amount: firstPrice, + })} - ${formatAmount({ + currency, + amount: lastPrice, + })}`; + } +}; + +export const getIntervalString = ({ + interval, + intervalCount, +}: { + interval: ProductItemInterval; + intervalCount?: number | null; +}) => { + if (!interval) return ""; + if (intervalCount == 1) { + return `per ${interval}`; + } + return `per ${intervalCount} ${interval}s`; +}; + +export const getFeatureItemDisplay = ({ + item, + feature, +}: { + item: ProductItem; + feature?: Feature; +}) => { + if (!feature) { + throw new Error(`Feature ${item.feature_id} not found`); + } + // 1. If feature + if (item.feature_type == ProductItemFeatureType.Static) { + return { + primary_text: getFeatureName({ + feature, + plural: false, + capitalize: true, + }), + }; + } + + let featureName = getFeatureName({ + feature, + units: item.included_usage, + }); + + let includedUsageTxt = + item.included_usage == Infinite + ? "Unlimited " + : nullish(item.included_usage) || item.included_usage == 0 + ? "" + : `${numberWithCommas(item.included_usage!)} `; + + return { + primary_text: `${includedUsageTxt}${featureName}`, + }; +}; + +export const getPriceItemDisplay = ({ + item, + currency, +}: { + item: ProductItem; + currency?: string | null; +}) => { + let primaryText = formatAmount({ + currency, + amount: item.price as number, + }); + + const intervalStr = getIntervalString({ + interval: item.interval!, + intervalCount: item.interval_count, + }); + + let secondaryText = intervalStr || undefined; + + return { + primary_text: primaryText, + secondary_text: secondaryText, + }; +}; + +export const getFeaturePriceItemDisplay = ({ + feature, + item, + currency, + isMainPrice = false, + minifyIncluded = false, +}: { + feature?: Feature; + item: ProductItem; + currency?: string | null; + isMainPrice?: boolean; + minifyIncluded?: boolean; +}) => { + if (!feature) { + throw new Error(`Feature ${item.feature_id} not found`); + } + + // 1. Get included usage + let includedFeatureName = getFeatureName({ + feature, + units: item.included_usage, + }); + + let includedUsage = item.included_usage as number | null; + let includedUsageStr = ""; + if (notNullish(includedUsage) && includedUsage! > 0) { + if (minifyIncluded) { + includedUsageStr = `${numberWithCommas(includedUsage!)} included`; + } else { + includedUsageStr = `${numberWithCommas(includedUsage!)} ${includedFeatureName}`; + } + } + + let priceStr = formatTiers({ item, currency }); + let billingFeatureName = getFeatureName({ + feature, + units: item.billing_units, + }); + + let priceStr2 = ""; + if (item.billing_units && item.billing_units > 1) { + priceStr2 = `${numberWithCommas(item.billing_units)} ${billingFeatureName}`; + } else { + priceStr2 = `${billingFeatureName}`; + } + + // let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : ""; + let intervalStr = isMainPrice + ? getIntervalString({ + interval: item.interval!, + intervalCount: item.interval_count, + }) + : ""; + + if (includedUsageStr) { + return { + primary_text: includedUsageStr, + secondary_text: `then ${priceStr} per ${priceStr2} ${intervalStr}`, + }; + } + + if (isMainPrice) { + return { + primary_text: priceStr, + secondary_text: `per ${priceStr2} ${intervalStr}`, + }; + } + + return { + primary_text: priceStr + ` per ${priceStr2} ${intervalStr}`, + secondary_text: "", + }; +}; + +export const getProductItemDisplay = ({ + item, + features, + currency = "usd", +}: { + item: ProductItem; + features: Feature[]; + currency?: string | null; +}) => { + if (isFeatureItem(item)) { + return getFeatureItemDisplay({ + item, + feature: features.find((f) => f.id === item.feature_id), + }); + } + + if (isPriceItem(item)) { + return getPriceItemDisplay({ + item, + currency, + }); + } + + if (isFeaturePriceItem(item)) { + return getFeaturePriceItemDisplay({ + item, + feature: features.find((f) => f.id === item.feature_id), + currency, + }); + } + + return { + primary_text: "couldn't detect item type", + secondary_text: "", + }; +}; diff --git a/shared/utils/productDisplayUtils/getItemType.ts b/shared/utils/productDisplayUtils/getItemType.ts new file mode 100644 index 000000000..f106c631e --- /dev/null +++ b/shared/utils/productDisplayUtils/getItemType.ts @@ -0,0 +1,44 @@ +import { + ProductItem, + ProductItemType, +} from "../../models/productV2Models/productItemModels/productItemModels.js"; +import { notNullish, nullish } from "../utils.js"; + +export const isBooleanFeatureItem = (item: ProductItem) => { + return ( + notNullish(item.feature_id) && + (nullish(item.price) || item.price == 0) && + nullish(item.tiers) && + nullish(item.interval) && + nullish(item.included_usage) + ); +}; + +export const isFeatureItem = (item: ProductItem) => { + return ( + notNullish(item.feature_id) && + (nullish(item.price) || item.price == 0) && + nullish(item.tiers) + ); +}; + +export const isPriceItem = (item: ProductItem) => { + return notNullish(item.price) && nullish(item.feature_id); +}; + +export const isFeaturePriceItem = (item: ProductItem) => { + return ( + notNullish(item.feature_id) && + (notNullish(item.price) || notNullish(item.tiers)) + ); +}; + +export const getItemType = (item: ProductItem) => { + if (isFeatureItem(item)) { + return ProductItemType.Feature; + } else if (isFeaturePriceItem(item)) { + return ProductItemType.FeaturePrice; + } + + return ProductItemType.Price; +}; diff --git a/shared/utils/productDisplayUtils/getProductItemRes.ts b/shared/utils/productDisplayUtils/getProductItemRes.ts new file mode 100644 index 000000000..283c604ba --- /dev/null +++ b/shared/utils/productDisplayUtils/getProductItemRes.ts @@ -0,0 +1,147 @@ +import { FeatureOptions } from "../../models/cusProductModels/cusProductModels.js"; +import { Feature } from "../../models/featureModels/featureModels.js"; +import { ProductItemResponseSchema } from "../../models/productV2Models/productItemModels/prodItemResponseModels.js"; +import { + ProductItem, + UsageModel, +} from "../../models/productV2Models/productItemModels/productItemModels.js"; +import { toAPIFeature } from "../featureUtils.js"; +import { getProductItemDisplay } from "../productDisplayUtils.js"; +import { notNullish } from "../utils.js"; +import { getItemType } from "./getItemType.js"; +import { Decimal } from "decimal.js"; + +export const calculateProrationAmount = ({ + periodEnd, + periodStart, + now, + amount, + allowNegative = false, +}: { + periodEnd: number; + periodStart: number; + now: number; + amount: number; + allowNegative?: boolean; +}) => { + const num = new Decimal(periodEnd).minus(now); + const denom = new Decimal(periodEnd).minus(periodStart); + + const proratedAmount = num.div(denom).mul(amount); + + if (proratedAmount.lte(0) && !allowNegative) { + return 0; + } + + return proratedAmount.toNumber(); +}; + +export type Proration = { + start: number; + end: number; +}; + +export const itemToPriceOrTiers = ({ + item, + proration, + now, +}: { + item: ProductItem; + proration?: Proration; + now?: number; +}) => { + now = now || Date.now(); + if (item.price) { + return { + price: proration + ? calculateProrationAmount({ + periodEnd: proration.end, + periodStart: proration.start, + now, + amount: item.price, + }) + : item.price, + tiers: undefined, + }; + } else if (item.tiers) { + if (item.tiers.length > 1) { + return { + price: undefined, + tiers: item.tiers.map((tier) => ({ + ...tier, + amount: proration + ? calculateProrationAmount({ + periodEnd: proration.end, + periodStart: proration.start, + now, + amount: tier.amount, + }) + : tier.amount, + })), + }; + } else { + return { + price: proration + ? calculateProrationAmount({ + periodEnd: proration.end, + periodStart: proration.start, + now, + amount: item.tiers[0].amount, + }) + : item.tiers[0].amount, + tiers: undefined, + }; + } + } +}; + +export const getProductItemResponse = ({ + item, + features, + currency, + withDisplay = true, + options, +}: { + item: ProductItem; + features: Feature[]; + currency?: string | null; + withDisplay?: boolean; + options?: FeatureOptions[]; +}) => { + // 1. Get item type + let type = getItemType(item); + + // 2. Get display + let display = getProductItemDisplay({ + item, + features, + currency, + }); + + let priceData = itemToPriceOrTiers({ item }); + + let quantity = undefined; + let upcomingQuantity = undefined; + + if (item.usage_model == UsageModel.Prepaid && notNullish(options)) { + let option = options!.find((o) => o.feature_id == item.feature_id); + quantity = option?.quantity + ? option?.quantity * (item.billing_units ?? 1) + : undefined; + + upcomingQuantity = option?.upcoming_quantity + ? option?.upcoming_quantity * (item.billing_units ?? 1) + : undefined; + } + + let feature = features.find((f) => f.id == item.feature_id); + return ProductItemResponseSchema.parse({ + type, + ...item, + feature: feature ? toAPIFeature({ feature }) : null, + display: withDisplay ? display : undefined, + ...priceData, + quantity, + next_cycle_quantity: upcomingQuantity, + }); +}; diff --git a/shared/utils/productDisplayUtils/sortProductItems.ts b/shared/utils/productDisplayUtils/sortProductItems.ts new file mode 100644 index 000000000..36b0b7578 --- /dev/null +++ b/shared/utils/productDisplayUtils/sortProductItems.ts @@ -0,0 +1,47 @@ +import { Feature } from "../../models/featureModels/featureModels.js"; +import { ProductItem } from "../../models/productV2Models/productItemModels/productItemModels.js"; +import { isFeaturePriceItem, isPriceItem } from "./getItemType.js"; + +export const sortProductItems = (items: ProductItem[], features: Feature[]) => { + items.sort((a, b) => { + let aIsPriceItem = isPriceItem(a); + let bIsPriceItem = isPriceItem(b); + + if (aIsPriceItem && bIsPriceItem) { + return 0; + } + + if (aIsPriceItem && !bIsPriceItem) { + return -1; + } + + if (!aIsPriceItem && bIsPriceItem) { + return 1; + } + + // 2. Put feature price next + let aIsFeatureItem = isFeaturePriceItem(a); + let bIsFeatureItem = isFeaturePriceItem(b); + + if (aIsFeatureItem && !bIsFeatureItem) { + return -1; + } + + if (!aIsFeatureItem && bIsFeatureItem) { + return 1; + } + + // 3. Put feature price items in alphabetical order + let feature = features.find((f) => f.id == a.feature_id); + let aFeatureName = feature?.name; + let bFeatureName = features.find((f) => f.id == b.feature_id)?.name; + + if (!aFeatureName || !bFeatureName) { + return 0; + } + + return aFeatureName.localeCompare(bFeatureName); + }); + + return items; +}; diff --git a/shared/utils/productUtils.ts b/shared/utils/productUtils.ts new file mode 100644 index 000000000..1d9381f3d --- /dev/null +++ b/shared/utils/productUtils.ts @@ -0,0 +1,85 @@ +import { + BillingType, + Price, + ProductItem, + ProductResponse, + ProductV2, +} from "@autumn/shared"; +import { nullish } from "./utils.js"; +import { + isFeatureItem, + isFeaturePriceItem, + isPriceItem, +} from "./productDisplayUtils/getItemType.js"; +import { Decimal } from "decimal.js"; + +export const isFreeProductV2 = ({ items }: { items: ProductItem[] }) => { + return items.every((item) => nullish(item.price) && nullish(item.tiers)); +}; + +export const isProductUpgradeV2 = ({ + items1, + items2, +}: { + items1: ProductItem[]; + items2: ProductItem[]; +}) => { + if ( + !isFreeProductV2({ items: items1 }) && + isFreeProductV2({ items: items2 }) + ) { + return false; + } + + const prices1 = items1.filter( + (item) => isFeaturePriceItem(item) || isPriceItem(item) + ); + const prices2 = items2.filter( + (item) => isFeaturePriceItem(item) || isPriceItem(item) + ); + + // 2. Get total price for each product + const getTotalPrice = (items: ProductItem[]) => { + let totalPrice = new Decimal(0); + for (const item of items) { + if (item.price) totalPrice = totalPrice.plus(item.price); + if (item.tiers) { + let tierTotal = item.tiers.reduce( + (acc, tier) => acc.plus(tier.amount), + new Decimal(0) + ); + totalPrice = totalPrice.plus(tierTotal); + } + } + return totalPrice.toNumber(); + }; + + // Return interval...? + + return getTotalPrice(prices1) < getTotalPrice(prices2); +}; + +export const sortProductsV2 = ({ products }: { products: ProductV2[] }) => { + products.sort((a, b) => { + if (a.is_add_on !== b.is_add_on) { + return a.is_add_on ? 1 : -1; + } + + return 0; + }); + + return products.sort((a, b) => { + // Secondary sort: by add-on status (non-add-ons first) + if (a.is_add_on !== b.is_add_on) { + return a.is_add_on ? 1 : -1; + } + + // Primary sort: by price (using upgrade logic) + let isUpgrade = isProductUpgradeV2({ + items1: a.items, + items2: b.items, + }); + + return isUpgrade ? -1 : 1; + }); +}; diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index d954eec82..5f8b96b3a 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -1 +1,2 @@ export const notNullish = (value: any) => value !== null && value !== undefined; +export const nullish = (value: any) => value === null || value === undefined; diff --git a/vite/package.json b/vite/package.json index 3705d051e..79f7a6bf4 100644 --- a/vite/package.json +++ b/vite/package.json @@ -21,7 +21,7 @@ "@clerk/clerk-react": "^5.24.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", - "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -41,7 +41,7 @@ "ag-charts-community": "^12.0.2", "ag-grid-community": "^34.0.2", "ag-grid-react": "^34.0.2", - "autumn-js": "^0.0.93", + "autumn-js": "^0.1.8", "axios": "^1.8.3", "better-auth": "^1.2.9", "class-variance-authority": "^0.7.1", diff --git a/vite/public/claude.png b/vite/public/claude.png new file mode 100644 index 000000000..ed8571cab Binary files /dev/null and b/vite/public/claude.png differ diff --git a/vite/public/cursor.png b/vite/public/cursor.png new file mode 100644 index 000000000..85ae6f0ad Binary files /dev/null and b/vite/public/cursor.png differ diff --git a/vite/public/frameworks/Symbol Alternative.svg b/vite/public/frameworks/Symbol Alternative.svg new file mode 100644 index 000000000..cf7f77fb1 --- /dev/null +++ b/vite/public/frameworks/Symbol Alternative.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vite/public/frameworks/better-auth.png b/vite/public/frameworks/better-auth.png new file mode 100644 index 000000000..eefc8c85f Binary files /dev/null and b/vite/public/frameworks/better-auth.png differ diff --git a/vite/public/frameworks/clerk.png b/vite/public/frameworks/clerk.png new file mode 100644 index 000000000..919f9a97b Binary files /dev/null and b/vite/public/frameworks/clerk.png differ diff --git a/vite/public/frameworks/elysia.png b/vite/public/frameworks/elysia.png new file mode 100644 index 000000000..ad6210c51 Binary files /dev/null and b/vite/public/frameworks/elysia.png differ diff --git a/vite/public/frameworks/express.png b/vite/public/frameworks/express.png new file mode 100644 index 000000000..b748de7b4 Binary files /dev/null and b/vite/public/frameworks/express.png differ diff --git a/vite/public/frameworks/hono.png b/vite/public/frameworks/hono.png new file mode 100644 index 000000000..2c633ea06 Binary files /dev/null and b/vite/public/frameworks/hono.png differ diff --git a/vite/public/frameworks/nextjs.png b/vite/public/frameworks/nextjs.png new file mode 100644 index 000000000..a54c98329 Binary files /dev/null and b/vite/public/frameworks/nextjs.png differ diff --git a/vite/public/frameworks/react-router.svg b/vite/public/frameworks/react-router.svg new file mode 100644 index 000000000..a5c5dacca --- /dev/null +++ b/vite/public/frameworks/react-router.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/vite/public/frameworks/react.png b/vite/public/frameworks/react.png new file mode 100644 index 000000000..8bde5d08a Binary files /dev/null and b/vite/public/frameworks/react.png differ diff --git a/vite/public/frameworks/supabase.png b/vite/public/frameworks/supabase.png new file mode 100644 index 000000000..a5c9e1cff Binary files /dev/null and b/vite/public/frameworks/supabase.png differ diff --git a/vite/public/frameworks/vite.svg b/vite/public/frameworks/vite.svg new file mode 100644 index 000000000..de4aeddc1 --- /dev/null +++ b/vite/public/frameworks/vite.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/vite/src/App.tsx b/vite/src/App.tsx index 51fca3a15..3ad576dba 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -19,6 +19,7 @@ import { PasswordSignIn } from "./views/auth/components/PasswordSignIn"; import { Otp } from "./views/cli/Otp"; import { AnalyticsView } from "./views/customers/customer/analytics/AnalyticsView"; import { TerminalView } from "./views/TerminalView"; +import OnboardingView2 from "./views/onboarding2/OnboardingView2"; export default function App() { return ( @@ -32,8 +33,10 @@ export default function App() { } /> } /> - } /> - } /> + } /> + } /> + {/* } /> + } /> */} } /> {/* FEATURES */} +
- - - -
- -
+ + + + +
); diff --git a/vite/src/components/autumn/checkout-dialog.tsx b/vite/src/components/autumn/checkout-dialog.tsx new file mode 100644 index 000000000..5a90d5b8e --- /dev/null +++ b/vite/src/components/autumn/checkout-dialog.tsx @@ -0,0 +1,460 @@ +"use client"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import React, { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; +import { getCheckoutContent } from "@/lib/autumn/checkout-content"; +import { useCustomer } from "autumn-js/react"; +import { ArrowRight, ChevronDown, Loader2 } from "lucide-react"; +import type { CheckoutResult, ProductItem } from "autumn-js"; +import { + Accordion, + AccordionContent, + AccordionItem, +} from "@/components/ui/accordion"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { useModelPricingContext } from "@/views/onboarding2/model-pricing/ModelPricingContext"; + +export interface CheckoutDialogProps { + open: boolean; + setOpen: (open: boolean) => void; + checkoutResult: CheckoutResult; +} + +const formatCurrency = ({ + amount, + currency, +}: { + amount: number; + currency: string; +}) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency, + }).format(amount); +}; + +export default function CheckoutDialog(params: CheckoutDialogProps) { + const { attach } = useCustomer(); + const [checkoutResult, setCheckoutResult] = useState< + CheckoutResult | undefined + >(params?.checkoutResult); + + const { mutateAutumnProducts } = useModelPricingContext(); + + useEffect(() => { + if (params.checkoutResult) { + setCheckoutResult(params.checkoutResult); + } + }, [params.checkoutResult]); + + const [loading, setLoading] = useState(false); + + if (!checkoutResult) { + return <>; + } + + const { open, setOpen } = params; + const { title, message } = getCheckoutContent(checkoutResult); + + const isFree = checkoutResult?.product.properties?.is_free; + const isPaid = isFree === false; + + return ( + + + {title} +
{message}
+ + {isPaid && checkoutResult && ( + + )} + + + + +
+
+ ); +} + +function PriceInformation({ + checkoutResult, + setCheckoutResult, +}: { + checkoutResult: CheckoutResult; + setCheckoutResult: (checkoutResult: CheckoutResult) => void; +}) { + return ( +
+ + +
+ {checkoutResult?.has_prorations && checkoutResult.lines.length > 0 && ( + + )} + +
+
+ ); +} + +function DueAmounts({ checkoutResult }: { checkoutResult: CheckoutResult }) { + const { next_cycle, product } = checkoutResult; + const nextCycleAtStr = next_cycle + ? new Date(next_cycle.starts_at).toLocaleDateString() + : undefined; + + const hasUsagePrice = product.items.some( + (item) => item.usage_model === "pay_per_use" + ); + + const showNextCycle = next_cycle && next_cycle.total !== checkoutResult.total; + + return ( +
+
+
+

Total due today

+
+ +

+ {formatCurrency({ + amount: checkoutResult?.total, + currency: checkoutResult?.currency, + })} +

+
+ {showNextCycle && ( +
+
+

Due next cycle ({nextCycleAtStr})

+
+

+ {formatCurrency({ + amount: next_cycle.total, + currency: checkoutResult?.currency, + })} + {hasUsagePrice && + usage prices} +

+
+ )} +
+ ); +} + +function ProductItems({ + checkoutResult, + setCheckoutResult, +}: { + checkoutResult: CheckoutResult; + setCheckoutResult: (checkoutResult: CheckoutResult) => void; +}) { + const isUpdateQuantity = + checkoutResult?.product.scenario === "active" && + checkoutResult.product.properties.updateable; + return ( +
+

Price

+ {checkoutResult?.product.items + .filter((item) => item.type !== "feature") + .map((item, index) => { + if (item.usage_model == "prepaid") { + return ( + + ); + } + + if (isUpdateQuantity) { + return null; + } + + return ( +
+

+ {item.feature ? item.feature.name : "Subscription"} +

+

+ {item.display?.primary_text} {item.display?.secondary_text} +

+
+ ); + })} +
+ ); +} + +function CheckoutLines({ checkoutResult }: { checkoutResult: CheckoutResult }) { + return ( + + + +
+

View details

+ +
+
+ + {checkoutResult?.lines + .filter((line) => line.amount != 0) + .map((line, index) => { + return ( +
+

{line.description}

+

+ {new Intl.NumberFormat("en-US", { + style: "currency", + currency: checkoutResult?.currency, + }).format(line.amount)} +

+
+ ); + })} +
+
+
+ ); +} + +function CustomAccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + ); +} + +const PrepaidItem = ({ + item, + checkoutResult, + setCheckoutResult, +}: { + item: ProductItem; + checkoutResult: CheckoutResult; + setCheckoutResult: (checkoutResult: CheckoutResult) => void; +}) => { + const { quantity = 0, billing_units: billingUnits = 1 } = item; + const [quantityInput, setQuantityInput] = useState( + (quantity / billingUnits).toString() + ); + const { checkout } = useCustomer(); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const scenario = checkoutResult.product.scenario; + + const handleSave = async () => { + setLoading(true); + try { + const newOptions = checkoutResult.options + .filter((option) => option.feature_id !== item.feature_id) + .map((option) => { + return { + featureId: option.feature_id, + quantity: option.quantity, + }; + }); + + newOptions.push({ + featureId: item.feature_id!, + quantity: Number(quantityInput) * billingUnits, + }); + + const { data, error } = await checkout({ + productId: checkoutResult.product.id, + options: newOptions, + dialog: CheckoutDialog, + openInNewTab: true, + }); + + if (error) { + console.error(error); + return; + } + setCheckoutResult(data!); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + setOpen(false); + } + }; + + const disableSelection = scenario === "renew"; + + return ( +
+
+

{item.feature?.name}

+ + + Qty: {quantity} + + + +
+

{item.feature?.name}

+

+ {item.display?.primary_text} {item.display?.secondary_text} +

+
+ +
+
+ setQuantityInput(e.target.value)} + /> +

+ {billingUnits > 1 && `x ${billingUnits} `} + {item.feature?.name} +

+
+ + +
+
+
+
+

+ {item.display?.primary_text} {item.display?.secondary_text} +

+
+ ); +}; + +export const PriceItem = ({ + children, + className, + ...props +}: { + children: React.ReactNode; + className?: string; +} & React.HTMLAttributes) => { + return ( +
+ {children} +
+ ); +}; + +export const PricingDialogButton = ({ + children, + size, + onClick, + disabled, + className, +}: { + children: React.ReactNode; + size?: "sm" | "lg" | "default" | "icon"; + onClick: () => void; + disabled?: boolean; + className?: string; +}) => { + return ( + + ); +}; diff --git a/vite/src/components/autumn/pricing-table.tsx b/vite/src/components/autumn/pricing-table.tsx index 7517353fd..a74d3115f 100644 --- a/vite/src/components/autumn/pricing-table.tsx +++ b/vite/src/components/autumn/pricing-table.tsx @@ -1,34 +1,29 @@ import React from "react"; -import { useCustomer, usePricingTable } from "autumn-js/react"; + import { createContext, useContext, useState } from "react"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; -import { Check, Loader2 } from "lucide-react"; -import AttachDialog from "@/components/autumn/attach-dialog"; +import CheckoutDialog from "@/components/autumn/checkout-dialog"; import { getPricingTableContent } from "@/lib/autumn/pricing-table-content"; -import { Product, ProductItem } from "autumn-js"; +import type { Product, ProductItem } from "autumn-js"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { useCustomer } from "autumn-js/react"; +import { useModelPricingContext } from "@/views/onboarding2/model-pricing/ModelPricingContext"; export default function PricingTable({ - productDetails, + products, + stripeConnected, + setConnectStripeOpen, }: { - productDetails?: any; + products?: Product[]; + stripeConnected: boolean; + setConnectStripeOpen: (open: boolean) => void; }) { - const { attach } = useCustomer(); + const { mutateAutumnProducts } = useModelPricingContext(); + const { checkout } = useCustomer(); const [isAnnual, setIsAnnual] = useState(false); - const { products, isLoading, error } = usePricingTable({ productDetails }); - - if (isLoading) { - return ( -
- -
- ); - } - - if (error) { - return
Something went wrong...
; - } const intervals = Array.from( new Set( @@ -38,7 +33,7 @@ export default function PricingTable({ const multiInterval = intervals.length > 1; - const intervalFilter = (product: any) => { + const intervalFilter = (product: Product) => { if (!product.properties?.interval_group) { return true; } @@ -55,10 +50,10 @@ export default function PricingTable({ }; return ( -
+
{products && ( { + console.log("Inside onClick function"); + if (!stripeConnected) { + setConnectStripeOpen(true); + return; + } + if (product.id) { - await attach({ + const result = await checkout({ productId: product.id, - dialog: AttachDialog, + dialog: CheckoutDialog, openInNewTab: true, - successUrl: window.location.href, + successUrl: `${window.location.origin}`, }); } else if (product.display?.button_url) { window.open(product.display?.button_url, "_blank"); @@ -162,7 +165,8 @@ export const PricingTableContainer = ({ )}
@@ -194,9 +198,10 @@ export const PricingCard = ({ throw new Error(`Product with id ${productId} not found`); } - const { name, display: productDisplay, items } = product; + const { name, display: productDisplay } = product; const { buttonText } = getPricingTableContent(product); + const isRecommended = productDisplay?.recommend_text ? true : false; const mainPriceDisplay = product.properties?.is_free ? { @@ -211,7 +216,7 @@ export const PricingCard = ({ return (
-

- {productDisplay?.name || name} +

+ {productDisplay?.name || name || ( + + Name this product + + )}

{productDisplay?.description && (
@@ -239,7 +248,7 @@ export const PricingCard = ({ )}
-

+

{mainPriceDisplay?.primary_text}{" "} {mainPriceDisplay?.secondary_text && ( @@ -255,7 +264,6 @@ export const PricingCard = ({
@@ -266,7 +274,7 @@ export const PricingCard = ({ recommended={productDisplay?.recommend_text ? true : false} {...buttonProps} > - {buttonText} + {productDisplay?.button_text || buttonText}

@@ -277,12 +285,10 @@ export const PricingCard = ({ // Pricing Feature List export const PricingFeatureList = ({ items, - showIcon = true, everythingFrom, className, }: { items: ProductItem[]; - showIcon?: boolean; everythingFrom?: string; className?: string; }) => { @@ -293,10 +299,7 @@ export const PricingFeatureList = ({ )}
{items.map((item, index) => ( -
- {showIcon && ( - - )} +
{item.display?.primary_text} {item.display?.secondary_text && ( diff --git a/vite/src/components/general/OnboardingStep.tsx b/vite/src/components/general/OnboardingStep.tsx index eb9bc4999..7bb51faa1 100644 --- a/vite/src/components/general/OnboardingStep.tsx +++ b/vite/src/components/general/OnboardingStep.tsx @@ -15,7 +15,7 @@ function Step({ title, children, className, description, number }: StepProps) {
diff --git a/vite/src/components/general/PageSectionHeader.tsx b/vite/src/components/general/PageSectionHeader.tsx index 98d925b63..03e863e8d 100644 --- a/vite/src/components/general/PageSectionHeader.tsx +++ b/vite/src/components/general/PageSectionHeader.tsx @@ -8,6 +8,7 @@ export const PageSectionHeader = ({ addButton, className, classNames, + menuComponent, }: { title?: string; titleComponent?: React.ReactNode; @@ -15,16 +16,18 @@ export const PageSectionHeader = ({ isOnboarding?: boolean; addButton?: React.ReactNode; className?: string; - classNames?: { + classNames?: { title?: string; }; + menuComponent?: React.ReactNode; }) => { return (
@@ -35,8 +38,15 @@ export const PageSectionHeader = ({ )} {titleComponent}
- {endContent} - {addButton &&
{addButton}
} +
+ {endContent} + {addButton && ( +
{addButton}
+ )} + {menuComponent && ( +
{menuComponent}
+ )} +
); }; diff --git a/vite/src/components/general/ToggleButton.tsx b/vite/src/components/general/ToggleButton.tsx index ce6cf2eb1..369f1698c 100644 --- a/vite/src/components/general/ToggleButton.tsx +++ b/vite/src/components/general/ToggleButton.tsx @@ -26,7 +26,11 @@ export const ToggleButton = ({ - - - Create Credit System - - + + + + Create Credit System + + + - + - - + + + {/* + + */} ); } diff --git a/vite/src/views/credits/CreditSystemConfig.tsx b/vite/src/views/credits/CreditSystemConfig.tsx index be36bb027..99969e781 100644 --- a/vite/src/views/credits/CreditSystemConfig.tsx +++ b/vite/src/views/credits/CreditSystemConfig.tsx @@ -38,7 +38,7 @@ function CreditSystemConfig({ : { name: "", id: "", - }, + } ); const [idChanged, setIdChanged] = useState(creditSystem.name !== ""); const [creditSystemConfig, setCreditSystemConfig] = useState( @@ -52,7 +52,7 @@ function CreditSystemConfig({ credit_amount: 0, }, ], - }, + } ); const handleSchemaChange = (index: number, key: string, value: any) => { @@ -89,8 +89,8 @@ function CreditSystemConfig({ }, [fields, creditSystemConfig]); return ( -
-
+
+
Name
-
+
Metered Feature Credit Amount
-
+
{creditSystemConfig.schema.map((item: any, index: number) => ( - -
-
+
+
+
-
+
@@ -185,7 +189,7 @@ function CreditSystemConfig({
- +
))}
@@ -197,7 +201,7 @@ function CreditSystemConfig({ disabled={ creditSystemConfig.schema.length == features.filter( - (feature: Feature) => feature.type === FeatureType.Metered, + (feature: Feature) => feature.type === FeatureType.Metered ).length } > diff --git a/vite/src/views/credits/UpdateCreditSystem.tsx b/vite/src/views/credits/UpdateCreditSystem.tsx index e17d21105..eba2895b6 100644 --- a/vite/src/views/credits/UpdateCreditSystem.tsx +++ b/vite/src/views/credits/UpdateCreditSystem.tsx @@ -13,6 +13,12 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; import { toast } from "sonner"; import { useFeaturesContext } from "../features/FeaturesContext"; import { validateCreditSystem } from "./CreateCreditSystem"; +import { + CustomDialogBody, + CustomDialogContent, + CustomDialogFooter, +} from "@/components/general/modal-components/DialogContentWrapper"; +import { getBackendErr } from "@/utils/genUtils"; function UpdateCreditSystem({ open, @@ -43,36 +49,38 @@ function UpdateCreditSystem({ selectedCreditSystem.id, { ...selectedCreditSystem, - }, + } ); await mutate(); setOpen(false); } catch (error) { - toast.error("Failed to update credit system"); + toast.error(getBackendErr(error, "Failed to update credit system")); } setUpdateLoading(false); }; return ( - - Update Credit System + + + Update Credit System - + + - + - - + + ); } diff --git a/vite/src/views/customers/CreateCustomer.tsx b/vite/src/views/customers/CreateCustomer.tsx index e985a87f5..0e1da0682 100644 --- a/vite/src/views/customers/CreateCustomer.tsx +++ b/vite/src/views/customers/CreateCustomer.tsx @@ -61,10 +61,10 @@ function CreateCustomer() { return ( - + - -
+ ); }; @@ -147,20 +142,38 @@ export const CreateFeatureDialog = () => { return ( - + - - - Create Feature - - {}} - setSelectedFeature={() => {}} - setOpen={setOpen} - open={open} - /> - + + + ); }; + +// +// +// Create Feature +// +// {/* {}} +// setSelectedFeature={() => {}} +// setOpen={setOpen} +// open={open} +// /> */} +// +{ + /* + + */ +} diff --git a/vite/src/views/features/UpdateFeature.tsx b/vite/src/views/features/UpdateFeature.tsx index 64ab8f518..ac3610606 100644 --- a/vite/src/views/features/UpdateFeature.tsx +++ b/vite/src/views/features/UpdateFeature.tsx @@ -13,6 +13,12 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; import { toast } from "sonner"; import { FeatureType } from "@autumn/shared"; import { getBackendErr } from "@/utils/genUtils"; +import { + CustomDialogBody, + CustomDialogContent, + CustomDialogFooter, +} from "@/components/general/modal-components/DialogContentWrapper"; +import { CircleArrowUp, Save } from "lucide-react"; export default function UpdateFeature({ open, @@ -80,6 +86,36 @@ export default function UpdateFeature({ setUpdateLoading(false); }; + return ( + + + + Update Feature + + + + + + + + + ); + return ( diff --git a/vite/src/views/features/components/CreateFeatureFooter.tsx b/vite/src/views/features/components/CreateFeatureFooter.tsx new file mode 100644 index 000000000..f13c2c99b --- /dev/null +++ b/vite/src/views/features/components/CreateFeatureFooter.tsx @@ -0,0 +1,41 @@ +import { CustomDialogFooter } from "@/components/general/modal-components/DialogContentWrapper"; +import { Button } from "@/components/ui/button"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { ArrowLeft } from "lucide-react"; +import { useState } from "react"; + +export const CreateFeatureFooter = ({ + handleBack, + handleCreate, +}: { + handleBack?: () => void; + handleCreate: () => Promise; +}) => { + const [loading, setLoading] = useState(false); + return ( + + {handleBack ? ( + + ) : ( +
+ )} + +
+ ); +}; diff --git a/vite/src/views/features/hooks/useFeatureDialogState.tsx b/vite/src/views/features/hooks/useFeatureDialogState.tsx new file mode 100644 index 000000000..b5cc66a1d --- /dev/null +++ b/vite/src/views/features/hooks/useFeatureDialogState.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; +import { getDefaultFeature } from "../utils/defaultFeature"; + +export const useFeatureDialogState = ({ + entityCreate, +}: { + entityCreate?: boolean; +}) => { + const [feature, setFeature] = useState(getDefaultFeature(entityCreate)); + const [eventNameInput, setEventNameInput] = useState(""); + const [eventNameChanged, setEventNameChanged] = useState(true); + + return { + feature, + setFeature, + eventNameInput, + setEventNameInput, + eventNameChanged, + setEventNameChanged, + }; +}; diff --git a/vite/src/views/features/metered-features/FeatureConfig.tsx b/vite/src/views/features/metered-features/FeatureConfig.tsx index bc2684e0d..f81004a13 100644 --- a/vite/src/views/features/metered-features/FeatureConfig.tsx +++ b/vite/src/views/features/metered-features/FeatureConfig.tsx @@ -167,7 +167,7 @@ export function FeatureConfig({
Name { const newFields: any = { ...fields, name: e.target.value }; diff --git a/vite/src/views/features/utils/defaultFeature.ts b/vite/src/views/features/utils/defaultFeature.ts new file mode 100644 index 000000000..f0dfbf411 --- /dev/null +++ b/vite/src/views/features/utils/defaultFeature.ts @@ -0,0 +1,21 @@ +import { CreateFeature, FeatureType, FeatureUsageType } from "@autumn/shared"; + +export const getDefaultFeature = (entityCreate?: boolean): CreateFeature => { + return { + type: FeatureType.Metered, + config: { + filters: [ + { + property: "", + operator: "", + value: [], + }, + ], + usage_type: entityCreate + ? FeatureUsageType.Continuous + : FeatureUsageType.Single, + }, + name: "", + id: "", + }; +}; diff --git a/vite/src/views/onboarding/components/CodeBlock.tsx b/vite/src/views/onboarding/components/CodeBlock.tsx index 6d0ec3527..7b36ef44d 100644 --- a/vite/src/views/onboarding/components/CodeBlock.tsx +++ b/vite/src/views/onboarding/components/CodeBlock.tsx @@ -72,7 +72,7 @@ const CodeBlock = ({ snippets, className }: CodeBlockProps) => {
-
+
 `// app/page.tsx
 
 import { useCustomer } from "autumn-js/react";
 
 export default function CheckAccess() {
-  const { customer, allowed } = useCustomer();
+  const { customer, check } = useCustomer();
 
   const handleCheckAccess = () => {
+    const { data } = check({ ${isProduct ? "productId" : "featureId"}: "${id}" });
+    
     ${
       isProduct
         ? `// Check if customer has an active product
-    if (allowed({ productId: "${id}" })) {
+    if (data?.allowed) {
       alert("You have access to ${id}");
     } else {
       alert("You don't have access to ${id}");
     }`
         : `// Check feature balance
-    if (allowed({ featureId: "${id}" });) {
-      alert("You have access to ${id}. Balance: " + feature.balance);
+    if (data?.allowed) {
+      alert("You have access to ${id}. Balance: " + data?.balance);
     } else {
       alert("You don't have access to ${id}");
     }`
@@ -50,7 +52,7 @@ export default function CheckAccess() {
 const checkAccessCodeTypescript = (
   apiKey: string,
   id: string,
-  isProduct: boolean,
+  isProduct: boolean
 ) => `import { Autumn } from "autumn-js";
 
 const autumn = new Autumn({ secretKey: "am_sk_..." });
@@ -68,7 +70,7 @@ if (!data?.allowed) {
 const usageEventCode = (
   apiKey: string,
   id: string,
-  isProduct: boolean,
+  isProduct: boolean
 ) => `import { Autumn } from "autumn-js";
 
 const autumn = new Autumn({ secretKey: "am_sk_..." });
@@ -93,10 +95,10 @@ export default function CheckAccessStep({
 }) {
   const [isProduct, setIsProduct] = useState(true);
   const [selectedFeature, setSelectedFeature] = useState(
-    features.length > 0 ? features[0] : undefined,
+    features.length > 0 ? features[0] : undefined
   );
   const [selectedProductId, setSelectedProductId] = useState(
-    products.length > 0 ? products[0].id! : "",
+    products.length > 0 ? products[0].id! : ""
   );
 
   const selectedId = isProduct ? selectedProductId : selectedFeature?.id || "";
diff --git a/vite/src/views/onboarding/onboarding-steps/ConnectStripe.tsx b/vite/src/views/onboarding/onboarding-steps/ConnectStripe.tsx
index 1f7d3e2a0..0c0aded5d 100644
--- a/vite/src/views/onboarding/onboarding-steps/ConnectStripe.tsx
+++ b/vite/src/views/onboarding/onboarding-steps/ConnectStripe.tsx
@@ -46,7 +46,7 @@ export const ConnectStripeStep = ({
   };
 
   // console.log("productData", productData);
-  let stripeConnected = productData?.org.stripe_connected;
+  const stripeConnected = productData?.org.stripe_connected;
   return (
      void;
   number: number;
 }) => {
-  let env = useEnv();
-  let [apiKeyName, setApiKeyName] = useState("");
-  let [apiCreated, setApiCreated] = useState(false);
+  const env = useEnv();
+  const [apiKeyName, setApiKeyName] = useState("");
+  const [apiCreated, setApiCreated] = useState(false);
 
   const [loading, setLoading] = useState(false);
   const [copied, setCopied] = useState(false);
-  let axiosInstance = useAxiosInstance({ env });
+  const axiosInstance = useAxiosInstance({ env });
 
   const handleCreate = async () => {
     console.log("creating api key", apiKeyName ? apiKeyName : name);
diff --git a/vite/src/views/onboarding/onboarding-steps/ProductList.tsx b/vite/src/views/onboarding/onboarding-steps/ProductList.tsx
index ed6664a8b..f100692cd 100644
--- a/vite/src/views/onboarding/onboarding-steps/ProductList.tsx
+++ b/vite/src/views/onboarding/onboarding-steps/ProductList.tsx
@@ -134,7 +134,7 @@ export const ProductList = ({
   );
 };
 
-const EditProductDialog = ({
+export const EditProductDialog = ({
   product,
   features,
   setProduct,
diff --git a/vite/src/views/onboarding2/AttachProduct.tsx b/vite/src/views/onboarding2/AttachProduct.tsx
new file mode 100644
index 000000000..eefb6850b
--- /dev/null
+++ b/vite/src/views/onboarding2/AttachProduct.tsx
@@ -0,0 +1,99 @@
+import Step from "./Step";
+import CodeBlock from "../onboarding/components/CodeBlock";
+import { ArrowUpRightFromSquare } from "lucide-react";
+import { Product } from "@autumn/shared";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { useState } from "react";
+
+const attachCodeNextjs = (productId: string, apiKey: string) => {
+  return `import { useCustomer } from 'autumn-js/react';
+
+export default function PurchaseButton() {
+  const { attach } = useCustomer();
+
+  return (
+    
+  );
+}
+`;
+};
+
+export default function AttachProduct({
+  products,
+  apiKey,
+  number,
+}: {
+  products: Product[];
+  apiKey: string;
+  number: number;
+}) {
+  const [selectedProductId, setSelectedProductId] = useState(
+    products.length > 0 ? products[0].id! : ""
+  );
+
+  return (
+    
+          

+ The{" "} + + + /attach + + + {" "} + endpoint will return a Stripe Checkout URL. Once paid, the user will + be granted access to the features you defined above. +

+
+ +
+
+ } + > + + + ); +} diff --git a/vite/src/views/onboarding2/AutumnProvider.tsx b/vite/src/views/onboarding2/AutumnProvider.tsx new file mode 100644 index 000000000..9ccf8922f --- /dev/null +++ b/vite/src/views/onboarding2/AutumnProvider.tsx @@ -0,0 +1,99 @@ +import Step from "./Step"; +import CodeBlock from "../onboarding/components/CodeBlock"; +import { ArrowUpRightFromSquare } from "lucide-react"; + +const nextjs = () => { + return `// app/layout.tsx +import { AutumnProvider } from "autumn-js/react"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode, +}) { + return ( + + + + {children} + + + + ); +} +`; +}; +const vite = () => { + return `// main.tsx +import { AutumnProvider } from "autumn-js/react"; + +createRoot(document.getElementById("root")!).render( + // backendUrl is the URL of your server (eg. hono) + + + +); +`; +}; +const reactRouter = () => { + return `// root.tsx +import { AutumnProvider } from "autumn-js/react"; +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + {children} + + + + + + ); +} +`; +}; + +export default function AutumnProviderStep({ number }: { number: number }) { + return ( + + Wrap your root layout with the AutumnProvider component, and pass in + your backend URL. Works with any React framework. +

+ } + > + +
+ ); +} diff --git a/vite/src/views/onboarding2/ConnectStripeDialog.tsx b/vite/src/views/onboarding2/ConnectStripeDialog.tsx new file mode 100644 index 000000000..236c5c55f --- /dev/null +++ b/vite/src/views/onboarding2/ConnectStripeDialog.tsx @@ -0,0 +1,79 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useModelPricingContext } from "./model-pricing/ModelPricingContext"; +import { ConnectStripeStep } from "./integrate/ConnectStripeStep"; +import { + CustomDialogBody, + CustomDialogContent, + CustomDialogFooter, +} from "@/components/general/modal-components/DialogContentWrapper"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { connectStripe } from "./utils/connectStripe"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export default function ConnectStripeDialog({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + const { stripeConnected, mutate, productDataState, data } = + useModelPricingContext(); + const axiosInstance = useAxiosInstance(); + + const [testApiKey, setTestApiKey] = useState(""); + const [loading, setLoading] = useState(false); + const handleConnectStripe = async () => { + setLoading(true); + await connectStripe({ testApiKey, axiosInstance, mutate }); + setOpen(false); + setLoading(false); + }; + + return ( + + + + + Connect your Stripe account + +

+ To add a product to a customer, first connect your Stripe account. + Grab your secret key{" "} + + here + +

+ {/* */} + setTestApiKey(e.target.value)} + disabled={stripeConnected} + /> +
+ + + +
+
+ ); +} diff --git a/vite/src/views/onboarding2/Env.tsx b/vite/src/views/onboarding2/Env.tsx new file mode 100644 index 000000000..265e8d851 --- /dev/null +++ b/vite/src/views/onboarding2/Env.tsx @@ -0,0 +1,24 @@ +import { CodeDisplay } from "@/components/general/CodeDisplay"; +import Step from "@/components/general/OnboardingStep"; + +import CodeBlock from "../onboarding/components/CodeBlock"; +import { ArrowUpRightFromSquare } from "lucide-react"; + +const envCode = `AUTUMN_SECRET_KEY=am_sk_1234567890`; + +export default function EnvStep() { + return ( +
+ +
+ ); +} diff --git a/vite/src/views/onboarding2/Install.tsx b/vite/src/views/onboarding2/Install.tsx new file mode 100644 index 000000000..fc217d2d4 --- /dev/null +++ b/vite/src/views/onboarding2/Install.tsx @@ -0,0 +1,37 @@ +import { CodeDisplay } from "@/components/general/CodeDisplay"; +import Step from "@/components/general/OnboardingStep"; + +import CodeBlock from "../onboarding/components/CodeBlock"; +import { ArrowUpRightFromSquare } from "lucide-react"; + +const installCode = `npm install autumn-js`; +const installCodePnpm = `pnpm install autumn-js`; +const installCodeYarn = `yarn add autumn-js`; +export default function Install() { + return ( +
+ +
+ ); +} diff --git a/vite/src/views/onboarding2/MountHandler.tsx b/vite/src/views/onboarding2/MountHandler.tsx new file mode 100644 index 000000000..aec3b98f2 --- /dev/null +++ b/vite/src/views/onboarding2/MountHandler.tsx @@ -0,0 +1,193 @@ +import Step from "./Step"; +import CodeBlock from "../onboarding/components/CodeBlock"; +import { ArrowUpRightFromSquare } from "lucide-react"; + +const nextjs = () => { + return `// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@/lib/auth"; + +export const { GET, POST } = autumnHandler({ + identify: async (request) => { + return { + customerId: "demo_user_id", // your internal customer id + customerData: { + name: "John Doe", + email: "john.doe@example.com", + }, + }; + }, +}); +`; +}; + +const remix = () => { + return `// app/routes/api.autumn.$.ts + +import { autumnHandler } from "autumn-js/remix"; +import { auth } from "../lib/auth.server"; + +export const { loader, action } = autumnHandler({ + identify: async (args) => { + return { + customerId: "demo_user_id", // your internal customer id + customerData: { + name: "John Doe", + email: "john.doe@example.com", + }, + }; + }, +}); +`; +}; + +const Tanstack = () => { + return `// routes/api/autumn.$.ts + +import { createAPIFileRoute } from "@tanstack/react-start/api"; +import { auth } from "~/lib/auth"; +import { autumnHandler } from "autumn-js/tanstack"; + +const handler = autumnHandler({ + identify: async ({ request }) => { + return { + customerId: "demo_user_id", // your internal customer id + customerData: { + name: "John Doe", + email: "john.doe@example.com", + }, + }; + }, +}); + +export const APIRoute = createAPIFileRoute("/api/autumn/$")(handler); +`; +}; + +const hono = () => { + return `//index.ts + +import { autumnHandler } from "autumn-js/hono"; + +app.use( + "/api/autumn/*", + autumnHandler({ + identify: async (c: Context) => { + return { + customerId: "demo_user_id", // your internal customer id + customerData: { + name: "John Doe", + email: "john.doe@example.com", + }, + }; + }, + }) +); +`; +}; + +const express = () => { + return `//index.ts + +import { autumnHandler } from "autumn-js/express"; + +app.use(express.json()); // need to parse request body before autumnHandler +app.use( + "/api/autumn", + autumnHandler({ + identify: async (req) => { + return { + customerId: "demo_user_id", // your internal customer id + customerData: { + name: "John Doe", + email: "john.doe@example.com", + }, + }; + }, + }) +); +`; +}; + +const fastify = () => { + return `//index.ts + +import { autumnHandler } from "autumn-js/fastify"; + +fastify.route({ + method: ["GET", "POST"], + url: "/api/autumn/*", + handler: autumnHandler({ + identify: async (request) => { + return { + customerId: "demo_user_id", // your internal customer id + customerData: { + name: "John Doe", + email: "john.doe@example.com", + }, + }; + }, + }), +}); +`; +}; + +export default function MountHandler({ number }: { number: number }) { + return ( + + Mounts routes on the /api/autumn/* path which is used by Autumn's + React library. Requires an identify function that returns the + customerId for authentication. +

+ } + > + +
+ ); +} diff --git a/vite/src/views/onboarding2/OnboardingView2.tsx b/vite/src/views/onboarding2/OnboardingView2.tsx new file mode 100644 index 000000000..e8e76ccb7 --- /dev/null +++ b/vite/src/views/onboarding2/OnboardingView2.tsx @@ -0,0 +1,108 @@ +import { useAxiosSWR } from "@/services/useAxiosSwr"; +import LoadingScreen from "../general/LoadingScreen"; +import { ModelPricing } from "./model-pricing/ModelPricing"; +import { useListProducts } from "./model-pricing/usePricingTable"; +import { parseAsBoolean, parseAsString, useQueryStates } from "nuqs"; +import IntegrateAutumn from "./integrate/IntegrateAutumn"; +import { useEffect, useRef, useState } from "react"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useSearchParams } from "react-router"; +import { useSession } from "@/lib/auth-client"; + +export default function OnboardingView2() { + const [queryStates, setQueryStates] = useQueryStates( + { + page: parseAsString.withDefault("pricing"), + reactTypescript: parseAsBoolean.withDefault(true), + frontend: parseAsString.withDefault(""), + backend: parseAsString.withDefault(""), + auth: parseAsString.withDefault(""), + customerType: parseAsString.withDefault("user"), + productId: parseAsString.withDefault(""), + }, + { + history: "push", + } + ); + + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + const [loading, setLoading] = useState(true); + const axiosInstance = useAxiosInstance(); + const hasHandledToken = useRef(false); + const { data } = useSession(); + const orgId = data?.session?.activeOrganizationId; + + const { + products: autumnProducts, + isLoading: isAutumnLoading, + mutate: mutateAutumnProducts, + } = useListProducts({ customerId: "onboarding_demo_user" }); + + const { + data: productsData, + mutate: productMutate, + isLoading, + } = useAxiosSWR({ url: `/products/data` }); + + const { data: productCounts, mutate: mutateCounts } = useAxiosSWR({ + url: `/products/counts?latest_version=true`, + }); + + useEffect(() => { + const handleToken = async () => { + try { + await axiosInstance.post("/onboarding", { + token, + }); + + await productMutate(); + await mutateAutumnProducts(); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + if (token && !hasHandledToken.current) { + hasHandledToken.current = true; + handleToken(); + } + }, [searchParams, token, axiosInstance, productMutate]); + + useEffect(() => { + if (orgId && !token) { + setLoading(false); + } + }, [orgId, token]); + + if (isLoading || isAutumnLoading || loading) return ; + + return ( + <> + {queryStates.page === "integrate" ? ( + + ) : ( + { + await productMutate(); + await mutateAutumnProducts(); + }} + mutateAutumnProducts={mutateAutumnProducts} + autumnProducts={autumnProducts} + productCounts={productCounts} + mutateCounts={mutateCounts} + queryStates={queryStates} + setQueryStates={setQueryStates} + /> + )} + + ); +} diff --git a/vite/src/views/onboarding2/SampleApp.tsx b/vite/src/views/onboarding2/SampleApp.tsx new file mode 100644 index 000000000..283f4db15 --- /dev/null +++ b/vite/src/views/onboarding2/SampleApp.tsx @@ -0,0 +1,487 @@ +import { Button } from "@/components/ui/button"; +import Step from "@/components/general/OnboardingStep"; +import CheckDialog from "@/components/autumn/paywall-dialog"; + +import { useEnv } from "@/utils/envUtils"; + +import { + DialogHeader, + DialogContent, + DialogTrigger, + DialogTitle, +} from "@/components/ui/dialog"; +import { useEffect, useState } from "react"; +import { Dialog } from "@/components/ui/dialog"; + +import { toast } from "sonner"; + +import { useSearchParams } from "react-router"; + +import { useCustomer, PricingTable } from "autumn-js/react"; +// import PricingTable from "@/components/autumn/pricing-table"; +import { + Check, + Lock, + Send, + ChevronDown, + ChevronRight, + Code, + ArrowUpRightFromSquare, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import CodeBlock from "@/views/onboarding/components/CodeBlock"; +import { useListProducts } from "./model-pricing/usePricingTable"; +// import PaywallDialog from "@/components/autumn/paywall-dialog"; + +export const SampleApp = ({ data }: { data: any }) => { + const env = useEnv(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + const [open, setOpen] = useState(false); + const [checkData, setCheckData] = useState(null); + const [trackData, setTrackData] = useState(null); + const [showCodeSection, setShowCodeSection] = useState(true); + const [showCheckSnippet, setShowCheckSnippet] = useState(true); + const [showTrackSnippet, setShowTrackSnippet] = useState(true); + const [showCustomerSnippet, setShowCustomerSnippet] = useState(true); + const [lastUsedFeature, setLastUsedFeature] = useState({ + featureId: data.features?.[0]?.id, + value: 1, + }); + + const { customer, openBillingPortal } = useCustomer(); + const { products } = useListProducts({ customerId: "onboarding_demo_user" }); + + return ( + + + + + +
+ + Sample App + + +
+ + Every time you use a feature, we{" "} + + check + {" "} + for access permission, then{" "} + + track + {" "} + the usage. Test using features, hitting usage limits, upgrade and + downgrade flows, and making changes to your products. + +
+
+
+
+
+
+

Available Features

+
+
+ {data.features + ?.filter((feature: any) => customer?.features?.[feature.id]) + .concat( + data.features?.filter( + (feature: any) => !customer?.features?.[feature.id] + ) || [] + ) + .map((feature: any, index: number) => { + const customerFeature = customer?.features?.[feature.id]; + return ( + + ); + })} +
+
+ + {/* Products List */} +
+
+

Billing

+ {customer?.stripe_id && ( + + )} +
+ +
+ +
+
+
+ + {/* Code Snippets Toggle Button */} + {showCodeSection && ( +
+ {/* Code Snippets */} +
+
+
+
+ Check feature + +
+ {showCheckSnippet && ( + { +if ( !allowed({ featureId: '${ + lastUsedFeature?.featureId || + data.features?.[0]?.id || + "feature-id" + }' }) ) { + alert('Feature not allowed'); + } +} ` + : "// Click 'Send' on a feature" + }`, + }, + { + title: "Node.js", + language: "typescript", + displayLanguage: "typescript", + content: `${ + checkData + ? `import { Autumn } from 'autumn-js'; + +const autumn = new Autumn({ + secretKey: 'am_sk_1234567890' +}); + +const { data } = await autumn.check({ + customerId: 'user_123', + featureId: '${ + lastUsedFeature?.featureId || data.features?.[0]?.id || "feature-id" + }' +}); +` + : "// Click 'Send' on a feature" + }`, + }, + { + title: "Response", + language: "typescript", + displayLanguage: "typescript", + content: `${ + checkData + ? JSON.stringify(checkData, null, 2) + : "// Click 'Send' on a feature" + }`, + }, + ]} + /> + )} +
+ +
+
+ Track usage + +
+ {showTrackSnippet && ( + { + await track({ + featureId: '${ + lastUsedFeature?.featureId || data.features?.[0]?.id || "feature-id" + }', + value: ${lastUsedFeature?.value || 1} + }); +};` + : "// Click 'Send' on a feature" + }`, + }, + { + title: "Node.js", + language: "typescript", + displayLanguage: "typescript", + content: `${ + trackData + ? `import { Autumn } from 'autumn-js'; + +const autumn = new Autumn({ + secretKey: 'am_sk_1234567890' +}); + +const response = await autumn.track({ + featureId: '${ + lastUsedFeature?.featureId || data.features?.[0]?.id || "feature-id" + }', + value: ${lastUsedFeature?.value || 1} +}); +` + : "// Click 'Send' on a feature" + }`, + }, + { + title: "Response", + language: "typescript", + displayLanguage: "typescript", + content: `${ + trackData + ? JSON.stringify(trackData, null, 2) + : "// Click 'Send' on a feature" + }`, + }, + ]} + /> + )} +
+ +
+
+ Customer data + +
+ {showCustomerSnippet && ( + { + //refresh customer data after feature is used + await refetch(); +};`, + }, + { + title: "Node.js", + language: "typescript", + displayLanguage: "typescript", + content: `import { Autumn } from 'autumn-js'; + +const autumn = new Autumn({ + secretKey: 'am_sk_1234567890' +}); + +const { customer } = await autumn.customers.get('user_123');`, + }, + { + title: "Response", + language: "typescript", + displayLanguage: "typescript", + content: `${ + customer + ? JSON.stringify(customer, null, 2) + : "// Customer data will appear here" + }`, + }, + ]} + /> + )} +
+
+
+
+ )} +
+
+
+ ); +}; + +const FeatureUsageItem = ({ + feature, + customerFeature, + onCheckData, + onTrackData, + onFeatureUsed, +}: { + feature: any; + customerFeature: any; + onCheckData: (data: any) => void; + onTrackData: (data: any) => void; + onFeatureUsed: (feature: any) => void; +}) => { + const { check, track, refetch } = useCustomer(); + const [trackValue, setTrackValue] = useState(1); + + if (feature.type === "boolean") { + return ( +
+
+ {feature.name || `Feature ${feature.id || "Unknown"}`} + {customerFeature ? ( + + ) : ( + + )} +
+
+ ); + } + return ( +
+
+
+ + {feature.name || `Feature ${feature.id || "Unknown"}`} + + {!customerFeature && } +
+
+
+
+ setTrackValue(e.target.value)} + className="w-12 h-6 px-1 text-xs bg-white border rounded-xs text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + disabled={!customerFeature} + /> + +
+ {customerFeature && ( + + {customerFeature.unlimited + ? "unlimited" + : `${customerFeature.usage}${ + customerFeature.included_usage > 0 + ? ` / ${customerFeature.included_usage}` + : "" + }`} + + )} +
+
+ ); +}; diff --git a/vite/src/views/onboarding2/Step.tsx b/vite/src/views/onboarding2/Step.tsx new file mode 100644 index 000000000..6dc0afa05 --- /dev/null +++ b/vite/src/views/onboarding2/Step.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { cn } from "@/lib/utils"; +import { Wallet } from "lucide-react"; + +interface StepProps { + title: string; + children: React.ReactNode; + className?: string; + description?: React.ReactNode; + number?: number; +} + +function Step({ title, children, className, description, number }: StepProps) { + return ( +
+
+
+ {number || "1"} +
+ +

{title}

+
+
{description}
+ + {children} +
+ ); +} + +export default Step; diff --git a/vite/src/views/onboarding2/integrate/AITools.tsx b/vite/src/views/onboarding2/integrate/AITools.tsx new file mode 100644 index 000000000..7dcc76097 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/AITools.tsx @@ -0,0 +1,120 @@ +import { StepHeader } from "./StepHeader"; +import { Button } from "@/components/ui/button"; +import CopyButton from "@/components/general/CopyButton"; +import { ExternalLink, Download, Info } from "lucide-react"; +import { toast } from "sonner"; +import { + Accordion, + AccordionTrigger, + AccordionContent, + AccordionItem, +} from "@/components/ui/accordion"; +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import CodeBlock from "@/views/onboarding/components/CodeBlock"; +import { InfoBox } from "./components/InfoBox"; +import { CodeSpan } from "./components/CodeSpan"; + +export const AITools = () => { + // MCP configuration for Autumn + const mcpConfig = { + name: "Autumn Docs", + url: "https://docs.useautumn.com/mcp", + headers: {}, + }; + + // Base64 encode the configuration for Cursor's install URL + const encodedConfig = btoa(JSON.stringify(mcpConfig)); + const cursorInstallUrl = `cursor://anysphere.cursor-deeplink/mcp/install?name=Autumn%20Docs&config=${encodedConfig}`; + + // Manual JSON configuration for copy-paste + const manualConfig = JSON.stringify( + { + mcpServers: { + autumn: mcpConfig, + }, + }, + null, + 2 + ); + + const handleCursorInstall = () => { + window.open(cursorInstallUrl, "_blank"); + }; + + return ( +
+ {/* */} + + + +
+ + Add Autumn to your AI tools +
+
+ +

+ Install our MCP with Cursor or Claude Code to use AI to integrate + Autumn. +

+
+
+ Cursor + +
+
+ Claude Code + +
+
+ + When using Cursor or Claude, prompt the model to use the + `autumn-docs` MCP to integrate Autumn. + +
+
+
+ + {/* One-click install for Cursor */} +
+
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/ConnectStripeStep.tsx b/vite/src/views/onboarding2/integrate/ConnectStripeStep.tsx new file mode 100644 index 000000000..b70efbc6b --- /dev/null +++ b/vite/src/views/onboarding2/integrate/ConnectStripeStep.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { OrgService } from "@/services/OrgService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { AppEnv } from "@autumn/shared"; +import { toast } from "sonner"; +import { getBackendErr } from "@/utils/genUtils"; +import { StepHeader } from "./StepHeader"; + +export const ConnectStripeStep = ({ + mutate, + productData, +}: { + mutate: () => Promise; + productData: any; +}) => { + const [testApiKey, setTestApiKey] = useState(""); + + const [loading, setLoading] = useState(false); + + const axiosInstance = useAxiosInstance({ env: AppEnv.Live }); + + const handleConnectStripe = async () => { + setLoading(true); + try { + await OrgService.connectStripe(axiosInstance, { + testApiKey, + liveApiKey: testApiKey, + successUrl: `https://useautumn.com`, + }); + + toast.success("Successfully connected to Stripe"); + await mutate(); + } catch (error) { + console.log("Failed to connect Stripe", error); + toast.error(getBackendErr(error, "Failed to connect Stripe")); + } + + setLoading(false); + }; + + // console.log("productData", productData); + const stripeConnected = productData?.org.stripe_connected; + return ( +
+ +

+ Stripe is required to checkout and add your products to customers. Grab + your API key{" "} + + here + + . +

+
+ setTestApiKey(e.target.value)} + disabled={stripeConnected} + /> + + +
+
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx b/vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx new file mode 100644 index 000000000..71e06c94c --- /dev/null +++ b/vite/src/views/onboarding2/integrate/IntegrateAutumn.tsx @@ -0,0 +1,82 @@ +import { AITools } from "./AITools"; +import { SelectStack } from "./SelectStack"; + +import { IntegrateContext } from "./IntegrateContext"; +import { notNullish } from "@/utils/genUtils"; +import { Install } from "./integration-steps/Install"; +import { AutumnHandler } from "./integration-steps/AutumnHandler"; + +import { AddAutumnProvider } from "./integration-steps/AddAutumnProvider"; +import { CheckoutPricingTable } from "./integration-steps/CheckoutPricingTable"; +import { EnvStep } from "./integration-steps/EnvStep"; +import { ArrowLeftIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { NextSteps } from "./NextSteps"; +import { AutumnProvider } from "autumn-js/react"; +import { ConnectStripeStep } from "./ConnectStripeStep"; + +export default function IntegrateAutumn({ + data, + mutate, + queryStates, + setQueryStates, +}: { + data: any; + mutate: any; + queryStates: any; + setQueryStates: any; +}) { + const stackSelected = + queryStates.frontend && queryStates.backend && queryStates.auth; + + return ( + +
+
+
+ +

Integrate Autumn

+

+ Let's integrate Autumn and get your first customer onto one of + your plans +

+
+ + +
+ + {stackSelected && queryStates.reactTypescript && ( + <> + + + + + + + + + + + )} +
+
+
+
+ ); +} diff --git a/vite/src/views/onboarding2/integrate/IntegrateContext.tsx b/vite/src/views/onboarding2/integrate/IntegrateContext.tsx new file mode 100644 index 000000000..8f16ae543 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/IntegrateContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +export const IntegrateContext = createContext(null); + +export const useIntegrateContext = () => { + const context = useContext(IntegrateContext); + + if (context === undefined) { + throw new Error( + "useProductContext must be used within a ProductContextProvider" + ); + } + + return context; +}; diff --git a/vite/src/views/onboarding2/integrate/NextSteps.tsx b/vite/src/views/onboarding2/integrate/NextSteps.tsx new file mode 100644 index 000000000..0f1344970 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/NextSteps.tsx @@ -0,0 +1,29 @@ +import { Button } from "@/components/ui/button"; +import { StepHeader } from "./StepHeader"; +import { SampleApp } from "../SampleApp"; +import { useIntegrateContext } from "./IntegrateContext"; + +export const NextSteps = () => { + const { data, mutate } = useIntegrateContext(); + return ( + <> +
+ Next Steps

} /> +

+ Congrats on setting up Autumn! The next steps are to learn how to use + Autumn to check if a user has access to features in your application, + and track usage for those features. Learn how to do so{" "} + + here + + . +

+ + +
+ + ); +}; diff --git a/vite/src/views/onboarding2/integrate/SelectStack.tsx b/vite/src/views/onboarding2/integrate/SelectStack.tsx new file mode 100644 index 000000000..88f0bfd13 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/SelectStack.tsx @@ -0,0 +1,66 @@ +import { StepHeader } from "./StepHeader"; +import { useIntegrateContext } from "./IntegrateContext"; +import { SelectFrameworks } from "./select-stack/SelectFrameworks"; +import { InfoBox } from "./components/InfoBox"; +import { Button } from "@/components/ui/button"; + +export const SelectStack = () => { + const { queryStates, setQueryStates } = useIntegrateContext(); + + const tabClassName = `rounded-xs h-8 data-[state=active]:bg-stone-100 data-[state=active]:text-t2 data-[state=active]:shadow-inner data-[state=active]:border`; + return ( +
+
+ +

+ Help us customize the integration guide for your specific tech stack. + Click{" "} + { + setQueryStates({ ...queryStates, reactTypescript: false }); + }} + className="underline cursor-pointer" + > + here + {" "} + if you're not using a React + Typescript backend stack. +

+
+ {queryStates.reactTypescript ? ( + + ) : ( + <> + +

+ This onboarding guide shows how to set up Autumn using our + frontend components / hooks on React and server-side framework + adaptors. +
+
+ In your case, you should integrate with Autumn's API directly on + the backend. We have SDKs for Typescript and Python. Learn how to + do so{" "} + + here + + . +

+
+ + + )} +
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/StackEnums.tsx b/vite/src/views/onboarding2/integrate/StackEnums.tsx new file mode 100644 index 000000000..243a5317b --- /dev/null +++ b/vite/src/views/onboarding2/integrate/StackEnums.tsx @@ -0,0 +1,15 @@ +export enum Backend { + Nextjs = "nextjs", + ReactRouter = "react-router", + Express = "express", + Elysia = "elysia", + Hono = "hono", + Other = "other", +} + +export enum Frontend { + Nextjs = "nextjs", + ReactRouter = "react-router", + Vite = "vite", + Other = "other", +} diff --git a/vite/src/views/onboarding2/integrate/StepHeader.tsx b/vite/src/views/onboarding2/integrate/StepHeader.tsx new file mode 100644 index 000000000..79751e60b --- /dev/null +++ b/vite/src/views/onboarding2/integrate/StepHeader.tsx @@ -0,0 +1,16 @@ +export const StepHeader = ({ + number, + title, +}: { + number: number; + title: React.ReactNode; +}) => { + return ( +
+
+ {number} +
+
{title}
+
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/components/CodeSpan.tsx b/vite/src/views/onboarding2/integrate/components/CodeSpan.tsx new file mode 100644 index 000000000..ffbc6ef17 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/components/CodeSpan.tsx @@ -0,0 +1,7 @@ +export const CodeSpan = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/vite/src/views/onboarding2/integrate/components/InfoBox.tsx b/vite/src/views/onboarding2/integrate/components/InfoBox.tsx new file mode 100644 index 000000000..6462880a6 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/components/InfoBox.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils"; +import { Info } from "lucide-react"; + +export const InfoBox = ({ + classNames, + children, +}: { + classNames?: { + infoIcon?: string; + infoBox?: string; + }; + children: React.ReactNode; +}) => { + return ( +
+
+ +
+ {children} +
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/AddAutumnProvider.tsx b/vite/src/views/onboarding2/integrate/integration-steps/AddAutumnProvider.tsx new file mode 100644 index 000000000..03970611b --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/AddAutumnProvider.tsx @@ -0,0 +1,162 @@ +import CodeBlock from "@/views/onboarding/components/CodeBlock"; +import { CodeSpan } from "../components/CodeSpan"; +import { StepHeader } from "../StepHeader"; +import { useIntegrateContext } from "../IntegrateContext"; +import { Backend, Frontend } from "../StackEnums"; +import { InfoBox } from "../components/InfoBox"; + +const nextjsAutumnProvider = ({ + includeBackendUrl, +}: { + includeBackendUrl: boolean; +}) => { + const backendUrlStr = includeBackendUrl + ? ` backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL}` + : ""; + return `// layout.tsx + +import { AutumnProvider, PricingTable } from "autumn-js/react"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +}`; +}; + +const rr7AutumnProvider = ({ + includeBackendUrl, +}: { + includeBackendUrl: boolean; +}) => { + const backendUrlStr = includeBackendUrl + ? ` backendUrl={import.meta.env.VITE_BACKEND_URL}` + : ""; + return `// root.tsx + +import { AutumnProvider } from "autumn-js/react"; +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +}`; +}; + +const viteAutumnProvider = () => { + return `// main.tsx +import { AutumnProvider } from "autumn-js/react"; + +createRoot(document.getElementById("root")!).render( + + + +);`; +}; + +const otherAutumnProvider = () => { + return `// main.tsx +import { AutumnProvider } from "autumn-js/react"; + +// 1. Simply wrap the root of your app in the AutumnProvider component +// 2. Pass in your server's URL to the backendUrl prop + +createRoot(document.getElementById("root")!).render( + + + + + +);`; +}; + +const getSnippet = (queryStates: any) => { + const { backend, frontend } = queryStates; + if (frontend === Frontend.Nextjs) { + return nextjsAutumnProvider({ + includeBackendUrl: backend !== Backend.Nextjs, + }); + } else if (frontend === Frontend.ReactRouter) { + return rr7AutumnProvider({ + includeBackendUrl: backend === Backend.Nextjs, + }); + } else if (frontend === Frontend.Vite) { + return viteAutumnProvider(); + } else { + return otherAutumnProvider(); + } +}; + +export const AddAutumnProvider = () => { + const { queryStates } = useIntegrateContext(); + return ( +
+ + Wrap your React app in {""} +

+ } + /> +

+ This allows you to use our React hooks and components in your app. If + your server URL is different to your client, you will need to pass in + the backend URL as a prop. +

+ + {queryStates.auth === "supabase" && ( + +

+ The examples above assume that Supabase auth is implemented using + server-side cookie authentication, following the guide{" "} + + here + + . +
+
+ If you need to set {"Bearer "} in + your request headers to authenticate, you can use the{" "} + getBearerToken prop in the{" "} + AutumnProvider component. +

+
+ )} +
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/AutumnHandler.tsx b/vite/src/views/onboarding2/integrate/integration-steps/AutumnHandler.tsx new file mode 100644 index 000000000..8363d2142 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/AutumnHandler.tsx @@ -0,0 +1,182 @@ +import CodeBlock from "@/views/onboarding/components/CodeBlock"; +import { StepHeader } from "../StepHeader"; +import { useIntegrateContext } from "../IntegrateContext"; + +import {} from "./handlerSnippets"; +import { + nextjsBetterAuthOrg, + nextjsBetterAuthUser, + nextjsClerkOrg, + nextjsClerkUser, + nextjsOther, + nextjsSupabaseOrg, + nextjsSupabaseUser, +} from "./snippets/nextjsHandler"; +import { + rr7BetterAuth, + rr7Clerk, + rr7Other, + rr7Supabase, +} from "./snippets/rr7Handler"; +import { + honoBetterAuth, + honoClerk, + honoSupabase, + honoOther, +} from "./snippets/honoHandler"; +import { + expressBetterAuth, + expressClerk, + expressOther, + expressSupabase, +} from "./snippets/expressHandler"; +import { + elysiaBetterAuth, + elysiaClerk, + elysiaOther, +} from "./snippets/elysiaHandler"; +import { general } from "./snippets/general"; +import { CodeSpan } from "../components/CodeSpan"; +import { Backend } from "../StackEnums"; + +const snippet = () => { + return { + [Backend.Nextjs]: { + ["better_auth"]: { + user: nextjsBetterAuthUser, + org: nextjsBetterAuthOrg, + }, + ["supabase"]: { + user: nextjsSupabaseUser, + org: nextjsSupabaseOrg, + }, + ["clerk"]: { + user: nextjsClerkUser, + org: nextjsClerkOrg, + }, + ["other"]: { + user: nextjsOther, + org: nextjsOther, + }, + }, + [Backend.ReactRouter]: { + ["better_auth"]: { + user: rr7BetterAuth("user"), + org: rr7BetterAuth("org"), + }, + ["supabase"]: { + user: rr7Supabase("user"), + org: rr7Supabase("org"), + }, + ["clerk"]: { + user: rr7Clerk("user"), + org: rr7Clerk("org"), + }, + ["other"]: { + user: rr7Other("user"), + org: rr7Other("org"), + }, + }, + [Backend.Hono]: { + ["clerk"]: { + user: honoClerk("user"), + org: honoClerk("org"), + }, + ["supabase"]: { + user: honoSupabase("user"), + org: honoSupabase("org"), + }, + ["better_auth"]: { + user: honoBetterAuth("user"), + org: honoBetterAuth("org"), + }, + ["other"]: { + user: honoOther("user"), + org: honoOther("org"), + }, + }, + [Backend.Express]: { + ["better_auth"]: { + user: expressBetterAuth("user"), + org: expressBetterAuth("org"), + }, + ["clerk"]: { + user: expressClerk("user"), + org: expressClerk("org"), + }, + ["supabase"]: { + user: expressSupabase("user"), + org: expressSupabase("org"), + }, + ["other"]: { + user: expressOther("user"), + org: expressOther("org"), + }, + }, + [Backend.Elysia]: { + ["better_auth"]: { + user: elysiaBetterAuth("user"), + org: elysiaBetterAuth("org"), + }, + ["clerk"]: { + user: elysiaClerk("user"), + org: elysiaClerk("org"), + }, + ["other"]: { + user: elysiaOther("user"), + org: elysiaOther("org"), + }, + }, + } as any; +}; + +export const AutumnHandler = () => { + const { queryStates } = useIntegrateContext(); + + const getSnippetContent = () => { + const backendLang = queryStates.backend; + const authProvider = queryStates.auth; + const customerType = queryStates.customerType; + + const templates = snippet(); + + let template = templates[backendLang]?.[authProvider]?.[customerType] || ""; + + template = template.trim("\n"); + + if (!template) { + return general(); + } + + return template; + }; + + return ( +
+ + Mount autumnHandler to your backend +

+ } + /> +

+ autumnHandler mounts routes on the{" "} + /api/autumn/* paths which allows our React hooks + and components to interact with the Autumn API directly. +

+ + +
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/CheckoutPricingTable.tsx b/vite/src/views/onboarding2/integrate/integration-steps/CheckoutPricingTable.tsx new file mode 100644 index 000000000..e1f7faa52 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/CheckoutPricingTable.tsx @@ -0,0 +1,70 @@ +import CodeBlock from "@/views/onboarding/components/CodeBlock"; +import { CodeSpan } from "../components/CodeSpan"; +import { StepHeader } from "../StepHeader"; +import { Frontend } from "../StackEnums"; +import { useIntegrateContext } from "../IntegrateContext"; +import { InfoBox } from "../components/InfoBox"; + +const nextjsSnippet = () => { + return `import { PricingTable } from "autumn-js/react"; + +export default function Home() { + return ( +
+
+ +
+
+ ); +}`; +}; + +const getSnippet = (queryStates: any) => { + return nextjsSnippet(); +}; + +export const CheckoutPricingTable = () => { + const { queryStates } = useIntegrateContext(); + return ( +
+ + Drop in {""} +

+ } + /> +

+ Display a pricing table with the plans you have created and let your + customers choose a plan. +

+ + +

+ { + "Our component is completely customisable by installing it as a shadcn component. " + } + Learn how to do so{" "} + + here + + . +

+
+
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/CreateSecretKey.tsx b/vite/src/views/onboarding2/integrate/integration-steps/CreateSecretKey.tsx new file mode 100644 index 000000000..dc22a6e12 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/CreateSecretKey.tsx @@ -0,0 +1,114 @@ +import { Input } from "@/components/ui/input"; + +import { DevContext } from "@/views/developer/DevContext"; + +import Step from "@/components/general/OnboardingStep"; + +import { useEnv } from "@/utils/envUtils"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DevService } from "@/services/DevService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { Button } from "@/components/ui/button"; +import { CheckIcon, CopyIcon, PlusIcon } from "lucide-react"; + +export const CreateSecretKey = ({ + apiKey, + setApiKey, +}: { + apiKey: string; + setApiKey: (apiKey: string) => void; +}) => { + const env = useEnv(); + // const [apiKeyName, setApiKeyName] = useState(""); + const [apiCreated, setApiCreated] = useState(false); + + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const axiosInstance = useAxiosInstance({ env }); + + const handleCreate = async () => { + setLoading(true); + try { + const { api_key } = await DevService.createAPIKey(axiosInstance, { + name: "Autumn Onboarding", + }); + + setApiKey(api_key); + } catch (error) { + console.log("Error:", error); + toast.error("Failed to create API key"); + } + + setLoading(false); + }; + + return ( + {}, + onboarding: true, + apiCreated, + setApiCreated, + }} + > +
+ {apiKey ? ( +
+ + +
+ ) : ( +
+ {/* setApiKeyName(e.target.value)} + /> */} + +
+ )} +
+
+ ); +}; + +{ + /*
+ {env === AppEnv.Sandbox ? ( + + ) : ( + + )} +
*/ +} diff --git a/vite/src/views/onboarding2/integrate/integration-steps/EnvStep.tsx b/vite/src/views/onboarding2/integrate/integration-steps/EnvStep.tsx new file mode 100644 index 000000000..5a1fd7f93 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/EnvStep.tsx @@ -0,0 +1,36 @@ +import CodeBlock from "@/views/onboarding/components/CodeBlock"; +import { CodeSpan } from "../components/CodeSpan"; +import { StepHeader } from "../StepHeader"; +import { useState } from "react"; +import { CreateSecretKey } from "./CreateSecretKey"; + +export const EnvStep = () => { + const [apiKey, setApiKey] = useState(""); + return ( + <> +
+ + Add the Autumn secret key to your {".env"}{" "} + file +

+ } + /> + + + +
+ + ); +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/Install.tsx b/vite/src/views/onboarding2/integrate/integration-steps/Install.tsx new file mode 100644 index 000000000..74c677256 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/Install.tsx @@ -0,0 +1,44 @@ +import CodeBlock from "@/views/onboarding/components/CodeBlock"; +import { StepHeader } from "../StepHeader"; + +const installCode = `npm install autumn-js`; +const installCodePnpm = `pnpm add autumn-js`; +const installCodeYarn = `yarn add autumn-js`; +const installCodeBun = `bun add autumn-js`; + +export const Install = () => { + return ( +
+ + + +
+ ); +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/handlerSnippets.ts b/vite/src/views/onboarding2/integrate/integration-steps/handlerSnippets.ts new file mode 100644 index 000000000..d5477ad2b --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/handlerSnippets.ts @@ -0,0 +1,74 @@ +export const nextjsBetterAuthUser = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + return { + customerId: session?.user.id, + customerData: { + name: session?.user.name, + email: session?.user.email, + }, + }; + }, +}); +`; + +export const nextjsBetterAuthOrg = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + return { + customerId: session?.session.activeOrganizationId, + customerData: { + name: session?.user.name, + email: session?.user.email, + }, + }; + }, +}); +`; + +export const nextjsBetterAuthOther = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + // From the request, retrieve the ID you want to use as the customer ID + const customerId = "customer_id"; + + return { + customerId, + customerData: { + name: session?.user.name, + email: session?.user.email, + }, + }; + }, +}); +`; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/betterAuth.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/betterAuth.tsx new file mode 100644 index 000000000..6c356fbba --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/betterAuth.tsx @@ -0,0 +1,33 @@ +export const betterAuthSnippet = ( + customerType: "user" | "org", + headersString: string, + tabLevel: number = 0 +) => { + const tabs = " ".repeat(tabLevel); + + let snippet = ``; + if (customerType === "user") { + snippet = `${tabs}const session = await auth.api.getSession({ +${tabs} headers: ${headersString}, +${tabs}}); +${tabs} +${tabs}return { +${tabs} customerId: session?.user.id, +${tabs} customerData: { +${tabs} name: session?.user.name, +${tabs} email: session?.user.email, +${tabs} }, +${tabs}};`; + } else { + snippet = `${tabs}const session = await auth.api.getSession({ +${tabs} headers: ${headersString}, +${tabs}}); +${tabs} +${tabs}return { +${tabs} customerId: session?.session.activeOrganizationId, +${tabs} customerData: { name: "", email: "" } +${tabs}};`; + } + + return snippet; +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/elysiaHandler.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/elysiaHandler.tsx new file mode 100644 index 000000000..9c99bb277 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/elysiaHandler.tsx @@ -0,0 +1,78 @@ +import { betterAuthSnippet } from "./betterAuth"; + +export const elysiaBetterAuth = (customerType: "user" | "org") => { + return `import { autumnHandler } from "autumn-js/elysia"; +import { auth } from "./auth"; + +const app = new Elysia() + .use(cors()) + .mount(auth.handler) + .use( + autumnHandler({ + identify: async (context) => { +${betterAuthSnippet(customerType, "context.headers", 4)} + }, + }) + ) + .listen(8000); + + `; +}; + +export const elysiaClerk = (customerType: "user" | "org") => { + return `import { clerkPlugin } from "elysia-clerk"; +import { autumnHandler } from "autumn-js/backend"; + +const app = new Elysia() + .use(cors()) + .use(clerkPlugin()) + .all("*", async (ctx: any) => { + console.log("Request received"); + }) + .all("/api/autumn/*", async (ctx: any) => { + const auth = ctx.auth(); + + let body = null; + if (ctx.request.method !== "GET") { + body = await ctx.request.json(); + } + + const { statusCode, response } = await autumnHandler({ + customerId: ${customerType === "user" ? "auth.userId" : "auth.orgId"}, + customerData: { name: "", email: "" }, + request: { + url: ctx.request.url, + method: ctx.request.method, + body: body, + }, + }); + + ctx.set.status = statusCode; + return response; + }) + .listen(8000);`; +}; + +export const elysiaOther = (customerType: "user" | "org") => { + return `import { autumnHandler } from "autumn-js/elysia"; +import { auth } from "./auth"; + +const app = new Elysia() + .use(cors()) + .mount(auth.handler) + .use( + autumnHandler({ + identify: async (context) => { + const customerId = "your_customer_id"; // Authenticate and get customer ID from your DB + + return { + customerId, + customerData: { name: "", email: "" }, + }; + }, + }) + ) + .listen(8000); + + `; +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/expressHandler.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/expressHandler.tsx new file mode 100644 index 000000000..10c666549 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/expressHandler.tsx @@ -0,0 +1,67 @@ +import { betterAuthSnippet } from "./betterAuth"; +import { clerkSnippet, supabaseSnippet } from "./honoHandler"; + +export const expressBetterAuth = (customerType: "user" | "org") => { + return `import { autumnHandler } from "autumn-js/express"; +import { auth } from "@/lib/auth"; + +app.use(express.json()); // need to parse request body before autumnHandler +app.use( + "/api/autumn", + autumnHandler({ + identify: async (req) => { +${betterAuthSnippet(customerType, "req.headers", 3)} + }, + }) +);`; +}; + +export const expressClerk = (customerType: "user" | "org") => { + return `import { autumnHandler } from "autumn-js/express"; +import { clerkMiddleware, getAuth } from "@clerk/express"; + +app.use(express.json()); // need to parse request body before autumnHandler +app.use(clerkMiddleware()); +app.use( + "/api/autumn", + autumnHandler({ + identify: async (req) => { + ${clerkSnippet(customerType, "req")} + }, + }) +);`; +}; + +export const expressSupabase = (customerType: "user" | "org") => { + return `import { autumnHandler } from "autumn-js/express"; +import { createClient } from "./lib/supabase"; + +app.use(express.json()); // need to parse request body before autumnHandler +app.use( + "/api/autumn", + autumnHandler({ + identify: async (req, res) => { + ${supabaseSnippet(customerType, "createClient({ req, res })")} + }, + }) +);`; +}; + +export const expressOther = (customerType: "user" | "org") => { + return `import { autumnHandler } from "autumn-js/express"; + +app.use(express.json()); // need to parse request body before autumnHandler +app.use( + "/api/autumn", + autumnHandler({ + identify: async (req, res) => { + const customerId = "your_customer_id"; // Get customer id from your database + + return { + customerId, + customerData: { name: "", email: "" }, + }; + }, + }) +);`; +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/general.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/general.tsx new file mode 100644 index 000000000..e3da5a42c --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/general.tsx @@ -0,0 +1,41 @@ +export const general = () => { + return `import { autumnHandler } from "autumn-js/backend"; + +// 1. autumnHandler takes in request properties and returns a response +// 2. Simply mount the handler onto the /api/autumn/* path in your backend +// 3. Call autumnHandler and pass in the required parameters +// 4. Return the response from the autumnHandler + +// Example using autumnHandler with Hono & Clerk +import { autumnHandler } from "autumn-js/backend"; +import { clerkMiddleware, getAuth } from "@hono/clerk-auth"; + +app.use("*", clerkMiddleware()); +app.use( + "/api/autumn/*", + async (c) => { + const auth = getAuth(c); + + if (!auth?.userId) { + return c.json({ message: "Unauthorized" }, 401); + } + + let body = null; + if (c.req.method !== "GET") { + body = await c.req.json(); + } + + const { statusCode, response } = await autumnHandler({ + customerId: auth.userId, + customerData: { name: "", email: "" }, + request: { + url: c.req.url, + method: c.req.method, + body: body, + }, + }); + + return c.json(response, statusCode); + } +);`; +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/honoHandler.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/honoHandler.tsx new file mode 100644 index 000000000..f052f0815 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/honoHandler.tsx @@ -0,0 +1,126 @@ +import { betterAuthSnippet } from "./betterAuth"; + +export const clerkSnippet = ( + customerType: "user" | "org", + reqParam: string = "c" +) => { + if (customerType === "user") { + return `const auth = getAuth(${reqParam}); + + if (!auth?.userId) return null; + + return { + customerId: auth.userId, + customerData: { name: "", email: "" }, + }; + `; + } + + return `const auth = getAuth(${reqParam}); + + if (!auth?.userId || !auth?.orgId) return null; + + return { + customerId: auth.orgId, + customerData: { name: "", email: "" }, + }; + `; +}; + +export const honoClerk = (customerType: "user" | "org") => { + return `import { autumnHandler } from "autumn-js/hono"; +import { clerkMiddleware, getAuth } from "@hono/clerk-auth"; + +app.use("*", clerkMiddleware()); +app.use( + "/api/autumn/*", + autumnHandler({ + identify: async (c: Context) => { + ${clerkSnippet(customerType)} + }, + }) +);`; +}; + +export const supabaseSnippet = ( + customerType: "user" | "org", + supabaseInit: string = "getSupabase(c)" +) => { + if (customerType === "user") { + return `const supabase = ${supabaseInit}; + + const { data, error } = await supabase.auth.getUser(); + + if (!data?.user?.id) return null; + + return { + customerId: data.user.id, + customerData: { name: "", email: "" }, + };`; + } + + return `const supabase = ${supabaseInit}; + + const { data, error } = await supabase.auth.getUser(); + + if (!data?.user?.id) return null; + + const orgId = "users_org_id"; // Get the orgId from your DB + + return { + customerId: orgId, + customerData: { name: "", email: "" }, + };`; +}; + +export const honoSupabase = (customerType: "user" | "org") => { + return `// index.ts + +import { autumnHandler } from "autumn-js/hono"; +import { getSupabase, supabaseMiddleware } from "./middleware/auth.middleware.js"; + +app.use("*", supabaseMiddleware()); +app.use( + "/api/autumn/*", + autumnHandler({ + identify: async (c: Context) => { + ${supabaseSnippet(customerType)} + }, + }) +);`; +}; +export const honoBetterAuth = (customerType: "user" | "org") => { + return `// index.ts + +import { autumnHandler } from "autumn-js/hono"; +import { auth } from "@/lib/auth" + +app.use( + "/api/autumn/*", + autumnHandler({ + identify: async (c: Context) => { +${betterAuthSnippet(customerType, "c.req.raw.headers", 3)} + }, + }) +);`; +}; +export const honoOther = (customerType: "user" | "org") => { + return `// index.ts + +import { autumnHandler } from "autumn-js/hono"; +import { auth } from "@/lib/auth" + +app.use( + "/api/autumn/*", + autumnHandler({ + identify: async (c: Context) => { + const customerId = "your_customer_id"; // Get customer id from your database + + return { + customerId, + customerData: { name: "", email: "" }, + }; + }, + }) +);`; +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/nextjsHandler.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/nextjsHandler.tsx new file mode 100644 index 000000000..b295fd420 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/nextjsHandler.tsx @@ -0,0 +1,161 @@ +export const nextjsBetterAuthUser = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + return { + customerId: session?.user.id, + customerData: { + name: session?.user.name, + email: session?.user.email, + }, + }; + }, +}); +`; + +export const nextjsBetterAuthOrg = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + return { + customerId: session?.session.activeOrganizationId, + customerData: { + name: session?.user.name, + email: session?.user.email, + }, + }; + }, +}); +`; + +// Supabase Auth +export const nextjsSupabaseUser = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { createClient } from "@/utils/supabase/server"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const supabase = await createClient(); + const { data, error } = await supabase.auth.getUser(); + + if (error || !data?.user) { + return null; + } + + return { + customerId: data.user.id, + customerData: { + name: data.user.user_metadata?.name, + email: data.user.email, + }, + }; + }, +});`; + +export const nextjsSupabaseOrg = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { createClient } from "@/utils/supabase/server"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const supabase = await createClient(); + const { data, error } = await supabase.auth.getUser(); + + if (error || !data?.user) { + return null; + } + + // Get the orgId of the user from your DB + const customerId = "users_org_id"; + + return { + customerId, + customerData: { + name: data.user.user_metadata?.name, + email: data.user.email, + }, + }; + }, +}); +`; + +export const nextjsClerkUser = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@clerk/nextjs/server"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const { userId } = await auth(); + + if (!userId) return null; + + return { + customerId: userId, + // To store the customer name and email + customerData: { name: "", email: "" }, + }; + }, +}); +`; + +export const nextjsClerkOrg = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; +import { auth } from "@clerk/nextjs/server"; + +export const { GET, POST } = autumnHandler({ + identify: async () => { + const { userId, orgId } = await auth(); + + if (!userId || !orgId) return null; + + return { + customerId: orgId, + // To store the customer name and email + customerData: { name: "", email: "" }, + }; + }, +}); +`; + +export const nextjsOther = ` +// app/api/autumn/[...all]/route.ts + +import { autumnHandler } from "autumn-js/next"; + +export const { GET, POST } = autumnHandler({ + identify: async (request) => { + // Authenticate the request and get the customer ID + const customerId = "customer_id"; + return { + customerId, + customerData: { name: "", email: "" }, + }; + }, +}); +`; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/rr7Handler.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/rr7Handler.tsx new file mode 100644 index 000000000..d46fdc2d5 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/rr7Handler.tsx @@ -0,0 +1,89 @@ +import { betterAuthSnippet } from "./betterAuth"; +import { supabaseAuthSnippet } from "./supabaseAuth"; + +export const rr7BetterAuth = (customerType: "user" | "org") => { + return `// app/routes/api.autumn.tsx + +import { autumnHandler } from "autumn-js/react-router"; +import { auth } from "@/lib/auth"; + +const handler = autumnHandler({ + identify: async (args) => { +${betterAuthSnippet(customerType, "args.request.headers", 2)} + }, +}); + +export const loader = handler.loader; +export const action = handler.action;`; +}; +export const rr7Supabase = (customerType: "user" | "org") => { + return `// app/routes/api.autumn.tsx + +import { autumnHandler } from "autumn-js/react-router"; +import { createClient } from "@/utils/supabase/server"; + +const handler = autumnHandler({ + identify: async (args) => { + ${supabaseAuthSnippet({ customerType })} + }, +}); + +export const loader = handler.loader; +export const action = handler.action;`; +}; + +export const clerkSnippet = (customerType: "user" | "org") => { + if (customerType === "user") { + return `const { userId } = await getAuth(args); + + if (!userId) return null; + + return { + customerId: userId, + customerData: { name: "", email: "" }, + };`; + } + + return `const { userId, orgId } = await getAuth(args); + + if (!userId || !orgId) return null; + + return { + customerId: orgId, + customerData: { name: "", email: "" }, + };`; +}; +export const rr7Clerk = (customerType: "user" | "org") => { + return `// app/routes/api.autumn.tsx + +import { autumnHandler } from "autumn-js/react-router"; +import { getAuth } from "@clerk/react-router/ssr.server"; + +const handler = autumnHandler({ + identify: async (args) => { + ${clerkSnippet(customerType)} + }, +}); + +export const loader = handler.loader; +export const action = handler.action;`; +}; +export const rr7Other = (customerType: "user" | "org") => { + return `// app/routes/api.autumn.tsx + +import { autumnHandler } from "autumn-js/react-router"; + +const handler = autumnHandler({ + identify: async (args) => { + const customerId = "your_customer_id"; // Get customer id from your database + + return { + customerId, + customerData: { name: "", email: "" }, + }; + }, +}); + +export const loader = handler.loader; +export const action = handler.action;`; +}; diff --git a/vite/src/views/onboarding2/integrate/integration-steps/snippets/supabaseAuth.tsx b/vite/src/views/onboarding2/integrate/integration-steps/snippets/supabaseAuth.tsx new file mode 100644 index 000000000..9c7087120 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/integration-steps/snippets/supabaseAuth.tsx @@ -0,0 +1,35 @@ +export const supabaseAuthSnippet = ({ + backendLang, + customerType, +}: { + backendLang?: string; + customerType: "user" | "org"; +}) => { + if (customerType === "user") { + return `const supabase = await createClient(request); + const { data, error } = await supabase.auth.getUser(); + + if (error || !data?.user) return null; + + return { + customerId: "123", + customerData: { + name: data.user.user_metadata?.name, + email: data.user.email, + }, + };`; + } + + return `const supabase = await createClient(request); + const { data, error } = await supabase.auth.getUser(); + + if (error || !data?.user) return null; + + // Get the orgId from your DB + const orgId = "users_org_id"; + + return { + customerId: orgId, + customerData: { name: "", email: "" }, + };`; +}; diff --git a/vite/src/views/onboarding2/integrate/select-stack/SelectFrameworks.tsx b/vite/src/views/onboarding2/integrate/select-stack/SelectFrameworks.tsx new file mode 100644 index 000000000..5fe996607 --- /dev/null +++ b/vite/src/views/onboarding2/integrate/select-stack/SelectFrameworks.tsx @@ -0,0 +1,172 @@ +import { Code, Fingerprint, CircleUserRound, Building } from "lucide-react"; +import { useIntegrateContext } from "../IntegrateContext"; +import { Backend, Frontend } from "../StackEnums"; + +export const SelectFrameworks = () => { + const { queryStates, setQueryStates } = useIntegrateContext(); + + const iconSize = 12; + const frontendOptions = [ + { value: Frontend.Nextjs, label: "Next.js", logo: "nextjs.png" }, + { value: Frontend.ReactRouter, label: "RR7", logo: "react-router.svg" }, + { value: Frontend.Vite, label: "Vite SPA", logo: "vite.svg" }, + { + value: Frontend.Other, + label: "Other", + icon: , + }, + ]; + + const backendOptions = [ + { value: Backend.Nextjs, label: "Next.js", logo: "nextjs.png" }, + { + value: Backend.ReactRouter, + label: "RR7", + logo: "react-router.svg", + }, + { value: Backend.Hono, label: "Hono", logo: "hono.png" }, + { value: Backend.Express, label: "Express", logo: "express.png" }, + { value: Backend.Elysia, label: "Elysia", logo: "elysia.png" }, + { + value: Backend.Other, + label: "Other", + icon: , + }, + ]; + + const authOptions = [ + { value: "better_auth", label: "Better Auth", logo: "better-auth.png" }, + { value: "supabase", label: "Supabase", logo: "supabase.png" }, + { value: "clerk", label: "Clerk", logo: "clerk.png" }, + { + value: "other", + label: "Other", + icon: , + }, + ]; + + const customerOptions = [ + { + value: "user", + label: "Users", + icon: , + }, + { + value: "org", + label: "Orgs", + icon: , + }, + ]; + return ( +
+
+ +
+ {frontendOptions.map((option) => { + return ( + + ); + })} +
+
+
+ +
+ {backendOptions.map((option) => { + return ( + + ); + })} +
+
+ +
+
+ +
+ {authOptions.map((option) => { + return ( + + ); + })} +
+
+
+ +
+ {customerOptions.map((option) => { + return ( + + ); + })} +
+
+
+
+ ); +}; + +const FrameworkContainer = ({ + option, + setQueryStates, + queryStates, + type, +}: { + option: { + value: string; + label: string; + logo?: string; + icon?: React.ReactNode; + }; + setQueryStates: (queryStates: any) => void; + queryStates: any; + type: "backend" | "auth" | "customerType" | "frontend"; +}) => { + const isSelected = queryStates[type] === option.value; + return ( +
setQueryStates({ ...queryStates, [type]: option.value })} + > + {option.logo ? ( + {option.label} + ) : option.icon ? ( + <>{option.icon} + ) : ( + + )} + + {option.label} + +
+ ); +}; diff --git a/vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx b/vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx new file mode 100644 index 000000000..22576f236 --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/AddTrialButton.tsx @@ -0,0 +1,66 @@ +import { Button } from "@/components/ui/button"; +import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial"; +import { useState } from "react"; +import { useProductContext } from "@/views/products/product/ProductContext"; +import { PlusIcon, Trash, X } from "lucide-react"; +import { handleAutoSave } from "./model-pricing-utils/modelPricingUtils"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export const AddTrialButton = () => { + const { product, setProduct, mutate, autoSave } = useProductContext(); + const [open, setOpen] = useState(false); + const axiosInstance = useAxiosInstance(); + + return ( + <> + + +
+ ) : ( +

+ Add Free Trial + +

+ )} + + + ); +}; diff --git a/vite/src/views/onboarding2/model-pricing/EditProduct.tsx b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx new file mode 100644 index 000000000..419671735 --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/EditProduct.tsx @@ -0,0 +1,234 @@ +import ConfirmNewVersionDialog from "@/views/products/product/versioning/ConfirmNewVersionDialog"; +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import { ToggleButton } from "@/components/general/ToggleButton"; +import { Input } from "@/components/ui/input"; +import { slugify } from "@/utils/formatUtils/formatTextUtils"; +import { FeaturesContext } from "@/views/features/FeaturesContext"; +import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial"; +import { CreateProductItem2 } from "@/views/products/product/product-item/CreateProductItem2"; +import { ProductItemTable } from "@/views/products/product/product-item/ProductItemTable"; +import { ProductContext } from "@/views/products/product/ProductContext"; +import { AddTrialButton } from "./AddTrialButton"; +import { useState } from "react"; +import { useEnv } from "@/utils/envUtils"; +import { handleAutoSave } from "./model-pricing-utils/modelPricingUtils"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useModelPricingContext } from "./ModelPricingContext"; +import { isFreeProduct } from "@/utils/product/priceUtils"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { SaveIcon } from "lucide-react"; +import { Product } from "@autumn/shared"; +import { updateProduct } from "@/views/products/product/utils/updateProduct"; +import { getBackendErr } from "@/utils/genUtils"; +import { EditProductDetails } from "./edit-product/EditProductDetails"; + +export const EditProduct = ({ mutate }: { mutate: any }) => { + const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); + const { data, productCount, productDataState, mutateCounts } = + useModelPricingContext(); + + const [showNewVersionDialog, setShowNewVersionDialog] = useState(false); + + const { + product, + setProduct, + features, + setFeatures, + entityFeatureIds, + setEntityFeatureIds, + actionState, + } = productDataState; + + const [details, setDetails] = useState({ + name: product.name, + id: product.id, + }); + + const axiosInstance = useAxiosInstance(); + const env = useEnv(); + + const [saveLoading, setSaveLoading] = useState(false); + + const runUpdateProduct = async () => { + setSaveLoading(true); + try { + await updateProduct({ + axiosInstance, + product, + mutate, + mutateCount: mutateCounts, + }); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update product")); + } finally { + setSaveLoading(false); + } + }; + + const handleSaveClicked = async () => { + if (productCount?.all > 0) { + setShowNewVersionDialog(true); + return; + } + await runUpdateProduct(); + }; + + const hasItems = product.items.length > 0; + const hasCustomers = productCount?.all > 0; + const showSaveButton = hasCustomers || product.version > 1; + const firstProductCreated = data.products.length > 0; + + const autoSave = !showSaveButton && firstProductCreated; + + const handleToggleSettings = async (key: string) => { + if (!product) return; + + const curValue = product[key as keyof Product]; + const newProduct = { ...product, [key]: !curValue }; + + // Validate + if (key === "is_default" && !isFreeProduct(product.items)) { + toast.error("Default product must be a free product"); + return; + } + + setProduct(newProduct); + + if (!showSaveButton) { + handleAutoSave({ + axiosInstance, + productId: product.id ? product.id : details.id, + product: { ...product, [key]: !curValue }, + mutate, + }); + } + }; + + return ( +
+
+ + + + + +
+
+ + {showSaveButton && ( + + )} +
+ + {firstProductCreated && ( + <> + {product.items.length == 0 ? ( +

+ {/* Next, add items to define what customers with this product + get access to, and how much they should be charged for it. */} + Next, add which features your customers can use on this + product and how much it should cost. +

+ ) : ( +
+ +
+ )} + {" "} + + )} +
+ +
+
+
+ handleToggleSettings("is_default")} + /> +
+ A default product is enabled by default for all new users, + typically used for your free plan. +
+
+
+ handleToggleSettings("is_add_on")} + /> +
+ A product that can be added on top of a customer's main + plan. Eg. one time purchases or top ups. +
+
+
+ +
+ Add a free trial to your product. +
+
+
+
+
+
+
+
+ ); +}; diff --git a/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx b/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx new file mode 100644 index 000000000..2f088a441 --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/EditProductOld.tsx @@ -0,0 +1,192 @@ +import { DialogFooter } from "@/components/ui/dialog"; +import { ProductService } from "@/services/products/ProductService"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useEnv } from "@/utils/envUtils"; +import { getBackendErr } from "@/utils/genUtils"; +import { FeaturesContext } from "@/views/features/FeaturesContext"; +import { CreateFreeTrial } from "@/views/products/product/free-trial/CreateFreeTrial"; +import { ManageProduct } from "@/views/products/product/ManageProduct"; +import { ProductContext } from "@/views/products/product/ProductContext"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Check, X } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Tooltip } from "@/components/ui/tooltip"; +import { toast } from "sonner"; +import { ToggleButton } from "@/components/general/ToggleButton"; + +export const EditProductDialog = ({ + product, + features, + setProduct, + setFeatures, + mutate, + open, + setOpen, + originalProduct, + entityFeatureIds, + setEntityFeatureIds, +}: { + product: any; + setProduct: (product: any) => void; + features: any[]; + setFeatures: (features: any[]) => void; + mutate: () => Promise; + open: boolean; + setOpen: (open: boolean) => void; + originalProduct: any; + entityFeatureIds: string[]; + setEntityFeatureIds: (entityFeatureIds: string[]) => void; +}) => { + const env = useEnv(); + const axiosInstance = useAxiosInstance(); + const [createProductLoading, setCreateProductLoading] = useState(false); + const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); + + // Store the original product state when modal opens + const handleOpenChange = async (newOpen: boolean) => { + if (!newOpen && open && product?.id) { + // Modal is being closed, check if there are changes + const hasChanges = + originalProduct && + JSON.stringify(product) !== JSON.stringify(originalProduct); + + if (hasChanges) { + // Only update if there are changes + updateProduct(); + } + } + setOpen(newOpen); + }; + + const updateProduct = async () => { + setCreateProductLoading(true); + try { + const res = await ProductService.updateProduct( + axiosInstance, + product.id, + product + ); + toast.success("Product updated successfully"); + await mutate(); + setOpen(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update product")); + } + setCreateProductLoading(false); + }; + + const handleFreeTrialClick = () => { + if (product?.free_trial) { + // Delete the free trial + setProduct({ ...product, free_trial: null }); + } else { + // Open the free trial modal + setFreeTrialModalOpen(true); + } + }; + + return ( + + + + {/* Edit Product */} + +
+ + + + + + +
+ +
+
+ + setProduct({ + ...product, + is_default: !product?.is_default, + }) + } + /> + + setProduct({ ...product, is_add_on: !product?.is_add_on }) + } + /> + +
+ ) : ( + //
+ + // {/* */} + //
+

Add Free Trial

+ )} + +
+ +
+ + + + ); +}; diff --git a/vite/src/views/onboarding2/model-pricing/ModelPricing.tsx b/vite/src/views/onboarding2/model-pricing/ModelPricing.tsx new file mode 100644 index 000000000..a6b0f3ded --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/ModelPricing.tsx @@ -0,0 +1,307 @@ +import PricingTable from "@/components/autumn/pricing-table"; +import { EditProduct } from "./EditProduct"; +import { useEffect, useState } from "react"; + +import { getBackendErr, nullish } from "@/utils/genUtils"; +import { Feature, Product } from "autumn-js"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowRight, PlusIcon } from "lucide-react"; +import { + ModelPricingContext, + useModelPricingContext, +} from "./ModelPricingContext"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { slugify } from "@/utils/formatUtils/formatTextUtils"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { toast } from "sonner"; +import { ProductsContext } from "@/views/products/ProductsContext"; +import { SelectEditProduct } from "./SelectEditProduct"; +import { ConnectStripeStep } from "../integrate/ConnectStripeStep"; +import { AutumnProvider } from "autumn-js/react"; +import { useProductData } from "@/views/products/product/hooks/useProductData"; +import ConnectStripeDialog from "../ConnectStripeDialog"; + +const defaultProduct = { + id: "", + name: "", + items: [], + is_default: false, + is_add_on: false, + free_trial: null, +}; + +export const ModelPricing = ({ + data, + mutate, + mutateAutumnProducts, + autumnProducts, + productCounts, + mutateCounts, + queryStates, + setQueryStates, +}: { + data: any; + mutate: any; + mutateAutumnProducts: any; + autumnProducts: Product[]; + productCounts: any; + mutateCounts: any; + queryStates: any; + setQueryStates: any; +}) => { + const getCurProduct = () => { + if (queryStates.productId) { + const prod = data.products.find( + (p: Product) => p.id === queryStates.productId + ); + if (prod) { + return prod; + } + } + + if (data.products.length > 0) { + return data.products[0]; + } + + return defaultProduct; + }; + + const curProduct = getCurProduct(); + // // console.log("curProduct:", curProduct); + // const [firstItemCreated, setFirstItemCreated] = useState( + // autumnProducts.some((p: Product) => p.items.length > 0) + // ); + + const firstItemCreated = autumnProducts.some( + (p: Product) => p.items.length > 0 + ); + + const [editingNewProduct, setEditingNewProduct] = useState( + nullish(curProduct) + ); + + const [connectStripeOpen, setConnectStripeOpen] = useState(false); + + const productDataState = useProductData({ + originalProduct: curProduct as any, + originalFeatures: data.features as any, + }); + + const { product } = productDataState; + + if (!product) return null; + + const stripeConnected = data?.org.stripe_connected; + + return ( + + + +
+
+
+
+
+

Create your products

+

+ To start, model your app's pricing by creating a product for + your free plans, paid plans and any add-ons or top-ups. +

+
+ {firstItemCreated && ( +
+ + {data.products.length > 0 && } +
+ )} +
+
+ +
+
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+ ); +}; + +const NewProductPopover = () => { + const [open, setOpen] = useState(false); + const { + mutate, + productDataState: { setProduct }, + } = useModelPricingContext(); + + const axiosInstance = useAxiosInstance(); + const [details, setDetails] = useState({ + name: "", + id: "", + }); + + const [creating, setCreating] = useState(false); + + const handleSave = async () => { + try { + setCreating(true); + await axiosInstance.post("/v1/products", { + name: details.name, + id: details.id, + }); + await mutate(); + const newProduct = { + ...defaultProduct, + name: details.name, + id: details.id, + }; + setProduct(newProduct); + setOpen(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to create product")); + } finally { + setCreating(false); + } + }; + + return ( + + + + + +
+

New Product

+
+ + setDetails({ + ...details, + name: e.target.value, + id: slugify(e.target.value), + }) + } + /> + +
+
+ +
+
+
+
+ ); +}; + +// Get latest product +const getAutumnProducts = () => { + // const curProductItems = product.items.map((item: any) => + // getProductItemResponse({ + // item, + // features: data.features, + // currency: "USD", + // }) + // ); + // return [product, ...autumnProducts]; + // const properties: ProductProperties = { + // has_trial: notNullish(product.free_trial), + // is_free: isFreeProduct(product.items), + // is_one_off: isOneOffProduct(product.items), + // updateable: product.items.some( + // (item: any) => item.usage_model == UsageModel.Prepaid + // ), + // }; + // const latestProduct = { + // ...product, + // items: curProductItems, + // properties, + // }; + // const curProducts = autumnProducts.filter( + // (p: Product) => p.id !== product.id + // ); + // if (!firstItemCreated) { + // return []; + // } + // const newProducts = [latestProduct, ...curProducts] as any; + // return sortProductsV2({ products: newProducts }) as Product[]; +}; diff --git a/vite/src/views/onboarding2/model-pricing/ModelPricingContext.tsx b/vite/src/views/onboarding2/model-pricing/ModelPricingContext.tsx new file mode 100644 index 000000000..a1ac58699 --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/ModelPricingContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +export const ModelPricingContext = createContext(null); + +export const useModelPricingContext = () => { + const context = useContext(ModelPricingContext); + + if (context === undefined) { + throw new Error( + "useProductContext must be used within a ProductContextProvider" + ); + } + + return context; +}; diff --git a/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx b/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx new file mode 100644 index 000000000..053a84e85 --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/SelectEditProduct.tsx @@ -0,0 +1,126 @@ +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useModelPricingContext } from "./ModelPricingContext"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { ChevronDownIcon, Trash } from "lucide-react"; +import { DeleteProductDialog } from "@/views/products/components/DeleteProductDialog"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +export const SelectEditProduct = () => { + const { + data, + productDataState: { product, setProduct }, + queryStates, + setQueryStates, + } = useModelPricingContext(); + const [deleteProductOpen, setDeleteProductOpen] = useState(false); + + const selectedClassName = "!bg-zinc-100 h-7 border"; + if (data.products.length > 3) { + return ( + <> + + + + + + + {data.products.map((p: any) => { + if (!p.name) { + return null; + } + return ( + setProduct(p)} + className="flex items-center justify-between group" + > + {p.name} + + + ); + })} + + + + ); + } + + const tabTriggerClass = + "data-[state=active]:bg-stone-200 data-[state=active]:text-t2 data-[state=active]:font-medium"; + + return ( + <> + + + + {data.products.map((p: any) => { + if (!p.name) { + return null; + } + + const isSelected = p.id === product.id; + return ( + { + setProduct(p); + setQueryStates({ + productId: p.id, + }); + }} + > + {p.name} + {isSelected && ( + + )} + + ); + })} + + + + ); +}; diff --git a/vite/src/views/onboarding2/model-pricing/edit-product/EditProductDetails.tsx b/vite/src/views/onboarding2/model-pricing/edit-product/EditProductDetails.tsx new file mode 100644 index 000000000..ccf517f56 --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/edit-product/EditProductDetails.tsx @@ -0,0 +1,127 @@ +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import { Input } from "@/components/ui/input"; +import { useEffect, useState } from "react"; +import { handleAutoSave } from "../model-pricing-utils/modelPricingUtils"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useModelPricingContext } from "../ModelPricingContext"; +import { slugify } from "@/utils/formatUtils/formatTextUtils"; +import { Button } from "@/components/ui/button"; +import { useProductContext } from "@/views/products/product/ProductContext"; +import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; +import { getBackendErr } from "@/utils/genUtils"; +import { toast } from "sonner"; + +export const EditProductDetails = () => { + const { + productDataState: { product, setProduct }, + data, + mutate, + } = useModelPricingContext(); + + const allowCreate = data.products.length === 0; + const { autoSave } = useProductContext(); + const [createLoading, setCreateLoading] = useState(false); + + const axiosInstance = useAxiosInstance(); + const [details, setDetails] = useState({ + name: product?.name, + id: product?.id, + }); + + useEffect(() => { + if (product.id) { + setDetails({ + name: product.name, + id: product.id, + }); + } + }, [product]); + + const handleCreateProduct = async () => { + setCreateLoading(true); + try { + await axiosInstance.post("/v1/products", { + name: details.name, + id: details.id, + }); + await mutate(); + } catch (error) { + toast.error(getBackendErr(error, "Failed to create product")); + } finally { + setCreateLoading(false); + } + }; + + return ( +
+
+ Product Name + { + if (autoSave && !allowCreate) { + await handleAutoSave({ + axiosInstance, + productId: product.id ? product.id : details.id, + product: { + ...product, + name: details.name, + id: details.id, + }, + mutate, + }); + } + setProduct({ + ...product, + name: details.name, + id: details.id, + }); + }} + placeholder="Eg. Free Plan" + value={details.name} + onChange={(e) => { + const newIdData = allowCreate + ? { + id: slugify(e.target.value), + } + : {}; + setDetails({ + ...details, + name: e.target.value, + ...newIdData, + }); + }} + /> +
+
+
+ + Product ID + + +

+ The product ID is used to identify the product in the API when + you're making a payment. +

+
+
+ + { + setDetails({ + ...details, + id: e.target.value, + }); + }} + placeholder="Eg. free_plan" + /> +
+ {allowCreate && ( + + )} +
+ ); +}; diff --git a/vite/src/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils.ts b/vite/src/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils.ts new file mode 100644 index 000000000..bbf3dd218 --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils.ts @@ -0,0 +1,31 @@ +import { getBackendErr, notNullish } from "@/utils/genUtils"; +import { ProductV2 } from "@autumn/shared"; +import { AxiosInstance } from "axios"; +import { toast } from "sonner"; + +export const handleAutoSave = async ({ + axiosInstance, + productId, + product, + mutate, +}: { + axiosInstance: AxiosInstance; + productId: string; + product: ProductV2; + mutate: any; +}) => { + if (!productId || !product.id) return; + try { + await axiosInstance.post( + `v1/products/${productId}?upsert=true&disable_version=true`, + { + ...product, + group: notNullish(product.group) ? product.group : undefined, + } + ); + await mutate(); + } catch (error) { + console.log(error); + toast.error(getBackendErr(error, "Failed to auto save product")); + } +}; diff --git a/vite/src/views/onboarding2/model-pricing/model-pricing-utils/productToPricingCard.tsx b/vite/src/views/onboarding2/model-pricing/model-pricing-utils/productToPricingCard.tsx new file mode 100644 index 000000000..587eae21f --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/model-pricing-utils/productToPricingCard.tsx @@ -0,0 +1,7 @@ +export const productToPricingCard = (product: any) => { + return { + id: product.id, + name: product.name, + items: product.items, + }; +}; diff --git a/vite/src/views/onboarding2/model-pricing/usePricingTable.tsx b/vite/src/views/onboarding2/model-pricing/usePricingTable.tsx new file mode 100644 index 000000000..ef0515e8e --- /dev/null +++ b/vite/src/views/onboarding2/model-pricing/usePricingTable.tsx @@ -0,0 +1,17 @@ +import { useAxiosSWR } from "@/services/useAxiosSwr"; + +export const useListProducts = ({ customerId }: { customerId: string }) => { + const { data, isLoading, error, mutate } = useAxiosSWR({ + url: `/v1/products?customer_id=${customerId}`, + options: { + refreshInterval: 0, + }, + }); + + return { + products: data?.list || [], + isLoading, + error, + mutate, + }; +}; diff --git a/vite/src/views/onboarding2/utils/connectStripe.ts b/vite/src/views/onboarding2/utils/connectStripe.ts new file mode 100644 index 000000000..19b68f3b3 --- /dev/null +++ b/vite/src/views/onboarding2/utils/connectStripe.ts @@ -0,0 +1,28 @@ +import { OrgService } from "@/services/OrgService"; +import { getBackendErr } from "@/utils/genUtils"; +import { AxiosInstance } from "axios"; +import { toast } from "sonner"; + +export const connectStripe = async ({ + testApiKey, + axiosInstance, + mutate, +}: { + testApiKey: string; + axiosInstance: AxiosInstance; + mutate: () => void; +}) => { + try { + await OrgService.connectStripe(axiosInstance, { + testApiKey, + liveApiKey: testApiKey, + successUrl: `https://useautumn.com`, + }); + + toast.success("Successfully connected to Stripe"); + await mutate(); + } catch (error) { + console.log("Failed to connect Stripe", error); + toast.error(getBackendErr(error, "Failed to connect Stripe")); + } +}; diff --git a/vite/src/views/onboarding2/utils/useCustomerReplica.tsx b/vite/src/views/onboarding2/utils/useCustomerReplica.tsx new file mode 100644 index 000000000..d66183248 --- /dev/null +++ b/vite/src/views/onboarding2/utils/useCustomerReplica.tsx @@ -0,0 +1,37 @@ +import { CheckoutParams } from "autumn-js"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { toast } from "sonner"; +import { toSnakeCase } from "@/utils/formatUtils/formatUtils"; + +const cusData = { + customer_id: "demo_123", + customer_data: { + name: "Demo Customer", + email: "demo@example.com", + }, +}; + +export const useCustomerReplica = () => { + const axiosInstance = useAxiosInstance(); + + const checkout = async (params: any) => { + try { + const { data } = await axiosInstance.post("/v1/checkout", { + ...cusData, + ...toSnakeCase(params), + }); + + if (data.url) { + window.open(data.url, "_blank"); + } + return data; + } catch (error: any) { + toast.error(`Failed to checkout: ${error.message}`); + return null; + } + }; + + return { + checkout, + }; +}; diff --git a/vite/src/views/products/CreateProduct.tsx b/vite/src/views/products/CreateProduct.tsx index 4f6eae52b..b2baa1b15 100644 --- a/vite/src/views/products/CreateProduct.tsx +++ b/vite/src/views/products/CreateProduct.tsx @@ -77,7 +77,9 @@ function CreateProduct({ return ( - + Create Product diff --git a/vite/src/views/products/ProductsView.tsx b/vite/src/views/products/ProductsView.tsx index 1e0c15389..cfaf9c38f 100644 --- a/vite/src/views/products/ProductsView.tsx +++ b/vite/src/views/products/ProductsView.tsx @@ -141,43 +141,43 @@ function ProductsView({ env }: { env: AppEnv }) { )} } - addButton={ - <> - - setShowArchived((prev) => !prev), - }, - ]} - /> - + addButton={} + menuComponent={ + setShowArchived((prev) => !prev), + }, + ]} + /> } /> -
-
-

Features

- - {featuresData?.features?.length || 0} - - {showArchivedFeatures && ( - - Archived - - )} -
-
- + + + {featuresData?.features?.length} + + {showArchived && ( + + Archived + + )} + + } + addButton={} + menuComponent={ -
-
+ } + /> +
diff --git a/vite/src/views/products/components/ProductRowToolbar.tsx b/vite/src/views/products/components/ProductRowToolbar.tsx index dc305d212..53475bd62 100644 --- a/vite/src/views/products/components/ProductRowToolbar.tsx +++ b/vite/src/views/products/components/ProductRowToolbar.tsx @@ -22,10 +22,12 @@ import { DeleteProductDialog } from "./DeleteProductDialog"; export const ProductRowToolbar = ({ product, productCounts, + isOnboarding = false, }: { className?: string; product: Product; productCounts: ProductCounts; + isOnboarding?: boolean; }) => { const [deleteLoading, setDeleteLoading] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -69,44 +71,48 @@ export const ProductRowToolbar = ({ - - { - e.stopPropagation(); - e.preventDefault(); - setSelectedProduct(product); - setDialogType("copy"); - setModalOpen(true); - }} - > -
- Copy - {copyLoading ? ( - - ) : ( - - )} -
-
-
- - { - e.stopPropagation(); - e.preventDefault(); - setSelectedProduct(product); - setDialogType("update"); - setModalOpen(true); - }} - > -
- Edit - -
-
-
+ {!isOnboarding && ( + + { + e.stopPropagation(); + e.preventDefault(); + setSelectedProduct(product); + setDialogType("copy"); + setModalOpen(true); + }} + > +
+ Copy + {copyLoading ? ( + + ) : ( + + )} +
+
+
+ )} + {!isOnboarding && ( + + { + e.stopPropagation(); + e.preventDefault(); + setSelectedProduct(product); + setDialogType("update"); + setModalOpen(true); + }} + > +
+ Edit + +
+
+
+ )} { diff --git a/vite/src/views/products/product/ManageProduct.tsx b/vite/src/views/products/product/ManageProduct.tsx index 05a3bbabc..7b8960446 100644 --- a/vite/src/views/products/product/ManageProduct.tsx +++ b/vite/src/views/products/product/ManageProduct.tsx @@ -11,7 +11,7 @@ export const ManageProduct = ({ hideAdminHover?: boolean; }) => { const env = useEnv(); - const { isOnboarding, product, entityId, customer } = useProductContext(); + const { product, entityId, customer } = useProductContext(); return (
diff --git a/vite/src/views/products/product/ProductView.tsx b/vite/src/views/products/product/ProductView.tsx index f606f374c..cdb3aa78a 100644 --- a/vite/src/views/products/product/ProductView.tsx +++ b/vite/src/views/products/product/ProductView.tsx @@ -51,32 +51,14 @@ function ProductView({ env }: { env: AppEnv }) { setEntityFeatureIds, actionState, isNewProduct, - } = useProductData({ data }); + } = useProductData({ + originalProduct: data?.product as any, + originalFeatures: data?.features as any, + }); - // Replace the current useBlocker call with a fixed useEffect const { modal } = useProductChangedAlert({ hasChanges }); - const [buttonLoading, setButtonLoading] = useState(false); - // const hasCustomers = counts?.all > 0; - - // const handleAutoSave = async () => { - // setButtonLoading(true); - // try { - // console.log("Auto saving"); - // await new Promise((resolve) => setTimeout(resolve, 1000)); - // await updateProduct(); - // } catch (error) { - // toast.error(getBackendErr(error, "Failed to auto save product")); - // } - // setButtonLoading(false); - // }; - // useEffect(() => { - // if (hasChanges && !hasCustomers) { - // handleAutoSave(); - // } - // }, [hasChanges]); - if (isLoading) return ; if (!product) { @@ -153,7 +135,6 @@ function ProductView({ env }: { env: AppEnv }) { entityFeatureIds, setEntityFeatureIds, hasChanges, - buttonLoading, setButtonLoading, }} diff --git a/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx b/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx index 52d9ad153..212b8cea7 100644 --- a/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx +++ b/vite/src/views/products/product/free-trial/CreateFreeTrial.tsx @@ -10,6 +10,8 @@ import { useProductContext } from "../ProductContext"; import { FreeTrialConfig } from "./FreeTrialConfig"; import { toast } from "sonner"; import { FreeTrialDuration } from "@autumn/shared"; +import { handleAutoSave } from "@/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; export const CreateFreeTrial = ({ open, @@ -18,10 +20,10 @@ export const CreateFreeTrial = ({ open: boolean; setOpen: (open: boolean) => void; }) => { - // const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); - const [price, setPrice] = useState(null); - const { env, product, setProduct, prices } = useProductContext(); + const { product, setProduct, autoSave, mutate } = useProductContext(); + + const axiosInstance = useAxiosInstance(); const [freeTrial, setFreeTrial] = useState({ length: 7, @@ -44,6 +46,15 @@ export const CreateFreeTrial = ({ duration: freeTrial.duration, }, }); + + if (autoSave) { + handleAutoSave({ + axiosInstance, + productId: product.id, + product: { ...product, free_trial: freeTrial }, + mutate, + }); + } setOpen(false); }; diff --git a/vite/src/views/products/product/hooks/useProductData.tsx b/vite/src/views/products/product/hooks/useProductData.tsx index ff0b4887e..21fac3351 100644 --- a/vite/src/views/products/product/hooks/useProductData.tsx +++ b/vite/src/views/products/product/hooks/useProductData.tsx @@ -3,7 +3,13 @@ import { sortProductItems } from "@/utils/productUtils"; import { AppEnv, Feature, ProductItem, ProductV2 } from "@autumn/shared"; import { useEffect, useRef, useState } from "react"; -export const useProductData = ({ data }: { data: any }) => { +export const useProductData = ({ + originalProduct, + originalFeatures, +}: { + originalProduct: ProductV2 | null; + originalFeatures: Feature[] | null; +}) => { const initialProductRef = useRef(null); const [hasChanges, setHasChanges] = useState(false); const [product, setProduct] = useState(null); @@ -16,16 +22,16 @@ export const useProductData = ({ data }: { data: any }) => { new Set( product.items .filter((item: ProductItem) => item.entity_feature_id != null) - .map((item: ProductItem) => item.entity_feature_id!), - ), + .map((item: ProductItem) => item.entity_feature_id!) + ) ); }; useEffect(() => { - if (data?.product) { + if (originalProduct) { const sortedProduct = { - ...data.product, - items: sortProductItems(data.product.items), + ...originalProduct, + items: sortProductItems(originalProduct.items), }; initialProductRef.current = structuredClone(sortedProduct); @@ -33,10 +39,10 @@ export const useProductData = ({ data }: { data: any }) => { setProduct(sortedProduct); } - if (data?.features) { - setFeatures(data.features); + if (originalFeatures) { + setFeatures(originalFeatures); } - }, [data]); + }, [originalProduct, originalFeatures]); useEffect(() => { if (!product) return; diff --git a/vite/src/views/products/product/prices/CreateFixedPrice.tsx b/vite/src/views/products/product/prices/CreateFixedPrice.tsx index 19d626909..563b85f2a 100644 --- a/vite/src/views/products/product/prices/CreateFixedPrice.tsx +++ b/vite/src/views/products/product/prices/CreateFixedPrice.tsx @@ -4,6 +4,7 @@ import { useProductContext } from "../ProductContext"; import { SelectCycle } from "../product-item/product-item-config/components/feature-price/SelectBillingCycle"; import { useProductItemContext } from "../product-item/ProductItemContext"; import { + intervalsDifferent, ProductItem, ProductItemInterval, UpdateProductSchema, @@ -42,7 +43,7 @@ function CreateFixedPrice({ (item: ProductItem, index: number) => { const isSameItem = selectedIndex && selectedIndex == index; return !isSameItem && isPriceItem(item) && item.interval; - }, + } ); const newVariantMap: Record = { @@ -61,7 +62,18 @@ function CreateFixedPrice({ return null; } - if (item.interval == curFixedPrice.interval) { + const intervalsDiff = intervalsDifferent({ + intervalA: { + interval: item.interval, + intervalCount: item.interval_count, + }, + intervalB: { + interval: curFixedPrice.interval, + intervalCount: curFixedPrice.interval_count, + }, + }); + + if (!intervalsDiff) { return null; } @@ -70,6 +82,10 @@ function CreateFixedPrice({ ? "an annual" : `a ${newVariantMap[item.interval! as ProductItemInterval]}`; + if (item.interval_count > 1) { + return `A fixed price already exists on this product. If you're looking to create a version with a different interval, you should create a new product instead.`; + } + return `A fixed price already exists on this product. If you're looking to create ${newIntervalText} version, you should create a new product instead.`; }; @@ -123,7 +139,7 @@ function CreateFixedPrice({ step="any" className="h-full !text-lg min-w-36" /> - + {org?.default_currency?.toUpperCase() || "USD"}
diff --git a/vite/src/views/products/product/prices/PricingConfig.tsx b/vite/src/views/products/product/prices/PricingConfig.tsx index 095acc0c7..bb2205bb0 100644 --- a/vite/src/views/products/product/prices/PricingConfig.tsx +++ b/vite/src/views/products/product/prices/PricingConfig.tsx @@ -37,7 +37,7 @@ export const PricingConfig = ({ }; const [priceType, setPriceType] = useState( - priceConfig?.type || PriceType.Fixed, + priceConfig?.type || PriceType.Fixed ); const [name, setName] = useState(priceConfig?.name || ""); @@ -45,19 +45,19 @@ export const PricingConfig = ({ const [fixedConfig, setFixedConfig] = useState( priceConfig && priceConfig.type == PriceType.Fixed ? priceConfig - : defaultFixedConfig, + : defaultFixedConfig ); const [usageConfig, setUsageConfig]: any = useState( priceConfig?.config && priceConfig.config.type == PriceType.Usage ? priceConfig.config - : defaultUsageConfig, + : defaultUsageConfig ); const [originalPrice, _] = useState(priceConfig); useEffect(() => { setPriceConfig( - fixedConfig, + fixedConfig // { // ...originalPrice, // name: name, @@ -163,7 +163,7 @@ export const validateUsageConfig = (usageConfig: any) => { parseFloat(tier.to) < parseFloat(tier.from) ) { toast.error( - "Each tier's 'to' value must be greater than its 'from' value", + "Each tier's 'to' value must be greater than its 'from' value" ); return null; } @@ -179,11 +179,6 @@ export const validateUsageConfig = (usageConfig: any) => { // Validate fixed price config export const validateFixedConfig = (fixedConfig: any) => { - // if (!validBillingInterval(prices, fixedConfig)) { - // toast.error("Can't have two prices with different billing intervals"); - // return null; - // } - const config = { ...fixedConfig }; if (invalidNumber(config.amount) || !config.interval) { diff --git a/vite/src/views/products/product/product-item/CreateProductItem.tsx b/vite/src/views/products/product/product-item/CreateProductItem.tsx index fdbd6618b..0a54c2221 100644 --- a/vite/src/views/products/product/product-item/CreateProductItem.tsx +++ b/vite/src/views/products/product/product-item/CreateProductItem.tsx @@ -1,30 +1,23 @@ import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; -import { useState } from "react"; -import { ProductItemConfig } from "./ProductItemConfig"; +import { useEffect, useState } from "react"; import { ProductItemContext } from "./ProductItemContext"; -import { CreateFeature } from "@/views/features/CreateFeature"; + import { ProductItemInterval, ProductItem, CreateFeature as CreateFeatureType, - UpdateProductSchema, } from "@autumn/shared"; import { useProductContext } from "../ProductContext"; import { validateProductItem } from "@/utils/product/product-item/validateProductItem"; - -import { DialogContentWrapper } from "@/components/general/modal-components/DialogContentWrapper"; -import { ItemConfigFooter } from "./product-item-config/item-config-footer/ItemConfigFooter"; -import { ProductService } from "@/services/products/ProductService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { CreateItemDialogContent } from "./create-product-item/CreateItemDialogContent"; +import { Plus } from "lucide-react"; +import { useSteps } from "./useSteps"; +import { CreateItemStep } from "./utils/CreateItemStep"; +import { isFeatureItem } from "@/utils/product/getItemType"; export const defaultProductItem: ProductItem = { feature_id: null, @@ -66,13 +59,13 @@ export function CreateProductItem() { const { features, product, setProduct, setFeatures, counts, mutate } = useProductContext(); - const axiosInstance = useAxiosInstance(); - const hasCustomers = counts?.all > 0; + // const axiosInstance = useAxiosInstance(); + // const hasCustomers = counts?.all > 0; - const setSelectedFeature = (feature: CreateFeatureType) => { - setFeatures([...features, feature]); - setItem({ ...item, feature_id: feature.id! }); - }; + // const setSelectedFeature = (feature: CreateFeatureType) => { + // setFeatures([...features, feature]); + // setItem({ ...item, feature_id: feature.id! }); + // }; const handleCreateProductItem = async (entityFeatureId?: string) => { const validatedItem = validateProductItem({ @@ -96,6 +89,16 @@ export function CreateProductItem() { setOpen(false); }; + const stepState = useSteps({ + initialStep: CreateItemStep.CreateItem, + }); + + useEffect(() => { + if (open && isFeatureItem(item) && features.length == 0) { + stepState.replaceStep(CreateItemStep.CreateFeature); + } + }, [open]); + return (
+ + +
+ +
+
+ ); +} + +{ + /* - -
- + */ +} +{ + /*
@@ -173,10 +197,7 @@ export function CreateProductItem() { (features.length == 0 && item.price === null) ? (
) : ( - + {}} /> )} - -
- - ); + */ } diff --git a/vite/src/views/products/product/product-item/CreateProductItem2.tsx b/vite/src/views/products/product/product-item/CreateProductItem2.tsx new file mode 100644 index 000000000..59f77a036 --- /dev/null +++ b/vite/src/views/products/product/product-item/CreateProductItem2.tsx @@ -0,0 +1,126 @@ +import { Button } from "@/components/ui/button"; +import { Dialog, DialogTrigger } from "@/components/ui/dialog"; + +import { useState } from "react"; +import { ProductItemContext } from "./ProductItemContext"; + +import { + ProductItemInterval, + ProductItem, + CreateFeature as CreateFeatureType, +} from "@autumn/shared"; + +import { useProductContext } from "../ProductContext"; +import { validateProductItem } from "@/utils/product/product-item/validateProductItem"; +import { PlusIcon } from "lucide-react"; +import { CreateItemDialogContent } from "./create-product-item/CreateItemDialogContent"; +import { useModelPricingContext } from "@/views/onboarding2/model-pricing/ModelPricingContext"; +import { useSteps } from "./useSteps"; +import { CreateItemStep } from "./utils/CreateItemStep"; +import { cn } from "@/lib/utils"; +import { defaultPriceItem } from "./create-product-item/defaultItemConfigs"; + +const defaultProductItem: ProductItem = { + feature_id: null, + + included_usage: null, + + interval: ProductItemInterval.Month, + + // Price config + price: null, + tiers: null, + billing_units: 1, + + // Others + entity_feature_id: null, + reset_usage_when_enabled: true, +}; + +export function CreateProductItem2({ + classNames, +}: { + classNames?: { + button?: string; + }; +}) { + const [open, setOpen] = useState(false); + const [showCreateFeature, setShowCreateFeature] = useState(false); + const [item, setItem] = useState(defaultProductItem); + const { features, product, setProduct } = useProductContext(); + const { setFirstItemCreated } = useModelPricingContext(); + + const stepState = useSteps({ initialStep: CreateItemStep.SelectItemType }); + + const handleCreateProductItem = async (entityFeatureId?: string) => { + const validatedItem = validateProductItem({ + item: { + ...item, + entity_feature_id: entityFeatureId + ? entityFeatureId + : item.entity_feature_id, + }, + features, + }); + + if (!validatedItem) return; + + const newItems = [...product.items, validatedItem]; + const newProduct = { ...product, items: newItems }; + setProduct(newProduct); + setTimeout(() => { + setItem({ + ...defaultProductItem, + feature_id: null, + }); + }, 400); + + setOpen(false); + // setFirstItemCreated(true); + return newProduct; + }; + + return ( + + +
+ + + + + + +
+ +
+
+ ); +} diff --git a/vite/src/views/products/product/product-item/EntitiesDropdown.tsx b/vite/src/views/products/product/product-item/EntitiesDropdown.tsx index 1a87437e0..3b2824b9f 100644 --- a/vite/src/views/products/product/product-item/EntitiesDropdown.tsx +++ b/vite/src/views/products/product/product-item/EntitiesDropdown.tsx @@ -23,6 +23,7 @@ import { CheckIcon, PlusIcon } from "lucide-react"; import { CreateFeature } from "@/views/features/CreateFeature"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { CustomDialogContent } from "@/components/general/modal-components/DialogContentWrapper"; export const EntitiesDropdown = ({ open, @@ -55,7 +56,7 @@ export const EntitiesDropdownContent = () => { const continuousUseFeatures = features.filter( (feature: Feature) => - feature.config?.usage_type === FeatureUsageType.Continuous, + feature.config?.usage_type === FeatureUsageType.Continuous ); return ( @@ -71,12 +72,12 @@ export const EntitiesDropdownContent = () => { const itemsUsingEntity = product.items?.filter( (productItem: ProductItem) => - productItem.entity_feature_id === item.id, + productItem.entity_feature_id === item.id ) || []; if (itemsUsingEntity.length > 0) { toast.error( - "Please delete all items under this entity first", + "Please delete all items under this entity first" ); return currentIds; } @@ -110,19 +111,22 @@ export const EntitiesDropdownContent = () => { - - - Create Entity - + {}} - setSelectedFeature={() => {}} + // isFromEntitlement={false} + // setShowFeatureCreate={() => {}} + // setSelectedFeature={() => {}} setOpen={setCreateFeatureOpen} open={createFeatureOpen} entityCreate={true} /> - + + {/* + + Create Entity + + + */} ); diff --git a/vite/src/views/products/product/product-item/ProductItemConfig.tsx b/vite/src/views/products/product/product-item/ProductItemConfig.tsx index 27b4d8ceb..daa7c74a5 100644 --- a/vite/src/views/products/product/product-item/ProductItemConfig.tsx +++ b/vite/src/views/products/product/product-item/ProductItemConfig.tsx @@ -22,9 +22,7 @@ import { isFeaturePriceItem, isPriceItem } from "@/utils/product/getItemType"; export const ProductItemConfig = () => { // HOOKS const { features } = useProductContext(); - const { item, setItem } = useProductItemContext(); - const [show, setShow] = useState(getShowParams(item)); const handleAddPrice = () => { diff --git a/vite/src/views/products/product/product-item/ProductItemRow.tsx b/vite/src/views/products/product/product-item/ProductItemRow.tsx index 75e5139ea..54f377a9d 100644 --- a/vite/src/views/products/product/product-item/ProductItemRow.tsx +++ b/vite/src/views/products/product/product-item/ProductItemRow.tsx @@ -1,5 +1,6 @@ // ProductItemRow.tsx import { + BillingInterval, Feature, FeatureType, getFeatureName, @@ -21,6 +22,8 @@ import { Flag } from "lucide-react"; import { cn } from "@/lib/utils"; import { isFeatureItem, isPriceItem } from "@/utils/product/getItemType"; import { notNullish } from "@/utils/genUtils"; +import { useProductContext } from "../ProductContext"; +import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils"; interface ProductItemRowProps { item: ProductItem; @@ -29,6 +32,7 @@ interface ProductItemRowProps { features: Feature[]; org: any; onRowClick: (item: ProductItem, index: number) => void; + className?: string; } export const ProductItemRow = ({ @@ -38,7 +42,9 @@ export const ProductItemRow = ({ features, org, onRowClick, + className, }: ProductItemRowProps) => { + const { product } = useProductContext(); const getName = ({ featureId, units, @@ -81,6 +87,17 @@ export const ProductItemRow = ({ }); } + let intervalStr = ""; + if ( + item.interval_count && + item.interval_count > 1 && + notNullish(item.interval) + ) { + intervalStr = ` ${item.interval_count} ${item.interval}s`; + } else if (item.interval) { + intervalStr = ` ${item.interval}`; + } + return (
{item.included_usage ?? 0}  @@ -89,7 +106,7 @@ export const ProductItemRow = ({ per {entityFeatureName}   )} {notNullish(item.interval) && ( - per {item.interval} + per {intervalStr} )}
); @@ -128,8 +145,13 @@ export const ProductItemRow = ({ billUnitsFeatureName }`; + const intervalStr = formatIntervalText({ + billingInterval: item.interval as unknown as BillingInterval, + intervalCount: item.interval_count ?? 1, + }); + if (!intervalIsNone(item.interval)) { - amountStr += ` per ${item.interval}`; + amountStr += ` ${intervalStr}`; } if (item.included_usage) { @@ -151,8 +173,13 @@ export const ProductItemRow = ({ amount: item.price!, }); + const intervalStr = formatIntervalText({ + billingInterval: item.interval as unknown as BillingInterval, + intervalCount: item.interval_count ?? 1, + }); + if (!intervalIsNone(item.interval)) { - return `${formattedAmount} per ${item.interval}`; + return `${formattedAmount} ${intervalStr}`; } return `${formattedAmount}`; @@ -200,13 +227,15 @@ export const ProductItemRow = ({ }; const itemType = getItemType(item); + const isLast = index === product.items.length - 1; return (
onRowClick(item, index)} > @@ -226,7 +255,12 @@ export const ProductItemRow = ({ : getPaidFeatureString(item)} - + { - const { product, setProduct, features, org, entityFeatureIds } = +export const ProductItemTable = () => { + const { product, features, org, entityFeatureIds, isOnboarding, autoSave } = useProductContext(); + const [selectedItem, setSelectedItem] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); const [open, setOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); - const [entitiesOpen, setEntitiesOpen] = useState(false); const [freeTrialOpen, setFreeTrialOpen] = useState(false); const handleRowClick = (item: ProductItem, index: number) => { @@ -44,11 +40,11 @@ export const ProductItemTable = ({ const groupedItems = entityFeatureIds.reduce( (acc: Record, entityFeatureId: string) => { acc[entityFeatureId] = product.items.filter( - (item: ProductItem) => item.entity_feature_id === entityFeatureId, + (item: ProductItem) => item.entity_feature_id === entityFeatureId ); return acc; }, - {} as Record, + {} as Record ); return ( @@ -64,65 +60,83 @@ export const ProductItemTable = ({

Product Items

+
-
- - - -
+ {!isOnboarding && ( + <> + + + +
*/}
-
+ +
{/* Original product items mapping - excluding items that appear in grouped sections */} {product.items .filter( (item: ProductItem) => !entityFeatureIds.some( (entityFeatureId: string) => - item.entity_feature_id === entityFeatureId, - ), + item.entity_feature_id === entityFeatureId + ) ) .map((item: ProductItem, index: number) => ( -

+

{entityFeatureId}

@@ -163,7 +182,7 @@ export const ProductItemTable = ({ org={org} onRowClick={handleRowClick} /> - ), + ) )} {/* Show message if no items for this entityFeatureId */} @@ -171,7 +190,7 @@ export const ProductItemTable = ({
Add the features this entity gets access to @@ -181,7 +200,16 @@ export const ProductItemTable = ({ ))} {product.items.length === 0 && ( -
+
+ {/*

+ Product items determine what customers get access to and how + they're billed. Start by adding one. +

*/}

Product items determine what customers get access to and how they're billed{" "} diff --git a/vite/src/views/products/product/product-item/UpdateProductItem.tsx b/vite/src/views/products/product/product-item/UpdateProductItem.tsx index 5a13461b0..8b6a2ebdb 100644 --- a/vite/src/views/products/product/product-item/UpdateProductItem.tsx +++ b/vite/src/views/products/product/product-item/UpdateProductItem.tsx @@ -8,7 +8,11 @@ import { notNullish } from "@/utils/genUtils"; import { validateProductItem } from "@/utils/product/product-item/validateProductItem"; import CopyButton from "@/components/general/CopyButton"; -import { DialogContentWrapper } from "@/components/general/modal-components/DialogContentWrapper"; +import { + CustomDialogContent, + CustomDialogBody, + CustomDialogFooter, +} from "@/components/general/modal-components/DialogContentWrapper"; import { ItemConfigFooter } from "./product-item-config/item-config-footer/ItemConfigFooter"; export default function UpdateProductItem({ @@ -28,18 +32,19 @@ export default function UpdateProductItem({ const [showCreateFeature, setShowCreateFeature] = useState(false); const handleUpdateProductItem = () => { - console.log("Selected Item: ", selectedItem); const validatedItem = validateProductItem({ item: selectedItem!, features, }); - if (!validatedItem) return; + if (!validatedItem) return null; if (notNullish(selectedIndex)) { const newProduct = { ...product }; newProduct.items[selectedIndex!] = validatedItem; setProduct(newProduct); setOpen(false); + + return newProduct; } }; @@ -49,6 +54,7 @@ export default function UpdateProductItem({ newProduct.items.splice(selectedIndex!, 1); setProduct(newProduct); setOpen(false); + return newProduct; } }; @@ -66,7 +72,23 @@ export default function UpdateProductItem({ }} >

- + + +
+ Update Item + {selectedItem?.feature_id && ( + + {selectedItem.feature_id || ""} + + )} +
+ +
+ + +
+ + {/*
Update Item @@ -79,7 +101,7 @@ export default function UpdateProductItem({ - + */}
); diff --git a/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx b/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx index 309b320ac..0a4ceac3b 100644 --- a/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx +++ b/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx @@ -16,7 +16,7 @@ export const ConfigWithFeature = ({ handleAddPrice: () => void; }) => { const { features } = useProductContext(); - const { item, setItem } = useProductItemContext(); + const { item } = useProductItemContext(); const isBooleanFeature = getFeature(item.feature_id, features)?.type === FeatureType.Boolean; diff --git a/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx b/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx index b9c43880d..2713cb19d 100644 --- a/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx +++ b/vite/src/views/products/product/product-item/components/SelectItemFeature.tsx @@ -8,10 +8,13 @@ import { import { useProductItemContext } from "../ProductItemContext"; import { useProductContext } from "../../ProductContext"; import { FeatureTypeBadge } from "@/views/features/FeatureTypeBadge"; -import { Feature } from "@autumn/shared"; +import { Feature, FeatureType, ProductItemType } from "@autumn/shared"; import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PlusIcon } from "lucide-react"; +import { getItemType } from "@/utils/product/productItemUtils"; +import { CreateItemStep } from "../utils/CreateItemStep"; +import { useEffect, useState } from "react"; export const SelectItemFeature = ({ show, @@ -21,12 +24,21 @@ export const SelectItemFeature = ({ setShow: any; }) => { const { features } = useProductContext(); - const { item, setItem, setShowCreateFeature, isUpdate } = - useProductItemContext(); + const { item, setItem, isUpdate, stepState } = useProductItemContext(); + const [open, setOpen] = useState(false); + const itemType = getItemType(item); + + // useEffect(() => { + // if (stepState.previousStep === CreateItemStep.SelectItemType) { + // setOpen(true); + // } + // }, [stepState.previousStep]); return (
setIntervalCount(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSave(); + } + if (e.key === "Escape") { + setOpen(false); + } + }} + /> + +
+ + + ); +}; diff --git a/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx b/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx index 7c400c342..56467eeec 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx @@ -27,7 +27,7 @@ export const IncludedUsage = () => {
void; + pushStep: (step: CreateItemStep) => void; +}) => { + const { features } = useProductContext(); + const { item } = useProductItemContext(); + + return ( + <> + + Select a feature +
+ {features + .filter((feature: Feature) => { + if (isFeaturePriceItem(item)) { + return feature.type !== FeatureType.Boolean; + } + return true; + }) + .map((feature: Feature, index: number) => ( +
{ + // setItem({ ...item, feature_id: feature.id! }); + }} + > + {feature.name} + +
+ ))} +
+
+ +
+
+ + + + + ); +}; diff --git a/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx b/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx index 7de1f0f7d..884a6f92c 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx @@ -8,13 +8,53 @@ import { import { Select } from "@/components/ui/select"; import { useProductItemContext } from "../../ProductItemContext"; import { isFeaturePriceItem } from "@/utils/product/getItemType"; -import { EntInterval, FeatureUsageType, Infinite } from "@autumn/shared"; +import { + BillingInterval, + EntInterval, + FeatureUsageType, + Infinite, +} from "@autumn/shared"; import { itemToEntInterval } from "@/utils/product/itemIntervalUtils"; import FieldLabel from "@/components/general/modal-components/FieldLabel"; import { cn } from "@/lib/utils"; import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; import { getFeatureUsageType } from "@/utils/product/entitlementUtils"; import { useProductContext } from "../../../ProductContext"; +import { Button } from "@/components/ui/button"; +import { ArrowUp01, CheckIcon, PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ToggleDisplayButton } from "@/components/general/ToggleDisplayButton"; +import { Input } from "@/components/ui/input"; +import { CustomiseIntervalPopover } from "./CusomiseIntervalPopover"; + +const getIntervalText = ({ + interval, + intervalCount, + billingInterval, +}: { + interval?: EntInterval; + billingInterval?: BillingInterval; + intervalCount?: number; +}) => { + const finalInterval = interval ?? billingInterval; + if ( + finalInterval === EntInterval.Lifetime || + finalInterval === BillingInterval.OneOff + ) { + return "no reset"; + } + if (intervalCount && intervalCount > 1) { + return `per ${intervalCount} ${finalInterval}s`; + } + return finalInterval === BillingInterval.SemiAnnual + ? "per half year" + : `per ${finalInterval}`; +}; export const SelectResetCycle = () => { const { features } = useProductContext(); @@ -34,11 +74,13 @@ export const SelectResetCycle = () => { return null; } + const interval = itemToEntInterval(item); + return (
@@ -50,28 +92,124 @@ export const SelectResetCycle = () => { - +
+ +
); }; + +// const SelectIntervalCountPopover = () => { +// const [open, setOpen] = useState(false); +// const { item, setItem } = useProductItemContext(); +// const [intervalCount, setIntervalCount] = useState(item.interval_count || 1); + +// const handleSave = () => { +// setItem({ +// ...item, +// interval_count: parseInt(intervalCount || 1), +// }); +// setOpen(false); +// }; + +// return ( +// +// +// +// +// e.preventDefault()} +// onCloseAutoFocus={(e) => e.preventDefault()} +// > +//
+// Interval Count +//
+//
+// setIntervalCount(e.target.value)} +// onKeyDown={(e) => { +// if (e.key === "Enter") { +// handleSave(); +// } +// if (e.key === "Escape") { +// setOpen(false); +// } +// }} +// /> +// +//
+//
+//
+// ); +// }; + +const SelectIntervalItem = ({ + interval, + isSelected, +}: { + interval: EntInterval; + isSelected: boolean; +}) => { + const { item } = useProductItemContext(); + const [hover, setHover] = useState(false); + + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + > +
+ + {getIntervalText({ interval, intervalCount: item?.interval_count })} + +
+
+ ); +}; diff --git a/vite/src/views/products/product/product-item/product-item-config/components/feature-price/SelectBillingCycle.tsx b/vite/src/views/products/product/product-item/product-item-config/components/feature-price/SelectBillingCycle.tsx index 784befefc..171bae1cb 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/feature-price/SelectBillingCycle.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/feature-price/SelectBillingCycle.tsx @@ -10,6 +10,8 @@ import { import { BillingInterval, UsageModel } from "@autumn/shared"; import { useProductItemContext } from "../../../ProductItemContext"; import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; +import { CustomiseIntervalPopover } from "../CusomiseIntervalPopover"; +import { formatIntervalText } from "@/utils/formatUtils/formatTextUtils"; export const SelectCycle = () => { const { item, setItem } = useProductItemContext(); @@ -27,13 +29,13 @@ export const SelectCycle = () => { }); }; - const intervalText = (interval: BillingInterval) => { - return interval === BillingInterval.SemiAnnual - ? "per half year" - : interval === BillingInterval.OneOff - ? "one off" - : `per ${interval}`; - }; + // const intervalText = (interval: BillingInterval) => { + // return interval === BillingInterval.SemiAnnual + // ? "per half year" + // : interval === BillingInterval.OneOff + // ? "one off" + // : `per ${interval}`; + // }; return (
@@ -45,7 +47,38 @@ export const SelectCycle = () => { period. - {/* + +
+
+ +
+
+
+ ); +}; + +{ + /* @@ -67,28 +100,5 @@ export const SelectCycle = () => { )} - */} - -
-
- -
-
-
- ); -}; + */ +} diff --git a/vite/src/views/products/product/product-item/product-item-config/item-config-footer/ItemConfigFooter.tsx b/vite/src/views/products/product/product-item/product-item-config/item-config-footer/ItemConfigFooter.tsx index a3a6b7bfa..02ce3f3db 100644 --- a/vite/src/views/products/product/product-item/product-item-config/item-config-footer/ItemConfigFooter.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/item-config-footer/ItemConfigFooter.tsx @@ -2,12 +2,20 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useProductItemContext } from "../../ProductItemContext"; import { isEmptyItem } from "@/utils/product/getItemType"; -import { XIcon } from "lucide-react"; +import { ArrowLeftIcon, XIcon } from "lucide-react"; import { useProductContext } from "../../../ProductContext"; import { AddToEntityDropdown } from "./AddToEntityDropdown"; +import { handleAutoSave } from "@/views/onboarding2/model-pricing/model-pricing-utils/modelPricingUtils"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { CustomDialogFooter } from "@/components/general/modal-components/DialogContentWrapper"; -export const ItemConfigFooter = () => { - const { entityFeatureIds } = useProductContext(); +export const ItemConfigFooter = ({ + handleBack, +}: { + handleBack?: () => void; +}) => { + const axiosInstance = useAxiosInstance(); + const { entityFeatureIds, product, mutate, autoSave } = useProductContext(); const { item, handleCreateProductItem, @@ -22,40 +30,88 @@ export const ItemConfigFooter = () => { const isEmpty = isEmptyItem(item); + const showIntro = product.items.length === 0; + return ( -
- {/* */} - {/* */} - {handleUpdateProductItem && ( + + {handleBack ? ( + ) : ( +
)} - {handleUpdateProductItem && ( - - )} - {showEntityDropdown && handleCreateProductItem && } - {!showEntityDropdown && handleCreateProductItem && ( - - )} -
+ +
+ {handleUpdateProductItem && ( + + )} + {handleUpdateProductItem && ( + + )} + {showEntityDropdown && handleCreateProductItem && ( + + )} + {!showEntityDropdown && handleCreateProductItem && ( + + )} +
+ ); }; diff --git a/vite/src/views/products/product/product-item/useSteps.tsx b/vite/src/views/products/product/product-item/useSteps.tsx new file mode 100644 index 000000000..0caa353ab --- /dev/null +++ b/vite/src/views/products/product/product-item/useSteps.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { CreateItemStep } from "./utils/CreateItemStep"; + +export const useSteps = ({ initialStep }: { initialStep: CreateItemStep }) => { + const [stepVal, setStepVal] = useState(initialStep); + const [stepStack, setStepStack] = useState([initialStep]); + + // useEffect(() => { + // setStepStack([initialStep]); + // setStepVal(initialStep); + // }, []); + + const popStep = () => { + if (stepStack.length === 1) { + return; + } + + setStepStack((prev) => { + const newStack = prev.slice(0, -1); + const newStep = newStack[newStack.length - 1]; + setStepVal(newStep); + return newStack; + }); + }; + + const pushStep = (step: CreateItemStep) => { + console.log("Pushing step!", step); + setStepStack((prev) => [...prev, step]); + setStepVal(step); + }; + + const resetSteps = () => { + setStepStack([initialStep]); + setStepVal(initialStep); + }; + + const replaceStep = (step: CreateItemStep) => { + const curStack = stepStack; + if (curStack.length <= 1) { + setStepStack([step]); + setStepVal(step); + } else { + setStepStack((prev) => [...prev.slice(0, -1), step]); + setStepVal(step); + } + }; + + return { + stepVal, + popStep, + pushStep, + resetSteps, + replaceStep, + previousStep: stepStack.length > 1 ? stepStack[stepStack.length - 2] : null, + stepCount: stepStack.length, + }; +}; diff --git a/vite/src/views/products/product/product-item/utils/CreateItemStep.tsx b/vite/src/views/products/product/product-item/utils/CreateItemStep.tsx new file mode 100644 index 000000000..3e3c7a1c5 --- /dev/null +++ b/vite/src/views/products/product/product-item/utils/CreateItemStep.tsx @@ -0,0 +1,6 @@ +export enum CreateItemStep { + SelectItemType = "select_item_type", + CreateFeature = "create_feature", + SelectFeature = "select_feature", + CreateItem = "create_item", +} diff --git a/vite/src/views/products/product/utils/updateProduct.ts b/vite/src/views/products/product/utils/updateProduct.ts new file mode 100644 index 000000000..87f80eafb --- /dev/null +++ b/vite/src/views/products/product/utils/updateProduct.ts @@ -0,0 +1,32 @@ +import { ProductService } from "@/services/products/ProductService"; +import { getBackendErr } from "@/utils/genUtils"; +import { Product, ProductV2, UpdateProductSchema } from "@autumn/shared"; +import { AxiosInstance } from "axios"; +import { toast } from "sonner"; + +export const updateProduct = async ({ + axiosInstance, + product, + mutate, + mutateCount, +}: { + axiosInstance: AxiosInstance; + product: ProductV2; + mutate: () => void; + mutateCount: () => void; +}) => { + try { + await ProductService.updateProduct(axiosInstance, product.id, { + ...UpdateProductSchema.parse(product), + items: product.items, + free_trial: product.free_trial, + }); + + toast.success("Product created successfully"); + + await mutate(); + await mutateCount(); + } catch (error) { + toast.error(getBackendErr(error, "Failed to update product")); + } +};