diff --git a/apps/docs/mintlify/api-reference/billing/billingAttach.mdx b/apps/docs/mintlify/api-reference/billing/billingAttach.mdx index f7d0e1380..4de7e2831 100644 --- a/apps/docs/mintlify/api-reference/billing/billingAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/billingAttach.mdx @@ -244,7 +244,7 @@ const response = await autumn.billing.attach({ - + How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx index c4c6aedd2..b0ff5992a 100644 --- a/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/billingUpdate.mdx @@ -233,7 +233,7 @@ const response = await autumn.billing.update({ - + How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx index a09bd13a3..7b0e4d229 100644 --- a/apps/docs/mintlify/api-reference/billing/previewAttach.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewAttach.mdx @@ -199,7 +199,7 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx - + How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx index a6dd62f8d..6d71c2bc8 100644 --- a/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx +++ b/apps/docs/mintlify/api-reference/billing/previewUpdate.mdx @@ -199,7 +199,7 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx - + How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/apps/docs/mintlify/api/openapi.yml b/apps/docs/mintlify/api/openapi.yml index 1fb95ad9f..4fb881937 100644 --- a/apps/docs/mintlify/api/openapi.yml +++ b/apps/docs/mintlify/api/openapi.yml @@ -5415,7 +5415,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -5838,7 +5838,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -6268,7 +6268,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -6683,7 +6683,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the diff --git a/apps/docs/mintlify/docs.json b/apps/docs/mintlify/docs.json index 98b4bdd2b..fb58c1664 100644 --- a/apps/docs/mintlify/docs.json +++ b/apps/docs/mintlify/docs.json @@ -197,10 +197,6 @@ "api-reference/referrals/redeemReferralCode" ] }, - { - "group": "TODO", - "pages": ["api-reference/billing/setupPayment"] - }, { "group": "Platform (Beta)", "pages": [ diff --git a/bun.lock b/bun.lock index b30e1447f..8dbac195e 100644 --- a/bun.lock +++ b/bun.lock @@ -155,7 +155,7 @@ }, "packages/sdk": { "name": "@useautumn/sdk", - "version": "0.10.16", + "version": "0.10.17", "dependencies": { "zod": "^3.25.65 || ^4.0.0", }, diff --git a/others/python-sdk/.gitignore b/others/python-sdk/.gitignore index e26ccc191..61c9e816e 100644 --- a/others/python-sdk/.gitignore +++ b/others/python-sdk/.gitignore @@ -11,4 +11,4 @@ pyrightconfig.json .speakeasy/reports .env .env.local -.mypy_cache/ \ No newline at end of file +.mypy_cache/ diff --git a/others/python-sdk/.speakeasy/gen.lock b/others/python-sdk/.speakeasy/gen.lock index e266ddc1f..42c756f72 100644 --- a/others/python-sdk/.speakeasy/gen.lock +++ b/others/python-sdk/.speakeasy/gen.lock @@ -1,16 +1,16 @@ lockVersion: 2.0.0 id: 05940b80-1ef8-40f4-9878-822fb2792070 management: - docChecksum: 496841b4a90d1a69cc9da5b759f85137 + docChecksum: 49db0511a420181a25c6bf09d7bcac5a docVersion: 2.1.0 speakeasyVersion: 1.719.0 generationVersion: 2.824.1 - releaseVersion: 0.4.16 - configChecksum: 211b7870a80557cf24e27f54f43eaca2 + releaseVersion: 0.4.17 + configChecksum: 2fd2e3cdfeca190c82c97c016f010a78 persistentEdits: - generation_id: 8230bcef-cdfb-4dd0-b30f-4d655a695c5a - pristine_commit_hash: 204e2568a0f2581995a26c7fa8e67f12a54c7a9d - pristine_tree_hash: cd3bf981c9b772589240122e0a0be99572395a80 + generation_id: d867aa25-7002-45c0-9257-d1552210f0fe + pristine_commit_hash: fef7bd1893678ccd9300e5aec919a85cbd8d4d2e + pristine_tree_hash: 734ab30d9c0e0586332acd2645bdc679782d6bb2 features: python: additionalDependencies: 1.0.0 @@ -121,8 +121,8 @@ trackedFiles: pristine_git_object: 593a752a830a4abd82ce47a483331b88395e9835 docs/models/billingattachbillingbehavior.md: id: 931c5fa3bac8 - last_write_checksum: sha1:7f63c7595c0eb8c5226d6a209efebde2b7408416 - pristine_git_object: 65926ef8f013057008c30089fbacd1982f32ee7c + last_write_checksum: sha1:72042a6039ef727a0428cd5aa4e0bdfa6efd668c + pristine_git_object: 83b36aae378454f2e0bcc561063951ba074778ea docs/models/billingattachbillingmethod.md: id: 7ad8ef9b29bf last_write_checksum: sha1:241dab01905175215909ddd7458ee6213a286b8e @@ -241,8 +241,8 @@ trackedFiles: pristine_git_object: 71b0acda81003e7766e3e4401d45dc1247e527f5 docs/models/billingupdatebillingbehavior.md: id: 1f59cde41ae4 - last_write_checksum: sha1:2cf2d5690ce0906f67d76e3d392e39d3e2c0a85a - pristine_git_object: 6299d3db69544f12ee49122ebf8fd2b908ec03be + last_write_checksum: sha1:dc577791e575223afecdb7640936b1d42f34f6c7 + pristine_git_object: 85fea141afc3e3a6ccc343f740b6551a00c6758f docs/models/billingupdatebillingmethod.md: id: 12159f4a0d20 last_write_checksum: sha1:21bba85f59699f5bf1e8497ecce35e67b70fcac0 @@ -1249,8 +1249,8 @@ trackedFiles: pristine_git_object: 30f2fde544d6369ffb95e242d86285de93ba6877 docs/models/previewattachbillingbehavior.md: id: 09c5ce0d8d6a - last_write_checksum: sha1:c8760159f7391b802a88614b3c02804ade44f538 - pristine_git_object: 939760d3cd9da80372692bc08c6b3ab4297249de + last_write_checksum: sha1:c49cf325ae448c831a8fdfc738cf0e39817c3a93 + pristine_git_object: 2aec934913098829658d8a605370c86f2f3c01c9 docs/models/previewattachbillingmethod.md: id: 4fbccec2190f last_write_checksum: sha1:39e5c7556d0f2739d994e97698da4a0dba485cbf @@ -1373,8 +1373,8 @@ trackedFiles: pristine_git_object: 90c95d6ef72958ecea08dce511385caca0711e01 docs/models/previewupdatebillingbehavior.md: id: 4c825497a1a0 - last_write_checksum: sha1:1ea205c3b54899d5fc15e7e28a7290f946c7b451 - pristine_git_object: 622030b6784036aedbcc08c688621e5abcedb33a + last_write_checksum: sha1:81ea0792818a5625680925e167980b5576c66f93 + pristine_git_object: 36df79bc1fa6b7d4b4cb25814efac71c88454027 docs/models/previewupdatebillingmethod.md: id: 1d817ab2aabc last_write_checksum: sha1:d7eb39bc6e0796792c173162dbcc17b37bdec535 @@ -1885,8 +1885,8 @@ trackedFiles: pristine_git_object: 5650f49e06066f8a1338afdbc0e155f5277152df pyproject.toml: id: 5d07e7d72637 - last_write_checksum: sha1:38bae71ae83e0aac07f8d22e581c5b862521f8ec - pristine_git_object: 8788d172ee07d9252f679daf43bf0ff6deb770da + last_write_checksum: sha1:aba72bf104473833b5c27a14c12b72ec7056ccdf + pristine_git_object: fa66a09cdd4e41e9e96d6936449c424574e446b6 scripts/publish.sh: id: fe273b08f514 last_write_checksum: sha1:adc9b741c12ad1591ab4870eabe20f0d0a86cd1a @@ -1909,8 +1909,8 @@ trackedFiles: pristine_git_object: 3e604651c1eae73d815b276806e73b2f1334bb79 src/autumn_sdk/_version.py: id: a98babfdf4fc - last_write_checksum: sha1:830a760d87a75f10a5ecd1b11cf50dc944ee2236 - pristine_git_object: cd8aa447fd525660f52fe04b608134cb18f607c3 + last_write_checksum: sha1:45939110fb0d40ecfefdc0c29153e73f51bf4177 + pristine_git_object: 2d130c9fd4c3244a6b1f26d8d8f95dc1188aa2af src/autumn_sdk/balances.py: id: 0a15be654dad last_write_checksum: sha1:bcef18a651842ffe96a5a72f608c4c37e2443bf7 @@ -1977,12 +1977,12 @@ trackedFiles: pristine_git_object: 30665ef90067900c833e72a3c066687c03fa2b3a src/autumn_sdk/models/billingattachop.py: id: a3c4907bef48 - last_write_checksum: sha1:9adfbde080cb531280d43a3c9db2b0b0012ceee1 - pristine_git_object: 59ce78a23331d0a1196513c2dd7199df4ffeed01 + last_write_checksum: sha1:d314f4ee99218b80f4c71d9ddd5d10f9e817096c + pristine_git_object: a04420c28adabbd6255ce9f7558d28a16f866aa6 src/autumn_sdk/models/billingupdateop.py: id: a2f17c75cfd3 - last_write_checksum: sha1:5323cd5e48231487c86d7b98c347c5ead067b704 - pristine_git_object: f255a231bc5a9cb71d4891ef19af70712755d30e + last_write_checksum: sha1:3afd5c47f00c1d554f9d202f49a5c0b23080080f + pristine_git_object: a0f843812bb7f8534e85025c59a1d3875fe3c3d5 src/autumn_sdk/models/checkop.py: id: 31c2f84723c6 last_write_checksum: sha1:739049de845629f70c56a5cd5c202dabfdc6b59f @@ -2085,12 +2085,12 @@ trackedFiles: pristine_git_object: 76e8c1505e9102308c14c632d8b5193ee79ea500 src/autumn_sdk/models/previewattachop.py: id: 2b361be4bfa8 - last_write_checksum: sha1:2f00c5a157a31f3a3a3c1c9ef70f4085dc763357 - pristine_git_object: 6826b9228536c10719e94aa729df71e29cd6a7cc + last_write_checksum: sha1:d28e2f0d5dda8b9ebc9719709892909b7bb0270a + pristine_git_object: 805ce7cb4747408558cabf95528b47da81d57863 src/autumn_sdk/models/previewupdateop.py: id: 081d5f08508d - last_write_checksum: sha1:ce8b9f121251ccfa38bf5f0cf01a27c5ede5223c - pristine_git_object: 486da27a3b605ed39b12fbc5f838707d76035b68 + last_write_checksum: sha1:d3da48de15f96034c970837fbffbddf045b2fe67 + pristine_git_object: 8dad7fc3d8fdc508e6b85e06371f2a2e5cafca5e src/autumn_sdk/models/redeemreferralcodeop.py: id: 0abd7bfae718 last_write_checksum: sha1:9096caecd5e39572e409302b99c6c89be0ae9add diff --git a/others/python-sdk/.speakeasy/gen.yaml b/others/python-sdk/.speakeasy/gen.yaml index b0ea85422..61396e048 100644 --- a/others/python-sdk/.speakeasy/gen.yaml +++ b/others/python-sdk/.speakeasy/gen.yaml @@ -30,7 +30,7 @@ generation: generateNewTests: true skipResponseBodyAssertions: false python: - version: 0.4.16 + version: 0.4.17 additionalDependencies: dev: {} main: {} diff --git a/others/python-sdk/.vscode/settings.json b/others/python-sdk/.vscode/settings.json new file mode 100644 index 000000000..8d79f0abb --- /dev/null +++ b/others/python-sdk/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.testing.pytestArgs": ["tests", "-vv"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "pylint.args": ["--rcfile=pylintrc"] +} diff --git a/others/python-sdk/pyproject.toml b/others/python-sdk/pyproject.toml index 8788d172e..fa66a09cd 100644 --- a/others/python-sdk/pyproject.toml +++ b/others/python-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "autumn-sdk" -version = "0.4.16" +version = "0.4.17" description = "Python SDK for the Autumn billing API" authors = [{ name = "Autumn" },] readme = "README.md" diff --git a/others/python-sdk/src/autumn_sdk/_version.py b/others/python-sdk/src/autumn_sdk/_version.py index cd8aa447f..2d130c9fd 100644 --- a/others/python-sdk/src/autumn_sdk/_version.py +++ b/others/python-sdk/src/autumn_sdk/_version.py @@ -3,10 +3,10 @@ import importlib.metadata __title__: str = "autumn-sdk" -__version__: str = "0.4.16" +__version__: str = "0.4.17" __openapi_doc_version__: str = "2.1.0" __gen_version__: str = "2.824.1" -__user_agent__: str = "speakeasy-sdk/python 0.4.16 2.824.1 2.1.0 autumn-sdk" +__user_agent__: str = "speakeasy-sdk/python 0.4.17 2.824.1 2.1.0 autumn-sdk" try: if __package__ is not None: diff --git a/others/python-sdk/src/autumn_sdk/models/billingattachop.py b/others/python-sdk/src/autumn_sdk/models/billingattachop.py index 59ce78a23..a04420c28 100644 --- a/others/python-sdk/src/autumn_sdk/models/billingattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/billingattachop.py @@ -536,7 +536,7 @@ class BillingAttachInvoiceMode(BaseModel): BillingAttachBillingBehavior = Literal[ "prorate_immediately", - "next_cycle_only", + "none", ] r"""How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle.""" diff --git a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py index f255a231b..a0f843812 100644 --- a/others/python-sdk/src/autumn_sdk/models/billingupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/billingupdateop.py @@ -536,7 +536,7 @@ class BillingUpdateInvoiceMode(BaseModel): BillingUpdateBillingBehavior = Literal[ "prorate_immediately", - "next_cycle_only", + "none", ] r"""How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle.""" diff --git a/others/python-sdk/src/autumn_sdk/models/previewattachop.py b/others/python-sdk/src/autumn_sdk/models/previewattachop.py index 6826b9228..805ce7cb4 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewattachop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewattachop.py @@ -535,7 +535,7 @@ class PreviewAttachInvoiceMode(BaseModel): PreviewAttachBillingBehavior = Literal[ "prorate_immediately", - "next_cycle_only", + "none", ] r"""How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle.""" diff --git a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py index 486da27a3..8dad7fc3d 100644 --- a/others/python-sdk/src/autumn_sdk/models/previewupdateop.py +++ b/others/python-sdk/src/autumn_sdk/models/previewupdateop.py @@ -535,7 +535,7 @@ class PreviewUpdateInvoiceMode(BaseModel): PreviewUpdateBillingBehavior = Literal[ "prorate_immediately", - "next_cycle_only", + "none", ] r"""How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle.""" diff --git a/others/python-sdk/uv.lock b/others/python-sdk/uv.lock index df7ec8c62..edf1c40fd 100644 --- a/others/python-sdk/uv.lock +++ b/others/python-sdk/uv.lock @@ -44,7 +44,7 @@ wheels = [ [[package]] name = "autumn-sdk" -version = "0.4.16" +version = "0.4.17" source = { editable = "." } dependencies = [ { name = "httpcore" }, diff --git a/packages/autumn-js/TODO.md b/packages/autumn-js/TODO.md deleted file mode 100644 index 9535c62b0..000000000 --- a/packages/autumn-js/TODO.md +++ /dev/null @@ -1,102 +0,0 @@ -# autumn-js Remaining Work - -This document tracks features from the old `libraries/` folder that were deleted during the v2 migration and need to be reimplemented. - -## React Client (`src/react/`) - Partially Reimplemented - -### Completed -- [x] `AutumnProvider` - Migrated to TanStack Query -- [x] `useCustomer` - Reimplemented with TanStack Query -- [x] `useListPlans` - New hook for listing plans - -### Not Yet Reimplemented - -#### Hooks -- [ ] `usePricingTable` - Fetches products with display information, used by vite app -- [ ] `useEntity` - Fetches entity data by ID -- [ ] `useAutumn` - Provides imperative methods (attach, track, cancel, checkout, etc.) - -#### useAutumn Methods (from old `useAutumnBase.tsx`) -- [ ] `attach(params)` - Attach a product to customer, with optional dialog support -- [ ] `checkout(params)` - Create checkout session, with optional dialog support -- [ ] `track(params)` - Track usage event -- [ ] `cancel(params)` - Cancel subscription -- [ ] `openBillingPortal(params)` - Open Stripe billing portal -- [ ] `setupPayment(params)` - Setup payment method - -#### Dialog System (DEPRECATED - Do not reimplement) -- ~~AttachDialog~~ - Deprecated -- ~~PaywallDialog~~ - Deprecated -- ~~CheckoutDialog~~ - Deprecated -- ~~PricingTable component~~ - Deprecated - -#### Types/Utilities -- [ ] `ProductDetails` type - Product with display information -- [ ] `compareParams` utility - For SWR cache comparison (may not be needed with TanStack Query) - -## Backend (`src/backend/`) - Reimplemented - -The new backend is in `src/backend/` and uses RPC-style routes. - -### Deleted (Old System) -The entire `libraries/backend/` folder was deleted: -- [x] `next.ts` - Replaced by `src/backend/adapters/next.ts` -- [x] `hono.ts` - Replaced by `src/backend/adapters/hono.ts` -- [x] `express.ts` - Not reimplemented -- [x] `fastify.ts` - Not reimplemented -- [x] `elysia.ts` - Not reimplemented -- [x] `supabase.ts` - Not reimplemented -- [x] `tanstack.ts` - Not reimplemented -- [x] `react-router.ts` - Not reimplemented -- [x] `routes/` folder - Superseded by `src/backend/core/routes/` -- [x] `utils/` folder - Superseded by `src/backend/core/handlers/` - -### Better Auth Integration (DELETED - Needs Reimplementation) -- [ ] `better-auth.ts` - Better Auth plugin was deleted, needs to be reimplemented using new `src/backend/` route system -- [ ] `utils/betterAuth/middlewares.ts` - Organization/identity context helpers -- [ ] `utils/betterAuth/types.ts` - AutumnOptions type for Better Auth - -## Backend Routes Not Yet Implemented - -The new RPC-based backend (`src/backend/`) currently supports: -- [x] `customers.get_or_create` -- [x] `billing.attach` -- [x] `billing.setup_payment` -- [x] `balances.check` -- [x] `balances.track` -- [x] `plans.list` - -### Not Yet Implemented -- [ ] `billing.checkout` - Create checkout session -- [ ] `billing.cancel` - Cancel subscription -- [ ] `billing.portal` - Open billing portal -- [ ] `entities.create` - Create entity -- [ ] `entities.get` - Get entity by ID -- [ ] `entities.delete` - Delete entity -- [ ] `referrals.create_code` - Create referral code -- [ ] `referrals.redeem` - Redeem referral code -- [ ] `events.list` - List customer events - -## Types (Deleted) - -- [x] `CustomerData` schema/type was deleted - Available from `@useautumn/sdk` as `CustomerData` - -## Vite App Compatibility - -The vite app currently imports from `autumn-js/react` expecting: -- `AutumnProvider` ✅ (available in new location) -- `useCustomer` ✅ (available in new location) -- `usePricingTable` ❌ (not yet reimplemented) - -### Required for Vite App -1. Either reimplement `usePricingTable` or update vite app to use alternative -2. Ensure `autumn-js/react` export path resolves correctly - -## Priority Order - -1. **High**: Fix vite build errors (update imports or reimplement `usePricingTable`) -2. **High**: Delete old backend files (except better-auth) -3. **Medium**: Implement remaining backend routes -4. **Medium**: Implement `useAutumn` hook with imperative methods -5. **Low**: Migrate better-auth to new route system -6. **Low**: Implement entity/referral hooks if needed diff --git a/packages/openapi/CHECKLIST.md b/packages/openapi/CHECKLIST.md deleted file mode 100644 index 0cbab7402..000000000 --- a/packages/openapi/CHECKLIST.md +++ /dev/null @@ -1,55 +0,0 @@ -# OpenAPI v2.1 Migration Checklist - -## API Endpoints - -### Customers -- [x] getOrCreate -- [ ] list -- [ ] update -- [ ] delete - -### Plans -- [x] list -- [x] get -- [ ] create -- [ ] update -- [ ] delete - -### Features -- [ ] list -- [ ] get -- [ ] create -- [ ] update -- [ ] delete - -### Referrals -- [ ] createCode -- [ ] redeemCode - -### Balances -- [x] create -- [x] update -- [x] check -- [x] track - -### Billing -- [x] attach -- [x] previewAttach -- [x] update -- [x] previewUpdate -- [x] setupPayment -- [ ] openCustomerPortal - -### Events -- [ ] list -- [ ] aggregate - ---- - -## SDK Hooks (autumn-js) - -- [ ] useCustomer -- [ ] useEntity -- [ ] useListEvents -- [ ] useAggregateEvents -- [ ] useListPlans diff --git a/packages/openapi/openapi-stripped.yml b/packages/openapi/openapi-stripped.yml index c25a057b5..141618ddf 100644 --- a/packages/openapi/openapi-stripped.yml +++ b/packages/openapi/openapi-stripped.yml @@ -4996,7 +4996,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -5390,7 +5390,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -5791,7 +5791,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -6165,7 +6165,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the diff --git a/packages/openapi/openapi.yml b/packages/openapi/openapi.yml index bb3c51efb..6bb4abbe1 100644 --- a/packages/openapi/openapi.yml +++ b/packages/openapi/openapi.yml @@ -1111,6 +1111,8 @@ paths: ``` + @param id - Your unique identifier for the customer (optional) + @param name - Customer's name (optional) @param email - Customer's email address (optional) @@ -5386,7 +5388,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -5837,7 +5839,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -6308,7 +6310,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the @@ -6728,7 +6730,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the diff --git a/packages/sdk/.speakeasy/gen.lock b/packages/sdk/.speakeasy/gen.lock index 036df47f3..ea209f85b 100644 --- a/packages/sdk/.speakeasy/gen.lock +++ b/packages/sdk/.speakeasy/gen.lock @@ -1,16 +1,16 @@ lockVersion: 2.0.0 id: 7b300647-cd76-49e9-bf77-7d1bf5446d66 management: - docChecksum: 728e593776c0f14b63fd391fb223e079 + docChecksum: 86c76948b54379ea100745014d2e2c2c docVersion: 2.1.0 speakeasyVersion: 1.719.0 generationVersion: 2.824.1 - releaseVersion: 0.10.16 - configChecksum: ee830af0c6c545ecb6d1ae928ed35f3c + releaseVersion: 0.10.17 + configChecksum: 86155769f09f26b39c1899ef81e56460 persistentEdits: - generation_id: 21189aad-0241-4da1-8290-f578d27eb2f9 - pristine_commit_hash: bc3fbbf951471d5b8b44b5e0dc387a4d2a37eca7 - pristine_tree_hash: 69ee26ed3d67c98203f2a974eea58d5ad844af5d + generation_id: 9bad9fbe-6bdc-4b5a-83e6-2184ee50854d + pristine_commit_hash: 04db08d87d3064c5ae2c4ba740ae9df3866d5765 + pristine_tree_hash: 3639067febe0096684942994b20058b9c1476edc features: typescript: additionalDependencies: 0.1.0 @@ -144,8 +144,8 @@ trackedFiles: pristine_git_object: 038a6883854d90582b9758ec49195d01f38d6dc1 docs/models/billing-attach-billing-behavior.md: id: 69bb81e8c7dc - last_write_checksum: sha1:9166f12d3a5f6e639a8d871d1760e76e6e7016ed - pristine_git_object: 8c746b111471fe8bd917852dbea64c219ccf1b32 + last_write_checksum: sha1:f88dd1e238db4bd3a5273f238d312243a0d98d2a + pristine_git_object: d551aa37c2fe53364ca08c815511cf23df646fc8 docs/models/billing-attach-billing-method.md: id: b8065e6e9007 last_write_checksum: sha1:1ba7d88438747acd7bcb3cef4b014924093d7fed @@ -264,8 +264,8 @@ trackedFiles: pristine_git_object: 2a7148fde0e0cd4e38a970985affc03a78d27438 docs/models/billing-update-billing-behavior.md: id: e8a9f500c132 - last_write_checksum: sha1:72b10eef0e79146a1dfb3515d9f058932a0b7305 - pristine_git_object: 68874c9e1da701993b0d8c86fcddf0814d536872 + last_write_checksum: sha1:fb4e9ef8609b3aa0fd39fa2fac4140acfdf4530f + pristine_git_object: 2227b02ad1db267bfbf22dcae7bec20944c67275 docs/models/billing-update-billing-method.md: id: 7fa432ebb343 last_write_checksum: sha1:17f53127f8d8ec1a7828bf149b6c791027bacb83 @@ -1264,8 +1264,8 @@ trackedFiles: pristine_git_object: 4b2e0aaf440368a1d29a1a1a87149a162733b914 docs/models/preview-attach-billing-behavior.md: id: d74cdf2445f9 - last_write_checksum: sha1:dc93f1c2f72a5edc0632962b47e7642c1b206992 - pristine_git_object: 52a5ee918a72ccbb8b0c022d1b7e629526f7ba87 + last_write_checksum: sha1:87f4c0db8798be51a9c66480c5cee214ca0ebb20 + pristine_git_object: 055d38267a5bb2cb0f50c9de6bc7b4f36765907b docs/models/preview-attach-billing-method.md: id: ed9b4632aabc last_write_checksum: sha1:eb5a3f7f8f5ad0474de57572cfe430997d4d7d0e @@ -1388,8 +1388,8 @@ trackedFiles: pristine_git_object: e7f0025357984088cebe4aa6f9af87ef6848d774 docs/models/preview-update-billing-behavior.md: id: 993399767e7c - last_write_checksum: sha1:511fa4f9e620a7e0eb3bba9f4e207ee8fb99f7b1 - pristine_git_object: d576dca35cbb63a9111bbc9f933cccfed4f299e0 + last_write_checksum: sha1:9ef61df56a201739c628a235e63111bc1b28f23f + pristine_git_object: 815e889fc8541a73d4ba8ed86fac8ba92052a92c docs/models/preview-update-billing-method.md: id: 51dabedde884 last_write_checksum: sha1:2c62ff19bad8a6a43708790251fd93d5363031c6 @@ -1868,8 +1868,8 @@ trackedFiles: pristine_git_object: c1331f5a3f08cbc1fbccc88f0c75f53c251fe1db docs/sdks/customers/README.md: id: 9332759cffc2 - last_write_checksum: sha1:67ee9f4c5ba6f23fd7e16807f8503ed5a065e1ba - pristine_git_object: 72e1e0133dad64984f1ea4de472fd9b57728a036 + last_write_checksum: sha1:77a118d627b03bf22d75641916ff2488bb0a672a + pristine_git_object: 46b5a316942f520f76cd260de74ffeb86129fcce docs/sdks/entities/README.md: id: a140ac5181b9 last_write_checksum: sha1:31cb5af5782c55759d3c021195c27ec254539a64 @@ -1912,12 +1912,12 @@ trackedFiles: pristine_git_object: 900d545ed58929951e2208e1bec791cb264429a4 jsr.json: id: 7f6ab7767282 - last_write_checksum: sha1:182aebceba2b68f8150866413e2a55501acef211 - pristine_git_object: 3de4707b6302e048c32ad734b6ac48e7bc1cb583 + last_write_checksum: sha1:b13f704693b00e367a6be01d8999522450053545 + pristine_git_object: 397d986f1533891eddd2b39bf57e502c5d71613a package.json: id: 7030d0b2f71b - last_write_checksum: sha1:8a7beb2f79aaaa323ba36863961662687b924221 - pristine_git_object: dc9d6585288c1089ed72f6cfc634549ca2390799 + last_write_checksum: sha1:ccce7e66c9bb241f74bee50fac536d72da80c2f9 + pristine_git_object: cea8a792bbaf1e6c7e05c026273b04c6576f2441 src/core.ts: id: f431fdbcd144 last_write_checksum: sha1:f8f24a3ca09c1efb285d7a75ad3697d0128f47e2 @@ -1964,8 +1964,8 @@ trackedFiles: pristine_git_object: 97ab0552c007b0eb7c55d34684aa17e36d60b217 src/funcs/customers-get-or-create.ts: id: e47fc41cfecc - last_write_checksum: sha1:e8a108d9a8a32f9e764a9b80f7c1b454764d241b - pristine_git_object: 60eb2fbb28f64d9bd87055255fc60ca32faac346 + last_write_checksum: sha1:5a3c3f5242b8bf875bf5641d84c6ec2754886568 + pristine_git_object: af86db240da193691dec5325e2a1d50a94b5fcf6 src/funcs/customers-list.ts: id: 97f381c7d9f0 last_write_checksum: sha1:06aacf6e29eccaa29e79b05b3673271e10c38682 @@ -2068,8 +2068,8 @@ trackedFiles: pristine_git_object: 44be0eae8246521b230e8e711a88eff738fc015d src/lib/config.ts: id: 320761608fb3 - last_write_checksum: sha1:0ab3765168c2830b9c4deb8724cecb6f036d9fb2 - pristine_git_object: 0c35c6ebe2ac12f5ea748cd7c505d58c310ae4cd + last_write_checksum: sha1:f49c64a7def502aba4bb9ea2129a259afbd9a2d5 + pristine_git_object: 5f464f88245fbbac3290341925ec7d8514be6b3e src/lib/dlv.ts: id: b1988214835a last_write_checksum: sha1:1dd3e3fbb4550c4bf31f5ef997faff355d6f3250 @@ -2144,12 +2144,12 @@ trackedFiles: pristine_git_object: b378971b319bfdc4b03988d5897df347bc70d004 src/models/billing-attach-op.ts: id: c0a94471ba75 - last_write_checksum: sha1:44ca45fd6ebc8bfb1cbfb63c1fc77461bbdd1fc7 - pristine_git_object: 9ec756afd868daf823e84ccf9d53dfb93cc50a58 + last_write_checksum: sha1:bb2bd04bdca6657a3378f3b7a4381b94febbbd7e + pristine_git_object: 123cbdf68056d9dd2f858cf36e8a9c54820eaf83 src/models/billing-update-op.ts: id: e7371769c7ca - last_write_checksum: sha1:071a9b6c4254fbe05c74e95d7945d71863704b26 - pristine_git_object: 6fd50000462856c84f28a2d1c49f82684b16bbd7 + last_write_checksum: sha1:106f99c5935fa590658bb2205b5971037b6dc9b1 + pristine_git_object: ab2b3dd81297a4a2fadfdc83e593e6cf1062a5f0 src/models/check-op.ts: id: 42085bda016a last_write_checksum: sha1:1f9e9edaefce1df7169788f6937f9253fef71d6d @@ -2252,12 +2252,12 @@ trackedFiles: pristine_git_object: 532f8e2229d1cb10d65bc53a1dce4dc385b78aa3 src/models/preview-attach-op.ts: id: 3efc6e3443a7 - last_write_checksum: sha1:a15071a983ba541dcde675407602c9cf364a97b5 - pristine_git_object: 6e9a3651c41bb91079208e3c7a37c7077dbcd1b7 + last_write_checksum: sha1:39faf44bf16e48b92b57924ef0f8e258266b84b5 + pristine_git_object: 6e36d0966d2ad8649ffc06f7d80175c62357d38b src/models/preview-update-op.ts: id: fcbbbf3b22ac - last_write_checksum: sha1:c3fb6a74929cc59028f28e08df474d44899a87a2 - pristine_git_object: 9b566805ef15e904eed39526c9d845975c142b46 + last_write_checksum: sha1:9f1b73b70af2cb09a74190feb5b741a5cd32df2d + pristine_git_object: 61f24f33589ae10ca0bab9f1a47d7a6ba23ad870 src/models/redeem-referral-code-op.ts: id: 511bf73dc4c6 last_write_checksum: sha1:9ab6622018c82175ea98d2b26eadb4abf08f441a @@ -2308,8 +2308,8 @@ trackedFiles: pristine_git_object: 6d5e3103a53537c4859d36b23013a8903ab0f34a src/sdk/customers.ts: id: d33e193e0c00 - last_write_checksum: sha1:3e4d794f7a68a5e1962483b578b5643b339581fe - pristine_git_object: d1e0a4ac4103ca552dc0229185403106809a01d7 + last_write_checksum: sha1:5a0593915cd17675d251336bdffc0182109f5c11 + pristine_git_object: af2e398777d078ce9efec2288d0194219933c1fd src/sdk/entities.ts: id: 71997b5f9b62 last_write_checksum: sha1:a91d90292d175b72263eeeaaf08bfd32a22b2599 diff --git a/packages/sdk/.speakeasy/gen.yaml b/packages/sdk/.speakeasy/gen.yaml index 3e6c9acbf..4042a423c 100644 --- a/packages/sdk/.speakeasy/gen.yaml +++ b/packages/sdk/.speakeasy/gen.yaml @@ -33,7 +33,7 @@ generation: generateNewTests: true skipResponseBodyAssertions: false typescript: - version: 0.10.16 + version: 0.10.17 acceptHeaderEnum: false additionalDependencies: dependencies: {} diff --git a/packages/sdk/.speakeasy/out.openapi.yaml b/packages/sdk/.speakeasy/out.openapi.yaml index ce7ade0b6..4c80cb613 100644 --- a/packages/sdk/.speakeasy/out.openapi.yaml +++ b/packages/sdk/.speakeasy/out.openapi.yaml @@ -1062,6 +1062,8 @@ paths: ``` + @param id - Your unique identifier for the customer (optional) + @param name - Customer's name (optional) @param email - Customer's email address (optional) @@ -5023,7 +5025,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. discounts: type: array @@ -5416,7 +5418,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. discounts: type: array @@ -5833,7 +5835,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. cancel_action: enum: @@ -6202,7 +6204,7 @@ paths: billing_behavior: enum: - prorate_immediately - - next_cycle_only + - none description: How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. cancel_action: enum: diff --git a/packages/sdk/.speakeasy/workflow.lock b/packages/sdk/.speakeasy/workflow.lock index d2d3d475d..752309a92 100644 --- a/packages/sdk/.speakeasy/workflow.lock +++ b/packages/sdk/.speakeasy/workflow.lock @@ -9,8 +9,8 @@ sources: - 2.1.0 Autumn API Stripped: sourceNamespace: autumn-api-stripped - sourceRevisionDigest: sha256:1ba23ac0091b999dc6c9e794a75ac9c008fb937037cfc00ee7a6e5f875f547fe - sourceBlobDigest: sha256:8b4ebac21e57944966d95f11ea03411ab45d52bd21865f11259d6da37d462db6 + sourceRevisionDigest: sha256:edbf90fa4f47725e1ddd7fd1404ec034bc197efaf226d79c99e7c89367d0357e + sourceBlobDigest: sha256:705b6ae611198fcda278df9b5fa18ade84c757e5a7b9d6a4a4e322d1b1e7b554 tags: - latest - 2.1.0 @@ -25,8 +25,8 @@ targets: autumn-python: source: Autumn API Stripped sourceNamespace: autumn-api-stripped - sourceRevisionDigest: sha256:1ba23ac0091b999dc6c9e794a75ac9c008fb937037cfc00ee7a6e5f875f547fe - sourceBlobDigest: sha256:8b4ebac21e57944966d95f11ea03411ab45d52bd21865f11259d6da37d462db6 + sourceRevisionDigest: sha256:edbf90fa4f47725e1ddd7fd1404ec034bc197efaf226d79c99e7c89367d0357e + sourceBlobDigest: sha256:705b6ae611198fcda278df9b5fa18ade84c757e5a7b9d6a4a4e322d1b1e7b554 codeSamplesNamespace: autumn-api-python-code-samples codeSamplesRevisionDigest: sha256:ce4215f239e65976dbfffcd7e5d76f506dc0ba33a3046a99f846502ff192fe71 workflow: diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 3d2a28ab4..d1909c595 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -343,6 +343,7 @@ Use this as the primary entrypoint before billing operations so the customer rec const response = await client.getOrCreate({ customerId: "cus_123", name: "John Doe", email: "john@example.com" }); ``` +@param id - Your unique identifier for the customer (optional) @param name - Customer's name (optional) @param email - Customer's email address (optional) @param fingerprint - Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse (optional) @@ -704,6 +705,7 @@ Use this as the primary entrypoint before billing operations so the customer rec const response = await client.getOrCreate({ customerId: "cus_123", name: "John Doe", email: "john@example.com" }); ``` +@param id - Your unique identifier for the customer (optional) @param name - Customer's name (optional) @param email - Customer's email address (optional) @param fingerprint - Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse (optional) diff --git a/packages/sdk/jsr.json b/packages/sdk/jsr.json index 3de4707b6..397d986f1 100644 --- a/packages/sdk/jsr.json +++ b/packages/sdk/jsr.json @@ -2,7 +2,7 @@ { "name": "@useautumn/sdk", - "version": "0.10.16", + "version": "0.10.17", "exports": { ".": "./src/index.ts", "./models": "./src/models/index.ts", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 490682ca9..fe58d7845 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@useautumn/sdk", - "version": "0.10.16", + "version": "0.10.17", "author": "Speakeasy", "main": "./dist/commonjs/index.js", "module": "./dist/esm/index.js", diff --git a/packages/sdk/src/funcs/customers-get-or-create.ts b/packages/sdk/src/funcs/customers-get-or-create.ts index 60eb2fbb2..af86db240 100644 --- a/packages/sdk/src/funcs/customers-get-or-create.ts +++ b/packages/sdk/src/funcs/customers-get-or-create.ts @@ -36,6 +36,7 @@ import { Result } from "../types/fp.js"; * const response = await client.getOrCreate({ customerId: "cus_123", name: "John Doe", email: "john@example.com" }); * ``` * + * @param id - Your unique identifier for the customer (optional) * @param name - Customer's name (optional) * @param email - Customer's email address (optional) * @param fingerprint - Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse (optional) diff --git a/packages/sdk/src/lib/config.ts b/packages/sdk/src/lib/config.ts index 0c35c6ebe..5f464f882 100644 --- a/packages/sdk/src/lib/config.ts +++ b/packages/sdk/src/lib/config.ts @@ -66,7 +66,7 @@ export function serverURLFromOptions(options: SDKOptions): URL | null { export const SDK_METADATA = { language: "typescript", openapiDocVersion: "2.1.0", - sdkVersion: "0.10.16", + sdkVersion: "0.10.17", genVersion: "2.824.1", - userAgent: "speakeasy-sdk/typescript 0.10.16 2.824.1 2.1.0 @useautumn/sdk", + userAgent: "speakeasy-sdk/typescript 0.10.17 2.824.1 2.1.0 @useautumn/sdk", } as const; diff --git a/packages/sdk/src/models/billing-attach-op.ts b/packages/sdk/src/models/billing-attach-op.ts index 9ec756afd..123cbdf68 100644 --- a/packages/sdk/src/models/billing-attach-op.ts +++ b/packages/sdk/src/models/billing-attach-op.ts @@ -334,7 +334,7 @@ export type BillingAttachInvoiceMode = { */ export const BillingAttachBillingBehavior = { ProrateImmediately: "prorate_immediately", - NextCycleOnly: "next_cycle_only", + None: "none", } as const; /** * How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/packages/sdk/src/models/billing-update-op.ts b/packages/sdk/src/models/billing-update-op.ts index 6fd500004..ab2b3dd81 100644 --- a/packages/sdk/src/models/billing-update-op.ts +++ b/packages/sdk/src/models/billing-update-op.ts @@ -334,7 +334,7 @@ export type BillingUpdateInvoiceMode = { */ export const BillingUpdateBillingBehavior = { ProrateImmediately: "prorate_immediately", - NextCycleOnly: "next_cycle_only", + None: "none", } as const; /** * How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/packages/sdk/src/models/preview-attach-op.ts b/packages/sdk/src/models/preview-attach-op.ts index 6e9a3651c..6e36d0966 100644 --- a/packages/sdk/src/models/preview-attach-op.ts +++ b/packages/sdk/src/models/preview-attach-op.ts @@ -333,7 +333,7 @@ export type PreviewAttachInvoiceMode = { */ export const PreviewAttachBillingBehavior = { ProrateImmediately: "prorate_immediately", - NextCycleOnly: "next_cycle_only", + None: "none", } as const; /** * How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/packages/sdk/src/models/preview-update-op.ts b/packages/sdk/src/models/preview-update-op.ts index 9b566805e..61f24f335 100644 --- a/packages/sdk/src/models/preview-update-op.ts +++ b/packages/sdk/src/models/preview-update-op.ts @@ -333,7 +333,7 @@ export type PreviewUpdateInvoiceMode = { */ export const PreviewUpdateBillingBehavior = { ProrateImmediately: "prorate_immediately", - NextCycleOnly: "next_cycle_only", + None: "none", } as const; /** * How to handle billing when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' skips creating any charges and applies the change at the next billing cycle. diff --git a/packages/sdk/src/sdk/customers.ts b/packages/sdk/src/sdk/customers.ts index d1e0a4ac4..af2e39877 100644 --- a/packages/sdk/src/sdk/customers.ts +++ b/packages/sdk/src/sdk/customers.ts @@ -22,6 +22,7 @@ export class Customers extends ClientSDK { * const response = await client.getOrCreate({ customerId: "cus_123", name: "John Doe", email: "john@example.com" }); * ``` * + * @param id - Your unique identifier for the customer (optional) * @param name - Customer's name (optional) * @param email - Customer's email address (optional) * @param fingerprint - Unique identifier (eg, serial number) to detect duplicate customers and prevent free trial abuse (optional) diff --git a/scripts/testGroups/all.sh b/scripts/testGroups/all.sh index 53b5bf62a..da24f694d 100755 --- a/scripts/testGroups/all.sh +++ b/scripts/testGroups/all.sh @@ -4,8 +4,8 @@ source "$(dirname "$0")/config.sh" export TEST_FILE_CONCURRENCY=2 BUN_PARALLEL_V2 \ - 'integration/billing/attach' \ - # 'integration/billing/update-subscription' \ + 'integration/billing/update-subscription' \ + # 'integration/billing/attach' \ # 'integration/billing/migrations' \ # 'integration/crud/customers' \ # 'integration/billing/stripe-webhooks' \ diff --git a/scripts/testScripts/runTestsV2.tsx b/scripts/testScripts/runTestsV2.tsx index e56e24d39..87a568fb4 100644 --- a/scripts/testScripts/runTestsV2.tsx +++ b/scripts/testScripts/runTestsV2.tsx @@ -72,10 +72,12 @@ interface IndividualTest { interface TestFileResult { file: string; - status: "pending" | "running" | "passed" | "failed"; + status: "pending" | "running" | "passed" | "failed" | "retrying"; tests: IndividualTest[]; currentTest?: string; duration: number; + attempt: number; + passedOnRetry: boolean; } // ============================================================================ @@ -249,6 +251,7 @@ async function collectTestFiles(directories: string[]): Promise { async function runTestFile( file: string, onUpdate: (result: TestFileResult) => void, + attempt = 1, ): Promise { const startTime = performance.now(); @@ -257,6 +260,8 @@ async function runTestFile( status: "running", tests: [], duration: 0, + attempt, + passedOnRetry: false, }; onUpdate(result); @@ -312,6 +317,8 @@ async function runTestFile( status: hasFailures ? "failed" : "passed", tests, duration, + attempt, + passedOnRetry: false, }; onUpdate(finalResult); @@ -323,6 +330,8 @@ async function runTestFile( status: "failed", tests: [], duration, + attempt, + passedOnRetry: false, }; onUpdate(finalResult); return finalResult; @@ -333,18 +342,19 @@ async function runTestFile( // Ink Components // ============================================================================ +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + function Spinner() { const [frame, setFrame] = useState(0); - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; useEffect(() => { const timer = setInterval(() => { - setFrame((prev) => (prev + 1) % frames.length); + setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length); }, 80); return () => clearInterval(timer); }, []); - return {frames[frame]}; + return {SPINNER_FRAMES[frame]}; } function truncate(str: string, maxLength: number): string { @@ -371,11 +381,15 @@ function CompletedFile({ result }: CompletedFileProps) { const icon = result.status === "passed" ? "✓" : "✗"; const iconColor = result.status === "passed" ? "green" : "red"; + const retryBadge = result.passedOnRetry ? " (retry)" : ""; return ( {icon} - {fileName} + + {fileName} + {retryBadge}{" "} + (✓{passedCount} {failedCount > 0 && ✗{failedCount}}) @@ -435,6 +449,22 @@ function RunningFile({ result }: RunningFileProps) { ); } +interface RetryingFileProps { + result: TestFileResult; +} + +function RetryingFile({ result }: RetryingFileProps) { + const fileName = basename(result.file); + return ( + + + + {fileName} + (retrying...) + + ); +} + interface TestRunnerAppProps { testFiles: string[]; maxParallel: number; @@ -458,6 +488,9 @@ function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) { const emittedFilesRef = useRef>(new Set()); const [staticItems, setStaticItems] = useState([]); + // Track whether retry phase is complete (to defer static emission of failed files) + const retryPhaseCompleteRef = useRef(false); + // Initialize all files as pending useEffect(() => { const initial = new Map(); @@ -467,6 +500,8 @@ function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) { status: "pending", tests: [], duration: 0, + attempt: 1, + passedOnRetry: false, }); } pendingRef.current = initial; @@ -483,12 +518,16 @@ function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) { setResults(snapshot); // Emit newly-completed files to + // Only emit passed files immediately; defer failed files until retry phase is complete const newStatic: TestFileResult[] = []; for (const [file, result] of snapshot) { - if ( - (result.status === "passed" || result.status === "failed") && - !emittedFilesRef.current.has(file) - ) { + if (emittedFilesRef.current.has(file)) continue; + + const shouldEmit = + result.status === "passed" || + (result.status === "failed" && retryPhaseCompleteRef.current); + + if (shouldEmit) { emittedFilesRef.current.add(file); newStatic.push(result); } @@ -512,12 +551,49 @@ function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) { const runAllTests = async () => { const limit = pLimit(maxParallel); + // Phase 1: Initial run const promises = testFiles.map((file) => - limit(() => runTestFile(file, updateResult)), + limit(() => runTestFile(file, updateResult, 1)), ); await Promise.all(promises); + // Phase 2: Retry failed files + const failedFiles = Array.from(pendingRef.current.values()).filter( + (r) => r.status === "failed" && r.attempt === 1, + ); + + if (failedFiles.length > 0) { + // Mark files as retrying + for (const result of failedFiles) { + const retryingResult: TestFileResult = { + ...result, + status: "retrying", + }; + pendingRef.current.set(result.file, retryingResult); + } + dirtyRef.current = true; + + // Run retries sequentially to avoid resource contention + const retryLimit = pLimit(1); + const retryPromises = failedFiles.map((result) => + retryLimit(async () => { + const retryResult = await runTestFile(result.file, updateResult, 2); + // If passed on retry, mark it + if (retryResult.status === "passed") { + retryResult.passedOnRetry = true; + pendingRef.current.set(result.file, retryResult); + dirtyRef.current = true; + } + return retryResult; + }), + ); + await Promise.all(retryPromises); + } + + // Mark retry phase as complete so failed files can be emitted to static + retryPhaseCompleteRef.current = true; + // Final flush to ensure every completed result is captured const finalSnapshot = new Map(pendingRef.current); setResults(finalSnapshot); @@ -562,6 +638,7 @@ function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) { const allResults = Array.from(results.values()); const runningFiles = allResults.filter((r) => r.status === "running"); + const retryingFiles = allResults.filter((r) => r.status === "retrying"); const completedFiles = allResults.filter( (r) => r.status === "passed" || r.status === "failed", @@ -598,6 +675,15 @@ function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) { )} + {/* Retrying files */} + {retryingFiles.length > 0 && ( + + {retryingFiles.map((r) => ( + + ))} + + )} + {/* Progress */} {!isComplete && } @@ -614,6 +700,9 @@ function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) { {runningFiles.length > 0 && ( | {runningFiles.length} running )} + {retryingFiles.length > 0 && ( + | {retryingFiles.length} retrying + )} @@ -635,6 +724,7 @@ function FinalSummary({ results }: FinalSummaryProps) { const allTests = results.flatMap((r) => r.tests); const passedTests = allTests.filter((t) => t.status === "passed"); const failedTests = allTests.filter((t) => t.status === "failed"); + const passedOnRetryFiles = results.filter((r) => r.passedOnRetry); const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); const failedByFile = new Map(); @@ -646,13 +736,17 @@ function FinalSummary({ results }: FinalSummaryProps) { } if (failedTests.length === 0) { + const retryText = + passedOnRetryFiles.length > 0 + ? ` (${passedOnRetryFiles.length} on retry)` + : ""; return ( {"═".repeat(60)} - ✓ ALL {passedTests.length} TESTS PASSED ( + ✓ ALL {passedTests.length} TESTS PASSED{retryText} ( {(totalDuration / 1000).toFixed(1)}s) diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 28aecedfe..dba226f55 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -824,7 +824,7 @@ export class AutumnInt { } const data = await this.post( - `/subscriptions/update`, + `/billing.update`, params, Object.keys(headers).length > 0 ? headers : undefined, ); @@ -837,7 +837,7 @@ export class AutumnInt { previewUpdate: async ( params: TInput, ): Promise => { - const data = await this.post(`/subscriptions/preview_update`, params); + const data = await this.post(`/billing.preview_update`, params); return data; }, }; @@ -852,7 +852,7 @@ export class AutumnInt { { skipWebhooks, idempotencyKey, - timeout = 2000, + timeout, }: { skipWebhooks?: boolean; idempotencyKey?: string; @@ -868,13 +868,16 @@ export class AutumnInt { } const data = await this.post( - `/billing,attach`, + `/billing.attach`, { redirect_mode: "if_required", ...params }, Object.keys(headers).length > 0 ? headers : undefined, ); - if (timeout) { - await new Promise((resolve) => setTimeout(resolve, timeout)); + const concurrency = Number(process.env.TEST_FILE_CONCURRENCY || "0"); + const defaultTimeout = concurrency > 1 ? 5000 : 4000; + const finalTimeout = timeout ?? defaultTimeout; + if (finalTimeout) { + await new Promise((resolve) => setTimeout(resolve, finalTimeout)); } return data; }, diff --git a/server/src/honoMiddlewares/expandMiddleware.ts b/server/src/honoMiddlewares/expandMiddleware.ts index dab237799..4ceb31cee 100644 --- a/server/src/honoMiddlewares/expandMiddleware.ts +++ b/server/src/honoMiddlewares/expandMiddleware.ts @@ -6,29 +6,48 @@ type ExpandCarrier = { skip_cache?: boolean | string; }; +interface RequestWithValidation { + valid(target: "query"): ExpandCarrier | undefined; + valid(target: "json"): ExpandCarrier | undefined; +} + /** - * Extracts expand from query params and request body, sets it in context. - * Runs before validator, so accesses raw query and cloned body directly. + * Extracts expand from validated query/body data and sets it in context. + * Runs AFTER versionedValidator so it reads transformed data. */ export const expandMiddleware = (): MiddlewareHandler => { return async (c, next) => { - const rawQuery = c.req.query(); + const req = c.req as typeof c.req & RequestWithValidation; - // Try to parse body for POST/PUT/PATCH requests - let body: ExpandCarrier | undefined; + // Read from validated data (after version transformations applied) + // Fall back to raw query if no validated data exists + let validatedQuery: ExpandCarrier | undefined; + try { + validatedQuery = req.valid("query"); + } catch { + // No validated query data - use raw query + validatedQuery = c.req.query() as ExpandCarrier | undefined; + } + + // Try to get validated body for POST/PUT/PATCH requests + let validatedBody: ExpandCarrier | undefined; if (["POST", "PUT", "PATCH"].includes(c.req.method)) { try { - body = await c.req.json(); - // Re-set the body so downstream handlers can read it - // Hono caches the parsed body, so this should work + validatedBody = req.valid("json"); } catch { - body = undefined; + // No validated body - try raw body + try { + validatedBody = await c.req.json(); + } catch { + validatedBody = undefined; + } } } // Precedence: body expand > query expand - const expandValue = body?.expand ?? rawQuery?.expand; - const skipCacheQuery = body?.skip_cache ?? rawQuery?.skip_cache; + const expandValue = validatedBody?.expand ?? validatedQuery?.expand; + const skipCacheQuery = + validatedBody?.skip_cache ?? validatedQuery?.skip_cache; const skipCacheValue = (typeof skipCacheQuery === "boolean" && skipCacheQuery === true) || diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index 6a7447d69..6ace0b37d 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -78,7 +78,12 @@ const coreUrls: { method: string; url: string; source?: string }[] = [ { method: "POST", url: "/billing.update", - source: "attach", + source: "updateSubscription", + }, + { + method: "POST", + url: "/balances.create", + source: "createBalance", }, ]; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 5285987da..f68143ca9 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -7,7 +7,6 @@ import { getRequestListener } from "@hono/node-server"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { cors } from "hono/cors"; -import { rpcRouter } from "@/routers/rpcRouter.js"; import { autumnWebhookRouter } from "./external/autumn/autumnWebhookRouter.js"; import { revenuecatWebhookRouter } from "./external/revenueCat/revenuecatWebhookRouter.js"; import { stripeWebhookRouter } from "./external/stripe/stripeWebhookRouter.js"; @@ -150,7 +149,6 @@ const createHonoApp = () => { // Public routes (no auth required) app.route("", publicRouter); // API Middleware - app.route("/v1", rpcRouter); app.route("/v1", apiRouter); app.route("", internalRouter); diff --git a/server/src/internal/billing/billingRouter.ts b/server/src/internal/billing/billingRouter.ts index bc6e83bb4..ccf6353cb 100644 --- a/server/src/internal/billing/billingRouter.ts +++ b/server/src/internal/billing/billingRouter.ts @@ -20,15 +20,6 @@ billingRouter.post("/cancel", ...handleCancelV2); billingRouter.post("/setup_payment", ...handleSetupPayment); billingRouter.post("/checkout", ...handleCheckoutV2); billingRouter.post("/attach", ...handleAttach); -billingRouter.post("/subscriptions/update", ...handleUpdateSubscription); -billingRouter.post( - "/subscriptions/preview_update", - ...handlePreviewUpdateSubscription, -); - -// V2 Attach -// billingRouter.post("/billing/attach", ...handleAttachV2); -// billingRouter.post("/billing/preview_attach", ...handlePreviewAttach); export const billingRpcRouter = new Hono(); billingRpcRouter.post("/billing.update", ...handleUpdateSubscription); diff --git a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts index 501f6275b..35b0051e8 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts @@ -29,7 +29,7 @@ export const finalizeAttachPlan = ({ }); // Guard: if billing_behavior is 'next_cycle_only', clear line items (skip proration charges) - if (params.billing_behavior === "next_cycle_only") { + if (params.billing_behavior === "none") { plan.lineItems = []; } diff --git a/server/src/internal/billing/v2/actions/migrate/migrate.ts b/server/src/internal/billing/v2/actions/migrate/migrate.ts index 2230739fc..d0811c7a1 100644 --- a/server/src/internal/billing/v2/actions/migrate/migrate.ts +++ b/server/src/internal/billing/v2/actions/migrate/migrate.ts @@ -49,7 +49,7 @@ export async function migrate({ customer_product_id: currentCustomerProduct.id, entity_id: entity?.id, - billing_behavior: "next_cycle_only", + billing_behavior: "none", version: newProduct.version, // to trigger update custom plan intent transition_rules: transitionRules, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts index c96848701..5ac17d9d0 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts @@ -36,7 +36,7 @@ export const finalizeUpdateSubscriptionPlan = ({ } // Guard: if billing_behavior is 'next_cycle_only', clear line items (skip proration charges) - if (params.billing_behavior === "next_cycle_only") { + if (params.billing_behavior === "none") { plan.lineItems = []; } diff --git a/server/src/internal/billing/v2/common/errors/handleBillingBehaviorErrors.ts b/server/src/internal/billing/v2/common/errors/handleBillingBehaviorErrors.ts index 7b924527c..bd2f40981 100644 --- a/server/src/internal/billing/v2/common/errors/handleBillingBehaviorErrors.ts +++ b/server/src/internal/billing/v2/common/errors/handleBillingBehaviorErrors.ts @@ -36,7 +36,7 @@ export const handleBillingBehaviorErrors = ({ params: UpdateSubscriptionV1Params | AttachParamsV1; }) => { // Only validate when billing_behavior is 'next_cycle_only' (defer charges) - if (params.billing_behavior !== "next_cycle_only") return; + if (params.billing_behavior !== "none") return; const { autumn: autumnBillingPlan } = billingPlan; diff --git a/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts b/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts index 5856c3a29..972ba0792 100644 --- a/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts +++ b/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts @@ -18,8 +18,6 @@ export const handlePreviewUpdateSubscription = createRoute({ const ctx = c.get("ctx"); const body = c.req.valid("json"); - console.log("body", body); - const { billingContext, billingPlan } = await billingActions.updateSubscription({ ctx, diff --git a/server/src/internal/customers/cancel/handleCancelV2.ts b/server/src/internal/customers/cancel/handleCancelV2.ts index 8b959c9b1..c922b9c24 100644 --- a/server/src/internal/customers/cancel/handleCancelV2.ts +++ b/server/src/internal/customers/cancel/handleCancelV2.ts @@ -30,7 +30,7 @@ export const handleCancelV2 = createRoute({ cancel_action: cancel_immediately ? "cancel_immediately" : "cancel_end_of_cycle", - billing_behavior: bodyProrate ? "prorate_immediately" : "next_cycle_only", + billing_behavior: bodyProrate ? "prorate_immediately" : "none", }; ctx.logger.info( diff --git a/server/src/internal/customers/handlers/handleGetCustomerV2.ts b/server/src/internal/customers/handlers/handleGetCustomerV2.ts index d3eb0449d..8dfe36476 100644 --- a/server/src/internal/customers/handlers/handleGetCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleGetCustomerV2.ts @@ -22,6 +22,7 @@ export const handleGetCustomerV2 = createRoute({ const { expand } = ctx; const { with_autumn_id } = c.req.valid("query"); + // SIDE EFFECT // !ctx.org.config.disable_v1_invoices && if ( diff --git a/server/src/queue/initSqs.ts b/server/src/queue/initSqs.ts index bd9e2091a..b7777c87a 100644 --- a/server/src/queue/initSqs.ts +++ b/server/src/queue/initSqs.ts @@ -19,7 +19,7 @@ function extractRegionFromQueueUrl({ return match ? match[1] : undefined; } -export const sqs = new SQSClient({ +const getSqsClientConfig = () => ({ region: extractRegionFromQueueUrl({ queueUrl: process.env.SQS_QUEUE_URL, @@ -29,5 +29,21 @@ export const sqs = new SQSClient({ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", }, }); + +let sqsClient = new SQSClient(getSqsClientConfig()); + +export const sqs = sqsClient; + +/** Recreates the SQS client with fresh connections */ +export const recreateSqsClient = (): SQSClient => { + console.log(`[SQS] Recreating SQS client (stale connection suspected)`); + sqsClient.destroy(); + sqsClient = new SQSClient(getSqsClientConfig()); + return sqsClient; +}; + +/** Get the current SQS client (use this instead of direct sqs export for refreshable access) */ +export const getSqsClient = (): SQSClient => sqsClient; + // SQS Queue URL - you'll need to create this queue in AWS console or via terraform export const QUEUE_URL = process.env.SQS_QUEUE_URL || ""; diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index 12f9d9b59..df6cd768e 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -1,334 +1,261 @@ await import("../sentry.js"); +import { ms } from "@autumn/shared"; import { DeleteMessageBatchCommand, DeleteMessageCommand, type Message, ReceiveMessageCommand, + type SQSClient, } from "@aws-sdk/client-sqs"; import * as Sentry from "@sentry/bun"; -import type { Logger } from "pino"; import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; -import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; -import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; -import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; -import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js"; -import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js"; import { verifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.js"; -import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; -import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js"; -import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js"; -import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js"; -import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js"; -import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js"; import { generateId } from "@/utils/genUtils.js"; -import { addWorkflowToLogs } from "@/utils/logging/addContextToLogs.js"; import { hatchet } from "../external/hatchet/initHatchet.js"; -import { setSentryTags } from "../external/sentry/sentryUtils.js"; -import { createWorkerContext } from "./createWorkerContext.js"; -import { QUEUE_URL, sqs } from "./initSqs.js"; +import { getSqsClient, QUEUE_URL, recreateSqsClient } from "./initSqs.js"; import { JobName } from "./JobName.js"; +import { processMessage, type SqsJob } from "./processMessage.js"; -const actionHandlers = [ - JobName.HandleProductsUpdated, - JobName.HandleCustomerCreated, -]; - -interface SqsJob { - name: string; - data: any; -} - -/** - * Process a single SQS message - */ -const processMessage = async ({ - message, - db, -}: { - message: Message; - db: DrizzleCli; -}) => { - if (!message.Body) { - console.warn("Received message without body"); - return; - } - - const job: SqsJob = JSON.parse(message.Body); - - const workerLogger = addWorkflowToLogs({ - logger: logger, - workflowContext: { - id: message.MessageId ?? generateId("job"), - name: job.name, - payload: job.data, - }, - }); - - workerLogger.info(`Processing message: ${job.name}`); - - try { - if (job.name === JobName.DetectBaseVariant) { - await detectBaseVariant({ - db, - curProduct: job.data.curProduct, - logger: workerLogger as Logger, - }); - return; - } - - if (job.name === JobName.ClearCreditSystemCustomerCache) { - await runClearCreditSystemCacheTask({ - db, - payload: job.data, - logger: workerLogger, - }); - return; - } - - // Jobs below need worker context - const ctx = await createWorkerContext({ - db, - payload: job.data, - logger: workerLogger, - }); - - if (ctx) { - setSentryTags({ - ctx, - messageId: message.MessageId, - }); - } - - if (job.name === JobName.Migration) { - if (!ctx) { - workerLogger.error("No context found for migration job"); - return; - } - await runMigrationTask({ ctx, payload: job.data }); - return; - } - - if (job.name === JobName.GenerateFeatureDisplay) { - if (!ctx) { - workerLogger.error("No context found for generate feature display job"); - return; - } - await generateFeatureDisplay({ - ctx, - payload: job.data, - }); - return; - } - - if (job.name === JobName.SendProductsUpdated) { - if (!ctx) { - workerLogger.error("No context found for send products updated job"); - return; - } - await sendProductsUpdated({ - ctx, - payload: job.data, - }); - return; - } - - if (actionHandlers.includes(job.name as JobName)) { - // Note: action handlers need BullMQ queue for nested jobs - // This will need to be refactored when migrating action handlers to SQS - await runActionHandlerTask({ - ctx, - jobName: job.name as JobName, - payload: job.data, - }); - return; - } - - if (job.name === JobName.RewardMigration) { - await runRewardMigrationTask({ - db, - payload: job.data, - logger: workerLogger, - }); - return; - } - - if (job.name === JobName.SyncBalanceBatchV3) { - if (!ctx) { - workerLogger.error("No context found for sync balance batch v3 job"); - return; - } - - await syncItemV3({ - ctx, - payload: job.data, - }); - return; - } - - if (job.name === JobName.InsertEventBatch) { - await runInsertEventBatch({ - db, - payload: job.data, - logger: workerLogger as Logger, - }); - return; - } - - if (job.name === JobName.TriggerCheckoutReward) { - if (!ctx) { - workerLogger.error("No context found for trigger checkout reward job"); - return; - } - await runTriggerCheckoutReward({ - ctx, - payload: job.data, - }); - } - - if (job.name === JobName.GrantCheckoutReward) { - if (!ctx) { - workerLogger.error("No context found for grant checkout reward job"); - return; - } - await grantCheckoutReward({ - ctx, - payload: job.data, - }); - } - } catch (error) { - Sentry.captureException(error); - if (error instanceof Error) { - workerLogger.error(`Failed to process SQS job: ${job.name}`, { - jobName: job.name, - error: { - message: error.message, - stack: error.stack, - }, - }); - } - } -}; - +// ============ State ============ let isRunning = true; -const isFifoQueue = QUEUE_URL.endsWith(".fifo"); let abortController: AbortController; +const isFifoQueue = QUEUE_URL.endsWith(".fifo"); -// Tracking for periodic stats +// Stats tracking let messagesProcessed = 0; let lastStatsTime = Date.now(); -/** - * Single SQS polling loop - runs continuously until shutdown - */ +// Stale connection detection +let consecutiveEmptyPolls = 0; +let lastHeartbeatTime = Date.now(); +const EMPTY_POLL_THRESHOLD = 9; // ~3 min of empty polls (9 * 20s wait) +const HEARTBEAT_INTERVAL_MS = ms.minutes(5); + +// Zero-message alert tracking +let consecutiveZeroMessageIntervals = 0; +const ZERO_MESSAGE_ALERT_THRESHOLD = 20; // ~20 min of 0 messages + +// ============ Helper Functions ============ + +const logPrefix = () => `[SQS Worker ${process.pid}]`; + +const alertZeroMessages = () => { + const minutes = consecutiveZeroMessageIntervals; + logger.warn(`${logPrefix()} No messages processed for ${minutes} minutes`, { + type: "worker", + queueUrl: QUEUE_URL, + consecutiveIntervals: minutes, + }); + Sentry.captureMessage( + `SQS Worker ${process.pid}: No messages processed for ${minutes} minutes`, + "warning", + ); +}; + +const logStatsAndCheckZeroMessages = () => { + const elapsedSeconds = ((Date.now() - lastStatsTime) / 1000).toFixed(0); + console.log( + `${logPrefix()} Processed ${messagesProcessed} messages in ${elapsedSeconds}s`, + ); + + if (messagesProcessed === 0) { + consecutiveZeroMessageIntervals++; + if (consecutiveZeroMessageIntervals >= ZERO_MESSAGE_ALERT_THRESHOLD) { + alertZeroMessages(); + consecutiveZeroMessageIntervals = 0; + } + } else { + consecutiveZeroMessageIntervals = 0; + } + + messagesProcessed = 0; + lastStatsTime = Date.now(); +}; + +const createReceiveCommand = () => + new ReceiveMessageCommand({ + QueueUrl: QUEUE_URL, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 20, + VisibilityTimeout: 30, + ...(isFifoQueue && { ReceiveRequestAttemptId: generateId("receive") }), + }); + +const deleteMigrationJobImmediately = async ({ + sqs, + message, + job, +}: { + sqs: SQSClient; + message: Message; + job: SqsJob; +}) => { + logger.info( + `Returning success immediately for migration job ${job.data.migrationJobId}`, + ); + await sqs.send( + new DeleteMessageCommand({ + QueueUrl: QUEUE_URL, + ReceiptHandle: message.ReceiptHandle, + }), + ); +}; + +const handleSingleMessage = async ({ + sqs, + message, + db, +}: { + sqs: SQSClient; + message: Message; + db: DrizzleCli; +}): Promise<{ id: string; receiptHandle: string } | null> => { + if (!isRunning || !message.Body) return null; + + const job: SqsJob = JSON.parse(message.Body); + + // Migration jobs: delete IMMEDIATELY before processing (long-running, avoid timeout redelivery) + if (job.name === JobName.Migration) { + await deleteMigrationJobImmediately({ sqs, message, job }); + } + + await processMessage({ message, db }); + messagesProcessed++; + + // Return delete info (skip migration jobs - already deleted) + if (message.ReceiptHandle && job.name !== JobName.Migration) { + return { id: message.MessageId!, receiptHandle: message.ReceiptHandle }; + } + return null; +}; + +const batchDeleteMessages = async ({ + sqs, + toDelete, +}: { + sqs: SQSClient; + toDelete: { Id: string; ReceiptHandle: string }[]; +}) => { + if (toDelete.length === 0) return; + + try { + await sqs.send( + new DeleteMessageBatchCommand({ QueueUrl: QUEUE_URL, Entries: toDelete }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error(`${logPrefix()} Batch delete failed: ${message}`); + } +}; + +const handleEmptyPoll = (): SQSClient | null => { + consecutiveEmptyPolls++; + + // Periodic heartbeat + const now = Date.now(); + if (now - lastHeartbeatTime > HEARTBEAT_INTERVAL_MS) { + console.log( + `${logPrefix()} Heartbeat - polling active, ${consecutiveEmptyPolls} consecutive empty polls`, + ); + lastHeartbeatTime = now; + } + + // Recreate client if too many empty polls + if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) { + console.warn( + `${logPrefix()} ${consecutiveEmptyPolls} consecutive empty polls - recreating SQS client`, + ); + consecutiveEmptyPolls = 0; + abortController = new AbortController(); + return recreateSqsClient(); + } + + return null; +}; + +const handlePollingError = async ( + error: unknown, +): Promise => { + const err = error as { name?: string; message?: string }; + + if (err.name === "AbortError" || err.name === "RequestAbortedError") { + console.log(`${logPrefix()} Polling aborted (shutdown)`); + return null; + } + + if (!isRunning) return null; + + console.error(`${logPrefix()} Polling error: ${err.message}`); + + // Recreate client on repeated errors + consecutiveEmptyPolls++; + if (consecutiveEmptyPolls >= EMPTY_POLL_THRESHOLD) { + console.warn(`${logPrefix()} Repeated errors - recreating SQS client`); + consecutiveEmptyPolls = 0; + abortController = new AbortController(); + await new Promise((resolve) => setTimeout(resolve, 5000)); + return recreateSqsClient(); + } + + await new Promise((resolve) => setTimeout(resolve, 5000)); + return null; +}; + +// ============ Main Polling Loop ============ + const startPollingLoop = async ({ db }: { db: DrizzleCli }) => { - console.log(`[SQS Worker ${process.pid}] Started polling ${QUEUE_URL}`); + console.log(`${logPrefix()} Started polling ${QUEUE_URL}`); abortController = new AbortController(); - // Log stats every 60 seconds - const statsInterval = setInterval(() => { - const elapsed = ((Date.now() - lastStatsTime) / 1000).toFixed(0); - console.log( - `[SQS Worker ${process.pid}] Processed ${messagesProcessed} messages in ${elapsed}s`, - ); - messagesProcessed = 0; - lastStatsTime = Date.now(); - }, 60000); + const statsInterval = setInterval(logStatsAndCheckZeroMessages, 60000); + + let sqs = getSqsClient(); while (isRunning) { try { - const command = new ReceiveMessageCommand({ - QueueUrl: QUEUE_URL, - MaxNumberOfMessages: 10, - WaitTimeSeconds: 20, - VisibilityTimeout: 30, - ...(isFifoQueue && { - ReceiveRequestAttemptId: generateId("receive"), - }), - }); - - const response = await sqs.send(command, { + const response = await sqs.send(createReceiveCommand(), { abortSignal: abortController.signal, }); - if (response.Messages && response.Messages.length > 0) { - // Track messages to batch delete (excludes migration jobs which are deleted immediately) - const toDelete: { Id: string; ReceiptHandle: string }[] = []; + const messages = response.Messages ?? []; - await Promise.allSettled( - response.Messages.map(async (message) => { - if (!isRunning || !message.Body) return; + if (messages.length > 0) { + consecutiveEmptyPolls = 0; - const job: SqsJob = JSON.parse(message.Body); - - // Migration jobs: delete IMMEDIATELY before processing (long-running, avoid timeout redelivery) - if (job.name === JobName.Migration) { - logger.info( - `Returning success immediately for migration job ${job.data.migrationJobId}`, - ); - await sqs.send( - new DeleteMessageCommand({ - QueueUrl: QUEUE_URL, - ReceiptHandle: message.ReceiptHandle, - }), - ); - } - - try { - await processMessage({ message, db }); - messagesProcessed++; - } catch (error) { - if (error instanceof Error) { - logger.error( - `Failed to process message ${message.MessageId}: ${error.message}`, - ); - } - } - - // Queue for batch delete (skip migration jobs - already deleted) - if (message.ReceiptHandle && job.name !== JobName.Migration) { - toDelete.push({ - Id: message.MessageId!, - ReceiptHandle: message.ReceiptHandle, - }); - } - }), + const results = await Promise.allSettled( + messages.map((message) => handleSingleMessage({ sqs, message, db })), ); - // Batch delete all non-migration messages - if (toDelete.length > 0) { - try { - await sqs.send( - new DeleteMessageBatchCommand({ - QueueUrl: QUEUE_URL, - Entries: toDelete, - }), - ); - } catch (deleteError: any) { - console.error( - `[SQS Worker ${process.pid}] Batch delete failed: ${deleteError.message}`, - ); - } - } - } - } catch (error: any) { - if (error.name === "AbortError" || error.name === "RequestAbortedError") { - console.log(`[SQS Worker ${process.pid}] Polling aborted (shutdown)`); - break; - } + const toDelete = results + .filter( + ( + r, + ): r is PromiseFulfilledResult<{ + id: string; + receiptHandle: string; + }> => r.status === "fulfilled" && r.value !== null, + ) + .map((r) => ({ + Id: r.value.id, + ReceiptHandle: r.value.receiptHandle, + })); - if (isRunning) { - console.error( - `[SQS Worker ${process.pid}] Polling error: ${error.message}`, - ); - await new Promise((resolve) => setTimeout(resolve, 5000)); + await batchDeleteMessages({ sqs, toDelete }); + } else { + const newClient = handleEmptyPoll(); + if (newClient) sqs = newClient; } + } catch (error) { + const newClient = await handlePollingError(error); + if (newClient) sqs = newClient; + else if ((error as { name?: string }).name === "AbortError") break; } } clearInterval(statsInterval); - console.log(`[SQS Worker ${process.pid}] Stopped`); + console.log(`${logPrefix()} Stopped`); }; /** diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts new file mode 100644 index 000000000..041afc9be --- /dev/null +++ b/server/src/queue/processMessage.ts @@ -0,0 +1,199 @@ +import type { Message } from "@aws-sdk/client-sqs"; +import * as Sentry from "@sentry/bun"; +import type { Logger } from "pino"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { logger } from "@/external/logtail/logtailUtils.js"; +import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; +import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js"; +import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; +import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js"; +import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js"; +import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; +import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js"; +import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js"; +import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js"; +import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js"; +import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js"; +import { generateId } from "@/utils/genUtils.js"; +import { addWorkflowToLogs } from "@/utils/logging/addContextToLogs.js"; +import { setSentryTags } from "../external/sentry/sentryUtils.js"; +import { createWorkerContext } from "./createWorkerContext.js"; +import { JobName } from "./JobName.js"; + +const actionHandlers = [ + JobName.HandleProductsUpdated, + JobName.HandleCustomerCreated, +]; + +export interface SqsJob { + name: string; + data: any; +} + +export const processMessage = async ({ + message, + db, +}: { + message: Message; + db: DrizzleCli; +}) => { + if (!message.Body) { + console.warn("Received message without body"); + return; + } + + const job: SqsJob = JSON.parse(message.Body); + + const workerLogger = addWorkflowToLogs({ + logger: logger, + workflowContext: { + id: message.MessageId ?? generateId("job"), + name: job.name, + payload: job.data, + }, + }); + + workerLogger.info(`Processing message: ${job.name}`); + + try { + if (job.name === JobName.DetectBaseVariant) { + await detectBaseVariant({ + db, + curProduct: job.data.curProduct, + logger: workerLogger as Logger, + }); + return; + } + + if (job.name === JobName.ClearCreditSystemCustomerCache) { + await runClearCreditSystemCacheTask({ + db, + payload: job.data, + logger: workerLogger, + }); + return; + } + + // Jobs below need worker context + const ctx = await createWorkerContext({ + db, + payload: job.data, + logger: workerLogger, + }); + + if (ctx) { + setSentryTags({ + ctx, + messageId: message.MessageId, + }); + } + + if (job.name === JobName.Migration) { + if (!ctx) { + workerLogger.error("No context found for migration job"); + return; + } + await runMigrationTask({ ctx, payload: job.data }); + return; + } + + if (job.name === JobName.GenerateFeatureDisplay) { + if (!ctx) { + workerLogger.error("No context found for generate feature display job"); + return; + } + await generateFeatureDisplay({ + ctx, + payload: job.data, + }); + return; + } + + if (job.name === JobName.SendProductsUpdated) { + if (!ctx) { + workerLogger.error("No context found for send products updated job"); + return; + } + await sendProductsUpdated({ + ctx, + payload: job.data, + }); + return; + } + + if (actionHandlers.includes(job.name as JobName)) { + // Note: action handlers need BullMQ queue for nested jobs + // This will need to be refactored when migrating action handlers to SQS + await runActionHandlerTask({ + ctx, + jobName: job.name as JobName, + payload: job.data, + }); + return; + } + + if (job.name === JobName.RewardMigration) { + await runRewardMigrationTask({ + db, + payload: job.data, + logger: workerLogger, + }); + return; + } + + if (job.name === JobName.SyncBalanceBatchV3) { + if (!ctx) { + workerLogger.error("No context found for sync balance batch v3 job"); + return; + } + + await syncItemV3({ + ctx, + payload: job.data, + }); + return; + } + + if (job.name === JobName.InsertEventBatch) { + await runInsertEventBatch({ + db, + payload: job.data, + logger: workerLogger as Logger, + }); + return; + } + + if (job.name === JobName.TriggerCheckoutReward) { + if (!ctx) { + workerLogger.error("No context found for trigger checkout reward job"); + return; + } + await runTriggerCheckoutReward({ + ctx, + payload: job.data, + }); + } + + if (job.name === JobName.GrantCheckoutReward) { + if (!ctx) { + workerLogger.error("No context found for grant checkout reward job"); + return; + } + await grantCheckoutReward({ + ctx, + payload: job.data, + }); + } + } catch (error) { + Sentry.captureException(error); + if (error instanceof Error) { + workerLogger.error(`Failed to process SQS job: ${job.name}`, { + jobName: job.name, + error: { + message: error.message, + stack: error.stack, + }, + }); + } + } +}; diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 7ed18016d..1b6596982 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -50,12 +50,10 @@ apiRouter.use("*", queryMiddleware()); apiRouter.use("*", idempotencyMiddleware); apiRouter.route("", rpcRouter); - apiRouter.route("", billingRouter); apiRouter.route("", balancesRouter); apiRouter.route("", migrationRouter); apiRouter.route("", entityRouter); -apiRouter.route("", rpcRouter); apiRouter.route("/customers", cusRouter); apiRouter.route("/invoices", invoiceRouter); diff --git a/server/src/routers/rpcRouter.ts b/server/src/routers/rpcRouter.ts index 94c87a396..4da4bfa24 100644 --- a/server/src/routers/rpcRouter.ts +++ b/server/src/routers/rpcRouter.ts @@ -1,5 +1,4 @@ import { Hono } from "hono"; -import { responseFilterMiddleware } from "@/honoMiddlewares/responseFilter/responseFilterMiddleware"; import { referralRpcRouter } from "@/internal/api/rewards/referralRouter"; import { balancesRpcRouter } from "@/internal/balances/balancesRouter"; import { billingRpcRouter } from "@/internal/billing/billingRouter"; @@ -7,30 +6,21 @@ import { entityRpcRouter } from "@/internal/entities/entityRouter"; import { eventsRpcRouter } from "@/internal/events/eventsRouter"; import { featureRpcRouter } from "@/internal/features/featureRouter"; import { plansRpcRouter } from "@/internal/products/productRouter"; -import { analyticsMiddleware } from "../honoMiddlewares/analyticsMiddleware"; -import { apiVersionMiddleware } from "../honoMiddlewares/apiVersionMiddleware"; -import { idempotencyMiddleware } from "../honoMiddlewares/idempotencyMiddleware"; -import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware"; -import { queryMiddleware } from "../honoMiddlewares/queryMiddleware"; -import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware"; -import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware"; -import { refreshProductsCacheMiddleware } from "../honoMiddlewares/refreshProductsCacheMiddleware"; -import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware"; import type { HonoEnv } from "../honoUtils/HonoEnv"; import { customerRpcRouter } from "../internal/customers/cusRouter"; export const rpcRouter = new Hono(); -rpcRouter.use("*", responseFilterMiddleware); -rpcRouter.use("*", secretKeyMiddleware); -rpcRouter.use("*", orgConfigMiddleware); -rpcRouter.use("*", apiVersionMiddleware); -rpcRouter.use("*", refreshCacheMiddleware); -rpcRouter.use("*", refreshProductsCacheMiddleware); -rpcRouter.use("*", analyticsMiddleware); -rpcRouter.use("*", rateLimitMiddleware); -rpcRouter.use("*", queryMiddleware()); -rpcRouter.use("*", idempotencyMiddleware); +// rpcRouter.use("*", responseFilterMiddleware); +// rpcRouter.use("*", secretKeyMiddleware); +// rpcRouter.use("*", orgConfigMiddleware); +// rpcRouter.use("*", apiVersionMiddleware); +// rpcRouter.use("*", refreshCacheMiddleware); +// rpcRouter.use("*", refreshProductsCacheMiddleware); +// rpcRouter.use("*", analyticsMiddleware); +// rpcRouter.use("*", rateLimitMiddleware); +// rpcRouter.use("*", queryMiddleware()); +// rpcRouter.use("*", idempotencyMiddleware); rpcRouter.route("", customerRpcRouter); rpcRouter.route("", plansRpcRouter); diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 0cee5fc17..325e3aec9 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,10 +1,9 @@ -import { expect, test } from "bun:test"; +import { test } from "bun:test"; import { ApiVersion, FreeTrialDuration, ResetInterval } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { ProductService } from "@/internal/products/ProductService.js"; const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 }); const { db, org, env } = ctx; @@ -61,85 +60,4 @@ test.concurrent(`${chalk.yellowBright("temp: rest update then rpc inverse update id: productId, ...baseline, }); - - const initialFull = await ProductService.getFull({ - db, - idOrInternalId: productId, - orgId: org.id, - env, - }); - - expect(initialFull.name).toBe(baseline.name); - expect(initialFull.description).toBe(baseline.description); - expect(initialFull.group).toBe(baseline.group); - expect(initialFull.is_add_on).toBe(baseline.add_on); - expect(initialFull.is_default).toBe(baseline.auto_enable); - expect( - initialFull.entitlements.find((ent) => ent.feature_id === TestFeature.Messages) - ?.allowance, - ).toBe(100); - - await autumnV2_1.products.update(productId, restUpdates); - - const afterRestUpdate = await ProductService.getFull({ - db, - idOrInternalId: productId, - orgId: org.id, - env, - }); - - expect(afterRestUpdate.name).toBe(restUpdates.name); - expect(afterRestUpdate.group).toBe(restUpdates.group); - expect(afterRestUpdate.is_add_on).toBe(restUpdates.add_on); - expect( - afterRestUpdate.entitlements.find( - (ent) => ent.feature_id === TestFeature.Messages, - )?.allowance, - ).toBe(250); - - const rpcResponse = await autumnV2_1.post("/plans.update", { - plan_id: productId, - ...baseline, - }); - - expect(rpcResponse.id).toBe(productId); - expect(rpcResponse.name).toBe(baseline.name); - expect(rpcResponse.description).toBe(baseline.description); - expect(rpcResponse.group).toBe(baseline.group); - expect(rpcResponse.add_on).toBe(baseline.add_on); - expect(rpcResponse.auto_enable).toBe(baseline.auto_enable); - - const finalFull = await ProductService.getFull({ - db, - idOrInternalId: productId, - orgId: org.id, - env, - }); - - expect(finalFull.version).toBe(initialFull.version); - expect(finalFull.name).toBe(baseline.name); - expect(finalFull.description).toBe(baseline.description); - expect(finalFull.group).toBe(baseline.group); - expect(finalFull.is_add_on).toBe(baseline.add_on); - expect(finalFull.is_default).toBe(baseline.auto_enable); - expect(finalFull.free_trial?.duration).toBe( - baseline.free_trial.duration_type, - ); - expect(finalFull.free_trial?.length).toBe( - baseline.free_trial.duration_length, - ); - expect(finalFull.free_trial?.card_required).toBe( - baseline.free_trial.card_required, - ); - expect( - finalFull.entitlements.find((ent) => ent.feature_id === TestFeature.Messages) - ?.allowance, - ).toBe(100); - - const finalApi = await autumnV2_1.products.get(productId); - expect(finalApi.name).toBe(baseline.name); - expect(finalApi.description).toBe(baseline.description); - expect(finalApi.group).toBe(baseline.group); - expect(finalApi.add_on).toBe(baseline.add_on); - expect(finalApi.auto_enable).toBe(baseline.auto_enable); }); diff --git a/server/tests/attach/checkout/checkout6.test.ts b/server/tests/attach/checkout/checkout6.test.ts index cd0270bfa..167657033 100644 --- a/server/tests/attach/checkout/checkout6.test.ts +++ b/server/tests/attach/checkout/checkout6.test.ts @@ -95,6 +95,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checko product_id: premium.id, invoice: true, enable_product_immediately: true, + finalize_invoice: false, }); const customer = await autumn.customers.get(customerId); diff --git a/server/tests/attach/others/others10.test.ts b/server/tests/attach/others/others10.test.ts index 8f5b27ec7..5d8466c26 100644 --- a/server/tests/attach/others/others10.test.ts +++ b/server/tests/attach/others/others10.test.ts @@ -25,7 +25,6 @@ const testCase = "others10"; describe(`${chalk.yellowBright(`${testCase}/idempotency: idempotency key already exists`)}`, () => { const customerId = testCase; const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - const idempotencyKey = generateId("it"); let results: PromiseSettledResult< Awaited> @@ -45,6 +44,8 @@ describe(`${chalk.yellowBright(`${testCase}/idempotency: idempotency key already prefix: testCase, }); + const idempotencyKey = generateId("it"); + results = await Promise.allSettled([ autumnV1.attach( { diff --git a/server/tests/integration/balances/check/check-basic.test.ts b/server/tests/integration/balances/check/check-basic.test.ts index f78caf1a0..755842fde 100644 --- a/server/tests/integration/balances/check/check-basic.test.ts +++ b/server/tests/integration/balances/check/check-basic.test.ts @@ -128,6 +128,7 @@ test.concurrent(`${chalk.yellowBright("check-boolean: /check on boolean feature" overage_allowed: false, plan_id: freeProd.id, purchased_balance: 0, + expires_at: null, reset: null, usage: 0, }, @@ -163,6 +164,7 @@ test.concurrent(`${chalk.yellowBright("check-boolean: /check on boolean feature" interval_count: null, next_reset_at: null, overage_allowed: false, + expires_at: null, usage: 0, }, ], @@ -357,6 +359,7 @@ test.concurrent(`${chalk.yellowBright("check-unlimited: /check on unlimited feat interval_count: null, next_reset_at: null, overage_allowed: false, + expires_at: null, usage: 0, }, ], diff --git a/server/tests/integration/billing/attach/free-trial/trial-features.test.ts b/server/tests/integration/billing/attach/free-trial/trial-features.test.ts index 5c445c661..e52e151f6 100644 --- a/server/tests/integration/billing/attach/free-trial/trial-features.test.ts +++ b/server/tests/integration/billing/attach/free-trial/trial-features.test.ts @@ -82,12 +82,17 @@ test.concurrent(`${chalk.yellowBright("trial-features 1: prepaid messages during // 2. Attach product with prepaid // quantity becomes the new includedUsage (200), so 1 pack purchased - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: productWithTrial.id, - options: [{ feature_id: TestFeature.Messages, quantity: 200 }], - redirect_mode: "if_required", - }); + await autumnV1.billing.attach( + { + customer_id: customerId, + product_id: productWithTrial.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + redirect_mode: "if_required", + }, + { + timeout: 5000, + }, + ); let customer = await autumnV1.customers.get(customerId); diff --git a/server/tests/integration/billing/attach/future-plans.md b/server/tests/integration/billing/attach/future-plans.md index a04c6d765..118986ac1 100644 --- a/server/tests/integration/billing/attach/future-plans.md +++ b/server/tests/integration/billing/attach/future-plans.md @@ -223,7 +223,7 @@ These folders need detailed test planning before implementation. Use the prompt **Key questions to answer:** - `"prorate_immediately"` — Default, charges prorated amount now -- `"next_cycle_only"` — No immediate proration, changes apply next cycle +- `"none"` — No immediate proration, changes apply next cycle - How does this interact with upgrades vs downgrades? - How does this interact with prepaid quantities? diff --git a/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts index c4c2f4cca..e93ee06b9 100644 --- a/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts +++ b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode.test.ts @@ -226,6 +226,7 @@ test.concurrent(`${chalk.yellowBright("legacy-inv-mode 3: upgrade")}`, async () customer_id: customerId, product_id: premium.id, invoice: true, + finalize_invoice: false, enable_product_immediately: true, }); @@ -290,6 +291,7 @@ test.concurrent(`${chalk.yellowBright("legacy-inv-mode 4: upgrade")}`, async () customer_id: customerId, product_id: premium.id, invoice: true, + enable_product_immediately: true, }); expect(res.checkout_url).toBeDefined(); @@ -315,7 +317,7 @@ test.concurrent(`${chalk.yellowBright("legacy-inv-mode 4: upgrade")}`, async () customerId, { skip_cache: "true" }, ); - expect(nonCachedCustomer.invoices?.[0].status).toBe("draft"); + expect(nonCachedCustomer.invoices?.[0].status).toBe("paid"); await expectSubToBeCorrect({ db: ctx.db, diff --git a/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts b/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts index 8f6467940..ce7cd13bf 100644 --- a/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts +++ b/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts @@ -88,7 +88,7 @@ test.concurrent(`${chalk.yellowBright("legacy-separate 1: separate subs via invo invoice: true, entity_id: entities[0].id, }); - await completeInvoiceCheckout({ url: res1.checkout_url }); + await completeInvoiceCheckout({ url: res1.checkout_url, isolatedBrowser: true }); // Attach Pro to entity 2 with invoice: true const res2 = await autumnV1_2.attach({ @@ -97,7 +97,7 @@ test.concurrent(`${chalk.yellowBright("legacy-separate 1: separate subs via invo invoice: true, entity_id: entities[1].id, }); - await completeInvoiceCheckout({ url: res2.checkout_url }); + await completeInvoiceCheckout({ url: res2.checkout_url, isolatedBrowser: true }); // Verify different subscription IDs per entity const fullCus = await CusService.getFull({ diff --git a/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts b/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts index 74e816054..29cbbc85a 100644 --- a/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts +++ b/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts @@ -77,7 +77,7 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only cancel: immediate cancel customer_id: customerId, product_id: pro.id, cancel_action: "cancel_immediately" as const, - billing_behavior: "next_cycle_only" as const, + billing_behavior: "none" as const, }; const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); expect(preview.total).toBe(0); @@ -201,7 +201,7 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only cancel: comparison with d customer_id: customerDeferred, product_id: proDeferred.id, cancel_action: "cancel_immediately" as const, - billing_behavior: "next_cycle_only" as const, + billing_behavior: "none" as const, }; const previewDeferred = await autumnDeferred.subscriptions.previewUpdate(deferredParams); @@ -296,7 +296,7 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only cancel: monthly price - n customer_id: customerId, product_id: pro.id, cancel_action: "cancel_immediately" as const, - billing_behavior: "next_cycle_only" as const, + billing_behavior: "none" as const, }; const preview = await autumnV1.subscriptions.previewUpdate(cancelParams); expect(preview.total).toBe(0); diff --git a/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only.test.ts b/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only.test.ts index affbd8cf4..73967b75e 100644 --- a/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only.test.ts +++ b/server/tests/integration/billing/update-subscription/billing-behavior/next-cycle-only.test.ts @@ -71,14 +71,14 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only: increase quantity - no i // Preview WITH next_cycle_only shows 0 (nothing charged now) const deferredPreview = await autumnV1.subscriptions.previewUpdate({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); expect(deferredPreview.total).toBe(0); // Nothing charged immediately // Execute update with next_cycle_only await autumnV1.subscriptions.update({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); const customer = await autumnV1.customers.get(customerId); @@ -139,14 +139,14 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only: decrease quantity - no i // Preview WITH next_cycle_only shows 0 (nothing credited now) const deferredPreview = await autumnV1.subscriptions.previewUpdate({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); expect(deferredPreview.total).toBe(0); // Nothing credited immediately // Execute update with next_cycle_only await autumnV1.subscriptions.update({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); const customer = await autumnV1.customers.get(customerId); @@ -202,14 +202,14 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only: paid-to-paid price incre // Preview WITH next_cycle_only shows 0 const deferredPreview = await autumnV1.subscriptions.previewUpdate({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); expect(deferredPreview.total).toBe(0); // Nothing charged now // Execute update with next_cycle_only await autumnV1.subscriptions.update({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); const customer = await autumnV1.customers.get(customerId); @@ -267,14 +267,14 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only: paid-to-paid price decre // Preview WITH next_cycle_only shows 0 const deferredPreview = await autumnV1.subscriptions.previewUpdate({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); expect(deferredPreview.total).toBe(0); // Nothing credited now // Execute update with next_cycle_only await autumnV1.subscriptions.update({ ...baseUpdateParams, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); const customer = await autumnV1.customers.get(customerId); @@ -365,7 +365,7 @@ test.concurrent(`${chalk.yellowBright("comparison: prorate_immediately creates i customer_id: customerDeferred, product_id: proDeferred.id, options: [{ feature_id: TestFeature.Messages, quantity: 10 }], - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); expect(deferredPreview.total).toBe(0); @@ -381,7 +381,7 @@ test.concurrent(`${chalk.yellowBright("comparison: prorate_immediately creates i customer_id: customerDeferred, product_id: proDeferred.id, options: [{ feature_id: TestFeature.Messages, quantity: 10 }], - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); const customerImmediateAfter = diff --git a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts index 504ca40c8..6f44aaa3c 100644 --- a/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts +++ b/server/tests/integration/billing/update-subscription/cancel/immediately/cancel-immediately-edge-cases.test.ts @@ -82,7 +82,7 @@ test.concurrent(`${chalk.yellowBright("cancel immediately edge: cancel pro after customer_id: customerId, product_id: pro.id, cancel_action: "cancel_immediately", - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); // Verify pro is removed, addon still active diff --git a/server/tests/integration/billing/update-subscription/errors/next-cycle-only-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/next-cycle-only-errors.test.ts index 0fac357f0..dbee300ae 100644 --- a/server/tests/integration/billing/update-subscription/errors/next-cycle-only-errors.test.ts +++ b/server/tests/integration/billing/update-subscription/errors/next-cycle-only-errors.test.ts @@ -51,7 +51,7 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only error: free to paid upgra customer_id: customerId, product_id: freeProduct.id, items: [paidMessagesItem, paidPriceItem], - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); }, }); @@ -87,7 +87,7 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only error: removing free tria customer_id: customerId, product_id: proTrial.id, free_trial: null, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); }, }); @@ -117,7 +117,7 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only: paid to paid upgrade is customer_id: customerId, product_id: pro.id, items: [messagesItem, newPriceItem], - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); // If we get here without error, test passes @@ -150,7 +150,7 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only: extending trial is allow duration: FreeTrialDuration.Day, card_required: true, }, - billing_behavior: "next_cycle_only", + billing_behavior: "none", }); // If we get here without error, test passes diff --git a/server/tests/integration/billing/update-subscription/errors/refund-behavior-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/refund-behavior-errors.test.ts index b86b00b07..caad840f9 100644 --- a/server/tests/integration/billing/update-subscription/errors/refund-behavior-errors.test.ts +++ b/server/tests/integration/billing/update-subscription/errors/refund-behavior-errors.test.ts @@ -44,7 +44,7 @@ test.skip(`${chalk.yellowBright("refund_behavior error: refund_payment_method + customer_id: customerId, product_id: pro.id, items: [messagesItem, newPriceItem], - billing_behavior: "next_cycle_only", + billing_behavior: "none", refund_behavior: "refund_payment_method", }); }, @@ -213,7 +213,7 @@ test.skip(`${chalk.yellowBright("refund_behavior: grant_invoice_credits with nex customer_id: customerId, product_id: pro.id, items: [messagesItem, newPriceItem], - billing_behavior: "next_cycle_only", + billing_behavior: "none", refund_behavior: "grant_invoice_credits", }); }); diff --git a/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts b/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts index d67f40a1e..fb0ed67f2 100644 --- a/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts +++ b/server/tests/integration/billing/update-subscription/errors/update-one-off-errors.test.ts @@ -1,11 +1,13 @@ import { test } from "bun:test"; -import { FreeTrialDuration } from "@autumn/shared"; +import { type ApiCustomerV3, FreeTrialDuration } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import { expectCustomerFeatureCorrect } from "../../utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "../../utils/expectCustomerInvoiceCorrect"; // ═══════════════════════════════════════════════════════════════════════════════ // ONE-OFF ERRORS - Price/Billing Changes Not Allowed @@ -330,6 +332,7 @@ test.concurrent(`${chalk.yellowBright("one-off: update prepaid item included usa ); // Update included usage from 50 to 100 (same quantity) + const newTotalGranted = 300; const newIncludedUsage = 100; const updatedPrepaidItem = items.oneOffMessages({ includedUsage: newIncludedUsage, @@ -343,9 +346,26 @@ test.concurrent(`${chalk.yellowBright("one-off: update prepaid item included usa items: [updatedPrepaidItem], }; - await expectAutumnError({ - func: async () => { - await autumnV1.subscriptions.update(updateParams); - }, + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: newTotalGranted, + balance: newTotalGranted - messagesUsed, + usage: messagesUsed, }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + }); + + // await expectAutumnError({ + // func: async () => { + // await autumnV1.subscriptions.update(updateParams); + // }, + // }); }); diff --git a/server/tests/utils/browserPool/completeInvoiceCheckout.ts b/server/tests/utils/browserPool/completeInvoiceCheckout.ts index 45b886689..6b4107edf 100644 --- a/server/tests/utils/browserPool/completeInvoiceCheckout.ts +++ b/server/tests/utils/browserPool/completeInvoiceCheckout.ts @@ -1,21 +1,36 @@ +import puppeteer, { type Browser } from "puppeteer-core"; import { timeout } from "../genUtils.js"; import { browserPool } from "./browserPool.js"; /** - * Complete a Stripe Invoice Checkout form using the shared browser pool. + * Complete a Stripe Invoice Checkout form. * This handles the Payment Element (iframe-based) checkout flow. - * Opens a new tab, fills the form, and closes the tab (browser stays open). + * + * @param isolatedBrowser - If true, launches a dedicated browser instance for this checkout. + * Use this when running multiple checkouts concurrently to avoid flakiness from shared browser state. */ export const completeInvoiceCheckout = async ({ url, - isLocal = false, + isolatedBrowser = false, }: { url: string; - isLocal?: boolean; + isolatedBrowser?: boolean; }): Promise => { console.log("[completeInvoiceCheckout] Starting invoice checkout..."); - const page = await browserPool.newPage(); + let browser: Browser | undefined; + let page; + if (isolatedBrowser) { + browser = await puppeteer.launch({ + headless: true, + executablePath: process.env.TESTS_CHROMIUM_PATH, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + } else { + page = await browserPool.newPage(); + } try { await page.goto(url); @@ -178,7 +193,9 @@ export const completeInvoiceCheckout = async ({ await timeout(20000); console.log("[completeInvoiceCheckout] Invoice checkout completed"); } finally { - // Close the page (tab), but keep browser open for reuse await page.close(); + if (browser) { + await browser.close(); + } } }; diff --git a/server/tests/utils/expectUtils/expectMultiAttach.ts b/server/tests/utils/expectUtils/expectMultiAttach.ts index 36c3ac74d..9b7a35a90 100644 --- a/server/tests/utils/expectUtils/expectMultiAttach.ts +++ b/server/tests/utils/expectUtils/expectMultiAttach.ts @@ -76,7 +76,6 @@ export const expectMultiAttachCorrect = async ({ if (attachParams?.invoice) { await completeInvoiceCheckout({ url: attachRes.checkout_url, - isLocal: true, }); } await completeStripeCheckoutForm({ url: attachRes.checkout_url }); diff --git a/shared/api/billing/common/billingBehavior.ts b/shared/api/billing/common/billingBehavior.ts index 490229aad..44e40a80a 100644 --- a/shared/api/billing/common/billingBehavior.ts +++ b/shared/api/billing/common/billingBehavior.ts @@ -1,10 +1,10 @@ import { z } from "zod/v4"; export const BillingBehaviorSchema = z - .enum(["prorate_immediately", "next_cycle_only"]) + .enum(["prorate_immediately", "none"]) .meta({ description: - "How to handle billing. 'prorate_immediately' charges/credits prorated amounts now, 'next_cycle_only' waits until the next billing cycle.", + "How to handle billing. 'prorate_immediately' charges/credits prorated amounts now, 'none' does not charge/credit anything.", }); export type BillingBehavior = z.infer; diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index 711c55ac4..aba5cdd11 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -96,12 +96,16 @@ const toV3BalanceParams = ({ const isBoolean = feature?.type === FeatureType.Boolean; if (isBoolean || unlimited) { + const expiresAt = + "expires_at" in input ? (input as ApiBalanceBreakdown).expires_at : null; + return { includedUsage: 0, balance: 0, usage: 0, overageAllowed: false, usageLimit: undefined, + expiresAt, }; } diff --git a/versionAttach.md b/versionAttach.md deleted file mode 100644 index 4f549c0c1..000000000 --- a/versionAttach.md +++ /dev/null @@ -1,124 +0,0 @@ -## Updated Test-First Plan (with itemsV2, typed inputs, and V1_Beta gate) - - ### Summary - - Sequence will be: - - 1. Add test scaffolding first (including new itemsV2 fixture file). - 2. Add new v2Params integration tests with typed request inputs. - 3. Implement request-versioning and internal param migration to pass tests. - 4. Fix remaining low-hanging type errors and run bun ts. - - ### Confirmed Constraints Applied - - 1. New fixture module will be a new file: - - - server/tests/utils/fixtures/itemsV2.ts - - 2. New test files must be exactly: - - - v2-customize.test.ts - - v2-free-trial.test.ts - - 3. Typed request-call pattern in tests: - - - autumnV2.billing.attach<..., AttachParamsV1Input>(...) style - - autumnV2.subscriptions.update<..., UpdateSubscriptionV1ParamsInput>(...) style - (using the same generic-template pattern as CRUD plan tests) - - 4. versionedBody previous version gate: - - - use ApiVersion.V1_Beta (not ApiVersion.V2_0) - - ——— - - ### Phase 1: Add Fixtures and Test Files First - - #### 1. New fixtures file - - Create server/tests/utils/fixtures/itemsV2.ts with V1-item builders suitable for customize.items, mirroring existing fixture ergonomics: - - - itemsV2.monthlyPrice(...) - - itemsV2.monthlyMessages(...) - - itemsV2.monthlyWords(...) - - itemsV2.dashboard(...) - - any minimal helpers needed for your specified edge cases - - These will return shapes compatible with V1 plan-item input (CreatePlanItemParamsV1 shape), not legacy product-item shape. - - #### 2. Attach tests - - Create: - - - server/tests/integration/billing/attach/v2Params/v2-customize.test.ts - - server/tests/integration/billing/attach/v2Params/v2-free-trial.test.ts - - v2-customize.test.ts coverage: - - - customize with both price + items - - customize with only price - - customize with only items - - 1-2 edge cases validating planItemsV1 -> internal product-item transform behavior through outcomes (preview total, customer feature state, invoice/subscription consistency) - - v2-free-trial.test.ts coverage: - - - 1-3 tests validating transformation correctness from legacy trial shape to V1 behavior (set/remove/preserve as needed) - - #### 3. Update-subscription tests - - Create: - - - server/tests/integration/billing/update-subscription/v2Params/v2-customize.test.ts - - server/tests/integration/billing/update-subscription/v2Params/v2-free-trial.test.ts - - Same scenario matrix as attach for customize and free-trial transform correctness. - - ——— - - ### Phase 2: Implement Versioning and Param Migration - - #### 1. Shared request schemas - - - Finalize attach V1 params to rely on customize (no top-level items in V1 schema). - - Add/update update-subscription V1 params schema with customize + V1 free_trial. - - #### 2. Request change files (v0 -> v1 body mapping) - - Add request-change classes for attach/update that map: - - free_trial.length/duration -> free_trial.duration_length/duration_type - Register these in version change registry. - - Update handlers (attach/update + previews) to use: - - - latest: V1 schema - - [ApiVersion.V1_Beta]: V0 schema - with correct resource for request-change application. - - #### 4. Internal billing v2 updates - - Refactor attach/update internal action graph to read: - No broader behavior changes beyond these two params. - - ——— - - ### Phase 3: Type-Error Cleanup and Verification - - 1. Run bun ts in server/. - 2. Fix low-hanging caller type mismatches surfaced by migration (including preview/checkout-adjacent paths if impacted by these param types). - 3. Run new v2Params test files individually. - ——— - - ### API / Interface Changes - - - New request-change classes for attach/update body transforms - - Handlers switched to version-aware body validation with V1 latest and V1_Beta legacy gate - - ——— - - ### Acceptance Criteria - - 1. All four v2Params test files exist with required names and scenario coverage. - 2. Tests use itemsV2 fixtures and typed generic input-call pattern. - 3. versionedBody uses ApiVersion.V1_Beta as legacy schema key. - 4. Attach/update endpoints correctly transform legacy free_trial and items into V1 format. - 5. bun ts passes in server/. \ No newline at end of file diff --git a/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx index 6ad8c8f3c..fcefa48db 100644 --- a/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx @@ -117,7 +117,7 @@ export function AttachAdvancedSection() { if (hasCustomBilling) { parts.push( - `Billing: ${effectiveBillingBehavior === "next_cycle_only" ? "No Charges" : "Prorate"}`, + `Billing: ${effectiveBillingBehavior === "none" ? "No Charges" : "Prorate"}`, ); } @@ -341,17 +341,15 @@ export function AttachAdvancedSection() { iconOrientation="left" variant="secondary" size="sm" - checked={ - effectiveBillingBehavior === "next_cycle_only" - } + checked={effectiveBillingBehavior === "none"} disabled={!isNoChargesAllowed} onCheckedChange={() => - handleBillingBehaviorChange("next_cycle_only") + handleBillingBehaviorChange("none") } className={cn( "rounded-l-none", - effectiveBillingBehavior !== - "next_cycle_only" && "border-l-0", + effectiveBillingBehavior !== "none" && + "border-l-0", )} > No Charges diff --git a/vite/src/components/forms/attach-v2/hooks/usePlanScheduleField.ts b/vite/src/components/forms/attach-v2/hooks/usePlanScheduleField.ts index dba4277a4..eee8c2944 100644 --- a/vite/src/components/forms/attach-v2/hooks/usePlanScheduleField.ts +++ b/vite/src/components/forms/attach-v2/hooks/usePlanScheduleField.ts @@ -126,7 +126,7 @@ export function usePlanScheduleField() { }, [canChooseBillingCycle, form, newBillingSubscription]); useEffect(() => { - if (billingBehavior !== "next_cycle_only") return; + if (billingBehavior !== "none") return; if (isNoChargesAllowed) return; form.setFieldValue("billingBehavior", null); }, [billingBehavior, form, isNoChargesAllowed]); diff --git a/vite/src/components/forms/attach-v2/utils/attachBillingBehaviorRules.ts b/vite/src/components/forms/attach-v2/utils/attachBillingBehaviorRules.ts index 4a598f5cb..825365d4e 100644 --- a/vite/src/components/forms/attach-v2/utils/attachBillingBehaviorRules.ts +++ b/vite/src/components/forms/attach-v2/utils/attachBillingBehaviorRules.ts @@ -24,7 +24,7 @@ export function normalizeAttachBillingBehavior({ newBillingSubscription: boolean; blocksNextCycleOnly?: boolean; }) { - if (billingBehavior !== "next_cycle_only") { + if (billingBehavior !== "none") { return billingBehavior; } diff --git a/vite/src/components/forms/cancel-subscription/components/BillingBehaviorSection.tsx b/vite/src/components/forms/cancel-subscription/components/BillingBehaviorSection.tsx index 8f6832962..f820c9dff 100644 --- a/vite/src/components/forms/cancel-subscription/components/BillingBehaviorSection.tsx +++ b/vite/src/components/forms/cancel-subscription/components/BillingBehaviorSection.tsx @@ -53,10 +53,8 @@ export function BillingBehaviorSection() {
- form.setFieldValue("billingBehavior", "next_cycle_only") - } + isSelected={billingBehavior === "none"} + onClick={() => form.setFieldValue("billingBehavior", "none")} icon={} />
diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionMutation.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionMutation.ts index f640642f0..8d2a969c7 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionMutation.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionMutation.ts @@ -45,7 +45,7 @@ export function useUpdateSubscriptionMutation({ } const response = await axiosInstance.post( - "/v1/subscriptions/update", + "/v1/billing.update", requestBody, ); return { data: response.data, useInvoice }; diff --git a/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts b/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts index 1a1b88014..f3a0ffd47 100644 --- a/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts +++ b/vite/src/components/forms/update-subscription/use-update-subscription-preview.ts @@ -1,17 +1,16 @@ import type { BillingBehavior, + CancelAction, CreateFreeTrial, PreviewUpdateSubscriptionResponse, ProductItem, + RefundBehavior, } from "@autumn/shared"; import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { useEffect, useMemo, useState } from "react"; import type { UpdateSubscriptionFormContext } from "@/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider"; -import type { - CancelActionValue, - RefundBehaviorValue, -} from "@/components/forms/update-subscription-v2/updateSubscriptionFormSchema"; + import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useUpdateSubscriptionBodyBuilder } from "./use-update-subscription-body-builder"; @@ -32,9 +31,9 @@ export function useUpdateSubscriptionPreview({ enabled?: boolean; items?: ProductItem[] | null; version?: number; - cancelAction?: CancelActionValue | null; + cancelAction?: CancelAction | null; billingBehavior?: BillingBehavior | null; - refundBehavior?: RefundBehaviorValue | null; + refundBehavior?: RefundBehavior | null; }) { const { customerId, product, entityId } = updateSubscriptionFormContext; const axiosInstance = useAxiosInstance(); @@ -88,7 +87,7 @@ export function useUpdateSubscriptionPreview({ const response = await axiosInstance.post( - "/v1/subscriptions/preview_update", + "/v1/billing.preview_update", updateSubscriptionBody, );