From 42eb32b47ecb4cf40e401dac5f26fb9c8cb1a838 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 8 May 2026 13:16:56 +0100 Subject: [PATCH 01/36] =?UTF-8?q?fix:=20=F0=9F=90=9B=20half=20state=20migr?= =?UTF-8?q?ation=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../billing/v2/actions/migrate/migrate.ts | 16 ++++ .../billing/migrations/migrate-states.test.ts | 86 ++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/server/src/internal/billing/v2/actions/migrate/migrate.ts b/server/src/internal/billing/v2/actions/migrate/migrate.ts index 276bc365e..a4514c5a1 100644 --- a/server/src/internal/billing/v2/actions/migrate/migrate.ts +++ b/server/src/internal/billing/v2/actions/migrate/migrate.ts @@ -2,13 +2,17 @@ import { type AttachBillingContext, type BillingPlan, type BillingResult, + ErrCode, type FullCusProduct, type FullCustomer, type FullProduct, featureUtils, + nullish, + RecaseError, type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { TransitionRules } from "@shared/api/billing/common/transitionRules"; +import { StatusCodes } from "http-status-codes"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { billingActions } from "@/internal/billing/v2/actions"; @@ -34,6 +38,18 @@ export async function migrate({ noBillingChanges?: boolean; }; }) { + const willTouchStripe = options.noBillingChanges !== true; + const isHalfCancelled = + currentCustomerProduct.canceled === true && + nullish(currentCustomerProduct.ended_at); + if (willTouchStripe && isHalfCancelled) { + throw new RecaseError({ + message: `[migrate] Refusing to migrate cusProduct ${currentCustomerProduct.id} (${currentCustomerProduct.product_id}): canceled=true but ended_at is null. Set noBillingChanges:true or repair ended_at before retrying.`, + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.CONFLICT, + }); + } + // 1. Build update subscription params const entity = fullCustomer.entities.find( (e) => e.internal_id === currentCustomerProduct.internal_entity_id, diff --git a/server/tests/integration/billing/migrations/migrate-states.test.ts b/server/tests/integration/billing/migrations/migrate-states.test.ts index a4423fadb..975b54bf0 100644 --- a/server/tests/integration/billing/migrations/migrate-states.test.ts +++ b/server/tests/integration/billing/migrations/migrate-states.test.ts @@ -9,7 +9,7 @@ * - Scheduled downgrade is preserved */ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; import type { ApiCustomerV3 } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { @@ -23,6 +23,8 @@ import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; const waitForMigration = (ms = 20000) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -217,3 +219,85 @@ test.concurrent(`${chalk.yellowBright("migrate-states-2: scheduled downgrade pre env: ctx.env, }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Half-cancelled state (canceled=true, ended_at=null) blocks migration +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Regression for the May 5 incident: 13 Micro customers got silently + * uncancelled when a v4 product migration ran against rows in the half-state + * (canceled=true but ended_at=null). The schedule rebuilder treated the row + * as "no future cancellation" and POSTed cancel_at:null to Stripe. + * + * Migration must hard-fail on the half-state instead of touching Stripe. + */ +test.concurrent(`${chalk.yellowBright("migrate-states-3: half-cancelled state hard-blocks migration")}`, async () => { + const customerId = "migrate-states-half-cancelled"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: "pro", timeout: 10000 }), + s.updateSubscription({ + productId: "pro", + cancelAction: "cancel_end_of_cycle", + }), + ], + }); + + const fullCusBefore = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const proCusProduct = fullCusBefore.customer_products.find( + (cp) => cp.product_id === pro.id, + ); + expect(proCusProduct).toBeDefined(); + expect(proCusProduct!.subscription_ids?.length).toBeGreaterThan(0); + + const subId = proCusProduct!.subscription_ids![0]; + const subBefore = await ctx.stripeCli.subscriptions.retrieve(subId); + expect(subBefore.cancel_at).toBeTruthy(); + const stripeCancelAtBefore = subBefore.cancel_at!; + + // Inject the production half-state. + await CusProductService.update({ + ctx, + cusProductId: proCusProduct!.id, + updates: { ended_at: null }, + }); + + const monthlyPrice = items.monthlyPrice({ price: 20 }); + const v2Items = [monthlyPrice, items.monthlyMessages({ includedUsage: 600 })]; + await autumnV1.products.update(pro.id, { items: v2Items }); + + await autumnV1.migrate({ + from_product_id: pro.id, + to_product_id: pro.id, + from_version: 1, + to_version: 2, + }); + await waitForMigration(); + + const subAfter = await ctx.stripeCli.subscriptions.retrieve(subId); + expect(subAfter.cancel_at).toBe(stripeCancelAtBefore); + + const fullCusAfter = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const v1Active = fullCusAfter.customer_products.find( + (cp) => cp.product.version === 1 && cp.status === "active", + ); + expect(v1Active).toBeDefined(); +}); From dc77b683e443cada39c8eb82b4f6944b31a1328b Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 8 May 2026 13:17:12 +0100 Subject: [PATCH 02/36] =?UTF-8?q?fix:=20=F0=9F=90=9B=20merge=20conflcit=20?= =?UTF-8?q?in=20agent.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a4bee656f..73556f02b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,11 +126,7 @@ Only update when the current work IS part of a project (see default-off rule abo Do NOT update context during normal coding work. Work first, compact at breakpoints. -<<<<<<< HEAD -A STATUS.md entry should record changes to the project itself -- not one-off work that merely uses the project (e.g. writing a consumer script of a framework is not a framework-project update). -======= A STATUS.md entry should record changes to the project itself — not one-off work that merely uses the project (e.g. writing a consumer script of a framework is not a framework-project update). ->>>>>>> a62195ba706f12fbd35f7ecf48b4701011115101 ### Compaction quality STATUS.md must be: From 71038a6c9a4e0ab14ce81ba4748ef72cc58d8e98 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 8 May 2026 13:17:28 +0100 Subject: [PATCH 03/36] =?UTF-8?q?chore:=20=F0=9F=A4=96=20sync=20ai=20submo?= =?UTF-8?q?dule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai b/ai index 008fd7a29..eb7800f5f 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 008fd7a292eae7088e874ea86df4f251165e1f59 +Subproject commit eb7800f5f7f0481769c2a946078c05d23b609ef5 From 45468e6ffeca2fc027456021aae4dbd288d12926 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 8 May 2026 13:18:02 +0100 Subject: [PATCH 04/36] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20git=20ignore=20ope?= =?UTF-8?q?ncode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ .opencode/opencode.json | 58 ----------------------------------------- 2 files changed, 2 insertions(+), 58 deletions(-) delete mode 100644 .opencode/opencode.json diff --git a/.gitignore b/.gitignore index 7a99f88fb..e4fbd1e89 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,5 @@ AGENTS.md server/.turbo .context +.opencode/opencode.json +.opencode/opencode.json diff --git a/.opencode/opencode.json b/.opencode/opencode.json deleted file mode 100644 index 14dbb803e..000000000 --- a/.opencode/opencode.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "axiom": { - "type": "remote", - "url": "https://mcp.axiom.co/mcp" - }, - "linear": { - "type": "remote", - "url": "https://mcp.linear.app/mcp", - "oauth": {} - }, - "trigger": { - "type": "local", - "command": [ - "bunx", - "trigger.dev@latest", - "mcp" - ] - }, - "tinybird": { - "type": "remote", - "url": "https://mcp.tinybird.co?token={env:TINYBIRD_READ_TOKEN}" - }, - "mintlify": { - "type": "remote", - "url": "https://mintlify.com/docs/mcp" - }, - "planetscale": { - "type": "remote", - "url": "https://mcp.pscale.dev/mcp/planetscale" - }, - "incident-io": { - "type": "remote", - "url": "https://mcp.incident.io/mcp", - "oauth": {} - }, - "plain": { - "type": "remote", - "url": "https://mcp.plain.com/mcp", - "oauth": {} - }, - "autumn-internal": { - "type": "local", - "command": [ - "sh", - "-c", - "cd \"/Users/johnyeocx/Autumn/autumn-cloud\" && exec infisical run --env=prod --recursive -- bun run \"/Users/johnyeocx/Autumn/autumn-cloud/ai/src/mcp/index.ts\"" - ], - "env": { - "AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud" - } - } - }, - "plugin": [ - "opencode-supermemory@latest" - ] -} From 2d11c6279554e2b3dec07f8c03709ef220de7c34 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 8 May 2026 16:26:42 +0100 Subject: [PATCH 05/36] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20better=20git=20wor?= =?UTF-8?q?kflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 4 ++-- .github/workflows/validate-schema.yml | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e8364ebdd..8f963d32d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -135,8 +135,8 @@ jobs: --push \ --provenance=false \ --sbom=false \ - --cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG_BRANCH} \ - --cache-to type=inline \ + --cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH} \ + --cache-to type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH},mode=max \ --tag ${TAGS//,/ --tag } \ -f docker/Dockerfile \ . diff --git a/.github/workflows/validate-schema.yml b/.github/workflows/validate-schema.yml index 4a0455ba7..24ca98076 100644 --- a/.github/workflows/validate-schema.yml +++ b/.github/workflows/validate-schema.yml @@ -4,9 +4,15 @@ on: pull_request: branches: - main + paths: + - "shared/**" + - "server/**" push: branches: - main + paths: + - "shared/**" + - "server/**" jobs: validate-schema: From ef1edcc3faf5f8957f0f9b236eaf0e2b5b657726 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 11 May 2026 11:28:26 +0100 Subject: [PATCH 06/36] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20live=20normalise?= =?UTF-8?q?=20expired=20purchase=20limit=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../limits/autoTopupLimitWindowUtils.ts | 26 ++++++++----------- .../getCusAutoTopupPurchaseLimits.ts | 16 +++++++++--- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts index 9e89754b7..d34eeb8a9 100644 --- a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts +++ b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts @@ -28,30 +28,18 @@ const intervalToEntInterval = ({ } }; -const getWindowEndsAt = ({ - now, - windowConfig, -}: { - now: number; - windowConfig: AutoTopupPurchaseLimit | AutoTopupWindowLimitConfig; -}) => { - return addInterval({ - from: now, - interval: intervalToEntInterval({ interval: windowConfig.interval }), - intervalCount: windowConfig.interval_count ?? 1, - }); -}; - export const normalizeWindowCounter = ({ now, windowEndsAt, count, windowConfig, + from, }: { now: number; windowEndsAt: number; count: number; windowConfig?: AutoTopupPurchaseLimit | AutoTopupWindowLimitConfig; + from?: number; }) => { if (!windowConfig) return undefined; @@ -59,8 +47,16 @@ export const normalizeWindowCounter = ({ return { windowEndsAt, count }; } + const interval = intervalToEntInterval({ interval: windowConfig.interval }); + const intervalCount = windowConfig.interval_count ?? 1; + + let projected = from ?? now; + do { + projected = addInterval({ from: projected, interval, intervalCount }); + } while (projected <= now); + return { - windowEndsAt: getWindowEndsAt({ now, windowConfig }), + windowEndsAt: projected, count: 0, }; }; diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts index a95a6608c..3c250ab76 100644 --- a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts @@ -5,6 +5,7 @@ import { } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; +import { normalizeWindowCounter } from "@/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.js"; /** * When `expand=billing_controls.auto_topups.purchase_limit` is requested, @@ -17,12 +18,12 @@ import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; * - Returns `undefined` when there are no configured auto_topups. * - For each configured auto_topup with a matching DB row, the `purchase_limit` * object is rebuilt with `count` + `next_reset_at` from the row. If the + * stored window has elapsed, `count` is projected to 0 and `next_reset_at` + * is advanced from the stored boundary by the configured interval. If the * config had no `purchase_limit`, `interval` / `interval_count` / `limit` * are returned as `null`. * - For configured auto_topups WITHOUT a matching DB row (no top-up has * ever fired), the entry is passed through unchanged from config. - * - * Per design: no live window normalization — raw DB values are surfaced as-is. */ export const getCusAutoTopupPurchaseLimits = async ({ ctx, @@ -60,6 +61,13 @@ export const getCusAutoTopupPurchaseLimits = async ({ } const configuredLimit = config.purchase_limit; + const normalized = normalizeWindowCounter({ + now: Date.now(), + windowEndsAt: row.purchase_window_ends_at, + count: row.purchase_count, + windowConfig: configuredLimit, + from: row.purchase_window_ends_at, + }); return { ...config, @@ -67,8 +75,8 @@ export const getCusAutoTopupPurchaseLimits = async ({ interval: configuredLimit?.interval ?? null, interval_count: configuredLimit?.interval_count ?? null, limit: configuredLimit?.limit ?? null, - count: row.purchase_count, - next_reset_at: row.purchase_window_ends_at, + count: normalized?.count ?? row.purchase_count, + next_reset_at: normalized?.windowEndsAt ?? row.purchase_window_ends_at, }, }; }); From bcf95b6b79494142f56d17d64d16fc1c2feec74c Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 11 May 2026 11:28:48 +0100 Subject: [PATCH 07/36] =?UTF-8?q?test:=20=F0=9F=92=8D=20tests,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 40 ++- .../rewards/RewardRedemptionService.ts | 249 ------------------ .../customer-billing-controls.test.ts | 90 +++++++ 3 files changed, 129 insertions(+), 250 deletions(-) delete mode 100644 server/src/internal/rewards/RewardRedemptionService.ts diff --git a/bun.lock b/bun.lock index 6caefd47d..14ad35b74 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "autumn", @@ -536,6 +537,7 @@ "react": "^18.2.0", "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", + "react-grab": "^0.1.29", "react-hotkeys-hook": "^4.6.1", "react-router": "^7.3.0", "react-router-dom": "^7.6.2", @@ -1795,6 +1797,8 @@ "@react-email/text": ["@react-email/text@0.1.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg=="], + "@react-grab/cli": ["@react-grab/cli@0.1.33", "", { "dependencies": { "@antfu/ni": "^30.1.0", "commander": "^14.0.3", "ignore": "^7.0.5", "jsonc-parser": "^3.3.1", "ora": "^9.4.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "smol-toml": "^1.6.1" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-UOc3PwN11Osw0NzaxRLK8trP4X+5iW1Dst3gvHRCafe3wXHyadzHYH8H1hdkcXdlIx3gsoD9ASJ+G/JH+A/jqA=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], @@ -2759,6 +2763,8 @@ "bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="], + "bippy": ["bippy@0.5.39", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "blueimp-md5": ["blueimp-md5@2.19.0", "", {}, "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="], @@ -4055,7 +4061,7 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonc-parser": ["jsonc-parser@2.2.1", "", {}, "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonc-simple-parser": ["jsonc-simple-parser@3.0.0", "", { "dependencies": { "reghex": "^3.0.2" } }, "sha512-0qi9Kuj4JPar4/3b9wZteuPZrTeFzXsQyOZj7hksnReCZN3Vr17Doz7w/i3E9XH7vRkVTHhHES+r1h97I+hfww=="], @@ -4861,6 +4867,8 @@ "react-email": ["react-email@4.0.16", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chalk": "^5.0.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "next": "^15.3.1", "normalize-path": "^3.0.0", "ora": "^8.0.0", "socket.io": "^4.8.1" }, "bin": { "email": "dist/cli/index.mjs" } }, "sha512-auhFU+nQxAkKkP6lQhPyGsa9exwfUEzp2BwZnjHokCwphZlg30tu4t1LgdKRwGPYsi7XNGy6asbVLAUhOVpzzg=="], + "react-grab": ["react-grab@0.1.33", "", { "dependencies": { "@react-grab/cli": "0.1.33", "bippy": "^0.5.39" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-ER919JMsE4TTrb2CpEivqsIjNMSycD4HtS8v7mS3pq67U7WL1K3+C8m9AYOwW4dpuYh+EanC2eJBmfuczHJZ0A=="], + "react-hotkeys-hook": ["react-hotkeys-hook@4.6.2", "", { "peerDependencies": { "react": ">=16.8.1", "react-dom": ">=16.8.1" } }, "sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -6365,6 +6373,12 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@react-grab/cli/@antfu/ni": ["@antfu/ni@30.1.0", "", { "dependencies": { "fzf": "^0.5.2", "package-manager-detector": "^1.6.0", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15" }, "bin": { "ni": "bin/ni.mjs", "nci": "bin/nci.mjs", "nr": "bin/nr.mjs", "nup": "bin/nup.mjs", "nd": "bin/nd.mjs", "nlx": "bin/nlx.mjs", "na": "bin/na.mjs", "nun": "bin/nun.mjs" } }, "sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw=="], + + "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@react-grab/cli/ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@sentry-internal/browser-utils/@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="], @@ -6407,6 +6421,8 @@ "@stoplight/better-ajv-errors/leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "@stoplight/json/jsonc-parser": ["jsonc-parser@2.2.1", "", {}, "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w=="], + "@stoplight/json/safe-stable-stringify": ["safe-stable-stringify@1.1.1", "", {}, "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw=="], "@stoplight/json-ref-readers/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -7965,6 +7981,22 @@ "@prisma/instrumentation/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "@react-grab/cli/@antfu/ni/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], + + "@react-grab/cli/ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "@react-grab/cli/ora/cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + + "@react-grab/cli/ora/is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "@react-grab/cli/ora/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "@react-grab/cli/ora/log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + + "@react-grab/cli/ora/stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], + + "@react-grab/cli/ora/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "@sentry/node-core/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -9157,6 +9189,8 @@ "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "@react-grab/cli/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "@tailwindcss/postcss/@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/postcss/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], @@ -9585,6 +9619,10 @@ "@mintlify/scraping/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "@react-grab/cli/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "artillery-plugin-ensure/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "artillery-plugin-publish-metrics/@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], diff --git a/server/src/internal/rewards/RewardRedemptionService.ts b/server/src/internal/rewards/RewardRedemptionService.ts deleted file mode 100644 index 1d9ca261c..000000000 --- a/server/src/internal/rewards/RewardRedemptionService.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - customers, - ErrCode, - type RewardRedemption, - type RewardTriggerEvent, - referralCodes, - rewardPrograms, - rewardRedemptions, - rewards, -} from "@autumn/shared"; -import { and, eq, or, sql } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import RecaseError from "@/utils/errorUtils.js"; - -export class RewardRedemptionService { - static async getById({ db, id }: { db: DrizzleCli; id: string }) { - const data = await db.query.rewardRedemptions.findFirst({ - where: eq(rewardRedemptions.id, id), - }); - - if (!data) { - throw new RecaseError({ - code: ErrCode.RewardRedemptionNotFound, - message: `Reward redemption ${id} not found`, - statusCode: 404, - }); - } - - return data; - } - - static async getByCustomer({ - db, - internalCustomerId, - triggered, - withReferralCode = false, - withRewardProgram = false, - internalRewardProgramId, - triggerWhen, - limit, - }: { - db: DrizzleCli; - internalCustomerId: string; - triggered?: boolean; - withReferralCode?: boolean; - withRewardProgram?: boolean; - internalRewardProgramId?: string; - triggerWhen?: RewardTriggerEvent; - limit?: number; - }) { - const data = await db.query.rewardRedemptions.findMany({ - where: and( - eq(rewardRedemptions.internal_customer_id, internalCustomerId), - internalRewardProgramId - ? eq( - rewardRedemptions.internal_reward_program_id, - internalRewardProgramId, - ) - : undefined, - triggered ? eq(rewardRedemptions.triggered, triggered) : undefined, - ), - with: { - reward_program: { - with: { - reward: true, - }, - }, - referral_code: true, - }, - limit: limit ?? 100, - }); - - return data as any; - } - - static async getByReferrer({ - db, - internalCustomerId, - withCustomer = false, - limit = 100, - withRewardProgram = false, - }: { - db: DrizzleCli; - internalCustomerId: string; - withCustomer?: boolean; - limit?: number; - withRewardProgram?: boolean; - }) { - let query = db - .select() - .from(rewardRedemptions) - .innerJoin( - referralCodes, - eq(rewardRedemptions.referral_code_id, referralCodes.id), - ) - .innerJoin( - customers, - sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ${customers.internal_id}`, - ); - - if (withRewardProgram) { - query = query.innerJoin( - rewardPrograms, - eq( - rewardRedemptions.internal_reward_program_id, - rewardPrograms.internal_id, - ), - ); - } - const data = await query - .where( - sql`${referralCodes.internal_customer_id} COLLATE "C" = ${internalCustomerId}`, - ) - .limit(limit); - - const processed = data.map((d) => ({ - ...d.reward_redemptions, - referral_code: d.referral_codes, - customer: d.customers, - reward_program: withRewardProgram - ? (d as any).reward_programs - : undefined, - })); - - return processed; - } - - static async insert({ - db, - rewardRedemption, - }: { - db: DrizzleCli; - rewardRedemption: RewardRedemption; - }) { - const data = await db - .insert(rewardRedemptions) - .values(rewardRedemption) - .returning(); - - if (data.length === 0) { - throw new RecaseError({ - code: ErrCode.InsertRewardRedemptionFailed, - message: `Failed to insert reward redemption`, - statusCode: 500, - }); - } - - return data[0] as RewardRedemption; - } - - static async update({ - db, - id, - updates, - }: { - db: DrizzleCli; - id: string; - updates: any; - }) { - const data = await db - .update(rewardRedemptions) - .set(updates) - .where(eq(rewardRedemptions.id, id)) - .returning(); - - if (data.length === 0) { - throw new RecaseError({ - code: "REWARD_REDEMPTION_NOT_FOUND", - message: `Reward redemption ${id} not found`, - }); - } - - return data[0] as RewardRedemption; - } - - static async getUnappliedRedemptions({ - db, - internalCustomerId, - }: { - db: DrizzleCli; - internalCustomerId: string; - }) { - const data = await db - .select() - .from(rewardRedemptions) - .innerJoin( - referralCodes, - eq(rewardRedemptions.referral_code_id, referralCodes.id), - ) - .innerJoin( - customers, - sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ${customers.internal_id}`, - ) - .innerJoin( - rewardPrograms, - eq( - rewardRedemptions.internal_reward_program_id, - rewardPrograms.internal_id, - ), - ) - .innerJoin( - rewards, - eq(rewardPrograms.internal_reward_id, rewards.internal_id), - ) - .where( - or( - and( - sql`${referralCodes.internal_customer_id} COLLATE "C" = ${internalCustomerId}`, - eq(rewardRedemptions.triggered, true), - eq(rewardRedemptions.applied, false), - ), - and( - sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ${internalCustomerId}`, - eq(rewardRedemptions.triggered, true), - eq(rewardRedemptions.redeemer_applied, false), - ), - ), - ); - - if (data.length === 0) return []; - - const processed = data.map((d) => ({ - ...d.reward_redemptions, - referral_code: d.referral_codes, - reward_program: { - ...d.reward_programs, - reward: d.rewards, - }, - })); - - return processed; - } - - static async _resetCustomerRedemptions({ - db, - internalCustomerId, - }: { - db: DrizzleCli; - internalCustomerId: string | string[]; - }) { - if (!Array.isArray(internalCustomerId)) - internalCustomerId = [internalCustomerId]; - return await db - .delete(rewardRedemptions) - .where( - sql`${rewardRedemptions.internal_customer_id} COLLATE "C" = ANY(${internalCustomerId})`, - ); - } -} diff --git a/server/tests/integration/crud/customers/customer-billing-controls.test.ts b/server/tests/integration/crud/customers/customer-billing-controls.test.ts index 30cd73cef..f03fe12c6 100644 --- a/server/tests/integration/crud/customers/customer-billing-controls.test.ts +++ b/server/tests/integration/crud/customers/customer-billing-controls.test.ts @@ -1,8 +1,10 @@ import { expect, test } from "bun:test"; import { + addInterval, type ApiCustomerV5, type CustomerBillingControls, CustomerExpand, + EntInterval, PurchaseLimitInterval, } from "@autumn/shared"; import { makeAutoTopupConfig } from "@tests/integration/balances/auto-topup/utils/makeAutoTopupConfig"; @@ -13,6 +15,7 @@ import { products } from "@tests/utils/fixtures/products.js"; import { timeout } from "@tests/utils/genUtils.js"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; +import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; import { CusService } from "@/internal/customers/CusService.js"; const AUTO_TOPUP_WAIT_MS = 20000; @@ -648,3 +651,90 @@ test.concurrent(`${chalk.yellowBright("customer billing controls: auto_topups.pu }); expect(typeof phaseCPurchaseLimit?.next_reset_at).toBe("number"); }); + +test.concurrent(`${chalk.yellowBright("customer billing controls: auto_topups.purchase_limit expand projects next_reset_at forward when stored window is stale")}`, async () => { + const customerId = "customer-billing-controls-13"; + const oneOffProd = products.oneOffAddOn({ + id: "topup-expand-stale", + items: [ + items.oneOffMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }), + ], + }); + + const { autumnV2_1, autumnV2_2, ctx, customer } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOffProd] }), + ], + actions: [ + s.attach({ + productId: oneOffProd.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }), + ], + }); + + await autumnV2_2.customers.update(customerId, { + billing_controls: makeAutoTopupConfig({ + threshold: 50, + quantity: 100, + purchaseLimit: { interval: PurchaseLimitInterval.Month, limit: 4 }, + }), + }); + + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 260, + }); + await timeout(AUTO_TOPUP_WAIT_MS); + + const limitRow = await autoTopupLimitRepo.findByScope({ + ctx, + internalCustomerId: customer.internal_id, + featureId: TestFeature.Credits, + }); + expect(limitRow).toBeDefined(); + + const staleWindowEndsAt = Date.now() - 6 * 24 * 60 * 60 * 1000; + await autoTopupLimitRepo.updateById({ + ctx, + id: limitRow!.id, + updates: { purchase_window_ends_at: staleWindowEndsAt }, + }); + + const expanded = await autumnV2_1.customers.get(customerId, { + expand: [CustomerExpand.AutoTopupsPurchaseLimit], + skip_cache: "true", + }); + const purchaseLimit = expanded.billing_controls?.auto_topups?.[0] + ?.purchase_limit as + | { + interval: PurchaseLimitInterval | null; + interval_count: number | null; + limit: number | null; + count: number; + next_reset_at: number; + } + | undefined; + + expect(purchaseLimit).toMatchObject({ + interval: PurchaseLimitInterval.Month, + interval_count: 1, + limit: 4, + count: 0, + }); + expect(purchaseLimit?.next_reset_at).toBe( + addInterval({ + from: staleWindowEndsAt, + interval: EntInterval.Month, + intervalCount: 1, + }), + ); + expect(purchaseLimit?.next_reset_at).toBeGreaterThan(Date.now()); +}); From 4d62d6d774cf1fc09d2454a086df1cb21743faa4 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 11 May 2026 11:55:47 +0100 Subject: [PATCH 08/36] =?UTF-8?q?test:=20=F0=9F=92=8D=20fix=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customer-billing-controls.test.ts | 55 ++++++------------- 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/server/tests/integration/crud/customers/customer-billing-controls.test.ts b/server/tests/integration/crud/customers/customer-billing-controls.test.ts index f03fe12c6..73435968d 100644 --- a/server/tests/integration/crud/customers/customer-billing-controls.test.ts +++ b/server/tests/integration/crud/customers/customer-billing-controls.test.ts @@ -17,6 +17,7 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; import { CusService } from "@/internal/customers/CusService.js"; +import { generateId } from "@/utils/genUtils.js"; const AUTO_TOPUP_WAIT_MS = 20000; @@ -654,29 +655,11 @@ test.concurrent(`${chalk.yellowBright("customer billing controls: auto_topups.pu test.concurrent(`${chalk.yellowBright("customer billing controls: auto_topups.purchase_limit expand projects next_reset_at forward when stored window is stale")}`, async () => { const customerId = "customer-billing-controls-13"; - const oneOffProd = products.oneOffAddOn({ - id: "topup-expand-stale", - items: [ - items.oneOffMessages({ - includedUsage: 0, - billingUnits: 100, - price: 10, - }), - ], - }); const { autumnV2_1, autumnV2_2, ctx, customer } = await initScenario({ customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [oneOffProd] }), - ], - actions: [ - s.attach({ - productId: oneOffProd.id, - options: [{ feature_id: TestFeature.Messages, quantity: 300 }], - }), - ], + setup: [s.customer({ testClock: false })], + actions: [], }); await autumnV2_2.customers.update(customerId, { @@ -687,25 +670,23 @@ test.concurrent(`${chalk.yellowBright("customer billing controls: auto_topups.pu }), }); - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 260, - }); - await timeout(AUTO_TOPUP_WAIT_MS); - - const limitRow = await autoTopupLimitRepo.findByScope({ - ctx, - internalCustomerId: customer.internal_id, - featureId: TestFeature.Credits, - }); - expect(limitRow).toBeDefined(); - const staleWindowEndsAt = Date.now() - 6 * 24 * 60 * 60 * 1000; - await autoTopupLimitRepo.updateById({ + const now = Date.now(); + await autoTopupLimitRepo.insert({ ctx, - id: limitRow!.id, - updates: { purchase_window_ends_at: staleWindowEndsAt }, + data: { + id: generateId("atlim"), + internal_customer_id: customer.internal_id, + customer_id: customerId, + feature_id: TestFeature.Messages, + purchase_window_ends_at: staleWindowEndsAt, + purchase_count: 3, + attempt_window_ends_at: now, + attempt_count: 0, + failed_attempt_window_ends_at: now, + failed_attempt_count: 0, + updated_at: now, + }, }); const expanded = await autumnV2_1.customers.get(customerId, { From 8ced4fc3834f1917051c293268d848928d88c411 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 11 May 2026 12:06:49 +0100 Subject: [PATCH 09/36] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20expand=20atu=20sta?= =?UTF-8?q?te=20in=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customers2/customer/components/ShowCustomerObjectSheet.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx b/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx index 3975215d1..4d50884e2 100644 --- a/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx +++ b/vite/src/views/customers2/customer/components/ShowCustomerObjectSheet.tsx @@ -31,6 +31,7 @@ const EXPAND_PARAMS = [ "entities", "referrals", "payment_method", + "billing_controls.auto_topups.purchase_limit", ].join(","); export function ShowCustomerObjectSheet({ From b4f72f0bc0f98e3c09aa2f5b3a21219e68513e51 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 11 May 2026 19:43:06 +0100 Subject: [PATCH 10/36] =?UTF-8?q?feat(workbench):=20=F0=9F=8E=B8=20add=20A?= =?UTF-8?q?xiom-backed=20API=20endpoint=20for=20customer=20request=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /workbench/requests gated on Scopes.Superuser. Queries the express Axiom dataset for HTTP request logs filtered by org_slug, env, and customer_id over a 7-day window. Returns the full raw event plus a normalised projection. New external/axiom/ module wraps the singleton client and APL query builder with escapeApl. --- server/package.json | 1 + server/src/external/axiom/aplUtils.ts | 74 +++++++++ server/src/external/axiom/initAxiom.ts | 20 +++ .../handlers/handleListRequestLogs.ts | 143 ++++++++++++++++++ .../src/internal/workbench/workbenchRouter.ts | 7 + server/src/routers/internalRouter.ts | 2 + 6 files changed, 247 insertions(+) create mode 100644 server/src/external/axiom/aplUtils.ts create mode 100644 server/src/external/axiom/initAxiom.ts create mode 100644 server/src/internal/workbench/handlers/handleListRequestLogs.ts create mode 100644 server/src/internal/workbench/workbenchRouter.ts diff --git a/server/package.json b/server/package.json index f3ec18c57..fbee53607 100644 --- a/server/package.json +++ b/server/package.json @@ -52,6 +52,7 @@ "@aws-sdk/client-s3": "^3.1017.0", "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", + "@axiomhq/js": "^1.6.1", "@axiomhq/pino": "^1.3.1", "@better-auth/dash": "catalog:", "@better-auth/oauth-provider": "catalog:", diff --git a/server/src/external/axiom/aplUtils.ts b/server/src/external/axiom/aplUtils.ts new file mode 100644 index 000000000..faea744dd --- /dev/null +++ b/server/src/external/axiom/aplUtils.ts @@ -0,0 +1,74 @@ +export const escapeApl = (value: string): string => + value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + +export type StatusBucket = "all" | "2xx" | "4xx" | "5xx"; +export type HttpMethodFilter = + | "all" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; + +const statusBucketClause = (bucket: StatusBucket): string | null => { + switch (bucket) { + case "2xx": + return "statusCode >= 200 and statusCode < 300"; + case "4xx": + return "statusCode >= 400 and statusCode < 500"; + case "5xx": + return "statusCode >= 500 and statusCode < 600"; + default: + return null; + } +}; + +export const buildRequestLogsQuery = ({ + orgSlug, + env, + customerId, + method, + statusBucket, + search, + limit = 200, + rangeDays = 7, +}: { + orgSlug: string; + env: string; + customerId: string; + method?: HttpMethodFilter; + statusBucket?: StatusBucket; + search?: string; + limit?: number; + rangeDays?: number; +}): string => { + const filters: string[] = [ + `_time > ago(${rangeDays}d)`, + `isnotnull(statusCode)`, + `isnotnull(['req.url'])`, + `(['context.org_slug'] == '${escapeApl(orgSlug)}' or orgSlug == '${escapeApl(orgSlug)}')`, + `(['context.env'] == '${escapeApl(env)}' or env == '${escapeApl(env)}')`, + `(['req.customer_id'] == '${escapeApl(customerId)}' or customer_id == '${escapeApl(customerId)}')`, + ]; + + if (method && method !== "all") { + filters.push(`['req.method'] == '${escapeApl(method)}'`); + } + + const statusClause = statusBucketClause(statusBucket ?? "all"); + if (statusClause) filters.push(statusClause); + + if (search?.trim()) { + const needle = escapeApl(search.trim()); + filters.push( + `(['req.url'] contains '${needle}' or msg contains '${needle}')`, + ); + } + + const wheres = filters.map((f) => `| where ${f}`).join("\n"); + + return `['express'] +${wheres} +| order by _time desc +| limit ${limit}`; +}; diff --git a/server/src/external/axiom/initAxiom.ts b/server/src/external/axiom/initAxiom.ts new file mode 100644 index 000000000..d6445f156 --- /dev/null +++ b/server/src/external/axiom/initAxiom.ts @@ -0,0 +1,20 @@ +import { Axiom } from "@axiomhq/js"; + +const AXIOM_ADMIN_TOKEN = process.env.AXIOM_ADMIN_TOKEN; +const AXIOM_ORG_ID = process.env.AXIOM_ORG_ID; + +export const axiomClient: Axiom | null = AXIOM_ADMIN_TOKEN + ? new Axiom({ + token: AXIOM_ADMIN_TOKEN, + orgId: AXIOM_ORG_ID, + }) + : null; + +export const getAxiomClient = (): Axiom => { + if (!axiomClient) { + throw new Error("Axiom is not configured (AXIOM_ADMIN_TOKEN missing)"); + } + return axiomClient; +}; + +export const isAxiomConfigured = (): boolean => axiomClient !== null; diff --git a/server/src/internal/workbench/handlers/handleListRequestLogs.ts b/server/src/internal/workbench/handlers/handleListRequestLogs.ts new file mode 100644 index 000000000..002551dd9 --- /dev/null +++ b/server/src/internal/workbench/handlers/handleListRequestLogs.ts @@ -0,0 +1,143 @@ +import { ErrCode, RecaseError, Scopes } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import { z } from "zod/v4"; +import { + buildRequestLogsQuery, + type HttpMethodFilter, + type StatusBucket, +} from "@/external/axiom/aplUtils.js"; +import { + getAxiomClient, + isAxiomConfigured, +} from "@/external/axiom/initAxiom.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CusService } from "@/internal/customers/CusService.js"; + +const ListRequestLogsSchema = z.object({ + customer_id: z.string().min(1), + method: z.enum(["all", "GET", "POST", "PUT", "PATCH", "DELETE"]).optional(), + status: z.enum(["all", "2xx", "4xx", "5xx"]).optional(), + search: z.string().optional(), +}); + +export interface RequestLogEntry { + id: string; + time: string; + statusCode: number; + durationMs: number | null; + method: string | null; + url: string | null; + path: string | null; + reqId: string | null; + ip: string | null; + userAgent: string | null; + customerId: string | null; + msg: string | null; + raw: Record; +} + +const extractPath = (url: string | null | undefined): string | null => { + if (!url) return null; + try { + return new URL(url).pathname; + } catch { + return url; + } +}; + +const pickString = ( + d: Record, + keys: string[], +): string | null => { + for (const k of keys) { + const v = d[k]; + if (typeof v === "string" && v.length > 0) return v; + } + return null; +}; + +const pickNumber = ( + d: Record, + keys: string[], +): number | null => { + for (const k of keys) { + const v = d[k]; + if (typeof v === "number") return v; + } + return null; +}; + +export const handleListRequestLogs = createRoute({ + scopes: [Scopes.Superuser], + body: ListRequestLogsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const { org, env } = ctx; + const { customer_id, method, status, search } = c.req.valid("json"); + + const customer = await CusService.getFull({ + ctx, + idOrInternalId: customer_id, + }); + + if (!customer) { + throw new RecaseError({ + message: "Customer not found", + code: ErrCode.CustomerNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } + + if (!isAxiomConfigured()) { + return c.json({ logs: [], unconfigured: true }); + } + + const apl = buildRequestLogsQuery({ + orgSlug: org.slug, + env, + customerId: customer.id ?? customer_id, + method: method as HttpMethodFilter | undefined, + statusBucket: status as StatusBucket | undefined, + search, + }); + + try { + const axiom = getAxiomClient(); + const result = await axiom.query(apl); + const matches = result.matches ?? []; + + const logs: RequestLogEntry[] = matches.map((entry, i) => { + const raw = (entry.data ?? {}) as Record; + const url = pickString(raw, ["req.url", "url"]); + return { + id: pickString(raw, ["req.id", "reqId"]) ?? `${entry._time}-${i}`, + time: entry._time, + statusCode: pickNumber(raw, ["statusCode"]) ?? 0, + durationMs: pickNumber(raw, ["durationMs"]), + method: pickString(raw, ["req.method", "method"]), + url, + path: extractPath(url), + reqId: pickString(raw, ["req.id", "reqId"]), + ip: pickString(raw, ["req.ip_address"]), + userAgent: pickString(raw, ["req.user_agent"]), + customerId: pickString(raw, [ + "req.customer_id", + "customer_id", + "cusId", + ]), + msg: pickString(raw, ["msg", "message"]), + raw, + }; + }); + + return c.json({ logs }); + } catch (err) { + ctx.logger?.error("Axiom workbench query failed", { err }); + throw new RecaseError({ + message: "Failed to query request logs", + code: ErrCode.InternalError, + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + }); + } + }, +}); diff --git a/server/src/internal/workbench/workbenchRouter.ts b/server/src/internal/workbench/workbenchRouter.ts new file mode 100644 index 000000000..c0e4e41bc --- /dev/null +++ b/server/src/internal/workbench/workbenchRouter.ts @@ -0,0 +1,7 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleListRequestLogs } from "./handlers/handleListRequestLogs.js"; + +export const workbenchRouter = new Hono(); + +workbenchRouter.post("/requests", ...handleListRequestLogs); diff --git a/server/src/routers/internalRouter.ts b/server/src/routers/internalRouter.ts index a792697d9..b86f99c71 100644 --- a/server/src/routers/internalRouter.ts +++ b/server/src/routers/internalRouter.ts @@ -20,6 +20,7 @@ import { pricingAgentRouter } from "../internal/misc/pricingAgent/pricingAgentRo import { savedViewsRouter } from "../internal/misc/savedViews/savedViewsRouter"; import { internalOrgRouter } from "../internal/orgs/orgRouter"; import { internalProductRouter } from "../internal/products/internalProductRouter"; +import { workbenchRouter } from "../internal/workbench/workbenchRouter"; export const internalRouter = new Hono(); @@ -45,6 +46,7 @@ internalRouter.route("/trmnl", internalTrmnlRouter); internalRouter.route("/feedback", feedbackRouter); internalRouter.route("/saved_views", savedViewsRouter); internalRouter.route("/query", internalAnalyticsRouter); +internalRouter.route("/workbench", workbenchRouter); // Autumn SDK handler (requires session auth) if (process.env.AUTUMN_SECRET_KEY) { From 1316a737bc075155a40a4910152cb1cfb9abc4dc Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 11 May 2026 19:43:34 +0100 Subject: [PATCH 11/36] =?UTF-8?q?feat(workbench):=20=F0=9F=8E=B8=20add=20S?= =?UTF-8?q?tripe-style=20workbench=20panel=20for=20customer=20API=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-only resizable drawer mounted on the customer page. Vaul for slide-up, shadcn Resizable for the horizontal list/detail split, custom pointer-drag handle for vertical resize. Four Monaco JSON viewers (request, response, context, raw event) auto-fold nested objects on mount and capture Ctrl+F for the native find widget. Escape closes the workbench only when no Monaco editor, dialog, or sheet is consuming the key. Filters by method, status bucket, and free-text search with a 300ms debounce. Sidebar button shows only for admins on customer routes. --- bun.lock | 6 + vite/package.json | 2 + vite/src/components/ui/resizable.tsx | 50 +++++ .../hooks/queries/useCusRequestLogsQuery.tsx | 93 ++++++++++ vite/src/hooks/stores/useWorkbenchStore.ts | 64 +++++++ .../customers2/customer/CustomerView2.tsx | 2 + .../customer/workbench/Workbench.tsx | 153 +++++++++++++++ .../customer/workbench/WorkbenchButton.tsx | 52 ++++++ .../workbench/WorkbenchEmptyState.tsx | 12 ++ .../workbench/WorkbenchFilterSelect.tsx | 43 +++++ .../customer/workbench/WorkbenchFilters.tsx | 58 ++++++ .../workbench/WorkbenchJsonViewer.tsx | 145 +++++++++++++++ .../customer/workbench/WorkbenchLogDetail.tsx | 174 ++++++++++++++++++ .../customer/workbench/WorkbenchLogList.tsx | 86 +++++++++ .../customer/workbench/WorkbenchLogRow.tsx | 50 +++++ .../workbench/hooks/useWorkbenchEscape.ts | 30 +++ .../workbench/hooks/useWorkbenchResize.ts | 52 ++++++ .../customer/workbench/workbenchUtils.ts | 125 +++++++++++++ vite/src/views/main-sidebar/SidebarBottom.tsx | 16 +- 19 files changed, 1199 insertions(+), 14 deletions(-) create mode 100644 vite/src/components/ui/resizable.tsx create mode 100644 vite/src/hooks/queries/useCusRequestLogsQuery.tsx create mode 100644 vite/src/hooks/stores/useWorkbenchStore.ts create mode 100644 vite/src/views/customers2/customer/workbench/Workbench.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchButton.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchEmptyState.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchFilterSelect.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchFilters.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchJsonViewer.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchLogDetail.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchLogList.tsx create mode 100644 vite/src/views/customers2/customer/workbench/WorkbenchLogRow.tsx create mode 100644 vite/src/views/customers2/customer/workbench/hooks/useWorkbenchEscape.ts create mode 100644 vite/src/views/customers2/customer/workbench/hooks/useWorkbenchResize.ts create mode 100644 vite/src/views/customers2/customer/workbench/workbenchUtils.ts diff --git a/bun.lock b/bun.lock index 1846c3ae7..2c77a92f8 100644 --- a/bun.lock +++ b/bun.lock @@ -525,6 +525,7 @@ "react-dom": "^18.2.0", "react-grab": "^0.1.29", "react-hotkeys-hook": "^4.6.1", + "react-resizable-panels": "^4.11.0", "react-router": "^7.3.0", "react-router-dom": "^7.6.2", "recharts": "^3.3.0", @@ -538,6 +539,7 @@ "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", "use-stick-to-bottom": "^1.1.1", + "vaul": "^1.1.2", "zod": "^3.25.23", "zustand": "^5.0.8", }, @@ -4871,6 +4873,8 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-resizable-panels": ["react-resizable-panels@4.11.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-LPk/AkFDGkg7SsbOyL93ojrE6E7lhrxxDwnYNjfmnSeI6BE7Sje6dB24PXgZk8DeugdeXNk1LO+ohRqIjhxiLw=="], + "react-router": ["react-router@7.14.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="], "react-router-dom": ["react-router-dom@7.14.2", "", { "dependencies": { "react-router": "7.14.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ=="], @@ -5577,6 +5581,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], diff --git a/vite/package.json b/vite/package.json index e9237ef39..02a037ae9 100644 --- a/vite/package.json +++ b/vite/package.json @@ -64,6 +64,7 @@ "react-dom": "^18.2.0", "react-grab": "^0.1.29", "react-hotkeys-hook": "^4.6.1", + "react-resizable-panels": "^4.11.0", "react-router": "^7.3.0", "react-router-dom": "^7.6.2", "recharts": "^3.3.0", @@ -77,6 +78,7 @@ "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", "use-stick-to-bottom": "^1.1.1", + "vaul": "^1.1.2", "zod": "^3.25.23", "zustand": "^5.0.8" }, diff --git a/vite/src/components/ui/resizable.tsx b/vite/src/components/ui/resizable.tsx new file mode 100644 index 000000000..c9e767a94 --- /dev/null +++ b/vite/src/components/ui/resizable.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as ResizablePrimitive from "react-resizable-panels" + +import { cn } from "@/lib/utils" + +function ResizablePanelGroup({ + className, + ...props +}: ResizablePrimitive.GroupProps) { + return ( + + ) +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean +}) { + return ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ )} + + ) +} + +export { ResizableHandle, ResizablePanel, ResizablePanelGroup } diff --git a/vite/src/hooks/queries/useCusRequestLogsQuery.tsx b/vite/src/hooks/queries/useCusRequestLogsQuery.tsx new file mode 100644 index 000000000..efd1245f3 --- /dev/null +++ b/vite/src/hooks/queries/useCusRequestLogsQuery.tsx @@ -0,0 +1,93 @@ +import { useQuery } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { + useWorkbenchStore, + type WorkbenchMethod, + type WorkbenchStatus, +} from "@/hooks/stores/useWorkbenchStore"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export interface RequestLogEntry { + id: string; + time: string; + statusCode: number; + durationMs: number | null; + method: string | null; + url: string | null; + path: string | null; + reqId: string | null; + ip: string | null; + userAgent: string | null; + customerId: string | null; + msg: string | null; + raw: Record; +} + +interface ListResponse { + logs: RequestLogEntry[]; + unconfigured?: boolean; +} + +export const useCusRequestLogsQuery = ({ + customerId, + enabled, +}: { + customerId: string | undefined; + enabled: boolean; +}) => { + const axiosInstance = useAxiosInstance(); + const buildKey = useQueryKeyFactory(); + const filters = useWorkbenchStore((s) => s.filters); + + const { data, isLoading, isFetching, error, refetch } = + useQuery({ + queryKey: buildKey([ + "customer_request_logs", + customerId, + filters.method, + filters.status, + filters.search, + ]), + queryFn: async () => { + const { data } = await axiosInstance.post( + "/workbench/requests", + { + customer_id: customerId, + method: filters.method, + status: filters.status, + search: filters.search || undefined, + }, + ); + return data; + }, + enabled: enabled && !!customerId, + staleTime: 30_000, + refetchOnWindowFocus: true, + placeholderData: (prev) => prev, + }); + + return { + logs: data?.logs ?? [], + unconfigured: data?.unconfigured === true, + isLoading, + isFetching, + error, + refetch, + }; +}; + +export const filterMethods: { value: WorkbenchMethod; label: string }[] = [ + { value: "all", label: "All methods" }, + { value: "GET", label: "GET" }, + { value: "POST", label: "POST" }, + { value: "PUT", label: "PUT" }, + { value: "PATCH", label: "PATCH" }, + { value: "DELETE", label: "DELETE" }, +]; + +export const filterStatuses: { value: WorkbenchStatus; label: string }[] = [ + { value: "all", label: "All statuses" }, + { value: "2xx", label: "2xx" }, + { value: "4xx", label: "4xx" }, + { value: "5xx", label: "5xx" }, +]; diff --git a/vite/src/hooks/stores/useWorkbenchStore.ts b/vite/src/hooks/stores/useWorkbenchStore.ts new file mode 100644 index 000000000..ccf36302d --- /dev/null +++ b/vite/src/hooks/stores/useWorkbenchStore.ts @@ -0,0 +1,64 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type WorkbenchMethod = + | "all" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; +export type WorkbenchStatus = "all" | "2xx" | "4xx" | "5xx"; + +export const WORKBENCH_MIN_HEIGHT = 200; +export const WORKBENCH_MAX_HEIGHT_RATIO = 0.92; +export const WORKBENCH_DEFAULT_HEIGHT = 360; + +interface WorkbenchFilters { + method: WorkbenchMethod; + status: WorkbenchStatus; + search: string; +} + +interface WorkbenchState { + isOpen: boolean; + height: number; + filters: WorkbenchFilters; + selectedLogId: string | null; + + open: () => void; + close: () => void; + toggle: () => void; + setHeight: (height: number) => void; + setFilters: (filters: Partial) => void; + setSelectedLogId: (id: string | null) => void; +} + +const DEFAULT_FILTERS: WorkbenchFilters = { + method: "all", + status: "all", + search: "", +}; + +export const useWorkbenchStore = create()( + persist( + (set) => ({ + isOpen: false, + height: WORKBENCH_DEFAULT_HEIGHT, + filters: DEFAULT_FILTERS, + selectedLogId: null, + + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + toggle: () => set((s) => ({ isOpen: !s.isOpen })), + setHeight: (height) => set({ height }), + setFilters: (filters) => + set((s) => ({ filters: { ...s.filters, ...filters } })), + setSelectedLogId: (id) => set({ selectedLogId: id }), + }), + { + name: "workbench-store", + partialize: (s) => ({ height: s.height }), + }, + ), +); diff --git a/vite/src/views/customers2/customer/CustomerView2.tsx b/vite/src/views/customers2/customer/CustomerView2.tsx index 17231943a..8f343da2d 100644 --- a/vite/src/views/customers2/customer/CustomerView2.tsx +++ b/vite/src/views/customers2/customer/CustomerView2.tsx @@ -37,6 +37,7 @@ import { CustomerPageDetails } from "./CustomerPageDetails"; import { CustomerSheets } from "./CustomerSheets"; import { SelectedEntityDetails } from "./components/SelectedEntityDetails"; import { SHEET_ANIMATION } from "./customerAnimations"; +import { Workbench } from "./workbench/Workbench"; export default function CustomerView2() { const { @@ -228,6 +229,7 @@ export default function CustomerView2() { +
); diff --git a/vite/src/views/customers2/customer/workbench/Workbench.tsx b/vite/src/views/customers2/customer/workbench/Workbench.tsx new file mode 100644 index 000000000..4636c196e --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/Workbench.tsx @@ -0,0 +1,153 @@ +import { + ArrowClockwiseIcon, + TerminalWindowIcon, + XIcon, +} from "@phosphor-icons/react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { useCusRequestLogsQuery } from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { cn } from "@/lib/utils"; +import { useCustomerContext } from "../CustomerContext"; +import { useWorkbenchEscape } from "./hooks/useWorkbenchEscape"; +import { useWorkbenchResize } from "./hooks/useWorkbenchResize"; +import { WorkbenchFilters } from "./WorkbenchFilters"; +import { WorkbenchLogDetail } from "./WorkbenchLogDetail"; +import { WorkbenchLogList } from "./WorkbenchLogList"; + +export const Workbench = () => { + const { customer } = useCustomerContext(); + const isOpen = useWorkbenchStore((s) => s.isOpen); + const height = useWorkbenchStore((s) => s.height); + const close = useWorkbenchStore((s) => s.close); + + const { handleProps } = useWorkbenchResize(); + useWorkbenchEscape(); + + const { refetch, isFetching } = useCusRequestLogsQuery({ + customerId: customer?.id, + enabled: isOpen, + }); + + return ( + { + if (!open) close(); + }} + modal={false} + dismissible={false} + autoFocus={false} + repositionInputs={false} + noBodyStyles + > + + + + Workbench + + + refetch()} + isFetching={isFetching} + onClose={close} + handleProps={handleProps} + /> + +
+ + + + + + + + + +
+
+
+
+ ); +}; + +type ResizeHandleProps = ReturnType["handleProps"]; + +const WorkbenchHeader = ({ + onRefresh, + isFetching, + onClose, + handleProps, +}: { + onRefresh: () => void; + isFetching: boolean; + onClose: () => void; + handleProps: ResizeHandleProps; +}) => ( +
+ + +
+
+ + Workbench +
+
+
+ Logs +
+
+ +
+ +
+ + } + title="Refresh" + className="cursor-pointer" + /> + } + title="Close" + className="cursor-pointer" + /> +
+
+); diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchButton.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchButton.tsx new file mode 100644 index 000000000..c1ac9b298 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchButton.tsx @@ -0,0 +1,52 @@ +import { TerminalWindowIcon } from "@phosphor-icons/react"; +import { useMatch } from "react-router"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { cn } from "@/lib/utils"; +import { useAdmin } from "@/views/admin/hooks/useAdmin"; +import { useSidebarContext } from "@/views/main-sidebar/SidebarContext"; + +export const WorkbenchButton = () => { + const { isAdmin } = useAdmin(); + const onCustomerView = useMatch("/customers/:customer_id"); + const onCustomerSubView = useMatch("/customers/:customer_id/*"); + const isMobile = useIsMobile(); + const { expanded } = useSidebarContext(); + + const toggle = useWorkbenchStore((s) => s.toggle); + const isOpen = useWorkbenchStore((s) => s.isOpen); + + if (!isAdmin || isMobile || (!onCustomerView && !onCustomerSubView)) { + return null; + } + + return ( + + ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchEmptyState.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchEmptyState.tsx new file mode 100644 index 000000000..2e9b43741 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchEmptyState.tsx @@ -0,0 +1,12 @@ +export const WorkbenchEmptyState = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( +
+

{title}

+

{children}

+
+); diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchFilterSelect.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchFilterSelect.tsx new file mode 100644 index 000000000..5b9800c5e --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchFilterSelect.tsx @@ -0,0 +1,43 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/v2/selects/Select"; + +interface FilterOption { + value: T; + label: string; +} + +export const WorkbenchFilterSelect = ({ + value, + options, + onChange, + placeholder, +}: { + value: T; + options: readonly FilterOption[]; + onChange: (next: T) => void; + placeholder: string; +}) => { + const label = options.find((o) => o.value === value)?.label ?? placeholder; + + return ( + + ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchFilters.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchFilters.tsx new file mode 100644 index 000000000..a498ffdd4 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchFilters.tsx @@ -0,0 +1,58 @@ +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { Input } from "@/components/v2/inputs/Input"; +import { + filterMethods, + filterStatuses, +} from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { WorkbenchFilterSelect } from "./WorkbenchFilterSelect"; + +const SEARCH_DEBOUNCE_MS = 300; + +export const WorkbenchFilters = () => { + const filters = useWorkbenchStore((s) => s.filters); + const setFilters = useWorkbenchStore((s) => s.setFilters); + + const [searchInput, setSearchInput] = useState(filters.search); + + useEffect(() => { + if (searchInput === filters.search) return; + const t = setTimeout( + () => setFilters({ search: searchInput }), + SEARCH_DEBOUNCE_MS, + ); + return () => clearTimeout(t); + }, [searchInput, filters.search, setFilters]); + + return ( +
+
+ setSearchInput(e.target.value)} + placeholder="Search path or body" + className="!h-7 !text-xs !min-w-0 pl-2 pr-7 w-52" + /> + +
+ + setFilters({ method })} + placeholder="All methods" + /> + + setFilters({ status })} + placeholder="All statuses" + /> +
+ ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchJsonViewer.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchJsonViewer.tsx new file mode 100644 index 000000000..a182b04b5 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchJsonViewer.tsx @@ -0,0 +1,145 @@ +import Editor, { type OnMount } from "@monaco-editor/react"; +import { useCallback, useMemo, useRef } from "react"; +import CopyButton from "@/components/general/CopyButton"; + +const AUTUMN_DARK_THEME = "autumn-workbench-dark"; +const AUTUMN_LIGHT_THEME = "autumn-workbench-light"; + +export const WorkbenchJsonViewer = ({ + data, + height = "400px", +}: { + data: unknown; + height?: string; +}) => { + const editorRef = useRef[0] | null>(null); + + const formatted = useMemo(() => { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } + }, [data]); + + const onMount: OnMount = useCallback((editor, monaco) => { + editorRef.current = editor; + + monaco.editor.defineTheme(AUTUMN_DARK_THEME, { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#00000000", + "editor.foreground": "#e7e7e7", + "editorLineNumber.foreground": "#4a4a4a", + "editorLineNumber.activeForeground": "#8a8a8a", + "editor.lineHighlightBackground": "#ffffff08", + "editor.selectionBackground": "#3b82f640", + "editor.inactiveSelectionBackground": "#3b82f620", + "editorIndentGuide.background1": "#2a2a2a", + "editorIndentGuide.activeBackground1": "#3a3a3a", + "editorBracketMatch.background": "#3b82f620", + "editorBracketMatch.border": "#3b82f680", + "editorWidget.background": "#1a1a1a", + "editorWidget.border": "#2a2a2a", + "input.background": "#0f0f0f", + "input.border": "#2a2a2a", + "editor.findMatchBackground": "#fbbf2440", + "editor.findMatchHighlightBackground": "#fbbf2420", + "scrollbarSlider.background": "#ffffff14", + "scrollbarSlider.hoverBackground": "#ffffff20", + "scrollbarSlider.activeBackground": "#ffffff30", + }, + }); + + monaco.editor.defineTheme(AUTUMN_LIGHT_THEME, { + base: "vs", + inherit: true, + rules: [], + colors: { + "editor.background": "#00000000", + "editorLineNumber.foreground": "#c0c0c0", + "editor.lineHighlightBackground": "#00000005", + }, + }); + + const isDark = document.documentElement.classList.contains("dark"); + monaco.editor.setTheme(isDark ? AUTUMN_DARK_THEME : AUTUMN_LIGHT_THEME); + + let folded = false; + const foldChildren = () => { + if (folded) return; + editor.getAction("editor.foldLevel2")?.run(); + folded = true; + }; + + const sub = editor.onDidChangeModelDecorations(() => { + foldChildren(); + sub.dispose(); + }); + setTimeout(() => { + foldChildren(); + sub.dispose(); + }, 250); + }, []); + + return ( +
+
+ + +
+ +
+ ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchLogDetail.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchLogDetail.tsx new file mode 100644 index 000000000..bf3aea518 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchLogDetail.tsx @@ -0,0 +1,174 @@ +import CopyButton from "@/components/general/CopyButton"; +import { + type RequestLogEntry, + useCusRequestLogsQuery, +} from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { cn } from "@/lib/utils"; +import { WorkbenchJsonViewer } from "./WorkbenchJsonViewer"; +import { + extractRes, + extractScope, + formatLogDateTime, + methodColorClass, + statusBadgeClass, + statusText, +} from "./workbenchUtils"; + +export const WorkbenchLogDetail = ({ + customerId, + isOpen, +}: { + customerId: string | undefined; + isOpen: boolean; +}) => { + const selectedLogId = useWorkbenchStore((s) => s.selectedLogId); + const { logs } = useCusRequestLogsQuery({ customerId, enabled: isOpen }); + + const log = logs.find((l) => l.id === selectedLogId) ?? null; + + if (!log) { + return ( +
+ Select a request to view details +
+ ); + } + + return ( +
+ + + + + +
+ ); +}; + +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const ReqResSplit = ({ raw }: { raw: Record }) => { + const req = extractScope(raw, "req"); + const res = extractRes(raw); + const reqData = Object.keys(req).length > 0 ? req : {}; + const resData = + res && typeof res === "object" && Object.keys(res).length > 0 ? res : {}; + + return ( +
+
+ Request + +
+
+ Response + +
+
+ ); +}; + +const ContextViewer = ({ raw }: { raw: Record }) => { + const ctx = extractScope(raw, "context"); + const data = Object.keys(ctx).length > 0 ? ctx : {}; + return ( +
+ Context + +
+ ); +}; + +const DetailHeader = ({ log }: { log: RequestLogEntry }) => ( +
+
+ API request +
+
+ + {log.method ?? "—"} + + {log.path ?? "(unknown)"} +
+
+); + +const Row = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + <> +
{label}
+
{children}
+ +); + +const DetailRows = ({ log }: { log: RequestLogEntry }) => ( +
+ + + {statusText(log.statusCode)} + + + + {log.reqId && ( + +
+ {log.reqId} + +
+
+ )} + + + {formatLogDateTime(log.time)} + + + {log.durationMs != null && ( + + {log.durationMs}ms + + )} + + {log.ip && ( + + {log.ip} + + )} + + {log.userAgent && ( + + {log.userAgent} + + )} + + {log.customerId && ( + + {log.customerId} + + )} +
+); + +const RawJsonViewer = ({ raw }: { raw: Record }) => ( +
+ Raw event + +
+); diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchLogList.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchLogList.tsx new file mode 100644 index 000000000..59ecb739e --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchLogList.tsx @@ -0,0 +1,86 @@ +import { useCusRequestLogsQuery } from "@/hooks/queries/useCusRequestLogsQuery"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; +import { WorkbenchEmptyState } from "./WorkbenchEmptyState"; +import { WorkbenchLogRow } from "./WorkbenchLogRow"; +import { groupLogsByDay } from "./workbenchUtils"; + +const LoadingSkeleton = () => ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+); + +export const WorkbenchLogList = ({ + customerId, + isOpen, +}: { + customerId: string | undefined; + isOpen: boolean; +}) => { + const selectedLogId = useWorkbenchStore((s) => s.selectedLogId); + const setSelectedLogId = useWorkbenchStore((s) => s.setSelectedLogId); + + const { logs, unconfigured, isLoading, isFetching, error } = + useCusRequestLogsQuery({ customerId, enabled: isOpen }); + + const hasData = logs.length > 0; + const groups = groupLogsByDay(logs); + + const renderContent = () => { + if (isLoading && !hasData) return ; + if (error) { + return ( + + Check the server logs or your Axiom configuration. + + ); + } + if (unconfigured) { + return ( + + Set AXIOM_ADMIN_TOKEN on the server + to enable the workbench. + + ); + } + if (!hasData) { + return ( + + No API requests for this customer in the last 7 days. + + ); + } + return groups.map((group) => ( +
+
+ {group.label} +
+ {group.entries.map((log) => ( + setSelectedLogId(log.id)} + /> + ))} +
+ )); + }; + + return ( +
+
+ {isFetching && ( +
+ )} +
+
{renderContent()}
+
+ ); +}; diff --git a/vite/src/views/customers2/customer/workbench/WorkbenchLogRow.tsx b/vite/src/views/customers2/customer/workbench/WorkbenchLogRow.tsx new file mode 100644 index 000000000..9e4874eae --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/WorkbenchLogRow.tsx @@ -0,0 +1,50 @@ +import type { RequestLogEntry } from "@/hooks/queries/useCusRequestLogsQuery"; +import { cn } from "@/lib/utils"; +import { + formatLogTime, + methodColorClass, + statusBadgeClass, +} from "./workbenchUtils"; + +export const WorkbenchLogRow = ({ + log, + selected, + onSelect, +}: { + log: RequestLogEntry; + selected: boolean; + onSelect: () => void; +}) => ( + +); diff --git a/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchEscape.ts b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchEscape.ts new file mode 100644 index 000000000..e3d405ecd --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchEscape.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { useWorkbenchStore } from "@/hooks/stores/useWorkbenchStore"; + +const shouldDeferEscape = (target: Element | null): boolean => { + if (target?.closest(".monaco-editor")) return true; + if (useSheetStore.getState().type !== null) return true; + return ( + !!document.querySelector('[role="dialog"][data-state="open"]') || + !!document.querySelector('[role="alertdialog"][data-state="open"]') || + !!document.querySelector("dialog[open]") || + !!document.querySelector("[data-inline-editor-open]") + ); +}; + +export const useWorkbenchEscape = () => { + const isOpen = useWorkbenchStore((s) => s.isOpen); + const close = useWorkbenchStore((s) => s.close); + + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape" || e.defaultPrevented) return; + if (shouldDeferEscape(e.target as Element | null)) return; + close(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [isOpen, close]); +}; diff --git a/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchResize.ts b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchResize.ts new file mode 100644 index 000000000..2431c9e85 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/hooks/useWorkbenchResize.ts @@ -0,0 +1,52 @@ +import { useEffect, useMemo, useRef } from "react"; +import { + useWorkbenchStore, + WORKBENCH_MAX_HEIGHT_RATIO, + WORKBENCH_MIN_HEIGHT, +} from "@/hooks/stores/useWorkbenchStore"; + +const clampHeight = (h: number) => + Math.max( + WORKBENCH_MIN_HEIGHT, + Math.min(window.innerHeight * WORKBENCH_MAX_HEIGHT_RATIO, h), + ); + +export const useWorkbenchResize = () => { + const setHeight = useWorkbenchStore((s) => s.setHeight); + const dragStartY = useRef(null); + const dragStartHeight = useRef(0); + + const handleProps = useMemo( + () => ({ + onPointerDown: (e: React.PointerEvent) => { + e.preventDefault(); + dragStartY.current = e.clientY; + dragStartHeight.current = useWorkbenchStore.getState().height; + e.currentTarget.setPointerCapture(e.pointerId); + }, + onPointerMove: (e: React.PointerEvent) => { + if (dragStartY.current == null) return; + const delta = dragStartY.current - e.clientY; + setHeight(clampHeight(dragStartHeight.current + delta)); + }, + onPointerUp: (e: React.PointerEvent) => { + dragStartY.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + }, + onPointerCancel: (e: React.PointerEvent) => { + dragStartY.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + }, + }), + [setHeight], + ); + + useEffect(() => { + const onResize = () => + setHeight(clampHeight(useWorkbenchStore.getState().height)); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [setHeight]); + + return { handleProps }; +}; diff --git a/vite/src/views/customers2/customer/workbench/workbenchUtils.ts b/vite/src/views/customers2/customer/workbench/workbenchUtils.ts new file mode 100644 index 000000000..d11a8b9a3 --- /dev/null +++ b/vite/src/views/customers2/customer/workbench/workbenchUtils.ts @@ -0,0 +1,125 @@ +import type { RequestLogEntry } from "@/hooks/queries/useCusRequestLogsQuery"; + +export const statusBadgeClass = (status: number): string => { + if (status >= 200 && status < 300) { + return "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900"; + } + if (status >= 300 && status < 400) { + return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900"; + } + if (status >= 400 && status < 500) { + return "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-900"; + } + return "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900"; +}; + +export const statusText = (status: number): string => { + if (status === 200) return "200 OK"; + if (status === 201) return "201 Created"; + if (status === 204) return "204 No Content"; + if (status === 400) return "400 Bad Request"; + if (status === 401) return "401 Unauthorized"; + if (status === 403) return "403 Forbidden"; + if (status === 404) return "404 Not Found"; + if (status === 409) return "409 Conflict"; + if (status === 422) return "422 Unprocessable"; + if (status === 429) return "429 Rate Limited"; + if (status === 500) return "500 Server Error"; + return String(status); +}; + +export const methodColorClass = (method: string | null): string => { + switch (method) { + case "GET": + return "text-blue-600 dark:text-blue-400"; + case "POST": + return "text-emerald-600 dark:text-emerald-400"; + case "PUT": + return "text-amber-600 dark:text-amber-400"; + case "PATCH": + return "text-purple-600 dark:text-purple-400"; + case "DELETE": + return "text-red-600 dark:text-red-400"; + default: + return "text-t2"; + } +}; + +const dayBucketLabel = (date: Date, now: Date): string => { + const y = date.toDateString() === now.toDateString(); + if (y) return "Today"; + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) return "Yesterday"; + + return date.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +}; + +export const groupLogsByDay = ( + logs: RequestLogEntry[], +): { label: string; entries: RequestLogEntry[] }[] => { + const now = new Date(); + const groups = new Map(); + + for (const log of logs) { + const d = new Date(log.time); + const label = dayBucketLabel(d, now); + const existing = groups.get(label) ?? []; + existing.push(log); + groups.set(label, existing); + } + + return Array.from(groups.entries()).map(([label, entries]) => ({ + label, + entries, + })); +}; + +export const formatLogTime = (time: string): string => + new Date(time).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + +export const formatLogDateTime = (time: string): string => + new Date(time).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + +const isPlainObject = (v: unknown): v is Record => + v != null && typeof v === "object" && !Array.isArray(v); + +export const extractScope = ( + raw: Record, + prefix: string, +): Record => { + const base = prefix.replace(/\.$/, ""); + const direct = raw[base]; + if (isPlainObject(direct)) return direct; + + const dotted = `${base}.`; + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + if (k.startsWith(dotted)) { + out[k.slice(dotted.length)] = v; + } + } + return out; +}; + +export const extractRes = (raw: Record): unknown => { + if (raw.res !== undefined) return raw.res ?? {}; + return extractScope(raw, "res"); +}; diff --git a/vite/src/views/main-sidebar/SidebarBottom.tsx b/vite/src/views/main-sidebar/SidebarBottom.tsx index 96bc97a86..408432453 100644 --- a/vite/src/views/main-sidebar/SidebarBottom.tsx +++ b/vite/src/views/main-sidebar/SidebarBottom.tsx @@ -2,6 +2,7 @@ import { BooksIcon, DiscordLogoIcon } from "@phosphor-icons/react"; import { useEnv } from "@/utils/envUtils"; +import { WorkbenchButton } from "@/views/customers2/customer/workbench/WorkbenchButton"; import { FeedbackDialog } from "./FeedbackDialog"; import { NavButton } from "./NavButton"; import { SidebarContact } from "./SidebarContact"; @@ -15,20 +16,7 @@ export default function SidebarBottom() { return (
- {/* } - title="Connect to Stripe" - env={env} - /> */} - {/* {env === AppEnv.Sandbox && ( - } - title="Onboarding" - env={env} - /> - )} */} + } From 8c40f10cfe468936a9e391e2da3b4d263116f734 Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Tue, 12 May 2026 13:48:12 +0100 Subject: [PATCH 12/36] frontend fixes --- bun.lock | 1 - .../attach-v2/context/AttachFormProvider.tsx | 19 ++++++--------- .../forms/shared/SendInvoiceStage.tsx | 3 +-- .../CustomerBillingControlsSection.tsx | 24 +++++++++---------- .../components/sheets/AttachProductSheet.tsx | 7 ++---- .../sheets/BillingAutoTopupSheet.tsx | 2 +- .../components/sheets/RecordUsageSheet.tsx | 3 ++- 7 files changed, 25 insertions(+), 34 deletions(-) diff --git a/bun.lock b/bun.lock index 1846c3ae7..8fd482a5b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "autumn", diff --git a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx index 618cf0636..bbfd5325d 100644 --- a/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx +++ b/vite/src/components/forms/attach-v2/context/AttachFormProvider.tsx @@ -16,7 +16,6 @@ import { isFreeProductV2, isOneOffProductV2, productV2ToFrontendProduct, - UsageModel, } from "@autumn/shared"; import { useStore } from "@tanstack/react-form"; import { @@ -276,8 +275,7 @@ export function AttachFormProvider({ [fullCustomer?.customer_products], ); - const disableProration = - isFreeToPaidTransition && !hasActiveSubscription; + const disableProration = isFreeToPaidTransition && !hasActiveSubscription; const { prepaidItems } = usePrepaidItems({ product: effectiveProduct }); @@ -324,19 +322,16 @@ export function AttachFormProvider({ resetGrantFree(); } - // Initialize prepaid options for the selected product + // Initialize prepaid options for the selected product. + // Values start as undefined (not 0) so that unset quantities are omitted + // from the request — the backend carries over existing prepaid balances + // when no option is provided for a feature. if (product) { - const newInitialPrepaidOptions: Record = {}; - for (const item of product.items) { - if (item.usage_model === UsageModel.Prepaid && item.feature_id) { - newInitialPrepaidOptions[item.feature_id] = 0; - } - } const currentPrepaidOptions = form.store.state.values.prepaidOptions; const resolvedPrepaidOptions = isProductChange || Object.keys(currentPrepaidOptions).length === 0 - ? newInitialPrepaidOptions - : { ...newInitialPrepaidOptions, ...currentPrepaidOptions }; + ? {} + : { ...currentPrepaidOptions }; form.setFieldValue("prepaidOptions", resolvedPrepaidOptions); setInitialPrepaidOptions( resolvedPrepaidOptions as Record, diff --git a/vite/src/components/forms/shared/SendInvoiceStage.tsx b/vite/src/components/forms/shared/SendInvoiceStage.tsx index 54545370d..c0e95abff 100644 --- a/vite/src/components/forms/shared/SendInvoiceStage.tsx +++ b/vite/src/components/forms/shared/SendInvoiceStage.tsx @@ -363,8 +363,7 @@ export function SendInvoiceStageWithPreview({ scheduledStartDate?: number | null; }) { const previewData = previewQuery.data; - const effectiveScheduledStartDate = - scheduledStartDate ?? previewData?.next_cycle?.starts_at ?? null; + const effectiveScheduledStartDate = scheduledStartDate ?? null; const totals = useMemo( () => buildAttachPreviewTotals({ previewData, startDate: null }), diff --git a/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx b/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx index 03e931d42..a86a12a2e 100644 --- a/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx +++ b/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx @@ -105,18 +105,18 @@ const AutoTopupRow = ({
Threshold: {autoTopup.threshold.toLocaleString()} Qty: {autoTopup.quantity.toLocaleString()} - {purchaseLimit && ( - - {hasExpandedLimit - ? `${purchaseLimit.count}/${purchaseLimit.limit} per ${purchaseLimit.interval}` - : `Limit: ${purchaseLimit.limit} per ${purchaseLimit.interval}`} - - )} - {hasExpandedLimit && purchaseLimit.next_reset_at && ( - - Resets {format(new Date(purchaseLimit.next_reset_at), "MMM d")} - - )} + {purchaseLimit && purchaseLimit.limit != null && purchaseLimit.interval != null && ( + + {hasExpandedLimit + ? `${purchaseLimit.count}/${purchaseLimit.limit} per ${purchaseLimit.interval}` + : `Limit: ${purchaseLimit.limit} per ${purchaseLimit.interval}`} + + )} + {hasExpandedLimit && purchaseLimit.next_reset_at && ( + + Resets {format(new Date(purchaseLimit.next_reset_at), "MMM d")} + + )}
); diff --git a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx index 4c9d18fec..7c59f993a 100644 --- a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx +++ b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx @@ -18,7 +18,7 @@ import { AttachFooterV3 } from "@/components/forms/attach-v2/components/AttachFo import { buildAttachPreviewTotals, getAttachPreviewLineItems, - getAttachScheduledStartDate, + isFutureStartDate, } from "@/components/forms/attach-v2/utils/buildAttachPreviewTotals"; import { GenerateCheckoutStageWithPreview, @@ -369,10 +369,7 @@ function SendInvoiceContent() { const { setSheet } = useSheetStore(); const itemId = useSheetStore((s) => s.itemId); const startDate = useStore(form.store, (state) => state.values.startDate); - const scheduledStartDate = getAttachScheduledStartDate({ - startDate, - previewData: previewQuery.data, - }); + const scheduledStartDate = isFutureStartDate(startDate) ? startDate : null; return ( Date: Tue, 12 May 2026 15:41:58 +0100 Subject: [PATCH 13/36] =?UTF-8?q?fix:=20=F0=9F=90=9B=20address=20pr=20comm?= =?UTF-8?q?ents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts index d34eeb8a9..f496dd967 100644 --- a/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts +++ b/server/src/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.ts @@ -48,7 +48,7 @@ export const normalizeWindowCounter = ({ } const interval = intervalToEntInterval({ interval: windowConfig.interval }); - const intervalCount = windowConfig.interval_count ?? 1; + const intervalCount = windowConfig.interval_count || 1; let projected = from ?? now; do { From 8d54142ba41467e3bdcd440877aa7435258b0534 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 12 May 2026 15:45:04 +0100 Subject: [PATCH 14/36] =?UTF-8?q?chore:=20=F0=9F=A4=96=20sync=20ai=20submo?= =?UTF-8?q?dule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai b/ai index 4bfb2b6ac..ade6ce69f 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 4bfb2b6ace3de7ae9db9ec1840da2d91e06f44da +Subproject commit ade6ce69f032c798a0417881471b6ad3edea7ed6 From 663f8cabe58e69c0ec6db8d1a0c461467d617fac Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 12 May 2026 15:50:17 +0100 Subject: [PATCH 15/36] chore: fix dropdown z index --- vite/src/components/ui/dropdown-menu.tsx | 4 ++-- vite/src/components/ui/popover.tsx | 2 +- vite/src/components/v2/FeatureSelector.tsx | 2 +- vite/src/components/v2/selects/Select.tsx | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/vite/src/components/ui/dropdown-menu.tsx b/vite/src/components/ui/dropdown-menu.tsx index 7c75f9fb7..a923688f9 100644 --- a/vite/src/components/ui/dropdown-menu.tsx +++ b/vite/src/components/ui/dropdown-menu.tsx @@ -49,7 +49,7 @@ const DropdownMenuSubContent = React.forwardRef< - +
{features.length === 0 ? (
diff --git a/vite/src/components/v2/selects/Select.tsx b/vite/src/components/v2/selects/Select.tsx index 8567c9d2c..3a3b7d579 100644 --- a/vite/src/components/v2/selects/Select.tsx +++ b/vite/src/components/v2/selects/Select.tsx @@ -110,8 +110,8 @@ function SelectContent({ onEscapeKeyDown?.(e); }} className={cn( - // z-[200] to appear above sheets (z-[150]) - "bg-interactive-secondary text-popover-foreground relative z-[200] max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md", + // z-[160] to appear above sheets (z-[150]) + "bg-interactive-secondary text-popover-foreground relative z-[160] max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", From 9ae91bc54c4d9fae466f493bab57608c3974db60 Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Tue, 12 May 2026 16:39:48 +0100 Subject: [PATCH 16/36] fix: save lock receipt for unlimited balance features When check is called with lock=true on an unlimited-balance feature, the deduction executors skipped the entire iteration (including lock receipt save), causing finalize to fail with "Lock not found". Now saves an empty lock receipt before continuing so finalize can find, claim, and delete it as a no-op. Co-authored-by: Cursor --- .../deductionV2/executePostgresDeductionV2.ts | 13 +- .../deductionV2/executeRedisDeductionV2.ts | 11 ++ .../lock/check-with-lock-unlimited.test.ts | 143 ++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 810964696..29297e04d 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -117,8 +117,19 @@ export const executePostgresDeductionV2 = async ({ options: resolvedOptions, }); - if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) + if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) { + if (unlimitedFeatureIds.length > 0 && preparedLock?.enabled) { + await saveLockReceiptV2({ + lock: preparedLock, + customerId: fullSubject.customerId || customerId, + featureId: feature.id, + entityId, + items: [], + redisInstance: ctx.redisV2, + }); + } continue; + } const result = await db.execute( sql`SELECT * FROM deduct_from_cus_ents( diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 3c20a5656..5af336591 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -13,6 +13,7 @@ import { } from "@/internal/balances/track/v3/trackIdempotencyKey.js"; import { fireTrackWebhooks } from "@/internal/balances/trackWebhooks/fireTrackWebhooks.js"; import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.js"; +import { saveLockReceiptV2 } from "@/internal/balances/utils/lockV2/saveLockReceiptV2.js"; import { buildDeductFromSubjectBalancesKeys } from "@/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.js"; import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; @@ -128,6 +129,16 @@ export const executeRedisDeductionV2 = async ({ }); if (unlimitedFeatureIds.length > 0) { + if (preparedLock) { + await saveLockReceiptV2({ + lock: preparedLock, + customerId, + featureId: feature.id, + entityId, + items: [], + redisInstance: redisInstance ?? ctx.redisV2, + }); + } continue; } diff --git a/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts b/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts new file mode 100644 index 000000000..16e1a2950 --- /dev/null +++ b/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts @@ -0,0 +1,143 @@ +/** + * TDD test for lock + finalize on unlimited balance features. + * + * Contract under test: + * New behaviors: + * - check with lock=true on unlimited feature → allowed=true, lock receipt saved + * - finalize confirm on unlimited lock → success, receipt deleted + * - finalize release on unlimited lock → success, receipt deleted + * Side effects: + * - Lock receipt is saved to Redis with empty items for unlimited features + * - Lock receipt is cleaned up after finalize + * + * Pre-fix red: check with lock on unlimited skips lock receipt save entirely, + * so finalize throws "Lock not found for ID: ...". + * Post-fix green: empty lock receipt is saved, finalize finds and deletes it. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { expectLockReceiptDeleted } from "@tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.js"; +import { TestFeature } from "@tests/setup/v2Features.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"; + +const makeUnlimitedProd = () => + products.base({ + id: "unlimited", + items: [items.unlimitedMessages()], + }); + +// ── Contract assertion 1: check with lock on unlimited → allowed, finalize confirm → success ── +test.concurrent(`${chalk.yellowBright("lock-unlimited: check with lock on unlimited feature, finalize confirm succeeds")}`, async () => { + const unlimitedProd = makeUnlimitedProd(); + const customerId = "lock-unlimited-confirm"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [unlimitedProd] }), + ], + actions: [s.attach({ productId: unlimitedProd.id })], + }); + + await deleteLock({ ctx, lockId: lockKey }); + + const checkResponse = await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 10, + lock: { enabled: true, lock_id: lockKey }, + }); + + expect(checkResponse.allowed).toBe(true); + + const finalizeResponse = await autumnV2_1.balances.finalize({ + lock_id: lockKey, + action: "confirm", + }); + + expect(finalizeResponse.success).toBe(true); + + await expectLockReceiptDeleted({ ctx, lockId: lockKey }); + + const customer = await autumnV2_1.customers.get(customerId); + expect(customer).toBeDefined(); +}); + +// ── Contract assertion 2: check with lock on unlimited → allowed, finalize release → success ── +test.concurrent(`${chalk.yellowBright("lock-unlimited: check with lock on unlimited feature, finalize release succeeds")}`, async () => { + const unlimitedProd = makeUnlimitedProd(); + const customerId = "lock-unlimited-release"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [unlimitedProd] }), + ], + actions: [s.attach({ productId: unlimitedProd.id })], + }); + + await deleteLock({ ctx, lockId: lockKey }); + + const checkResponse = await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 5, + lock: { enabled: true, lock_id: lockKey }, + }); + + expect(checkResponse.allowed).toBe(true); + + const finalizeResponse = await autumnV2_1.balances.finalize({ + lock_id: lockKey, + action: "release", + }); + + expect(finalizeResponse.success).toBe(true); + + await expectLockReceiptDeleted({ ctx, lockId: lockKey }); +}); + +// ── Contract assertion 3: check with lock=0 on unlimited, finalize confirm → success ── +test.concurrent(`${chalk.yellowBright("lock-unlimited: lock=0 on unlimited feature, finalize confirm succeeds")}`, async () => { + const unlimitedProd = makeUnlimitedProd(); + const customerId = "lock-unlimited-zero"; + const lockKey = `${customerId}-lock`; + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [unlimitedProd] }), + ], + actions: [s.attach({ productId: unlimitedProd.id })], + }); + + await deleteLock({ ctx, lockId: lockKey }); + + const checkResponse = await autumnV2_1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 0, + lock: { enabled: true, lock_id: lockKey }, + }); + + expect(checkResponse.allowed).toBe(true); + + const finalizeResponse = await autumnV2_1.balances.finalize({ + lock_id: lockKey, + action: "confirm", + }); + + expect(finalizeResponse.success).toBe(true); + + await expectLockReceiptDeleted({ ctx, lockId: lockKey }); +}); From 754c54919de27b490a3c3117fb487fe608b8e457 Mon Sep 17 00:00:00 2001 From: John Yeo <51376134+johnyeocx@users.noreply.github.com> Date: Tue, 12 May 2026 17:22:38 +0100 Subject: [PATCH 17/36] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../balances/utils/deductionV2/executeRedisDeductionV2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 5af336591..0935ea52e 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -129,7 +129,7 @@ export const executeRedisDeductionV2 = async ({ }); if (unlimitedFeatureIds.length > 0) { - if (preparedLock) { + if (preparedLock?.enabled) { await saveLockReceiptV2({ lock: preparedLock, customerId, From 37aedf546dbbdb00d246f27f666a97a7a194a749 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 13 May 2026 00:46:54 +0800 Subject: [PATCH 18/36] fix: release / finalize inserts into events table --- ai | 2 +- bun.lock | 1 + .../finalizeLock/buildFinalizeLockContext.ts | 6 ++++- .../deduction/executePostgresDeduction.ts | 13 ++++++++- .../utils/deduction/executeRedisDeduction.ts | 12 +++++++++ .../deductionV2/executePostgresDeductionV2.ts | 1 + .../deductionV2/executeRedisDeductionV2.ts | 1 + .../balances/utils/lock/fetchLockReceipt.ts | 27 +++++++++++++++---- .../balances/utils/lock/saveLockReceipt.ts | 3 +++ .../lockV2/buildFinalizeLockContextV2.ts | 6 ++++- .../utils/lockV2/saveLockReceiptV2.ts | 3 +++ .../lock/check-with-lock-unlimited.test.ts | 16 +++++++++++ 12 files changed, 82 insertions(+), 9 deletions(-) diff --git a/ai b/ai index 4bfb2b6ac..678c8ed7e 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 4bfb2b6ace3de7ae9db9ec1840da2d91e06f44da +Subproject commit 678c8ed7e94f1f3b02eb9a4fad5fcb396ea99c89 diff --git a/bun.lock b/bun.lock index 8fd482a5b..1846c3ae7 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "autumn", diff --git a/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts b/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts index 2650db1b8..cf88337b2 100644 --- a/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts +++ b/server/src/internal/balances/finalizeLock/buildFinalizeLockContext.ts @@ -52,7 +52,11 @@ export const buildFinalizeLockContext = async ({ source: "runFinalizeLock", }); - const lockValue = calculateLockValue({ items: receipt.items }); + const calculatedLockValue = calculateLockValue({ items: receipt.items }); + const lockValue = + receipt.items.length === 0 + ? (receipt.overrideLockValue ?? calculatedLockValue) + : calculatedLockValue; const finalValue = params.action === "release" ? 0 : (params.override_value ?? lockValue); diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index a93e5ecff..d0f3a84ac 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -118,8 +118,19 @@ export const executePostgresDeduction = async ({ options, }); - if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) + if (customerEntitlements.length === 0 || unlimitedFeatureIds.length > 0) { + if (unlimitedFeatureIds.length > 0 && preparedLock?.enabled) { + await saveLockReceipt({ + lock: preparedLock, + customerId: fullCustomer.id || customerId, + featureId: feature.id, + entityId, + items: [], + overrideLockValue: toDeduct, + }); + } continue; + } // Call the stored function to deduct from entitlements with credit costs const result = await db.execute( diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 056e3b54a..9ac4e797a 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -11,6 +11,7 @@ import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeatur import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { fireTrackWebhooks } from "../../trackWebhooks/fireTrackWebhooks.js"; +import { saveLockReceipt } from "../lock/saveLockReceipt.js"; import type { DeductionOptions } from "../types/deductionTypes.js"; import type { DeductionUpdate } from "../types/deductionUpdate.js"; import type { FeatureDeduction } from "../types/featureDeduction.js"; @@ -116,6 +117,17 @@ export const executeRedisDeduction = async ({ }); if (unlimitedFeatureIds.length > 0) { + if (preparedLock) { + await saveLockReceipt({ + lock: preparedLock, + customerId, + featureId: feature.id, + entityId, + items: [], + overrideLockValue: toDeduct, + redisInstance, + }); + } continue; } diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 29297e04d..95f010401 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -125,6 +125,7 @@ export const executePostgresDeductionV2 = async ({ featureId: feature.id, entityId, items: [], + overrideLockValue: toDeduct, redisInstance: ctx.redisV2, }); } diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 5af336591..4f19330bc 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -136,6 +136,7 @@ export const executeRedisDeductionV2 = async ({ featureId: feature.id, entityId, items: [], + overrideLockValue: toDeduct, redisInstance: redisInstance ?? ctx.redisV2, }); } diff --git a/server/src/internal/balances/utils/lock/fetchLockReceipt.ts b/server/src/internal/balances/utils/lock/fetchLockReceipt.ts index af65bf2f6..1c8fde046 100644 --- a/server/src/internal/balances/utils/lock/fetchLockReceipt.ts +++ b/server/src/internal/balances/utils/lock/fetchLockReceipt.ts @@ -14,6 +14,7 @@ export type LockReceipt = { entity_id?: string | null; expires_at?: number | null; region?: string | null; + overrideLockValue?: number | null; items: MutationLogItem[]; }; @@ -62,6 +63,26 @@ const fetchAndClaimLockReceiptV2FromCandidates = async ({ return { found: false as const }; }; +const isRedisWrongTypeError = (error: unknown) => + error instanceof Error && + /WRONGTYPE|wrong Redis type|wrong kind of value/i.test(error.message); + +const fetchLegacyLockReceiptJson = async ({ + lockReceiptKey, +}: { + lockReceiptKey: string; +}) => + tryRedisRead(async () => { + try { + return (await redis.call("JSON.GET", lockReceiptKey, "$")) as + | string + | null; + } catch (error) { + if (isRedisWrongTypeError(error)) return null; + throw error; + } + }, redis); + export const fetchLockReceipt = async ({ ctx, lockId, @@ -81,11 +102,7 @@ export const fetchLockReceipt = async ({ // During org Redis migrations, V2 checks both shared and dedicated Redis. // V1 half stays a plain JSON.GET — V1 finalize still claims via Lua afterwards. const [rawReceiptV1, v2Result] = await Promise.all([ - tryRedisRead( - () => - redis.call("JSON.GET", lockReceiptKey, "$") as Promise, - redis, - ), + fetchLegacyLockReceiptJson({ lockReceiptKey }), fetchAndClaimLockReceiptV2FromCandidates({ ctx, lockId, diff --git a/server/src/internal/balances/utils/lock/saveLockReceipt.ts b/server/src/internal/balances/utils/lock/saveLockReceipt.ts index c552c1e45..129304894 100644 --- a/server/src/internal/balances/utils/lock/saveLockReceipt.ts +++ b/server/src/internal/balances/utils/lock/saveLockReceipt.ts @@ -10,6 +10,7 @@ export const saveLockReceipt = async ({ featureId, entityId, items, + overrideLockValue, redisInstance, }: { lock: { @@ -24,6 +25,7 @@ export const saveLockReceipt = async ({ featureId: string; entityId?: string; items: MutationLogItem[]; + overrideLockValue?: number; redisInstance?: Redis; }) => { const targetRedis = redisInstance ?? redis; @@ -53,6 +55,7 @@ export const saveLockReceipt = async ({ entity_id: entityId ?? null, expires_at: lock.expires_at ?? null, created_at: lock.created_at, + overrideLockValue: overrideLockValue ?? null, items, }), ) as Promise<"OK" | null>, diff --git a/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts b/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts index 3082640e5..76be24c44 100644 --- a/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts +++ b/server/src/internal/balances/utils/lockV2/buildFinalizeLockContextV2.ts @@ -48,7 +48,11 @@ export const buildFinalizeLockContextV2 = async ({ source: "runFinalizeLockV2", }); - const lockValue = calculateLockValue({ items: receipt.items }); + const calculatedLockValue = calculateLockValue({ items: receipt.items }); + const lockValue = + receipt.items.length === 0 + ? (receipt.overrideLockValue ?? calculatedLockValue) + : calculatedLockValue; const finalValue = params.action === "release" ? 0 : (params.override_value ?? lockValue); diff --git a/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts b/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts index 311754541..5937807f9 100644 --- a/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts +++ b/server/src/internal/balances/utils/lockV2/saveLockReceiptV2.ts @@ -16,6 +16,7 @@ export const saveLockReceiptV2 = async ({ featureId, entityId, items, + overrideLockValue, redisInstance, }: { lock: { @@ -30,6 +31,7 @@ export const saveLockReceiptV2 = async ({ featureId: string; entityId?: string; items: MutationLogItem[]; + overrideLockValue?: number; redisInstance: Redis; }) => { const payload = JSON.stringify({ @@ -42,6 +44,7 @@ export const saveLockReceiptV2 = async ({ entity_id: entityId ?? null, expires_at: lock.expires_at ?? null, created_at: lock.created_at, + overrideLockValue: overrideLockValue ?? null, items, }); diff --git a/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts b/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts index 16e1a2950..5fb282443 100644 --- a/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts +++ b/server/tests/integration/balances/lock/check-with-lock-unlimited.test.ts @@ -17,6 +17,7 @@ import { expect, test } from "bun:test"; import type { ApiCustomerV5 } from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; import { expectLockReceiptDeleted } from "@tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.js"; import { TestFeature } from "@tests/setup/v2Features.js"; @@ -24,6 +25,7 @@ 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 { timeout } from "@/utils/genUtils"; const makeUnlimitedProd = () => products.base({ @@ -65,6 +67,10 @@ test.concurrent(`${chalk.yellowBright("lock-unlimited: check with lock on unlimi expect(finalizeResponse.success).toBe(true); await expectLockReceiptDeleted({ ctx, lockId: lockKey }); + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 10 }], + }); const customer = await autumnV2_1.customers.get(customerId); expect(customer).toBeDefined(); @@ -103,7 +109,13 @@ test.concurrent(`${chalk.yellowBright("lock-unlimited: check with lock on unlimi expect(finalizeResponse.success).toBe(true); + await timeout(4000); + await expectLockReceiptDeleted({ ctx, lockId: lockKey }); + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: -5 }, { value: 5 }], + }); }); // ── Contract assertion 3: check with lock=0 on unlimited, finalize confirm → success ── @@ -140,4 +152,8 @@ test.concurrent(`${chalk.yellowBright("lock-unlimited: lock=0 on unlimited featu expect(finalizeResponse.success).toBe(true); await expectLockReceiptDeleted({ ctx, lockId: lockKey }); + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 0 }], + }); }); From a1c1864bb324a035973e780ded81a2b823eccd7b Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 13 May 2026 01:06:15 +0800 Subject: [PATCH 19/36] fix: tests --- AGENTS.md | 4 ---- ai | 2 +- server/src/internal/api/events/EventService.ts | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a4bee656f..5bf9e2c58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,11 +126,7 @@ Only update when the current work IS part of a project (see default-off rule abo Do NOT update context during normal coding work. Work first, compact at breakpoints. -<<<<<<< HEAD A STATUS.md entry should record changes to the project itself -- not one-off work that merely uses the project (e.g. writing a consumer script of a framework is not a framework-project update). -======= -A STATUS.md entry should record changes to the project itself — not one-off work that merely uses the project (e.g. writing a consumer script of a framework is not a framework-project update). ->>>>>>> a62195ba706f12fbd35f7ecf48b4701011115101 ### Compaction quality STATUS.md must be: diff --git a/ai b/ai index 678c8ed7e..8e97d625d 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 678c8ed7e94f1f3b02eb9a4fad5fcb396ea99c89 +Subproject commit 8e97d625da9ec8907aba571e8df3fed0a10fcfb7 diff --git a/server/src/internal/api/events/EventService.ts b/server/src/internal/api/events/EventService.ts index 3449072a3..f8eed02a6 100644 --- a/server/src/internal/api/events/EventService.ts +++ b/server/src/internal/api/events/EventService.ts @@ -48,7 +48,7 @@ export class EventService { env: string; limit?: number; }) { - if (process.env.NODE_ENV !== "development") return []; + if (process.env.NODE_ENV === "production") return []; const results = await db .select({ id: events.id, From c83309c12a748932df073d14fb89efaa0d16f61c Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 13 May 2026 01:10:33 +0800 Subject: [PATCH 20/36] fix: reset ai submodule pointer --- ai | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai b/ai index 8e97d625d..678c8ed7e 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 8e97d625da9ec8907aba571e8df3fed0a10fcfb7 +Subproject commit 678c8ed7e94f1f3b02eb9a4fad5fcb396ea99c89 From fa43131b45beb16589ae8f6dee70b1140c12df83 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 13 May 2026 01:15:22 +0800 Subject: [PATCH 21/36] fix: align ai submodule with dev --- ai | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai b/ai index 678c8ed7e..ade6ce69f 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 678c8ed7e94f1f3b02eb9a4fad5fcb396ea99c89 +Subproject commit ade6ce69f032c798a0417881471b6ad3edea7ed6 From f04b73db8e1d9efe68ab7df62caa2406840785e4 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 20:18:34 +0100 Subject: [PATCH 22/36] expose per-balance mutations on track response --- server/src/external/tinybird/initTinybird.ts | 1 + .../external/tinybird/sendEvents/mapEvent.ts | 2 + .../src/internal/balances/events/initEvent.ts | 5 +- .../balances/track/v3/runPostgresTrackV3.ts | 10 +- .../balances/track/v3/runRedisTrackV3.ts | 20 +- .../balances/utils/deductionV2/index.ts | 1 + .../projectMutationLogsToTrackMutationsV2.ts | 80 ++++ .../track/basic/track-mutations.test.ts | 334 ++++++++++++++++ ...jectMutationLogsToTrackMutationsV2.test.ts | 366 ++++++++++++++++++ .../v20TrackChangeMutationStrip.test.ts | 39 ++ server/tinybird/datasources/events.datasource | 3 +- .../track/changes/V2.0_TrackChange.ts | 12 +- shared/api/balances/track/trackResponseV3.ts | 20 + shared/models/eventModels/eventTable.ts | 2 + 14 files changed, 889 insertions(+), 6 deletions(-) create mode 100644 server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.ts create mode 100644 server/tests/integration/balances/track/basic/track-mutations.test.ts create mode 100644 server/tests/unit/balances/track-v3/projectMutationLogsToTrackMutationsV2.test.ts create mode 100644 server/tests/unit/balances/track-v3/v20TrackChangeMutationStrip.test.ts diff --git a/server/src/external/tinybird/initTinybird.ts b/server/src/external/tinybird/initTinybird.ts index cb71e7f00..1d11b5d6e 100644 --- a/server/src/external/tinybird/initTinybird.ts +++ b/server/src/external/tinybird/initTinybird.ts @@ -40,6 +40,7 @@ const TinybirdEventSchema = z.object({ internal_entity_id: z.string().nullable(), customer_id: z.string(), properties: z.string().nullable(), + mutations: z.string().nullable(), }); /** Pre-built pipe callers */ diff --git a/server/src/external/tinybird/sendEvents/mapEvent.ts b/server/src/external/tinybird/sendEvents/mapEvent.ts index 1d7434506..8c482c872 100644 --- a/server/src/external/tinybird/sendEvents/mapEvent.ts +++ b/server/src/external/tinybird/sendEvents/mapEvent.ts @@ -17,6 +17,7 @@ export interface TinybirdEvent { internal_entity_id: string | null; customer_id: string; properties: string | null; + mutations: string | null; } /** Convert EventInsert to Tinybird schema */ @@ -46,5 +47,6 @@ export const mapToTinybirdEvent = (event: EventInsert): TinybirdEvent => { internal_entity_id: event.internal_entity_id ?? null, customer_id: event.customer_id, properties: event.properties ? JSON.stringify(event.properties) : null, + mutations: event.mutations ? JSON.stringify(event.mutations) : null, }; }; diff --git a/server/src/internal/balances/events/initEvent.ts b/server/src/internal/balances/events/initEvent.ts index 6c70218ac..9d06c9147 100644 --- a/server/src/internal/balances/events/initEvent.ts +++ b/server/src/internal/balances/events/initEvent.ts @@ -1,4 +1,4 @@ -import type { EventInsert, TrackParams } from "@autumn/shared"; +import type { EventInsert, TrackMutation, TrackParams } from "@autumn/shared"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { generateId } from "../../../utils/genUtils.js"; @@ -35,6 +35,7 @@ export const initEvent = (params: { internalEntityId?: string; customerId: string; entityId?: string; + mutations?: TrackMutation[]; }) => { const { ctx, @@ -43,6 +44,7 @@ export const initEvent = (params: { internalEntityId, customerId, entityId, + mutations, } = params; const { org, env } = ctx; @@ -69,6 +71,7 @@ export const initEvent = (params: { properties: eventInfo.properties ?? {}, idempotency_key: eventInfo.idempotency_key ?? null, set_usage: false, + mutations: mutations && mutations.length > 0 ? mutations : null, } satisfies EventInsert; return newEvent; diff --git a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts index d2f5acfa6..f4cf4bfa2 100644 --- a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts +++ b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts @@ -9,6 +9,7 @@ import { import { deductionToTrackResponseV2, executePostgresDeductionV2, + projectMutationLogsToTrackMutationsV2, } from "@/internal/balances/utils/deductionV2/index.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import { handlePostgresTrackError } from "../utils/handlePostgresTrackError.js"; @@ -45,7 +46,12 @@ export const runPostgresTrackV3 = async ({ }); } - const { fullSubject: updatedFullSubject, updates } = result; + const { fullSubject: updatedFullSubject, updates, mutationLogs } = result; + + const mutations = projectMutationLogsToTrackMutationsV2({ + fullSubject: updatedFullSubject, + mutationLogs, + }); if (!body.skip_event && !body.idempotency_key) { const eventInfo = buildEventInfo(body); @@ -56,6 +62,7 @@ export const runPostgresTrackV3 = async ({ internalEntityId: updatedFullSubject.internalEntityId, customerId: body.customer_id, entityId: body.entity_id, + mutations, }); globalEventBatchingManager.addEvent(event); @@ -75,5 +82,6 @@ export const runPostgresTrackV3 = async ({ value: body.value ?? 1, balance, balances, + mutations, }; }; diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index e3a320043..ce116da92 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -1,4 +1,9 @@ -import type { FullSubject, TrackParams, TrackResponseV3 } from "@autumn/shared"; +import type { + FullSubject, + TrackMutation, + TrackParams, + TrackResponseV3, +} from "@autumn/shared"; import { tryCatch } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { globalEventBatchingManager } from "@/internal/balances/events/EventBatchingManager.js"; @@ -9,6 +14,7 @@ import { import { deductionToTrackResponseV2, executeRedisDeductionV2, + projectMutationLogsToTrackMutationsV2, } from "@/internal/balances/utils/deductionV2/index.js"; import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; @@ -49,10 +55,12 @@ const queueEvent = ({ ctx, body, fullSubject, + mutations, }: { ctx: AutumnContext; body: TrackParams; fullSubject: FullSubject; + mutations: TrackMutation[]; }): void => { if (body.skip_event) return; @@ -66,6 +74,7 @@ const queueEvent = ({ internalEntityId: fullSubject.internalEntityId, customerId: body.customer_id, entityId: body.entity_id, + mutations, }), ); }; @@ -114,6 +123,7 @@ export const runRedisTrackV3 = async ({ fullSubject: updatedFullSubject, rolloverUpdates, modifiedCusEntIdsByFeatureId, + mutationLogs, } = result; queueSyncItem({ @@ -124,7 +134,12 @@ export const runRedisTrackV3 = async ({ modifiedCusEntIdsByFeatureId, }); - queueEvent({ ctx, body, fullSubject }); + const mutations = projectMutationLogsToTrackMutationsV2({ + fullSubject: updatedFullSubject, + mutationLogs, + }); + + queueEvent({ ctx, body, fullSubject, mutations }); const { balance, balances } = await deductionToTrackResponseV2({ ctx, @@ -140,5 +155,6 @@ export const runRedisTrackV3 = async ({ value: body.value ?? 1, balance, balances, + mutations, }; }; diff --git a/server/src/internal/balances/utils/deductionV2/index.ts b/server/src/internal/balances/utils/deductionV2/index.ts index e416db289..75a78ec17 100644 --- a/server/src/internal/balances/utils/deductionV2/index.ts +++ b/server/src/internal/balances/utils/deductionV2/index.ts @@ -8,4 +8,5 @@ export { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js"; export { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js"; export { prepareDeductionOptionsV2 } from "./prepareDeductionOptionsV2.js"; export { prepareFeatureDeductionV2 } from "./prepareFeatureDeductionV2.js"; +export { projectMutationLogsToTrackMutationsV2 } from "./projectMutationLogsToTrackMutationsV2.js"; export { rollbackDeductionV2 } from "./rollbackDeductionV2.js"; diff --git a/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.ts b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.ts new file mode 100644 index 000000000..e1dbaca0a --- /dev/null +++ b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.ts @@ -0,0 +1,80 @@ +import { + type FullSubject, + fullSubjectToCustomerEntitlements, + type TrackMutation, +} from "@autumn/shared"; +import type { MutationLogItem } from "../types/mutationLogItem.js"; + +export const projectMutationLogsToTrackMutationsV2 = ({ + fullSubject, + mutationLogs, +}: { + fullSubject: FullSubject; + mutationLogs: MutationLogItem[]; +}): TrackMutation[] => { + if (mutationLogs.length === 0) return []; + + const customerEntitlements = fullSubjectToCustomerEntitlements({ + fullSubject, + }); + + const customerEntitlementIdToFeatureId = new Map(); + const rolloverIdToFeatureId = new Map(); + + for (const customerEntitlement of customerEntitlements) { + const featureId = customerEntitlement.entitlement.feature.id; + customerEntitlementIdToFeatureId.set(customerEntitlement.id, featureId); + for (const rollover of customerEntitlement.rollovers ?? []) { + rolloverIdToFeatureId.set(rollover.id, featureId); + } + } + + // cus_ent_* and rollover_* share the same `balance_id` namespace in the + // public shape, but their internal types are scoped separately — qualify + // with the type when aggregating so the namespaces can't collide. + const aggregated = new Map(); + + for (const log of mutationLogs) { + if (log.balance_delta === 0) continue; + + let balanceId: string; + let featureId: string | undefined; + let typeQualifier: string; + + if ( + log.target_type === "customer_entitlement" && + log.customer_entitlement_id + ) { + balanceId = log.customer_entitlement_id; + featureId = customerEntitlementIdToFeatureId.get(balanceId); + typeQualifier = "ce"; + } else if (log.target_type === "rollover" && log.rollover_id) { + balanceId = log.rollover_id; + featureId = rolloverIdToFeatureId.get(balanceId); + typeQualifier = "ro"; + } else { + continue; + } + + if (!featureId) continue; + + const key = `${typeQualifier}::${balanceId}`; + const existing = aggregated.get(key); + // Lua emits balance_delta as negative for deductions; flip so the public + // shape reads "amount consumed = positive". + const valueDelta = -log.balance_delta; + + if (existing) { + existing.value += valueDelta; + continue; + } + + aggregated.set(key, { + balance_id: balanceId, + feature_id: featureId, + value: valueDelta, + }); + } + + return [...aggregated.values()]; +}; diff --git a/server/tests/integration/balances/track/basic/track-mutations.test.ts b/server/tests/integration/balances/track/basic/track-mutations.test.ts new file mode 100644 index 000000000..1fbceb3cf --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-mutations.test.ts @@ -0,0 +1,334 @@ +import { expect, test } from "bun:test"; + +import type { + ApiCustomerV3, + TrackMutation, + TrackResponseV3, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.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 { Decimal } from "decimal.js"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; + +const findMutationByFeature = ( + mutations: TrackMutation[] | undefined, + featureId: string, +): TrackMutation | undefined => + mutations?.find((mutation) => mutation.feature_id === featureId); + +// ═══════════════════════════════════════════════════════════════════ +// A: Track within a feature's own allowance — only the main balance +// is touched. Linked credit systems serve as overflow only and +// stay untouched while allowance remains. +// ═══════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("track-mutations-A: within-allowance track surfaces a single mutation against the main balance")}`, + async () => { + const action1Item = items.free({ + featureId: TestFeature.Action1, + includedUsage: 100, + }); + const creditsItem = items.free({ + featureId: TestFeature.Credits, + includedUsage: 200, + }); + const freeProd = products.base({ + id: "free", + items: [action1Item, creditsItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-mutations-a", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const trackRes: TrackResponseV3 = await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 10, + }); + + expect(trackRes.mutations).toBeDefined(); + expect(trackRes.mutations).toHaveLength(1); + + const action1Mutation = findMutationByFeature( + trackRes.mutations, + TestFeature.Action1, + ); + expect(action1Mutation).toBeDefined(); + expect(action1Mutation?.value).toBe(10); + + expect( + findMutationByFeature(trackRes.mutations, TestFeature.Credits), + ).toBeUndefined(); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// B: event_name fans out to two features; each within its own +// allowance → two mutations, no credit-system mutations. +// ═══════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("track-mutations-B: event_name across two features surfaces a mutation per touched balance")}`, + async () => { + const action1Item = items.free({ + featureId: TestFeature.Action1, + includedUsage: 80, + }); + const creditsItem = items.free({ + featureId: TestFeature.Credits, + includedUsage: 150, + }); + const action3Item = items.free({ + featureId: TestFeature.Action3, + includedUsage: 60, + }); + const credits2Item = items.free({ + featureId: TestFeature.Credits2, + includedUsage: 100, + }); + const freeProd = products.base({ + id: "free", + items: [action1Item, creditsItem, action3Item, credits2Item], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-mutations-b", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const trackRes: TrackResponseV3 = await autumnV2_2.track({ + customer_id: customerId, + event_name: "action-event", + value: 5, + }); + + expect(trackRes.mutations).toBeDefined(); + expect(trackRes.mutations).toHaveLength(2); + + const featureIds = (trackRes.mutations ?? []) + .map((mutation) => mutation.feature_id) + .sort(); + expect(featureIds).toEqual( + [TestFeature.Action1, TestFeature.Action3].sort(), + ); + expect( + findMutationByFeature(trackRes.mutations, TestFeature.Action1)?.value, + ).toBe(5); + expect( + findMutationByFeature(trackRes.mutations, TestFeature.Action3)?.value, + ).toBe(5); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// C: Single-feature track, no credit system. +// ═══════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("track-mutations-C: feature with no credit systems surfaces a single mutation")}`, + async () => { + const messagesItem = items.free({ + featureId: TestFeature.Messages, + includedUsage: 100, + }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-mutations-c", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const trackRes: TrackResponseV3 = await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 7, + }); + + expect(trackRes.mutations).toBeDefined(); + expect(trackRes.mutations).toHaveLength(1); + expect(trackRes.mutations?.[0].feature_id).toBe(TestFeature.Messages); + expect(trackRes.mutations?.[0].value).toBe(7); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// D: A negative-value track emits a mutation with a negative value +// (refund / restore). +// ═══════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("track-mutations-D: negative track value yields a negative-value mutation")}`, + async () => { + const messagesItem = items.free({ + featureId: TestFeature.Messages, + includedUsage: 100, + }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-mutations-d", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [ + s.attach({ productId: freeProd.id }), + s.track({ featureId: TestFeature.Messages, value: 10 }), + ], + }); + + const refundRes: TrackResponseV3 = await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: -4, + }); + + expect(refundRes.mutations).toBeDefined(); + expect(refundRes.mutations).toHaveLength(1); + expect(refundRes.mutations?.[0].feature_id).toBe(TestFeature.Messages); + expect(refundRes.mutations?.[0].value).toBe(-4); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// E: A linked credit-system feature exists in the org but the customer +// has no entitlement to it — no mutation emitted for that feature. +// ═══════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("track-mutations-E: missing entitlement on credit system is omitted from mutations")}`, + async () => { + const action1Item = items.free({ + featureId: TestFeature.Action1, + includedUsage: 100, + }); + const freeProd = products.base({ + id: "free", + items: [action1Item], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "track-mutations-e", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const trackRes: TrackResponseV3 = await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + + expect(trackRes.mutations).toBeDefined(); + expect(trackRes.mutations).toHaveLength(1); + expect(trackRes.mutations?.[0].feature_id).toBe(TestFeature.Action1); + expect(trackRes.mutations?.[0].value).toBe(5); + expect( + findMutationByFeature(trackRes.mutations, TestFeature.Credits), + ).toBeUndefined(); + }, +); + +// ═══════════════════════════════════════════════════════════════════ +// F: Overflow into a linked credit system. This is the load-bearing +// scenario for the feature — a single track event depletes BOTH +// the main balance AND the credit-system balance, and the response +// surfaces both via `mutations`. +// ═══════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("track-mutations-F: track that overflows the main balance surfaces credit-system mutations too")}`, + async () => { + const action1Item = items.free({ + featureId: TestFeature.Action1, + includedUsage: 100, + }); + const creditsItem = items.free({ + featureId: TestFeature.Credits, + includedUsage: 200, + }); + const freeProd = products.base({ + id: "free", + items: [action1Item, creditsItem], + }); + + const { customerId, autumnV2_2, autumnV1, ctx } = await initScenario({ + customerId: "track-mutations-f", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + const creditFeature = ctx.features.find( + (f) => f.id === TestFeature.Credits, + ); + expect(creditFeature).toBeDefined(); + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.features[TestFeature.Action1].balance).toBe(100); + expect(customerBefore.features[TestFeature.Credits].balance).toBe(200); + + // Drain Action1 down to 0 with a first track so the next event + // has to spill into Credits. + await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 100, + }); + + const overflowAmount = 50; + const expectedCreditCost = getCreditCost({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: overflowAmount, + }); + + const trackRes: TrackResponseV3 = await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: overflowAmount, + }); + + expect(trackRes.mutations).toBeDefined(); + expect(trackRes.mutations).toHaveLength(1); + + // Action1 is empty before the overflow event, so the whole 50 + // flows through the credit system and `mutations` has exactly + // the Credits row. + const creditsMutation = findMutationByFeature( + trackRes.mutations, + TestFeature.Credits, + ); + expect(creditsMutation).toBeDefined(); + expect( + new Decimal(creditsMutation?.value ?? 0) + .minus(expectedCreditCost) + .abs() + .lessThan(1e-9), + ).toBe(true); + }, +); diff --git a/server/tests/unit/balances/track-v3/projectMutationLogsToTrackMutationsV2.test.ts b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackMutationsV2.test.ts new file mode 100644 index 000000000..29fab5d6f --- /dev/null +++ b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackMutationsV2.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, test } from "bun:test"; +import { + AppEnv, + type Feature, + type FullCustomerEntitlement, + type FullSubject, + SubjectType, +} from "@autumn/shared"; +import { projectMutationLogsToTrackMutationsV2 } from "@/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.js"; +import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js"; + +const buildFeature = (id: string): Feature => + ({ + id, + internal_id: `feat_${id}`, + org_id: "org_1", + env: AppEnv.Live, + name: id, + type: "metered", + config: null, + display: null, + created_at: 1, + archived: false, + event_names: [], + }) as Feature; + +const buildCustomerEntitlement = ({ + id, + feature, + rolloverIds = [], +}: { + id: string; + feature: Feature; + rolloverIds?: string[]; +}): FullCustomerEntitlement => + ({ + id, + internal_customer_id: "cus_int_1", + internal_entity_id: null, + internal_feature_id: feature.internal_id, + customer_id: "cus_1", + feature_id: feature.id, + customer_product_id: null, + entitlement_id: `ent_${id}`, + created_at: 1, + unlimited: false, + balance: 10, + additional_balance: 0, + usage_allowed: true, + next_reset_at: null, + adjustment: 0, + expires_at: null, + cache_version: 0, + entities: null, + external_id: null, + entitlement: { + id: `ent_${id}`, + internal_product_id: "prod_1", + internal_feature_id: feature.internal_id, + feature_id: feature.id, + allowance_type: "fixed", + allowance: 10, + interval: "month", + interval_count: 1, + usage_limit: null, + carry_from_previous: false, + created_at: 1, + entity_feature_id: null, + is_custom: false, + org_id: "org_1", + rollover: null, + feature, + }, + replaceables: [], + rollovers: rolloverIds.map((rolloverId) => ({ + id: rolloverId, + cus_ent_id: id, + balance: 5, + usage: 0, + expires_at: null, + entities: null, + })), + }) as unknown as FullCustomerEntitlement; + +const buildFullSubject = ({ + customerEntitlements, +}: { + customerEntitlements: FullCustomerEntitlement[]; +}): FullSubject => + ({ + subjectType: SubjectType.Customer, + customerId: "cus_1", + internalCustomerId: "cus_int_1", + entityId: undefined, + internalEntityId: undefined, + customer: { + id: "cus_1", + internal_id: "cus_int_1", + org_id: "org_1", + env: AppEnv.Live, + created_at: 1, + }, + entity: undefined, + customer_products: [], + extra_customer_entitlements: customerEntitlements, + subscriptions: [], + invoices: [], + aggregated_customer_products: undefined, + aggregated_customer_entitlements: undefined, + }) as unknown as FullSubject; + +const buildLog = (overrides: Partial): MutationLogItem => ({ + target_type: "customer_entitlement", + customer_entitlement_id: null, + rollover_id: null, + entity_id: null, + credit_cost: 0, + balance_delta: 0, + adjustment_delta: 0, + usage_delta: 0, + value_delta: 0, + ...overrides, +}); + +describe("projectMutationLogsToTrackMutationsV2", () => { + test("returns an empty array when there are no logs", () => { + const fullSubject = buildFullSubject({ customerEntitlements: [] }); + + expect( + projectMutationLogsToTrackMutationsV2({ fullSubject, mutationLogs: [] }), + ).toEqual([]); + }); + + test("flips balance_delta sign so consumption is positive", () => { + const feature = buildFeature("messages"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ id: "cus_ent_messages", feature }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + balance_delta: -4, + }), + ], + }); + + expect(result).toEqual([ + { + balance_id: "cus_ent_messages", + feature_id: "messages", + value: 4, + }, + ]); + }); + + test("emits negative value when a track refunds balance (positive balance_delta)", () => { + const feature = buildFeature("messages"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ id: "cus_ent_messages", feature }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + balance_delta: 3, + }), + ], + }); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe(-3); + }); + + test("aggregates multiple logs against the same balance into one mutation", () => { + const feature = buildFeature("messages"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ id: "cus_ent_messages", feature }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + balance_delta: -2, + }), + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + balance_delta: -5, + }), + ], + }); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe(7); + }); + + test("emits a separate mutation for each touched balance across credit-system features", () => { + const messages = buildFeature("messages"); + const aiCredits = buildFeature("ai_credits"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ id: "cus_ent_messages", feature: messages }), + buildCustomerEntitlement({ + id: "cus_ent_ai_credits", + feature: aiCredits, + }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + balance_delta: -1, + }), + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_ai_credits", + balance_delta: -7, + }), + ], + }); + + expect(result).toHaveLength(2); + expect(result).toContainEqual({ + balance_id: "cus_ent_messages", + feature_id: "messages", + value: 1, + }); + expect(result).toContainEqual({ + balance_id: "cus_ent_ai_credits", + feature_id: "ai_credits", + value: 7, + }); + }); + + test("surfaces rollover mutations with the parent feature_id", () => { + const feature = buildFeature("messages"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ + id: "cus_ent_messages", + feature, + rolloverIds: ["roll_1"], + }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "rollover", + rollover_id: "roll_1", + balance_delta: -2, + }), + ], + }); + + expect(result).toEqual([ + { + balance_id: "roll_1", + feature_id: "messages", + value: 2, + }, + ]); + }); + + test("filters out grant-only adjustments (balance_delta === 0)", () => { + const feature = buildFeature("messages"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ id: "cus_ent_messages", feature }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + balance_delta: 0, + adjustment_delta: 5, + }), + ], + }); + + expect(result).toEqual([]); + }); + + test("skips logs whose balance row is not in the full subject", () => { + const feature = buildFeature("messages"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ id: "cus_ent_messages", feature }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_unknown", + balance_delta: -3, + }), + ], + }); + + expect(result).toEqual([]); + }); + + test("collapses per-entity logs against the same balance into one mutation (entity scope not exposed)", () => { + const feature = buildFeature("messages"); + const fullSubject = buildFullSubject({ + customerEntitlements: [ + buildCustomerEntitlement({ id: "cus_ent_messages", feature }), + ], + }); + + const result = projectMutationLogsToTrackMutationsV2({ + fullSubject, + mutationLogs: [ + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + entity_id: "entity_a", + balance_delta: -2, + }), + buildLog({ + target_type: "customer_entitlement", + customer_entitlement_id: "cus_ent_messages", + entity_id: "entity_b", + balance_delta: -3, + }), + ], + }); + + expect(result).toEqual([ + { + balance_id: "cus_ent_messages", + feature_id: "messages", + value: 5, + }, + ]); + }); +}); diff --git a/server/tests/unit/balances/track-v3/v20TrackChangeMutationStrip.test.ts b/server/tests/unit/balances/track-v3/v20TrackChangeMutationStrip.test.ts new file mode 100644 index 000000000..ba9ceb3f2 --- /dev/null +++ b/server/tests/unit/balances/track-v3/v20TrackChangeMutationStrip.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import type { + TrackMutation, + TrackResponseV2, + TrackResponseV3, +} from "@autumn/shared"; +import { V2_0_TrackChange } from "@autumn/shared/api/balances/track/changes/V2.0_TrackChange"; + +const buildMutations = (): TrackMutation[] => [ + { + balance_id: "cus_ent_messages", + feature_id: "messages", + value: 4, + }, +]; + +describe("V2_0_TrackChange mutation strip", () => { + test("does not leak the mutations field to V2.0 clients", () => { + const transform = new V2_0_TrackChange(); + const input: TrackResponseV3 = { + customer_id: "cus_1", + entity_id: undefined, + event_name: undefined, + value: 4, + balance: null, + balances: undefined, + mutations: buildMutations(), + }; + + const transformed = transform.transformResponse({ + input, + }) as TrackResponseV2; + + expect(transformed).not.toHaveProperty("mutations"); + expect(transformed.customer_id).toBe("cus_1"); + expect(transformed.value).toBe(4); + expect(transformed.balance).toBeNull(); + }); +}); diff --git a/server/tinybird/datasources/events.datasource b/server/tinybird/datasources/events.datasource index abb7e2ba7..dacbddd31 100644 --- a/server/tinybird/datasources/events.datasource +++ b/server/tinybird/datasources/events.datasource @@ -16,7 +16,8 @@ SCHEMA > `entity_id` Nullable(String) `json:$.entity_id`, `internal_entity_id` Nullable(String) `json:$.internal_entity_id`, `customer_id` String `json:$.customer_id`, - `properties` JSON `json:$.properties` + `properties` JSON `json:$.properties`, + `mutations` Nullable(String) `json:$.mutations` DEFAULT NULL ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" diff --git a/shared/api/balances/track/changes/V2.0_TrackChange.ts b/shared/api/balances/track/changes/V2.0_TrackChange.ts index 2b104b454..f7fd924bd 100644 --- a/shared/api/balances/track/changes/V2.0_TrackChange.ts +++ b/shared/api/balances/track/changes/V2.0_TrackChange.ts @@ -49,8 +49,18 @@ export const V2_0_TrackChange = defineVersionChange({ } } + const { + customer_id, + entity_id, + event_name, + value, + }: z.infer = input; + return { - ...input, + customer_id, + entity_id, + event_name, + value, balance: transformedBalance, balances: transformedBalances, }; diff --git a/shared/api/balances/track/trackResponseV3.ts b/shared/api/balances/track/trackResponseV3.ts index 8850f54ca..8e7cdd29b 100644 --- a/shared/api/balances/track/trackResponseV3.ts +++ b/shared/api/balances/track/trackResponseV3.ts @@ -1,6 +1,22 @@ import { z } from "zod/v4"; import { ApiBalanceV1Schema } from "../../customers/cusFeatures/apiBalanceV1.js"; +export const TrackMutationSchema = z.object({ + balance_id: z.string().meta({ + description: + "ID of the underlying balance row that was mutated (customer_entitlement or rollover).", + }), + feature_id: z.string().meta({ + description: "The feature this balance belongs to.", + }), + value: z.number().meta({ + description: + "Amount consumed from this balance. Positive when usage was deducted, negative when credit was restored (e.g. a negative track value).", + }), +}); + +export type TrackMutation = z.infer; + /** * Track response V3 - uses ApiBalanceV1 (V2.1 format) * This is the server's internal response format @@ -32,6 +48,10 @@ export const TrackResponseV3Schema = z.object({ description: "Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.", }), + mutations: z.array(TrackMutationSchema).optional().meta({ + description: + "Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.", + }), }); export type TrackResponseV3 = z.infer; diff --git a/shared/models/eventModels/eventTable.ts b/shared/models/eventModels/eventTable.ts index a094f79f9..210f63011 100644 --- a/shared/models/eventModels/eventTable.ts +++ b/shared/models/eventModels/eventTable.ts @@ -11,6 +11,7 @@ import { timestamp, unique, } from "drizzle-orm/pg-core"; +import type { TrackMutation } from "../../api/balances/track/trackResponseV3.js"; import { customers } from "../cusModels/cusTable.js"; export const events = pgTable( @@ -34,6 +35,7 @@ export const events = pgTable( // Optional stuff... customer_id: text("customer_id").notNull(), properties: jsonb().$type>(), + mutations: jsonb().$type(), }, (table) => [ foreignKey({ From a23bf29487df4ade252ebb0bf7dfecfbf021054e Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 20:51:04 +0100 Subject: [PATCH 23/36] surface mutations on events.list + propagate via Tinybird MV --- .../openapi/v2.1/contracts/balancesContract.ts | 7 +++++++ .../tinybird/pipes/listEventsPaginatedPipe.ts | 1 + .../src/internal/analytics/actions/listEvents.ts | 14 +++++++++++++- .../events_by_timestamp_mv.datasource | 3 ++- .../events_by_timestamp_mv_pipe.pipe | 3 ++- server/tinybird/pipes/list_events_paginated.pipe | 3 ++- shared/api/events/list/eventsListResponse.ts | 15 +++++++++++++++ 7 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/openapi/v2.1/contracts/balancesContract.ts b/packages/openapi/v2.1/contracts/balancesContract.ts index e2001e3ba..5129f9dad 100644 --- a/packages/openapi/v2.1/contracts/balancesContract.ts +++ b/packages/openapi/v2.1/contracts/balancesContract.ts @@ -112,6 +112,13 @@ export const balancesTrackContract = oc customer_id: "cus_123", value: 1, balance: API_BALANCE_V1_EXAMPLE, + mutations: [ + { + balance_id: "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", + feature_id: "messages", + value: 1, + }, + ], }, ], }), diff --git a/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts index 2dd8ba9fe..2f7d4d62f 100644 --- a/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts +++ b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts @@ -13,6 +13,7 @@ export const listEventsPaginatedPipeResponseSchema = z.object({ properties: z.string().nullable(), idempotency_key: z.string().nullable(), entity_id: z.string().nullable(), + mutations: z.string().nullable(), }); export type ListEventsPaginatedPipeRow = z.infer< diff --git a/server/src/internal/analytics/actions/listEvents.ts b/server/src/internal/analytics/actions/listEvents.ts index 92ab5d5bb..eefa07c85 100644 --- a/server/src/internal/analytics/actions/listEvents.ts +++ b/server/src/internal/analytics/actions/listEvents.ts @@ -1,4 +1,4 @@ -import type { ApiEventsListItem } from "@autumn/shared"; +import type { ApiEventsListItem, TrackMutation } from "@autumn/shared"; import { epochToDateTime } from "@autumn/shared/api/common/epochUtils"; import { getTinybirdPipes } from "@/external/tinybird/initTinybird.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -84,6 +84,17 @@ export const listEvents = async ({ } } + let mutations: TrackMutation[] | null = null; + if (row.mutations) { + try { + const parsed = JSON.parse(row.mutations); + if (Array.isArray(parsed)) mutations = parsed as TrackMutation[]; + } catch { + // Invalid JSON — leave null so the caller can distinguish missing + // vs explicit empty. + } + } + return { id: row.id, timestamp: new Date(row.timestamp).getTime(), @@ -91,6 +102,7 @@ export const listEvents = async ({ customer_id: row.customer_id, value: row.value ?? 0, properties, + mutations, }; }); diff --git a/server/tinybird/materializations/events_by_timestamp_mv.datasource b/server/tinybird/materializations/events_by_timestamp_mv.datasource index e7e5648aa..286a216a5 100644 --- a/server/tinybird/materializations/events_by_timestamp_mv.datasource +++ b/server/tinybird/materializations/events_by_timestamp_mv.datasource @@ -13,7 +13,8 @@ SCHEMA > `value` Nullable(Decimal(38, 19)), `properties` Nullable(String), `idempotency_key` Nullable(String), - `entity_id` String DEFAULT '' + `entity_id` String DEFAULT '', + `mutations` Nullable(String) ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" diff --git a/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe b/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe index 2ce837d0e..9d4f65d89 100644 --- a/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe +++ b/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe @@ -14,7 +14,8 @@ SQL > value, properties, idempotency_key, - coalesce(entity_id, '') as entity_id + coalesce(entity_id, '') as entity_id, + mutations FROM events ORDER BY timestamp DESC, id DESC diff --git a/server/tinybird/pipes/list_events_paginated.pipe b/server/tinybird/pipes/list_events_paginated.pipe index 0c1dbd1ba..f21a1acff 100644 --- a/server/tinybird/pipes/list_events_paginated.pipe +++ b/server/tinybird/pipes/list_events_paginated.pipe @@ -18,7 +18,8 @@ SQL > value, properties, idempotency_key, - entity_id + entity_id, + mutations FROM events_by_timestamp_mv WHERE org_id = {{ String(org_id, '') }} diff --git a/shared/api/events/list/eventsListResponse.ts b/shared/api/events/list/eventsListResponse.ts index b0fb071e2..5ecd6695e 100644 --- a/shared/api/events/list/eventsListResponse.ts +++ b/shared/api/events/list/eventsListResponse.ts @@ -1,5 +1,6 @@ import { createPagePaginatedResponseSchema } from "@api/common/pagePaginationSchemas"; import { z } from "zod/v4"; +import { TrackMutationSchema } from "../../balances/track/trackResponseV3"; export const EVENTS_LIST_EXAMPLE = { list: [ @@ -10,6 +11,13 @@ export const EVENTS_LIST_EXAMPLE = { customer_id: "0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx", value: 30, properties: {}, + mutations: [ + { + balance_id: "cus_ent_3DdSDtFBlvDbjyUuJeUIbQlyN12", + feature_id: "credits", + value: 30, + }, + ], }, { id: "evt_36xmHxxjAkqxufDf9yHAPNfRrLM", @@ -18,6 +26,7 @@ export const EVENTS_LIST_EXAMPLE = { customer_id: "0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx", value: 49, properties: {}, + mutations: null, }, ], total: 2, @@ -39,6 +48,12 @@ export const ApiEventsListItemSchema = z.object({ properties: z .record(z.string(), z.unknown()) .describe("Event properties (JSON)"), + mutations: z + .array(TrackMutationSchema) + .nullable() + .describe( + "Per-balance breakdown of what this event consumed. Null for events ingested before mutations were tracked; an empty array means the event was accepted but no balance moved.", + ), }); export const ApiEventsListResponseSchema = createPagePaginatedResponseSchema( From 3869a6983bc177a98cd60bba66d65149d120ca50 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Mon, 11 May 2026 18:43:25 +0100 Subject: [PATCH 24/36] Stop Stripe resource creation during previews Previews now set dryRunStripe on billing contexts and assign deterministic preview Stripe product and price IDs inside initStripeResourcesForBillingPlan instead of calling Stripe. Real Stripe resource initialization rejects preview IDs, and zero fixed/base prices no longer create Stripe resources or subscription item specs.\n\nTests:\n- bun test tests/unit/billing/init-stripe-resources-for-products.test.ts\n- cd server && bun ts\n- bunx biome check --- .../createStripePrice/createStripePrice.ts | 2 + .../stripe/previewStripeResourceIds.ts | 155 +++++++++++++ .../billing/v2/actions/attach/attach.ts | 1 + .../createSchedule/previewCreateSchedule.ts | 1 + .../v2/actions/multiAttach/multiAttach.ts | 1 + .../updateSubscription/updateSubscription.ts | 13 +- .../common/initStripeResourcesForProducts.ts | 105 +++++++-- .../cusPriceToStripeItemSpec.ts | 4 + server/src/internal/products/productUtils.ts | 2 + ...init-stripe-resources-for-products.test.ts | 212 +++++++++++++++++- .../billingModels/context/billingContext.ts | 1 + 11 files changed, 472 insertions(+), 25 deletions(-) create mode 100644 server/src/external/stripe/previewStripeResourceIds.ts diff --git a/server/src/external/stripe/createStripePrice/createStripePrice.ts b/server/src/external/stripe/createStripePrice/createStripePrice.ts index 260f4e29f..ac2e4bd33 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrice.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrice.ts @@ -11,6 +11,7 @@ import { getBillingType } from "@server/internal/products/prices/priceUtils"; import Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripePrepaidPriceV2 } from "@/external/stripe/createStripePrice/createStripePrepaidPriceV2.js"; +import { assertNoPreviewStripeIdsOnProduct } from "@/external/stripe/previewStripeResourceIds.js"; import { getStripePrice } from "@/external/stripe/prices/operations/getStripePrice.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { billingIntervalToStripe } from "../stripePriceUtils.js"; @@ -140,6 +141,7 @@ export const createStripePriceIFNotExist = async ({ // Fetch latest price data... const { org, logger, db, env } = ctx; + assertNoPreviewStripeIdsOnProduct({ product }); const stripeCli = createStripeCli({ org, env }); const billingType = getBillingType(price.config!); diff --git a/server/src/external/stripe/previewStripeResourceIds.ts b/server/src/external/stripe/previewStripeResourceIds.ts new file mode 100644 index 000000000..f9523b219 --- /dev/null +++ b/server/src/external/stripe/previewStripeResourceIds.ts @@ -0,0 +1,155 @@ +import { + type FullProduct, + InternalError, + isPrepaidPrice, + type Price, + ProcessorType, + type Product, + type UsagePriceConfig, +} from "@autumn/shared"; +import { hashJson } from "@/utils/hash/hashJson"; + +export const PREVIEW_STRIPE_PRICE_ID_PREFIX = "price_PREVIEW_"; +export const PREVIEW_STRIPE_PRODUCT_ID_PREFIX = "prod_PREVIEW_"; + +const previewHash = ({ value }: { value: unknown }) => + hashJson({ value }).slice(0, 24); + +export const isPreviewStripeId = ({ stripeId }: { stripeId?: string | null }) => + stripeId?.startsWith(PREVIEW_STRIPE_PRICE_ID_PREFIX) === true || + stripeId?.startsWith(PREVIEW_STRIPE_PRODUCT_ID_PREFIX) === true; + +export const assertNotPreviewStripeId = ({ + stripeId, + fieldName, +}: { + stripeId?: string | null; + fieldName: string; +}) => { + if (!isPreviewStripeId({ stripeId })) return; + + throw new InternalError({ + message: `Refusing to persist preview Stripe id in ${fieldName}`, + }); +}; + +export const previewStripeProductIdForProduct = ({ + product, +}: { + product: Product; +}) => + `${PREVIEW_STRIPE_PRODUCT_ID_PREFIX}${previewHash({ + value: { + env: product.env, + internalProductId: product.internal_id, + productId: product.id, + }, + })}`; + +const previewStripeProductIdForPrice = ({ + price, + product, + internalEntityId, +}: { + price: Price; + product: Product; + internalEntityId?: string; +}) => { + const config = price.config as Partial; + return `${PREVIEW_STRIPE_PRODUCT_ID_PREFIX}${previewHash({ + value: { + env: product.env, + featureId: config.feature_id, + internalEntityId, + internalFeatureId: config.internal_feature_id, + internalProductId: product.internal_id, + productId: product.id, + }, + })}`; +}; + +const previewStripePriceIdForPrice = ({ + price, + product, + fieldName, +}: { + price: Price; + product: Product; + fieldName: string; +}) => + `${PREVIEW_STRIPE_PRICE_ID_PREFIX}${previewHash({ + value: { + config: price.config, + fieldName, + internalProductId: product.internal_id, + }, + })}`; + +export const applyPreviewStripeResourcesToProduct = ({ + product, + internalEntityId, +}: { + product: FullProduct; + internalEntityId?: string; +}) => { + const productProcessorId = + product.processor?.id ?? previewStripeProductIdForProduct({ product }); + + product.processor = { + id: productProcessorId, + type: ProcessorType.Stripe, + }; + + for (const price of product.prices) { + const config = price.config as Partial; + + config.stripe_price_id ??= previewStripePriceIdForPrice({ + price, + product, + fieldName: "stripe_price_id", + }); + + if ("feature_id" in config && config.feature_id) { + config.stripe_product_id ??= previewStripeProductIdForPrice({ + price, + product, + internalEntityId, + }); + } + + if (isPrepaidPrice(price)) { + config.stripe_prepaid_price_v2_id ??= previewStripePriceIdForPrice({ + price, + product, + fieldName: "stripe_prepaid_price_v2_id", + }); + } + } +}; + +export const assertNoPreviewStripeIdsOnProduct = ({ + product, +}: { + product: FullProduct; +}) => { + assertNotPreviewStripeId({ + stripeId: product.processor?.id, + fieldName: "product.processor.id", + }); + + for (const price of product.prices) { + const config = price.config as Partial; + for (const fieldName of [ + "stripe_price_id", + "stripe_product_id", + "stripe_empty_price_id", + "stripe_placeholder_price_id", + "stripe_prepaid_price_v2_id", + ] as const) { + assertNotPreviewStripeId({ + stripeId: config[fieldName], + fieldName: `price.config.${fieldName}`, + }); + } + } +}; diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index ffb08ea8d..b826f7a5a 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -43,6 +43,7 @@ export async function attach({ params, contextOverride, }); + billingContext.dryRunStripe = preview; logAttachContext({ ctx, billingContext }); diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index d219c1ca0..d207f5acf 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -28,6 +28,7 @@ export const previewCreateScheduleWithContext = async ({ ctx, params, }); + billingContext.dryRunStripe = true; await handleCreateScheduleErrors({ db: ctx.db, diff --git a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts index ee74b5f79..705f9ee2a 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts @@ -35,6 +35,7 @@ export async function multiAttach({ ctx, params, }); + billingContext.dryRunStripe = preview; // 2. Errors await handleMultiAttachErrors({ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts index 70c19d47f..103c8e37f 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts @@ -45,6 +45,7 @@ export async function updateSubscription({ params, contextOverride, }); + billingContext.dryRunStripe = preview; logUpdateSubscriptionContext({ ctx, billingContext }); @@ -96,12 +97,12 @@ export async function updateSubscription({ ) { const autumnCheckoutResult = await createAutumnCheckout({ - ctx, - action: CheckoutAction.UpdateSubscription, - params, - billingContext, - billingPlan, - }); + ctx, + action: CheckoutAction.UpdateSubscription, + params, + billingContext, + billingPlan, + }); return autumnCheckoutResult; } diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index c35dbe12e..2bd9e4c85 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -2,10 +2,16 @@ import { type AutumnBillingPlan, type BillingContext, cusProductToProduct, + type FullCusProduct, + type FullProduct, + findCustomerProductById, + isFixedPrice, isPrepaidPrice, nullish, + type Price, } from "@autumn/shared"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice"; +import { applyPreviewStripeResourcesToProduct } from "@/external/stripe/previewStripeResourceIds"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { applyCustomerProductPatch, @@ -13,6 +19,17 @@ import { } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { checkStripeProductExists } from "@/internal/products/productUtils"; +const shouldInitializeStripePrice = ({ price }: { price: Price }) => { + if (!isFixedPrice(price)) return true; + + return (price.config.amount ?? 0) > 0; +}; + +const productNeedsPlanStripeProduct = ({ product }: { product: FullProduct }) => + product.prices.some( + (price) => isFixedPrice(price) && shouldInitializeStripePrice({ price }), + ); + export const initStripeResourcesForBillingPlan = async ({ ctx, autumnBillingPlan, @@ -26,22 +43,22 @@ export const initStripeResourcesForBillingPlan = async ({ const { fullCustomer } = billingContext; const { insertCustomerProducts } = autumnBillingPlan; + const patchCustomerProducts = getPatchCustomerProducts({ autumnBillingPlan }); - const newProducts = insertCustomerProducts.flatMap((cp) => - cusProductToProduct({ cusProduct: cp }), + const newProducts = insertCustomerProducts.flatMap((customerProduct) => + cusProductToProduct({ cusProduct: customerProduct }), ); - const patchProducts = getPatchCustomerProducts({ autumnBillingPlan }).map( - (patchCustomerProduct) => - cusProductToProduct({ - cusProduct: applyCustomerProductPatch({ - customerProduct: patchCustomerProduct.customerProduct, - patch: patchCustomerProduct, - }), + const patchProducts = patchCustomerProducts.map((patchCustomerProduct) => + cusProductToProduct({ + cusProduct: applyCustomerProductPatch({ + customerProduct: patchCustomerProduct.customerProduct, + patch: patchCustomerProduct, }), + }), ); const patchedCustomerProductIds = new Set( - getPatchCustomerProducts({ autumnBillingPlan }).map( + patchCustomerProducts.map( (patchCustomerProduct) => patchCustomerProduct.customerProduct.id, ), ); @@ -57,9 +74,10 @@ export const initStripeResourcesForBillingPlan = async ({ ...product, prices: product.prices.filter( (price) => - nullish(price.config.stripe_price_id) || - (isPrepaidPrice(price) && - nullish(price.config.stripe_prepaid_price_v2_id)), + shouldInitializeStripePrice({ price }) && + (nullish(price.config.stripe_price_id) || + (isPrepaidPrice(price) && + nullish(price.config.stripe_prepaid_price_v2_id))), ), })) .filter( @@ -67,10 +85,67 @@ export const initStripeResourcesForBillingPlan = async ({ ); const allProducts = [...newProducts, ...patchProducts, ...existingProducts]; + const internalEntityId = fullCustomer.entity?.internal_id; + + if (billingContext.dryRunStripe) { + const applyPreviewStripeResourcesToCustomerProduct = ({ + customerProduct, + }: { + customerProduct: FullCusProduct; + }) => { + const product = cusProductToProduct({ cusProduct: customerProduct }); + applyPreviewStripeResourcesToProduct({ product, internalEntityId }); + customerProduct.product.processor = product.processor ?? null; + }; + + for (const customerProduct of insertCustomerProducts) { + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct, + }); + } + + for (const patchCustomerProduct of patchCustomerProducts) { + const matchingCustomerProduct = + findCustomerProductById({ + fullCustomer, + customerProductId: patchCustomerProduct.customerProduct.id, + }) ?? patchCustomerProduct.customerProduct; + const patchedCustomerProduct = applyCustomerProductPatch({ + customerProduct: matchingCustomerProduct, + patch: patchCustomerProduct, + }); + + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct: patchedCustomerProduct, + }); + + if (matchingCustomerProduct === patchCustomerProduct.customerProduct) { + continue; + } + + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct: applyCustomerProductPatch({ + customerProduct: patchCustomerProduct.customerProduct, + patch: patchCustomerProduct, + }), + }); + } + + for (const customerProduct of fullCustomer.customer_products) { + if (patchedCustomerProductIds.has(customerProduct.id)) continue; + + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct, + }); + } + + return; + } const batchProductUpdates = []; for (const product of allProducts) { if (product.processor?.id != null) continue; + if (!productNeedsPlanStripeProduct({ product })) continue; batchProductUpdates.push( checkStripeProductExists({ @@ -86,10 +161,10 @@ export const initStripeResourcesForBillingPlan = async ({ const batchPriceUpdates = []; - const internalEntityId = fullCustomer.entity?.internal_id; - for (const product of allProducts) { for (const price of product.prices) { + if (!shouldInitializeStripePrice({ price })) continue; + batchPriceUpdates.push( createStripePriceIFNotExist({ ctx, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts index 8441f920b..3f4f14b15 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts @@ -1,6 +1,7 @@ import { type BillingContext, cusPriceToCusEntWithCusProduct, + type FixedPriceConfig, type FullCusProduct, type FullCustomerPrice, isAllocatedPrice, @@ -38,6 +39,9 @@ export const cusPriceToStripeItemSpec = ({ // 1. Fixed / one-off price (no entitlement needed) if (isFixedPrice(price)) { + const config = price.config as FixedPriceConfig; + if ((config.amount ?? 0) <= 0) return null; + spec = fixedPriceToStripeItemSpec({ cusPrice, cusProduct }); } else { // Resolve cusEntWithCusProduct for usage-based prices diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 9f7c56aba..967ccedc7 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -23,6 +23,7 @@ import { import type { DrizzleCli } from "@server/db/initDrizzle.js"; import { createStripeCli } from "@server/external/connect/createStripeCli.js"; import { createStripePriceIFNotExist } from "@server/external/stripe/createStripePrice/createStripePrice.js"; +import { assertNoPreviewStripeIdsOnProduct } from "@server/external/stripe/previewStripeResourceIds.js"; import { getBillingType } from "@server/internal/products/prices/priceUtils.js"; import RecaseError from "@server/utils/errorUtils.js"; import { generateId, notNullish } from "@server/utils/genUtils.js"; @@ -244,6 +245,7 @@ export const checkStripeProductExists = async ({ logger: any; }) => { let createNew = false; + assertNoPreviewStripeIdsOnProduct({ product }); const stripeCli = createStripeCli({ org, env, diff --git a/server/tests/unit/billing/init-stripe-resources-for-products.test.ts b/server/tests/unit/billing/init-stripe-resources-for-products.test.ts index 3da1717fb..b3040c31c 100644 --- a/server/tests/unit/billing/init-stripe-resources-for-products.test.ts +++ b/server/tests/unit/billing/init-stripe-resources-for-products.test.ts @@ -3,15 +3,18 @@ import { type AutumnBillingPlan, type BillingContext, BillingInterval, + BillWhen, type FullCusProduct, type FullCustomerPrice, type Price, PriceType, + type UsagePriceConfig, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; const mockState = { priceIds: [] as string[], + productCalls: 0, }; mock.module("@/external/stripe/createStripePrice/createStripePrice", () => ({ @@ -21,12 +24,21 @@ mock.module("@/external/stripe/createStripePrice/createStripePrice", () => ({ })); mock.module("@/internal/products/productUtils", () => ({ - checkStripeProductExists: async () => undefined, + checkStripeProductExists: async () => { + mockState.productCalls++; + }, })); import { initStripeResourcesForBillingPlan } from "@/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts"; +import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs"; -const fixedPrice = ({ id }: { id: string }): Price => ({ +const fixedPrice = ({ + id, + amount = 10, +}: { + id: string; + amount?: number; +}): Price => ({ id, internal_product_id: "prod_internal", org_id: "org_1", @@ -37,7 +49,7 @@ const fixedPrice = ({ id }: { id: string }): Price => ({ proration_config: null, config: { type: PriceType.Fixed, - amount: 10, + amount, interval: BillingInterval.Month, stripe_price_id: null, stripe_product_id: null, @@ -46,6 +58,30 @@ const fixedPrice = ({ id }: { id: string }): Price => ({ }, }); +const prepaidPrice = ({ id }: { id: string }): Price => ({ + id, + internal_product_id: "prod_internal", + org_id: "org_1", + created_at: 1, + tier_behavior: null, + is_custom: false, + entitlement_id: "ent_1", + proration_config: null, + config: { + type: PriceType.Usage, + bill_when: BillWhen.StartOfPeriod, + billing_units: 1, + internal_feature_id: "feature_internal", + feature_id: "messages", + usage_tiers: [{ amount: 10, to: -1 }], + interval: BillingInterval.Month, + interval_count: 1, + stripe_price_id: null, + stripe_product_id: null, + stripe_prepaid_price_v2_id: null, + }, +}); + const customerPrice = ({ id, price, @@ -109,6 +145,174 @@ const customerProduct = ({ describe("initStripeResourcesForBillingPlan", () => { beforeEach(() => { mockState.priceIds = []; + mockState.productCalls = 0; + }); + + test("uses preview Stripe IDs without initializing Stripe resources during dry run", async () => { + const basePrice = fixedPrice({ id: "price_base" }); + const messagesPrice = prepaidPrice({ id: "price_messages" }); + const baseCustomerPrice = customerPrice({ + id: "cus_price_base", + price: basePrice, + }); + const messagesCustomerPrice = customerPrice({ + id: "cus_price_messages", + price: messagesPrice, + }); + const newCustomerProduct = customerProduct({ + customerPrices: [baseCustomerPrice, messagesCustomerPrice], + }); + + await initStripeResourcesForBillingPlan({ + ctx: { + db: {}, + org: { id: "org_1" }, + env: "sandbox", + logger: { debug: () => undefined }, + } as unknown as AutumnContext, + billingContext: { + dryRunStripe: true, + fullCustomer: { + internal_id: "cus_internal", + customer_products: [], + }, + } as unknown as BillingContext, + autumnBillingPlan: { + customerId: "cus_1", + insertCustomerProducts: [newCustomerProduct], + } as AutumnBillingPlan, + }); + + const messagesConfig = messagesPrice.config as UsagePriceConfig; + + expect(mockState.productCalls).toBe(0); + expect(mockState.priceIds).toEqual([]); + expect(newCustomerProduct.product.processor?.id).toStartWith( + "prod_PREVIEW_", + ); + expect(basePrice.config.stripe_price_id).toStartWith("price_PREVIEW_"); + expect(messagesConfig.stripe_price_id).toStartWith("price_PREVIEW_"); + expect(messagesConfig.stripe_product_id).toStartWith("prod_PREVIEW_"); + expect(messagesConfig.stripe_prepaid_price_v2_id).toStartWith( + "price_PREVIEW_", + ); + }); + + test("uses preview Stripe IDs for patched customer products during dry run", async () => { + const keptPrice = fixedPrice({ id: "price_kept" }); + const insertedPrice = prepaidPrice({ id: "price_inserted" }); + const originalCustomerProduct = customerProduct({ + customerPrices: [ + customerPrice({ + id: "cus_price_kept", + price: keptPrice, + }), + ], + }); + const insertedCustomerPrice = customerPrice({ + id: "cus_price_inserted", + price: insertedPrice, + }); + + await initStripeResourcesForBillingPlan({ + ctx: { + db: {}, + org: { id: "org_1" }, + env: "sandbox", + logger: { debug: () => undefined }, + } as unknown as AutumnContext, + billingContext: { + dryRunStripe: true, + fullCustomer: { + internal_id: "cus_internal", + customer_products: [originalCustomerProduct], + }, + } as unknown as BillingContext, + autumnBillingPlan: { + customerId: "cus_1", + insertCustomerProducts: [], + patchCustomerProducts: [ + { + customerProduct: originalCustomerProduct, + insertCustomerPrices: [insertedCustomerPrice], + insertCustomerEntitlements: [], + deleteCustomerPrices: [], + deleteCustomerEntitlements: [], + }, + ], + } as AutumnBillingPlan, + }); + + const insertedConfig = insertedPrice.config as UsagePriceConfig; + + expect(mockState.productCalls).toBe(0); + expect(mockState.priceIds).toEqual([]); + expect(originalCustomerProduct.product.processor?.id).toStartWith( + "prod_PREVIEW_", + ); + expect(keptPrice.config.stripe_price_id).toStartWith("price_PREVIEW_"); + expect(insertedConfig.stripe_price_id).toStartWith("price_PREVIEW_"); + expect(insertedConfig.stripe_product_id).toStartWith("prod_PREVIEW_"); + expect(insertedConfig.stripe_prepaid_price_v2_id).toStartWith( + "price_PREVIEW_", + ); + }); + + test("does not initialize Stripe resources for zero fixed prices", async () => { + const zeroPrice = fixedPrice({ id: "price_free", amount: 0 }); + const freeCustomerProduct = customerProduct({ + customerPrices: [ + customerPrice({ + id: "cus_price_free", + price: zeroPrice, + }), + ], + }); + + await initStripeResourcesForBillingPlan({ + ctx: { + db: {}, + org: { id: "org_1" }, + env: "sandbox", + logger: { debug: () => undefined }, + } as unknown as AutumnContext, + billingContext: { + fullCustomer: { + internal_id: "cus_internal", + customer_products: [], + }, + } as unknown as BillingContext, + autumnBillingPlan: { + customerId: "cus_1", + insertCustomerProducts: [freeCustomerProduct], + } as AutumnBillingPlan, + }); + + expect(mockState.productCalls).toBe(0); + expect(mockState.priceIds).toEqual([]); + expect(zeroPrice.config.stripe_price_id).toBeNull(); + }); + + test("omits zero fixed prices from Stripe item specs", () => { + const zeroPrice = fixedPrice({ id: "price_free", amount: 0 }); + const freeCustomerProduct = customerProduct({ + customerPrices: [ + customerPrice({ + id: "cus_price_free", + price: zeroPrice, + }), + ], + }); + + const stripeItemSpecs = customerProductToStripeItemSpecs({ + ctx: {} as AutumnContext, + customerProduct: freeCustomerProduct, + }); + + expect(stripeItemSpecs).toEqual({ + oneOffItems: [], + recurringItems: [], + }); }); test("does not initialize Stripe resources for prices removed by a patch", async () => { @@ -138,7 +342,7 @@ describe("initStripeResourcesForBillingPlan", () => { internal_id: "cus_internal", customer_products: [originalCustomerProduct], }, - } as BillingContext, + } as unknown as BillingContext, autumnBillingPlan: { customerId: "cus_1", insertCustomerProducts: [], diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index 511f9c4a7..2c13ed27f 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -85,6 +85,7 @@ export interface BillingContext { userMetadata?: Record; skipBillingChanges?: boolean; + dryRunStripe?: boolean; checkoutMode?: CheckoutMode; From a2f345c75593a2d5f49444a438e54f6d59aafa70 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 12:00:27 +0100 Subject: [PATCH 25/36] Address preview-flow review feedback - Thread `preview` into the four billing-context setup functions and assign `dryRunStripe` there, instead of mutating the context in each action orchestrator. - Move the dry-run customer-product walk out of `initStripeResourcesForBillingPlan` into a dedicated `applyPreviewStripeResourcesToBillingPlan` helper alongside the other preview-Stripe-ID utilities. - Replace inline `"feature_id" in config && config.feature_id` with the existing `isUsagePrice` predicate when stamping preview IDs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stripe/previewStripeResourceIds.ts | 87 ++++++++++++++++++- .../billing/v2/actions/attach/attach.ts | 2 +- .../attach/setup/setupAttachBillingContext.ts | 6 +- ...etupImmediateMultiProductBillingContext.ts | 3 + .../createSchedule/previewCreateSchedule.ts | 2 +- .../setupCreateScheduleBillingContext.ts | 3 + .../v2/actions/multiAttach/multiAttach.ts | 2 +- .../setup/setupMultiAttachBillingContext.ts | 3 + .../setupUpdateSubscriptionBillingContext.ts | 3 + .../updateSubscription/updateSubscription.ts | 2 +- .../common/initStripeResourcesForProducts.ts | 82 ++++------------- 11 files changed, 124 insertions(+), 71 deletions(-) diff --git a/server/src/external/stripe/previewStripeResourceIds.ts b/server/src/external/stripe/previewStripeResourceIds.ts index f9523b219..248e282d6 100644 --- a/server/src/external/stripe/previewStripeResourceIds.ts +++ b/server/src/external/stripe/previewStripeResourceIds.ts @@ -1,12 +1,22 @@ import { + type AutumnBillingPlan, + type BillingContext, + cusProductToProduct, + type FullCusProduct, type FullProduct, + findCustomerProductById, InternalError, isPrepaidPrice, + isUsagePrice, type Price, ProcessorType, type Product, type UsagePriceConfig, } from "@autumn/shared"; +import { + applyCustomerProductPatch, + getPatchCustomerProducts, +} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { hashJson } from "@/utils/hash/hashJson"; export const PREVIEW_STRIPE_PRICE_ID_PREFIX = "price_PREVIEW_"; @@ -109,7 +119,7 @@ export const applyPreviewStripeResourcesToProduct = ({ fieldName: "stripe_price_id", }); - if ("feature_id" in config && config.feature_id) { + if (isUsagePrice({ price })) { config.stripe_product_id ??= previewStripeProductIdForPrice({ price, product, @@ -127,6 +137,81 @@ export const applyPreviewStripeResourcesToProduct = ({ } }; +const applyPreviewStripeResourcesToCustomerProduct = ({ + customerProduct, + internalEntityId, +}: { + customerProduct: FullCusProduct; + internalEntityId?: string; +}) => { + const product = cusProductToProduct({ cusProduct: customerProduct }); + applyPreviewStripeResourcesToProduct({ product, internalEntityId }); + customerProduct.product.processor = product.processor ?? null; +}; + +/** Stamp preview Stripe IDs onto every customer product touched by a dry-run billing plan. */ +export const applyPreviewStripeResourcesToBillingPlan = ({ + autumnBillingPlan, + billingContext, +}: { + autumnBillingPlan: AutumnBillingPlan; + billingContext: BillingContext; +}) => { + const { fullCustomer } = billingContext; + const internalEntityId = fullCustomer.entity?.internal_id; + const patchCustomerProducts = getPatchCustomerProducts({ autumnBillingPlan }); + const patchedCustomerProductIds = new Set( + patchCustomerProducts.map( + (patchCustomerProduct) => patchCustomerProduct.customerProduct.id, + ), + ); + + for (const customerProduct of autumnBillingPlan.insertCustomerProducts) { + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct, + internalEntityId, + }); + } + + for (const patchCustomerProduct of patchCustomerProducts) { + const matchingCustomerProduct = + findCustomerProductById({ + fullCustomer, + customerProductId: patchCustomerProduct.customerProduct.id, + }) ?? patchCustomerProduct.customerProduct; + const patchedCustomerProduct = applyCustomerProductPatch({ + customerProduct: matchingCustomerProduct, + patch: patchCustomerProduct, + }); + + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct: patchedCustomerProduct, + internalEntityId, + }); + + if (matchingCustomerProduct === patchCustomerProduct.customerProduct) { + continue; + } + + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct: applyCustomerProductPatch({ + customerProduct: patchCustomerProduct.customerProduct, + patch: patchCustomerProduct, + }), + internalEntityId, + }); + } + + for (const customerProduct of fullCustomer.customer_products) { + if (patchedCustomerProductIds.has(customerProduct.id)) continue; + + applyPreviewStripeResourcesToCustomerProduct({ + customerProduct, + internalEntityId, + }); + } +}; + export const assertNoPreviewStripeIdsOnProduct = ({ product, }: { diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index b826f7a5a..874ec8a31 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -41,9 +41,9 @@ export async function attach({ const billingContext = await setupAttachBillingContext({ ctx, params, + preview, contextOverride, }); - billingContext.dryRunStripe = preview; logAttachContext({ ctx, billingContext }); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index eae0df69e..c0db24f64 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -38,10 +38,12 @@ import { setupAttachTrialContext } from "./setupAttachTrialContext"; export const setupAttachBillingContext = async ({ ctx, params, + preview = false, contextOverride = {}, }: { ctx: AutumnContext; params: AttachParamsV1; + preview?: boolean; contextOverride?: BillingContextOverride; }): Promise => { const { fullCustomer: fullCustomerOverride } = contextOverride; @@ -194,7 +196,8 @@ export const setupAttachBillingContext = async ({ }); const billingStartsAt = - params.starts_at ?? (planTiming === "end_of_cycle" ? endOfCycleMs : undefined); + params.starts_at ?? + (planTiming === "end_of_cycle" ? endOfCycleMs : undefined); const hasFutureStartDate = isFutureStartDate( params.starts_at, currentEpochMs, @@ -277,6 +280,7 @@ export const setupAttachBillingContext = async ({ externalId: params.subscription_id, skipBillingChanges, + dryRunStripe: preview, anchorResetRefund: setupAnchorResetRefund({ billingCycleAnchor: params.billing_cycle_anchor, diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts index 746bccc85..d8cdde138 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -106,9 +106,11 @@ const setupImmediateMultiProductTrialContext = async ({ export const setupImmediateMultiProductBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: MultiAttachParamsV0; + preview?: boolean; }): Promise => { const fullCustomer = await setupFullCustomerContext({ ctx, @@ -254,5 +256,6 @@ export const setupImmediateMultiProductBillingContext = async ({ successUrl: params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }), checkoutSessionParams: params.checkout_session_params, + dryRunStripe: preview, }; }; diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index d207f5acf..481cfbf9c 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -27,8 +27,8 @@ export const previewCreateScheduleWithContext = async ({ const billingContext = await setupCreateScheduleBillingContext({ ctx, params, + preview: true, }); - billingContext.dryRunStripe = true; await handleCreateScheduleErrors({ db: ctx.db, diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index e71a1c5e5..3d38fea39 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -70,9 +70,11 @@ const setupCreateScheduleCheckoutMode = ({ export const setupCreateScheduleBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: CreateScheduleParamsV0; + preview?: boolean; }): Promise => { const normalizedPhases = normalizeCreateSchedulePhases({ phases: params.phases, @@ -99,6 +101,7 @@ export const setupCreateScheduleBillingContext = async ({ const billingContext = await setupImmediateMultiProductBillingContext({ ctx, params: immediateParams, + preview, }); validateCreateSchedulePhasePlans({ diff --git a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts index 705f9ee2a..1ef79e545 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts @@ -34,8 +34,8 @@ export async function multiAttach({ const billingContext = await setupMultiAttachBillingContext({ ctx, params, + preview, }); - billingContext.dryRunStripe = preview; // 2. Errors await handleMultiAttachErrors({ diff --git a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts index 6f10040d0..664a8b7d7 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts @@ -11,11 +11,14 @@ import { setupImmediateMultiProductBillingContext } from "../../common/immediate export const setupMultiAttachBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: MultiAttachParamsV0; + preview?: boolean; }): Promise => setupImmediateMultiProductBillingContext({ ctx, params, + preview, }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 8e168cd45..1fb1cc82f 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -40,10 +40,12 @@ const FIELDS_WITH_BILLING_CHANGES = [ export const setupUpdateSubscriptionBillingContext = async ({ ctx, params, + preview = false, contextOverride = {}, }: { ctx: AutumnContext; params: UpdateSubscriptionV1Params; + preview?: boolean; contextOverride?: UpdateSubscriptionBillingContextOverride; }): Promise => { const fullCustomer = await setupFullCustomerContext({ @@ -207,6 +209,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ actionSource: "updateSubscription", skipBillingChanges, + dryRunStripe: preview, checkoutMode, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts index 103c8e37f..7ea07a791 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts @@ -43,9 +43,9 @@ export async function updateSubscription({ const billingContext = await setupUpdateSubscriptionBillingContext({ ctx, params, + preview, contextOverride, }); - billingContext.dryRunStripe = preview; logUpdateSubscriptionContext({ ctx, billingContext }); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index 2bd9e4c85..e1ff9b2bb 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -2,16 +2,13 @@ import { type AutumnBillingPlan, type BillingContext, cusProductToProduct, - type FullCusProduct, - type FullProduct, - findCustomerProductById, isFixedPrice, isPrepaidPrice, nullish, type Price, } from "@autumn/shared"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice"; -import { applyPreviewStripeResourcesToProduct } from "@/external/stripe/previewStripeResourceIds"; +import { applyPreviewStripeResourcesToBillingPlan } from "@/external/stripe/previewStripeResourceIds"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { applyCustomerProductPatch, @@ -25,11 +22,6 @@ const shouldInitializeStripePrice = ({ price }: { price: Price }) => { return (price.config.amount ?? 0) > 0; }; -const productNeedsPlanStripeProduct = ({ product }: { product: FullProduct }) => - product.prices.some( - (price) => isFixedPrice(price) && shouldInitializeStripePrice({ price }), - ); - export const initStripeResourcesForBillingPlan = async ({ ctx, autumnBillingPlan, @@ -41,6 +33,14 @@ export const initStripeResourcesForBillingPlan = async ({ }) => { const { db, org, env, logger } = ctx; + if (billingContext.dryRunStripe) { + applyPreviewStripeResourcesToBillingPlan({ + autumnBillingPlan, + billingContext, + }); + return; + } + const { fullCustomer } = billingContext; const { insertCustomerProducts } = autumnBillingPlan; const patchCustomerProducts = getPatchCustomerProducts({ autumnBillingPlan }); @@ -87,65 +87,17 @@ export const initStripeResourcesForBillingPlan = async ({ const allProducts = [...newProducts, ...patchProducts, ...existingProducts]; const internalEntityId = fullCustomer.entity?.internal_id; - if (billingContext.dryRunStripe) { - const applyPreviewStripeResourcesToCustomerProduct = ({ - customerProduct, - }: { - customerProduct: FullCusProduct; - }) => { - const product = cusProductToProduct({ cusProduct: customerProduct }); - applyPreviewStripeResourcesToProduct({ product, internalEntityId }); - customerProduct.product.processor = product.processor ?? null; - }; - - for (const customerProduct of insertCustomerProducts) { - applyPreviewStripeResourcesToCustomerProduct({ - customerProduct, - }); - } - - for (const patchCustomerProduct of patchCustomerProducts) { - const matchingCustomerProduct = - findCustomerProductById({ - fullCustomer, - customerProductId: patchCustomerProduct.customerProduct.id, - }) ?? patchCustomerProduct.customerProduct; - const patchedCustomerProduct = applyCustomerProductPatch({ - customerProduct: matchingCustomerProduct, - patch: patchCustomerProduct, - }); - - applyPreviewStripeResourcesToCustomerProduct({ - customerProduct: patchedCustomerProduct, - }); - - if (matchingCustomerProduct === patchCustomerProduct.customerProduct) { - continue; - } - - applyPreviewStripeResourcesToCustomerProduct({ - customerProduct: applyCustomerProductPatch({ - customerProduct: patchCustomerProduct.customerProduct, - patch: patchCustomerProduct, - }), - }); - } - - for (const customerProduct of fullCustomer.customer_products) { - if (patchedCustomerProductIds.has(customerProduct.id)) continue; - - applyPreviewStripeResourcesToCustomerProduct({ - customerProduct, - }); - } - - return; - } - const batchProductUpdates = []; for (const product of allProducts) { if (product.processor?.id != null) continue; - if (!productNeedsPlanStripeProduct({ product })) continue; + if ( + !product.prices.some( + (price) => + isFixedPrice(price) && shouldInitializeStripePrice({ price }), + ) + ) { + continue; + } batchProductUpdates.push( checkStripeProductExists({ From f6a3e295a4f20e79a62932ffa16659fb67c189eb Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 12:53:06 +0100 Subject: [PATCH 26/36] Use isFreeProduct for plan-Stripe-product check --- .../utils/common/initStripeResourcesForProducts.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index e1ff9b2bb..d6a021a61 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -3,6 +3,7 @@ import { type BillingContext, cusProductToProduct, isFixedPrice, + isFreeProduct, isPrepaidPrice, nullish, type Price, @@ -90,14 +91,7 @@ export const initStripeResourcesForBillingPlan = async ({ const batchProductUpdates = []; for (const product of allProducts) { if (product.processor?.id != null) continue; - if ( - !product.prices.some( - (price) => - isFixedPrice(price) && shouldInitializeStripePrice({ price }), - ) - ) { - continue; - } + if (isFreeProduct({ prices: product.prices })) continue; batchProductUpdates.push( checkStripeProductExists({ From b56239b3bb6b137b571fe28e8ef882218a424132 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 12:55:58 +0100 Subject: [PATCH 27/36] Reuse Stripe resources for repeated plan items --- .../stripe/previewStripeResourceIds.ts | 10 +- .../common/initStripeResourcesForProducts.ts | 39 ++++ .../products/handlers/handleVersionProduct.ts | 2 +- .../productItemUtils/handleNewProductItems.ts | 57 ++++-- ...copyStripeResourcesToMatchingPrice.test.ts | 172 ++++++++++++++++++ .../shared/getPriceStripeReuseLevel.test.ts | 172 ++++++++++++++++++ shared/utils/index.ts | 16 +- .../copyStripeResourcesToMatchingPrice.ts | 106 +++++++++++ .../match/getPriceStripeReuseLevel.ts | 107 +++++++++++ .../isPreviewStripeId.ts | 6 + 10 files changed, 658 insertions(+), 29 deletions(-) create mode 100644 server/tests/unit/shared/copyStripeResourcesToMatchingPrice.test.ts create mode 100644 server/tests/unit/shared/getPriceStripeReuseLevel.test.ts create mode 100644 shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts create mode 100644 shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts create mode 100644 shared/utils/stripeUtils/classifyStripeResource/isPreviewStripeId.ts diff --git a/server/src/external/stripe/previewStripeResourceIds.ts b/server/src/external/stripe/previewStripeResourceIds.ts index 248e282d6..0b3fa7d6f 100644 --- a/server/src/external/stripe/previewStripeResourceIds.ts +++ b/server/src/external/stripe/previewStripeResourceIds.ts @@ -7,7 +7,10 @@ import { findCustomerProductById, InternalError, isPrepaidPrice, + isPreviewStripeId, isUsagePrice, + PREVIEW_STRIPE_PRICE_ID_PREFIX, + PREVIEW_STRIPE_PRODUCT_ID_PREFIX, type Price, ProcessorType, type Product, @@ -19,16 +22,9 @@ import { } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; import { hashJson } from "@/utils/hash/hashJson"; -export const PREVIEW_STRIPE_PRICE_ID_PREFIX = "price_PREVIEW_"; -export const PREVIEW_STRIPE_PRODUCT_ID_PREFIX = "prod_PREVIEW_"; - const previewHash = ({ value }: { value: unknown }) => hashJson({ value }).slice(0, 24); -export const isPreviewStripeId = ({ stripeId }: { stripeId?: string | null }) => - stripeId?.startsWith(PREVIEW_STRIPE_PRICE_ID_PREFIX) === true || - stripeId?.startsWith(PREVIEW_STRIPE_PRODUCT_ID_PREFIX) === true; - export const assertNotPreviewStripeId = ({ stripeId, fieldName, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index d6a021a61..b28802438 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -1,7 +1,9 @@ import { type AutumnBillingPlan, type BillingContext, + copyStripeResourcesToMatchingPrice, cusProductToProduct, + type FullProduct, isFixedPrice, isFreeProduct, isPrepaidPrice, @@ -15,6 +17,7 @@ import { applyCustomerProductPatch, getPatchCustomerProducts, } from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations"; +import { PriceService } from "@/internal/products/prices/PriceService"; import { checkStripeProductExists } from "@/internal/products/productUtils"; const shouldInitializeStripePrice = ({ price }: { price: Price }) => { @@ -23,6 +26,36 @@ const shouldInitializeStripePrice = ({ price }: { price: Price }) => { return (price.config.amount ?? 0) > 0; }; +const applyStripeReuseWithinProduct = async ({ + db, + product, +}: { + db: AutumnContext["db"]; + product: FullProduct; +}) => { + for (const targetPrice of product.prices) { + const candidatePrices = product.prices.filter( + (price) => price.id !== targetPrice.id, + ); + if (candidatePrices.length === 0) continue; + + const { copiedFields } = copyStripeResourcesToMatchingPrice({ + targetPrice, + candidatePrices, + targetEntitlements: product.entitlements, + candidateEntitlements: product.entitlements, + }); + + if (copiedFields.length === 0) continue; + + await PriceService.update({ + db, + id: targetPrice.id, + update: { config: targetPrice.config }, + }); + } +}; + export const initStripeResourcesForBillingPlan = async ({ ctx, autumnBillingPlan, @@ -88,6 +121,12 @@ export const initStripeResourcesForBillingPlan = async ({ const allProducts = [...newProducts, ...patchProducts, ...existingProducts]; const internalEntityId = fullCustomer.entity?.internal_id; + await Promise.all( + allProducts.map((product) => + applyStripeReuseWithinProduct({ db, product }), + ), + ); + const batchProductUpdates = []; for (const product of allProducts) { if (product.processor?.id != null) continue; diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 581f08c5b..a4ce06080 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -98,7 +98,7 @@ export const handleVersionProductV2 = async ({ newItems: newProductV2.items, features, product: newProduct, - logger: console, + logger: ctx.logger, isCustom: false, newVersion: true, }); diff --git a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts index 6fbbe6aba..d831fdfa5 100644 --- a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts @@ -1,5 +1,6 @@ import { type AppEnv, + copyStripeResourcesToMatchingPrice, type Entitlement, type Feature, isFeatureItem, @@ -10,6 +11,7 @@ import { type ProductItem, } from "@autumn/shared"; import type { DrizzleCli } from "@server/db/initDrizzle.js"; +import type { Logger } from "@server/external/logtail/logtailUtils.js"; import { FeatureService } from "@server/internal/features/FeatureService.js"; import { EntitlementService } from "@server/internal/products/entitlements/EntitlementService.js"; import { PriceService } from "@server/internal/products/prices/PriceService.js"; @@ -76,7 +78,7 @@ const updateDbPricesAndEnts = async ({ ids: deletedEntIds, }); } else { - const updateOrDelete: any = []; + const updateOrDelete: Promise[] = []; for (const ent of deletedEnts) { const hasCustomPrice = customPrices.some( (price) => price.entitlement_id === ent.id, @@ -106,8 +108,7 @@ const updateDbPricesAndEnts = async ({ } }; -const handleCustomProductItems = async ({ - db, +const handleCustomProductItems = ({ newPrices, newEnts, updatedPrices, @@ -116,7 +117,6 @@ const handleCustomProductItems = async ({ sameEnts, features, }: { - db: DrizzleCli; newPrices: Price[]; newEnts: Entitlement[]; updatedPrices: Price[]; @@ -124,17 +124,36 @@ const handleCustomProductItems = async ({ samePrices: Price[]; sameEnts: Entitlement[]; features: Feature[]; +}) => ({ + prices: [...newPrices, ...updatedPrices, ...samePrices], + entitlements: [...newEnts, ...updatedEnts, ...sameEnts].map((ent) => ({ + ...ent, + feature: features.find((f) => f.id === ent.feature_id), + })), + customPrices: [...newPrices, ...updatedPrices], + customEnts: [...newEnts, ...updatedEnts], + features, +}); + +const carryForwardStripeResources = ({ + targetPrices, + targetEntitlements, + candidatePrices, + candidateEntitlements, +}: { + targetPrices: Price[]; + targetEntitlements: Entitlement[]; + candidatePrices: Price[]; + candidateEntitlements: Entitlement[]; }) => { - return { - prices: [...newPrices, ...updatedPrices, ...samePrices], - entitlements: [...newEnts, ...updatedEnts, ...sameEnts].map((ent) => ({ - ...ent, - feature: features.find((f) => f.id === ent.feature_id), - })), - customPrices: [...newPrices, ...updatedPrices], - customEnts: [...newEnts, ...updatedEnts], - features, - }; + for (const targetPrice of targetPrices) { + copyStripeResourcesToMatchingPrice({ + targetPrice, + candidatePrices, + targetEntitlements, + candidateEntitlements, + }); + } }; export const handleNewProductItems = async ({ @@ -155,7 +174,7 @@ export const handleNewProductItems = async ({ newItems: ProductItem[]; features: Feature[]; product: Product; - logger: any; + logger: Logger; isCustom: boolean; newVersion?: boolean; saveToDb?: boolean; @@ -249,6 +268,13 @@ export const handleNewProductItems = async ({ } } + carryForwardStripeResources({ + targetPrices: [...newPrices, ...updatedPrices], + targetEntitlements: [...newEnts, ...updatedEnts, ...sameEnts], + candidatePrices: curPrices, + candidateEntitlements: curEnts, + }); + const printLogs = false; if (printLogs) { logPrices({ prices: newPrices, prefix: "New prices" }); @@ -272,7 +298,6 @@ export const handleNewProductItems = async ({ if ((isCustom || newVersion) && saveToDb) { return handleCustomProductItems({ - db, newPrices, newEnts, updatedPrices, diff --git a/server/tests/unit/shared/copyStripeResourcesToMatchingPrice.test.ts b/server/tests/unit/shared/copyStripeResourcesToMatchingPrice.test.ts new file mode 100644 index 000000000..e1c172e26 --- /dev/null +++ b/server/tests/unit/shared/copyStripeResourcesToMatchingPrice.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { + AllowanceType, + AppEnv, + BillingInterval, + BillWhen, + copyStripeResourcesToMatchingPrice, + EntInterval, + type Entitlement, + type Price, + PriceType, + TierInfinite, + type UsagePriceConfig, +} from "@autumn/shared"; + +const orgId = "org_copy"; +const internalProductId = "prod_internal_copy"; +const now = 1_800_000_000_000; + +const baseConfig: UsagePriceConfig = { + type: PriceType.Usage, + bill_when: BillWhen.EndOfPeriod, + billing_units: 1, + should_prorate: false, + internal_feature_id: "feat_internal_ai_credits", + feature_id: "ai_credits", + usage_tiers: [{ amount: 0.1, to: TierInfinite }], + interval: BillingInterval.Month, + interval_count: 1, + stripe_product_id: "prod_ai_credits", + stripe_price_id: "price_ai_credits", + stripe_meter_id: "meter_ai_credits", + stripe_event_name: "ai_credits_used", +}; + +const candidate = (overrides: Partial = {}): Price => ({ + id: "pr_existing", + org_id: orgId, + created_at: now, + internal_product_id: internalProductId, + is_custom: false, + config: { ...baseConfig }, + entitlement_id: "ent_existing", + proration_config: null, + tier_behavior: null, + ...overrides, +}); + +const target = (overrides: Partial = {}): Price => ({ + id: "pr_new", + org_id: orgId, + created_at: now, + internal_product_id: internalProductId, + is_custom: false, + config: { + ...baseConfig, + stripe_product_id: undefined, + stripe_price_id: undefined, + stripe_meter_id: undefined, + stripe_event_name: undefined, + } as UsagePriceConfig, + entitlement_id: "ent_new", + proration_config: null, + tier_behavior: null, + ...overrides, +}); + +const entitlement = (overrides: Partial = {}): Entitlement => ({ + id: "ent_existing", + org_id: orgId, + created_at: now, + is_custom: false, + internal_product_id: internalProductId, + internal_feature_id: baseConfig.internal_feature_id!, + feature_id: baseConfig.feature_id!, + allowance: 100, + allowance_type: AllowanceType.Fixed, + interval: EntInterval.Month, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: undefined, + usage_limit: null, + rollover: null, + ...overrides, +}); + +describe("copyStripeResourcesToMatchingPrice", () => { + test("prefers a full match over a stripeProductOnly match", () => { + const fullCandidate = candidate({ id: "pr_full" }); + const productOnlyCandidate = candidate({ + id: "pr_cheaper", + config: { + ...baseConfig, + usage_tiers: [{ amount: 0.05, to: TierInfinite }], + stripe_product_id: "prod_other_credits", + stripe_price_id: "price_other_credits", + }, + entitlement_id: "ent_existing_cheaper", + }); + const newPrice = target(); + + const result = copyStripeResourcesToMatchingPrice({ + targetPrice: newPrice, + candidatePrices: [productOnlyCandidate, fullCandidate], + targetEntitlements: [entitlement({ id: "ent_new" })], + candidateEntitlements: [ + entitlement(), + entitlement({ + id: "ent_existing_cheaper", + allowance: 200, + }), + ], + }); + + const config = newPrice.config as UsagePriceConfig; + expect(result.copiedFields).toContain("stripe_product_id"); + expect(config.stripe_product_id).toBe("prod_ai_credits"); + expect(config.stripe_price_id).toBe("price_ai_credits"); + expect(config.stripe_meter_id).toBe("meter_ai_credits"); + expect(config.stripe_event_name).toBe("ai_credits_used"); + }); + + test("copies only stripe_product_id from a stripeProductOnly match", () => { + const productOnlyCandidate = candidate({ + id: "pr_cheaper", + config: { + ...baseConfig, + usage_tiers: [{ amount: 0.05, to: TierInfinite }], + stripe_product_id: "prod_other_credits", + stripe_price_id: "price_other_credits", + stripe_meter_id: "meter_other_credits", + }, + }); + const newPrice = target(); + + const result = copyStripeResourcesToMatchingPrice({ + targetPrice: newPrice, + candidatePrices: [productOnlyCandidate], + targetEntitlements: [entitlement({ id: "ent_new" })], + candidateEntitlements: [entitlement()], + }); + + const config = newPrice.config as UsagePriceConfig; + expect(result.copiedFields).toEqual(["stripe_product_id"]); + expect(config.stripe_product_id).toBe("prod_other_credits"); + expect(config.stripe_price_id).toBeUndefined(); + expect(config.stripe_meter_id).toBeUndefined(); + }); + + test("returns no copied fields when nothing matches", () => { + const unrelatedCandidate = candidate({ + id: "pr_unrelated", + config: { + ...baseConfig, + feature_id: "other_feature", + internal_feature_id: "feat_internal_other", + }, + }); + + const newPrice = target(); + const result = copyStripeResourcesToMatchingPrice({ + targetPrice: newPrice, + candidatePrices: [unrelatedCandidate], + targetEntitlements: [entitlement({ id: "ent_new" })], + candidateEntitlements: [entitlement()], + }); + + const config = newPrice.config as UsagePriceConfig; + expect(result.copiedFields).toEqual([]); + expect(config.stripe_product_id).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts b/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts new file mode 100644 index 000000000..b4861bb47 --- /dev/null +++ b/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { + AllowanceType, + AppEnv, + BillingInterval, + BillWhen, + EntInterval, + type Entitlement, + type FixedPriceConfig, + getPriceStripeReuseLevel, + PREVIEW_STRIPE_PRICE_ID_PREFIX, + type Price, + PriceType, + TierInfinite, + type UsagePriceConfig, +} from "@autumn/shared"; + +const orgId = "org_match"; +const internalProductId = "prod_internal_match"; +const now = 1_800_000_000_000; + +const usageConfig: UsagePriceConfig = { + type: PriceType.Usage, + bill_when: BillWhen.EndOfPeriod, + billing_units: 1, + should_prorate: false, + internal_feature_id: "feat_internal_ai_credits", + feature_id: "ai_credits", + usage_tiers: [{ amount: 0.1, to: TierInfinite }], + interval: BillingInterval.Month, + interval_count: 1, + stripe_product_id: "prod_ai_credits", + stripe_price_id: "price_ai_credits", + stripe_meter_id: "meter_ai_credits", + stripe_event_name: "ai_credits_used", +}; + +const usagePrice = (overrides: Partial = {}): Price => ({ + id: "pr_ai_credits", + org_id: orgId, + created_at: now, + internal_product_id: internalProductId, + is_custom: false, + config: { ...usageConfig }, + entitlement_id: "ent_ai_credits", + proration_config: null, + tier_behavior: null, + ...overrides, +}); + +const entitlement = (overrides: Partial = {}): Entitlement => ({ + id: "ent_ai_credits", + org_id: orgId, + created_at: now, + is_custom: false, + internal_product_id: internalProductId, + internal_feature_id: usageConfig.internal_feature_id!, + feature_id: usageConfig.feature_id!, + allowance: 100, + allowance_type: AllowanceType.Fixed, + interval: EntInterval.Month, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: undefined, + usage_limit: null, + rollover: null, + ...overrides, +}); + +describe("getPriceStripeReuseLevel", () => { + test("returns full when configs and paired entitlements match", () => { + const level = getPriceStripeReuseLevel({ + newPrice: usagePrice({ id: "pr_new" }), + candidatePrice: usagePrice(), + newEntitlements: [entitlement()], + candidateEntitlements: [entitlement()], + }); + + expect(level).toBe("full"); + }); + + test("returns stripeProductOnly when config differs but feature scope matches", () => { + const cheaper = usagePrice({ + id: "pr_cheaper", + config: { + ...usageConfig, + usage_tiers: [{ amount: 0.05, to: TierInfinite }], + }, + }); + + const level = getPriceStripeReuseLevel({ + newPrice: cheaper, + candidatePrice: usagePrice(), + newEntitlements: [entitlement()], + candidateEntitlements: [entitlement()], + }); + + expect(level).toBe("stripeProductOnly"); + }); + + test("returns none when entity scope differs", () => { + const level = getPriceStripeReuseLevel({ + newPrice: usagePrice({ + id: "pr_new", + entitlement_id: "ent_per_seat", + }), + candidatePrice: usagePrice(), + newEntitlements: [ + entitlement({ id: "ent_per_seat", entity_feature_id: "seat" }), + ], + candidateEntitlements: [entitlement()], + }); + + expect(level).toBe("none"); + }); + + test("returns none when candidate has preview-only Stripe IDs", () => { + const previewCandidate = usagePrice({ + config: { + ...usageConfig, + stripe_product_id: `${PREVIEW_STRIPE_PRICE_ID_PREFIX}ai_credits`, + }, + }); + + const level = getPriceStripeReuseLevel({ + newPrice: usagePrice({ id: "pr_new" }), + candidatePrice: previewCandidate, + newEntitlements: [entitlement()], + candidateEntitlements: [entitlement()], + }); + + expect(level).toBe("none"); + }); + + test("returns full for matching fixed prices regardless of paired entitlements", () => { + const fixed: Price = { + id: "pr_base", + org_id: orgId, + created_at: now, + internal_product_id: internalProductId, + is_custom: false, + config: { + type: PriceType.Fixed, + amount: 500, + interval: BillingInterval.Month, + interval_count: 1, + stripe_product_id: null, + feature_id: null, + internal_feature_id: null, + stripe_price_id: "price_fixed", + } satisfies FixedPriceConfig, + proration_config: null, + }; + const fixedTarget: Price = { + ...fixed, + id: "pr_base_new", + config: { + ...(fixed.config as FixedPriceConfig), + stripe_price_id: null, + }, + }; + + const level = getPriceStripeReuseLevel({ + newPrice: fixedTarget, + candidatePrice: fixed, + newEntitlements: [], + candidateEntitlements: [], + }); + + expect(level).toBe("full"); + }); +}); diff --git a/shared/utils/index.ts b/shared/utils/index.ts index bcae47dc5..53b5d9970 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -25,13 +25,12 @@ export * from "./cusProductUtils/index"; // Cus utils export * from "./cusUtils/index"; export * from "./expandUtils"; - +export * from "./featureUtils"; // Feature utils export * from "./featureUtils/apiFeatureToDbFeature"; export * from "./featureUtils/convertFeatureUtils"; export * from "./featureUtils/findFeatureUtils"; export * from "./featureUtils/index"; -export * from "./featureUtils"; // INTERVAL UTILS export * from "./intervalUtils/addBillingInterval"; @@ -48,17 +47,24 @@ export * from "./productUtils/entUtils/index"; export * from "./productUtils/freeTrialUtils"; export * from "./productUtils/index"; export * from "./productUtils/isProductUpgrade"; -export * from "./productUtils/priceUtils/index"; export * from "./productUtils/priceUtils"; +export * from "./productUtils/priceUtils/index"; +// Price match utils +export * from "./productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice"; +export * from "./productUtils/priceUtils/match/getPriceStripeReuseLevel"; export * from "./productV2Utils/mapToProductV2"; export * from "./productV2Utils/productItemUtils/classifyItemUtils"; export * from "./productV2Utils/productItemUtils/getItemType"; -export * from "./productV2Utils/productItemUtils/matchPlanItem"; -export * from "./productV2Utils/productItemUtils/sortPlanItems"; // Item utils export * from "./productV2Utils/productItemUtils/mapToItem"; +export * from "./productV2Utils/productItemUtils/matchPlanItem"; export * from "./productV2Utils/productItemUtils/productItemUtils"; +export * from "./productV2Utils/productItemUtils/sortPlanItems"; export * from "./productV2Utils/productV2ToFrontendProduct"; export * from "./productV2Utils/productV2ToV1"; export * from "./productV3Utils/productItemUtils/productV3ItemUtils"; + +// Stripe resource utils +export * from "./stripeUtils/classifyStripeResource/isPreviewStripeId"; + export * from "./utils"; diff --git a/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts new file mode 100644 index 000000000..0677c560c --- /dev/null +++ b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts @@ -0,0 +1,106 @@ +import { type Entitlement, nullish, type Price } from "@autumn/shared"; +import { getPriceStripeReuseLevel } from "./getPriceStripeReuseLevel.js"; + +const stripeResourceFields = [ + "stripe_product_id", + "stripe_price_id", + "stripe_empty_price_id", + "stripe_placeholder_price_id", + "stripe_prepaid_price_v2_id", + "stripe_meter_id", + "stripe_event_name", +] as const; + +export type StripeResourceField = (typeof stripeResourceFields)[number]; + +type StripeResourceConfig = Partial>; + +const copyField = ({ + fromConfig, + toConfig, + field, +}: { + fromConfig: StripeResourceConfig; + toConfig: StripeResourceConfig; + field: StripeResourceField; +}) => { + if (!nullish(toConfig[field])) return false; + if (nullish(fromConfig[field])) return false; + + toConfig[field] = fromConfig[field]; + return true; +}; + +/** + * Copy Stripe resource IDs (and `stripe_event_name`) from the best-matching + * candidate price onto `targetPrice.config`, in place. Returns the fields that + * were copied. + * + * Two-pass search: a "full" match (identical config + paired entitlement) is + * preferred over a "stripeProductOnly" match (same feature/entity scope). For + * "full" matches every Stripe resource field is copied; for "stripeProductOnly" + * only `stripe_product_id` is copied so a fresh price is later minted under the + * existing per-feature Stripe product. + */ +export const copyStripeResourcesToMatchingPrice = ({ + targetPrice, + candidatePrices, + targetEntitlements, + candidateEntitlements, +}: { + targetPrice: Price; + candidatePrices: Price[]; + targetEntitlements: Entitlement[]; + candidateEntitlements: Entitlement[]; +}): { copiedFields: StripeResourceField[] } => { + const levels = candidatePrices.map((candidatePrice) => ({ + candidatePrice, + level: getPriceStripeReuseLevel({ + newPrice: targetPrice, + candidatePrice, + newEntitlements: targetEntitlements, + candidateEntitlements, + }), + })); + + const fullMatch = levels.find((entry) => entry.level === "full"); + const productOnlyMatch = + fullMatch ?? levels.find((entry) => entry.level === "stripeProductOnly"); + + const productSource = productOnlyMatch?.candidatePrice; + const fullSource = fullMatch?.candidatePrice; + + const targetConfig = targetPrice.config as StripeResourceConfig; + const copiedFields: StripeResourceField[] = []; + + if (productSource) { + const fromConfig = productSource.config as StripeResourceConfig; + if ( + copyField({ + fromConfig, + toConfig: targetConfig, + field: "stripe_product_id", + }) + ) { + copiedFields.push("stripe_product_id"); + } + } + + if (fullSource) { + const fromConfig = fullSource.config as StripeResourceConfig; + for (const field of stripeResourceFields) { + if (field === "stripe_product_id") continue; + if ( + copyField({ + fromConfig, + toConfig: targetConfig, + field, + }) + ) { + copiedFields.push(field); + } + } + } + + return { copiedFields }; +}; diff --git a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts new file mode 100644 index 000000000..0c04b351e --- /dev/null +++ b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts @@ -0,0 +1,107 @@ +import { + type Entitlement, + entsAreSame, + isPreviewStripeId, + type Price, + PriceType, + pricesAreSame, + type UsagePriceConfig, +} from "@autumn/shared"; + +export type PriceStripeReuseLevel = "full" | "stripeProductOnly" | "none"; + +const stripeResourceFields = [ + "stripe_product_id", + "stripe_price_id", + "stripe_empty_price_id", + "stripe_placeholder_price_id", + "stripe_prepaid_price_v2_id", + "stripe_meter_id", + "stripe_event_name", +] as const; + +const priceHasPreviewStripeId = ({ price }: { price: Price }) => { + const config = price.config as Partial< + Record<(typeof stripeResourceFields)[number], string | null> + >; + return stripeResourceFields.some((field) => + isPreviewStripeId({ stripeId: config[field] }), + ); +}; + +const findPairedEntitlement = ({ + price, + entitlements, +}: { + price: Price; + entitlements: Entitlement[]; +}) => + price.entitlement_id + ? entitlements.find( + (entitlement) => entitlement.id === price.entitlement_id, + ) + : undefined; + +/** + * Classify how much of the Stripe resource set on `candidatePrice` can be + * carried forward onto `newPrice` when the two represent the same logical + * Autumn price across a plan-update or version transition. + * + * - "full" — config + paired entitlement are identical; reuse all stripe_*_id + stripe_event_name fields. + * - "stripeProductOnly" — same (feature_id, entity_feature_id) usage scope; reuse just stripe_product_id so a new price is created under the existing plan-feature Stripe product. + * - "none" — no reuse, or candidate is preview-only. + */ +export const getPriceStripeReuseLevel = ({ + newPrice, + candidatePrice, + newEntitlements, + candidateEntitlements, +}: { + newPrice: Price; + candidatePrice: Price; + newEntitlements: Entitlement[]; + candidateEntitlements: Entitlement[]; +}): PriceStripeReuseLevel => { + if (priceHasPreviewStripeId({ price: candidatePrice })) return "none"; + if (newPrice.config?.type !== candidatePrice.config?.type) return "none"; + + if (pricesAreSame(candidatePrice, newPrice, false)) { + if (newPrice.config?.type !== PriceType.Usage) return "full"; + + const newEnt = findPairedEntitlement({ + price: newPrice, + entitlements: newEntitlements, + }); + const candidateEnt = findPairedEntitlement({ + price: candidatePrice, + entitlements: candidateEntitlements, + }); + + if (!newEnt || !candidateEnt) return "none"; + if (entsAreSame(candidateEnt, newEnt)) return "full"; + } + + if (newPrice.config?.type !== PriceType.Usage) return "none"; + + const newUsageConfig = newPrice.config as UsagePriceConfig; + const candidateUsageConfig = candidatePrice.config as UsagePriceConfig; + if (newUsageConfig.feature_id !== candidateUsageConfig.feature_id) { + return "none"; + } + + const newEnt = findPairedEntitlement({ + price: newPrice, + entitlements: newEntitlements, + }); + const candidateEnt = findPairedEntitlement({ + price: candidatePrice, + entitlements: candidateEntitlements, + }); + + if (!newEnt || !candidateEnt) return "none"; + if (newEnt.entity_feature_id !== candidateEnt.entity_feature_id) { + return "none"; + } + + return "stripeProductOnly"; +}; diff --git a/shared/utils/stripeUtils/classifyStripeResource/isPreviewStripeId.ts b/shared/utils/stripeUtils/classifyStripeResource/isPreviewStripeId.ts new file mode 100644 index 000000000..8f5034573 --- /dev/null +++ b/shared/utils/stripeUtils/classifyStripeResource/isPreviewStripeId.ts @@ -0,0 +1,6 @@ +export const PREVIEW_STRIPE_PRICE_ID_PREFIX = "price_PREVIEW_"; +export const PREVIEW_STRIPE_PRODUCT_ID_PREFIX = "prod_PREVIEW_"; + +export const isPreviewStripeId = ({ stripeId }: { stripeId?: string | null }) => + stripeId?.startsWith(PREVIEW_STRIPE_PRICE_ID_PREFIX) === true || + stripeId?.startsWith(PREVIEW_STRIPE_PRODUCT_ID_PREFIX) === true; From 9f346dca0633f8dbd7b9d6327fdb19095a7cf89a Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 17:01:46 +0100 Subject: [PATCH 28/36] Treat both-null entitlements as same scope in stripe-reuse matcher --- .../shared/getPriceStripeReuseLevel.test.ts | 35 +++++++++++++++++++ .../match/getPriceStripeReuseLevel.ts | 3 ++ 2 files changed, 38 insertions(+) diff --git a/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts b/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts index b4861bb47..745543b75 100644 --- a/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts +++ b/server/tests/unit/shared/getPriceStripeReuseLevel.test.ts @@ -132,6 +132,41 @@ describe("getPriceStripeReuseLevel", () => { expect(level).toBe("none"); }); + test("returns full when both usage prices share configs and lack paired entitlements", () => { + const orphan = usagePrice({ id: "pr_orphan", entitlement_id: null }); + const orphanNew = usagePrice({ id: "pr_orphan_new", entitlement_id: null }); + + const level = getPriceStripeReuseLevel({ + newPrice: orphanNew, + candidatePrice: orphan, + newEntitlements: [], + candidateEntitlements: [], + }); + + expect(level).toBe("full"); + }); + + test("returns stripeProductOnly when both usage prices lack entitlements but configs differ", () => { + const cheaper = usagePrice({ + id: "pr_orphan_cheaper", + entitlement_id: null, + config: { + ...usageConfig, + usage_tiers: [{ amount: 0.05, to: TierInfinite }], + }, + }); + const original = usagePrice({ id: "pr_orphan", entitlement_id: null }); + + const level = getPriceStripeReuseLevel({ + newPrice: cheaper, + candidatePrice: original, + newEntitlements: [], + candidateEntitlements: [], + }); + + expect(level).toBe("stripeProductOnly"); + }); + test("returns full for matching fixed prices regardless of paired entitlements", () => { const fixed: Price = { id: "pr_base", diff --git a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts index 0c04b351e..bdd014d29 100644 --- a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts +++ b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts @@ -77,6 +77,8 @@ export const getPriceStripeReuseLevel = ({ entitlements: candidateEntitlements, }); + // Both null = same (null) entity scope; both have ents = compare them. + if (!newEnt && !candidateEnt) return "full"; if (!newEnt || !candidateEnt) return "none"; if (entsAreSame(candidateEnt, newEnt)) return "full"; } @@ -98,6 +100,7 @@ export const getPriceStripeReuseLevel = ({ entitlements: candidateEntitlements, }); + if (!newEnt && !candidateEnt) return "stripeProductOnly"; if (!newEnt || !candidateEnt) return "none"; if (newEnt.entity_feature_id !== candidateEnt.entity_feature_id) { return "none"; From f46d3afcaa49114609b175ca2bf1fc2693ca9d50 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 17:11:19 +0100 Subject: [PATCH 29/36] Tighten stripe-reuse matcher: both-null ent scope + narrow return type --- .../priceUtils/match/getPriceStripeReuseLevel.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts index bdd014d29..6d8b8b9e3 100644 --- a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts +++ b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts @@ -35,12 +35,20 @@ const findPairedEntitlement = ({ }: { price: Price; entitlements: Entitlement[]; +<<<<<<< Updated upstream }) => price.entitlement_id ? entitlements.find( (entitlement) => entitlement.id === price.entitlement_id, ) : undefined; +======= +}): Entitlement | undefined => + priceToEnt({ + price, + entitlements: entitlements as EntitlementWithFeature[], + }); +>>>>>>> Stashed changes /** * Classify how much of the Stripe resource set on `candidatePrice` can be From 73ca939f7c121ef84b2ad619d7f6f720a4e129dc Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 17:31:36 +0100 Subject: [PATCH 30/36] Fix merge-marker leftovers in stripe-reuse matcher --- .../priceUtils/match/getPriceStripeReuseLevel.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts index 6d8b8b9e3..667fc17a2 100644 --- a/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts +++ b/shared/utils/productUtils/priceUtils/match/getPriceStripeReuseLevel.ts @@ -1,10 +1,12 @@ import { type Entitlement, + type EntitlementWithFeature, entsAreSame, isPreviewStripeId, type Price, PriceType, pricesAreSame, + priceToEnt, type UsagePriceConfig, } from "@autumn/shared"; @@ -35,20 +37,11 @@ const findPairedEntitlement = ({ }: { price: Price; entitlements: Entitlement[]; -<<<<<<< Updated upstream -}) => - price.entitlement_id - ? entitlements.find( - (entitlement) => entitlement.id === price.entitlement_id, - ) - : undefined; -======= }): Entitlement | undefined => priceToEnt({ price, entitlements: entitlements as EntitlementWithFeature[], }); ->>>>>>> Stashed changes /** * Classify how much of the Stripe resource set on `candidatePrice` can be From 9be41e26dd0cf7412634df899419f7f6458c72e2 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 12:53:18 +0100 Subject: [PATCH 31/36] Add versioning Stripe-ID carry-forward test --- ...andle-new-product-items-versioning.test.ts | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 server/tests/unit/products/handle-new-product-items-versioning.test.ts diff --git a/server/tests/unit/products/handle-new-product-items-versioning.test.ts b/server/tests/unit/products/handle-new-product-items-versioning.test.ts new file mode 100644 index 000000000..c0f3f7013 --- /dev/null +++ b/server/tests/unit/products/handle-new-product-items-versioning.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { + AllowanceType, + AppEnv, + BillingInterval, + BillWhen, + EntInterval, + type Entitlement, + type Feature, + FeatureType, + FeatureUsageType, + type FixedPriceConfig, + type Price, + PriceType, + type Product, + type ProductItem, + ProductItemInterval, + TierInfinite, + type UsagePriceConfig, +} from "@autumn/shared"; + +const insertCalls: { + features: unknown[][]; + prices: unknown[][]; + ents: unknown[][]; +} = { + features: [], + prices: [], + ents: [], +}; + +mock.module("@server/internal/features/FeatureService", () => ({ + FeatureService: { + insert: async ({ data }: { data: unknown[] }) => { + insertCalls.features.push(data); + }, + }, +})); + +mock.module( + "@server/internal/products/entitlements/EntitlementService", + () => ({ + EntitlementService: { + insert: async ({ data }: { data: unknown[] }) => { + insertCalls.ents.push(data); + }, + upsert: async () => {}, + deleteInIds: async () => {}, + update: async () => {}, + }, + }), +); + +mock.module("@server/internal/products/prices/PriceService", () => ({ + PriceService: { + insert: async ({ data }: { data: unknown[] }) => { + insertCalls.prices.push(data); + }, + upsert: async () => {}, + deleteInIds: async () => {}, + getCustomInEntIds: async () => [], + }, +})); + +import type { DrizzleCli } from "@server/db/initDrizzle"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems"; + +const orgId = "org_versioning"; +const previousProductInternalId = "prod_internal_v1"; +const newProductInternalId = "prod_internal_v2"; +const now = 1_800_000_000_000; + +const feature: Feature = { + internal_id: "feat_internal_ai_credits", + id: "ai_credits", + name: "AI Credits", + type: FeatureType.Metered, + config: { usage_type: FeatureUsageType.Single }, + org_id: orgId, + env: AppEnv.Sandbox, + created_at: now, + archived: false, + event_names: [], +}; + +const previousEntitlement: Entitlement = { + id: "ent_v1", + org_id: orgId, + created_at: now, + is_custom: false, + internal_product_id: previousProductInternalId, + internal_feature_id: feature.internal_id, + feature_id: feature.id, + allowance: 100, + allowance_type: AllowanceType.Fixed, + interval: EntInterval.Month, + interval_count: 1, + carry_from_previous: false, + entity_feature_id: undefined, + usage_limit: null, + rollover: null, +}; + +const previousUsageConfig: UsagePriceConfig = { + type: PriceType.Usage, + bill_when: BillWhen.EndOfPeriod, + billing_units: 1, + should_prorate: false, + internal_feature_id: feature.internal_id, + feature_id: feature.id, + usage_tiers: [{ amount: 0.1, to: TierInfinite }], + interval: BillingInterval.Month, + interval_count: 1, + stripe_product_id: "prod_ai_credits", + stripe_price_id: "price_ai_credits", + stripe_meter_id: "meter_ai_credits", + stripe_event_name: "ai_credits_used", + stripe_empty_price_id: "price_ai_credits_empty", +}; + +const previousUsagePrice: Price = { + id: "pr_v1", + org_id: orgId, + created_at: now, + internal_product_id: previousProductInternalId, + is_custom: false, + config: previousUsageConfig, + entitlement_id: previousEntitlement.id, + proration_config: null, + tier_behavior: null, +}; + +const previousFixedConfig: FixedPriceConfig = { + type: PriceType.Fixed, + amount: 500, + interval: BillingInterval.Month, + interval_count: 1, + stripe_product_id: null, + feature_id: null, + internal_feature_id: null, + stripe_price_id: "price_base_v1", +}; + +const previousFixedPrice: Price = { + id: "pr_fixed_v1", + org_id: orgId, + created_at: now, + internal_product_id: previousProductInternalId, + is_custom: false, + config: previousFixedConfig, + proration_config: null, +}; + +const newProduct: Product = { + id: "enterprise", + name: "Enterprise", + description: null, + is_add_on: false, + is_default: false, + version: 2, + group: "", + env: AppEnv.Sandbox, + internal_id: newProductInternalId, + org_id: orgId, + created_at: now, + processor: null, + base_variant_id: null, + archived: false, + config: { ignore_past_due: false }, +}; + +const baseItem: ProductItem = { + price: 500, + interval: ProductItemInterval.Month, + interval_count: 1, + price_id: previousFixedPrice.id, +}; + +const aiCreditsItem: ProductItem = { + feature_id: feature.id, + included_usage: 100, + price: 0.1, + interval: ProductItemInterval.Month, + interval_count: 1, + usage_model: "pay_per_use" as ProductItem["usage_model"], + billing_units: 1, + reset_usage_when_enabled: true, + price_id: previousUsagePrice.id, + entitlement_id: previousEntitlement.id, +}; + +const noopLogger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + child: () => noopLogger, +} as never; + +describe("handleNewProductItems versioning carries forward Stripe IDs", () => { + beforeEach(() => { + insertCalls.features = []; + insertCalls.prices = []; + insertCalls.ents = []; + }); + + test("copies every Stripe resource field onto the new version when config matches", async () => { + const result = await handleNewProductItems({ + db: {} as DrizzleCli, + curPrices: [previousFixedPrice, previousUsagePrice], + curEnts: [previousEntitlement], + newItems: [baseItem, aiCreditsItem], + features: [feature], + product: newProduct, + logger: noopLogger, + isCustom: false, + newVersion: true, + saveToDb: false, + }); + + const fixedNew = result.prices.find( + (price) => price.config.type === PriceType.Fixed, + ); + const usageNew = result.prices.find( + (price) => price.config.type === PriceType.Usage, + ); + + expect(fixedNew).toBeDefined(); + expect(usageNew).toBeDefined(); + expect((fixedNew?.config as FixedPriceConfig).stripe_price_id).toBe( + "price_base_v1", + ); + const usageConfig = usageNew?.config as UsagePriceConfig; + expect(usageConfig.stripe_product_id).toBe("prod_ai_credits"); + expect(usageConfig.stripe_price_id).toBe("price_ai_credits"); + expect(usageConfig.stripe_meter_id).toBe("meter_ai_credits"); + expect(usageConfig.stripe_event_name).toBe("ai_credits_used"); + expect(usageConfig.stripe_empty_price_id).toBe("price_ai_credits_empty"); + }); + + test("falls back to stripe_product_id only when the usage tier changes", async () => { + const changedAiCreditsItem: ProductItem = { + ...aiCreditsItem, + price: 0.2, + }; + + const result = await handleNewProductItems({ + db: {} as DrizzleCli, + curPrices: [previousUsagePrice], + curEnts: [previousEntitlement], + newItems: [changedAiCreditsItem], + features: [feature], + product: newProduct, + logger: noopLogger, + isCustom: false, + newVersion: true, + saveToDb: false, + }); + + const usageNew = result.prices.find( + (price) => price.config.type === PriceType.Usage, + ); + expect(usageNew).toBeDefined(); + const usageConfig = usageNew?.config as UsagePriceConfig; + expect(usageConfig.stripe_product_id).toBe("prod_ai_credits"); + expect(usageConfig.stripe_price_id).toBeUndefined(); + expect(usageConfig.stripe_meter_id).toBe("meter_ai_credits"); + expect(usageConfig.stripe_empty_price_id).toBeUndefined(); + }); +}); From 292db09bdad30465455498d0afb4b6503057d413 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Tue, 12 May 2026 15:13:50 +0100 Subject: [PATCH 32/36] Drop unnecessary service mocks from versioning test --- ...andle-new-product-items-versioning.test.ts | 52 +------------------ 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/server/tests/unit/products/handle-new-product-items-versioning.test.ts b/server/tests/unit/products/handle-new-product-items-versioning.test.ts index c0f3f7013..37a236b17 100644 --- a/server/tests/unit/products/handle-new-product-items-versioning.test.ts +++ b/server/tests/unit/products/handle-new-product-items-versioning.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { AllowanceType, AppEnv, @@ -18,50 +18,6 @@ import { TierInfinite, type UsagePriceConfig, } from "@autumn/shared"; - -const insertCalls: { - features: unknown[][]; - prices: unknown[][]; - ents: unknown[][]; -} = { - features: [], - prices: [], - ents: [], -}; - -mock.module("@server/internal/features/FeatureService", () => ({ - FeatureService: { - insert: async ({ data }: { data: unknown[] }) => { - insertCalls.features.push(data); - }, - }, -})); - -mock.module( - "@server/internal/products/entitlements/EntitlementService", - () => ({ - EntitlementService: { - insert: async ({ data }: { data: unknown[] }) => { - insertCalls.ents.push(data); - }, - upsert: async () => {}, - deleteInIds: async () => {}, - update: async () => {}, - }, - }), -); - -mock.module("@server/internal/products/prices/PriceService", () => ({ - PriceService: { - insert: async ({ data }: { data: unknown[] }) => { - insertCalls.prices.push(data); - }, - upsert: async () => {}, - deleteInIds: async () => {}, - getCustomInEntIds: async () => [], - }, -})); - import type { DrizzleCli } from "@server/db/initDrizzle"; import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems"; @@ -198,12 +154,6 @@ const noopLogger = { } as never; describe("handleNewProductItems versioning carries forward Stripe IDs", () => { - beforeEach(() => { - insertCalls.features = []; - insertCalls.prices = []; - insertCalls.ents = []; - }); - test("copies every Stripe resource field onto the new version when config matches", async () => { const result = await handleNewProductItems({ db: {} as DrizzleCli, From f004b1854514c6cdd39f4f3a04f46d72cf5477bc Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 13 May 2026 15:32:40 +0800 Subject: [PATCH 33/36] o integration tests Pleae enter the commit message for your changes. Lines starting :wq --- server/tests/utils/fixtures/itemsV2.ts | 60 +++++++++++++++++++ server/tests/utils/productUtils.ts | 10 +++- .../tests/utils/testInitUtils/initScenario.ts | 5 ++ .../api/products/crud/createPlanParamsV1.ts | 4 ++ .../crud/mappers/planParamsV1ToProductV2.ts | 6 ++ shared/api/products/productOpModels.ts | 4 ++ 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/server/tests/utils/fixtures/itemsV2.ts b/server/tests/utils/fixtures/itemsV2.ts index 696cf48df..493e7900c 100644 --- a/server/tests/utils/fixtures/itemsV2.ts +++ b/server/tests/utils/fixtures/itemsV2.ts @@ -122,6 +122,63 @@ const allocatedUsers = ({ }, }); +const allocatedWorkflows = ({ + amount = 10, + included = 0, +}: { + amount?: number; + included?: number; +} = {}) => ({ + feature_id: TestFeature.Workflows, + included, + price: { + amount, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + proration: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +const prepaidUsers = ({ + amount = 10, + billingUnits = 1, + included = 0, +}: { + amount?: number; + billingUnits?: number; + included?: number; +} = {}) => ({ + feature_id: TestFeature.Users, + included, + price: { + amount, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + billing_units: billingUnits, + }, +}); + +const consumableWords = ({ + amount = 0.05, + included = 0, +}: { + amount?: number; + included?: number; +} = {}) => ({ + feature_id: TestFeature.Words, + included, + price: { + amount, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, +}); + /** * Tiered prepaid messages - tier `to` values INCLUDE the included amount. * Default: included=100, tiers=[{to:600, amount:10}, {to:"inf", amount:5}] @@ -184,9 +241,12 @@ export const itemsV2 = { monthlyWords, dashboard, prepaidMessages, + prepaidUsers, prepaidWords, consumableMessages, + consumableWords, allocatedUsers, + allocatedWorkflows, tieredPrepaidMessages, volumePrepaidMessages, } as const; diff --git a/server/tests/utils/productUtils.ts b/server/tests/utils/productUtils.ts index 914e9351c..cbbc4e28d 100644 --- a/server/tests/utils/productUtils.ts +++ b/server/tests/utils/productUtils.ts @@ -15,6 +15,7 @@ export const createProduct = async ({ autumn, product, prefix, + createInStripe, }: { db: DrizzleCli; orgId: string; @@ -22,6 +23,7 @@ export const createProduct = async ({ autumn: AutumnInt; product: any; prefix?: string; + createInStripe?: boolean; }) => { try { const products = await ProductService.listFull({ @@ -60,6 +62,10 @@ export const createProduct = async ({ clone.name = `${clone.name} ${prefix}`; } + if (createInStripe === false) { + clone.create_in_stripe = false; + } + try { await autumn.products.create(clone); } catch (error: any) { @@ -83,6 +89,7 @@ export const createProducts = async ({ products, prefix, customerId, + createInStripe, }: { db: DrizzleCli; orgId: string; @@ -91,6 +98,7 @@ export const createProducts = async ({ products: any[]; prefix?: string; customerId?: string; + createInStripe?: boolean; }) => { if (customerId) { try { @@ -101,7 +109,7 @@ export const createProducts = async ({ const batchCreate = []; for (const product of products) { batchCreate.push( - createProduct({ db, orgId, env, autumn, product, prefix }), + createProduct({ db, orgId, env, autumn, product, prefix, createInStripe }), ); } diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index f2cb87e8f..41fb656f0 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -242,6 +242,7 @@ type ScenarioConfig = { stripeCustomerOverrides?: Partial; products: ProductV2[]; productPrefix?: string; + productCreateInStripe?: boolean; entityConfig?: EntityConfig; customerIds?: string[]; cleanup: CleanupConfig; @@ -326,16 +327,19 @@ const products = ({ list, prefix, customerIdsToDelete, + createInStripe, }: { list: ProductV2[]; prefix?: string; customerIdsToDelete?: string[]; + createInStripe?: boolean; }): ConfigFn => { return (config) => ({ ...config, products: list, productPrefix: prefix, customerIds: customerIdsToDelete, + productCreateInStripe: createInStripe, }); }; @@ -1104,6 +1108,7 @@ export async function initScenario({ products: config.products, prefix: productPrefix, customerIds: allCustomerIds, + createInStripe: config.productCreateInStripe, }); } diff --git a/shared/api/products/crud/createPlanParamsV1.ts b/shared/api/products/crud/createPlanParamsV1.ts index f97a578b4..258b40bf7 100644 --- a/shared/api/products/crud/createPlanParamsV1.ts +++ b/shared/api/products/crud/createPlanParamsV1.ts @@ -46,6 +46,10 @@ export const CreatePlanParamsV1Schema = z.object({ config: ProductConfigParamsSchema.optional().meta({ description: "Miscellaneous plan-level configuration flags.", }), + + create_in_stripe: z.boolean().default(true).meta({ + internal: true, + }), }); export const CreatePlanParamsV2Schema = z diff --git a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts index 57da6a97c..9dddd0f9c 100644 --- a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts +++ b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts @@ -44,6 +44,11 @@ export function planParamsV1ToProductV2({ ? params.config : undefined; + const createInStripe = + "create_in_stripe" in params && params.create_in_stripe !== undefined + ? params.create_in_stripe + : undefined; + return { id: params.id, // fallback just for placeholders... name: params.name, @@ -63,5 +68,6 @@ export function planParamsV1ToProductV2({ : params.free_trial, ...(archived !== undefined && { archived }), ...(config !== undefined && { config }), + ...(createInStripe !== undefined && { create_in_stripe: createInStripe }), }; } diff --git a/shared/api/products/productOpModels.ts b/shared/api/products/productOpModels.ts index faab01772..dd69394d2 100644 --- a/shared/api/products/productOpModels.ts +++ b/shared/api/products/productOpModels.ts @@ -99,6 +99,10 @@ export const CreateProductV2ParamsSchema = z config: ProductConfigParamsSchema.optional().meta({ description: "Miscellaneous product-level configuration flags.", }), + + create_in_stripe: z.boolean().optional().meta({ + internal: true, + }), }) .meta({ examples: [CREATE_PRODUCT_EXAMPLE], From f8ecfd354e14b3fa0112bdc7ee8859bf85e4c6db Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 13 May 2026 15:33:22 +0800 Subject: [PATCH 34/36] added integration tests --- .../internal/product/actions/createProduct.ts | 10 +- .../handleCreateProduct/handleCreatePlan.ts | 10 +- .../scriptUtils/testUtils/initProductsV0.ts | 3 + .../misc/preview-no-stripe-resources.test.ts | 209 ++++++ .../reuse-stripe-prices-custom-plan.test.ts | 607 ++++++++++++++++++ .../reuse-stripe-prices-versioning.test.ts | 270 ++++++++ .../misc/utils/expectNoStripeResources.ts | 70 ++ .../misc/utils/findCatalogAndCustomPrices.ts | 181 ++++++ shared/utils/index.ts | 1 + .../entUtils/compareEnt/entsAreSame.ts | 2 +- .../copyStripeResourcesToMatchingPrice.ts | 1 + .../match/priceStripeObjectsMatch.ts | 69 ++ .../shared/admin/AdminPlanIdsTooltip.tsx | 61 +- 13 files changed, 1441 insertions(+), 53 deletions(-) create mode 100644 server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts create mode 100644 server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts create mode 100644 server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts create mode 100644 server/tests/integration/billing/misc/utils/expectNoStripeResources.ts create mode 100644 server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts create mode 100644 shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts diff --git a/server/src/internal/product/actions/createProduct.ts b/server/src/internal/product/actions/createProduct.ts index 943749a49..2fe40c00b 100644 --- a/server/src/internal/product/actions/createProduct.ts +++ b/server/src/internal/product/actions/createProduct.ts @@ -105,10 +105,12 @@ export const createProduct = async ({ free_trial: newFreeTrial, }; - await initProductInStripe({ - ctx, - product: newFullProduct, - }); + if (data.create_in_stripe !== false) { + await initProductInStripe({ + ctx, + product: newFullProduct, + }); + } await addTaskToQueue({ jobName: JobName.DetectBaseVariant, diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts index f6ead34c8..674ef69fb 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts @@ -131,10 +131,12 @@ export const handleCreatePlan = createRoute({ free_trial: newFreeTrial, }; - await initProductInStripe({ - ctx, - product: newFullProduct, - }); + if (v1_2Body.create_in_stripe !== false) { + await initProductInStripe({ + ctx, + product: newFullProduct, + }); + } await addTaskToQueue({ jobName: JobName.DetectBaseVariant, diff --git a/server/src/utils/scriptUtils/testUtils/initProductsV0.ts b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts index 2d07507f9..f9fb23fd0 100644 --- a/server/src/utils/scriptUtils/testUtils/initProductsV0.ts +++ b/server/src/utils/scriptUtils/testUtils/initProductsV0.ts @@ -11,6 +11,7 @@ export const initProductsV0 = async ({ skipPrefixIds = [], customerId, customerIds, + createInStripe, }: { ctx: TestContext; products: ProductV2[]; @@ -18,6 +19,7 @@ export const initProductsV0 = async ({ skipPrefixIds?: string[]; customerId?: string; customerIds?: string[]; + createInStripe?: boolean; }) => { // 1. Add prefix to products (except those in skipPrefixIds) if (prefix) { @@ -55,5 +57,6 @@ export const initProductsV0 = async ({ env: ctx.env, autumn: autumn, products, + createInStripe, }); }; diff --git a/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts b/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts new file mode 100644 index 000000000..11a02035e --- /dev/null +++ b/server/tests/integration/billing/misc/preview-no-stripe-resources.test.ts @@ -0,0 +1,209 @@ +/** + * Preview billing actions must not create Stripe resources. + * + * Contract under test: + * - A plan created with create_in_stripe=false stays Stripe-less after + * these preview endpoints run: + * * POST /billing.preview_attach + * * POST /billing.preview_create_schedule (multi-phase) + * * POST /billing.preview_update (with customize) + * - No is_custom prices with Stripe IDs are persisted by customize + * previews. + * - Item coverage: monthlyMessages (metered), prepaidUsers, + * consumableWords, allocatedWorkflows. + * + * Implementation surface: + * server/src/internal/billing/v2/providers/stripe/utils/common/ + * initStripeResourcesForProducts.ts — early returns via + * applyPreviewStripeResourcesToBillingPlan when dryRunStripe=true. + */ + +import { expect, test } from "bun:test"; +import type { + AttachPreviewResponse, + CreateScheduleParamsV0Input, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { + expectNoCustomStripePrices, + expectNoStripeResources, +} from "@tests/integration/billing/misc/utils/expectNoStripeResources"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { ProductService } from "@/internal/products/ProductService"; + +const buildItems = () => [ + items.monthlyMessages({ includedUsage: 100 }), + items.prepaidUsers({ billingUnits: 1 }), + items.consumableWords({ includedUsage: 0 }), + items.allocatedWorkflows({ includedUsage: 0 }), +]; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: previewAttach against a plan created with create_in_stripe=false +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright( + "previewAttach on no-stripe plan: preview totals correct, no Stripe IDs created", +)}`, async () => { + const customerId = "preview-no-stripe-attach"; + + const proPlan = products.pro({ + id: "pro-no-stripe-attach", + items: buildItems(), + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan], createInStripe: false }), + ], + actions: [], + }); + + // s.products mutates proPlan.id to include the `_${customerId}` prefix. + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: proPlan.id, + })) as AttachPreviewResponse; + + expect(preview.subtotal).toBe(20); + expect(preview.total).toBe(20); + expect(preview.currency.toLowerCase()).toBe("usd"); + + await expectNoStripeResources({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + productId: proPlan.id, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: preview_create_schedule with multiple phases on no-stripe plans +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright( + "preview_create_schedule multi-phase on no-stripe plans: preview works, no Stripe IDs created", +)}`, async () => { + const customerId = "preview-no-stripe-schedule"; + + const proPlan = products.pro({ + id: "pro-no-stripe-schedule", + items: buildItems(), + }); + const premiumPlan = products.premium({ + id: "premium-no-stripe-schedule", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, advancedTo, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proPlan, premiumPlan], createInStripe: false }), + ], + actions: [], + }); + + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: advancedTo, + plans: [ + { + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 35 }), + }, + }, + ], + }, + { + starts_at: advancedTo + 30 * 24 * 60 * 60 * 1000, + plans: [{ plan_id: premiumPlan.id }], + }, + ], + }; + + const preview = (await autumnV1.post( + "/billing.preview_create_schedule", + params, + )) as AttachPreviewResponse; + + expect(preview.total).toBe(35); + expect(preview.subtotal).toBe(35); + + await expectNoStripeResources({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + productId: proPlan.id, + }); + await expectNoStripeResources({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + productId: premiumPlan.id, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: previewUpdate with customize on an active no-stripe sub. The customer +// is already on a no-stripe plan attached via /billing.preview_attach + +// /billing.preview_update is impossible — instead we attach the no-stripe plan +// via attach which would normally create Stripe resources. To avoid that, we +// rely on the contract that any sub created via real attach on a no-stripe +// plan still preview-cleanly. We attach a stripe-backed plan A so the customer +// has an active sub, then previewUpdate with customize.items shapes drawn from +// every payable category; the assertion is that no is_custom prices got +// persisted with Stripe IDs by the preview. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright( + "previewUpdate customize on active sub: no is_custom prices with Stripe IDs created", +)}`, async () => { + const customerId = "preview-no-stripe-update"; + + const stripeBackedPlan = products.pro({ + id: "pro-with-stripe-update", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [stripeBackedPlan] }), + ], + actions: [s.billing.attach({ productId: stripeBackedPlan.id })], + }); + + const fullBefore = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: stripeBackedPlan.id, + orgId: ctx.org.id, + env: ctx.env, + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + plan_id: stripeBackedPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 30 }), + }, + }; + + const preview = await autumnV2_2.subscriptions.previewUpdate(params); + expect(typeof preview.total).toBe("number"); + + await expectNoCustomStripePrices({ + db: ctx.db, + internalProductId: fullBefore.internal_id, + }); +}); diff --git a/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts b/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts new file mode 100644 index 000000000..1d6c916cf --- /dev/null +++ b/server/tests/integration/billing/misc/reuse-stripe-prices-custom-plan.test.ts @@ -0,0 +1,607 @@ +/** + * Custom plans reuse the base plan's Stripe resources where possible. + * + * When a customer attaches a plan with `customize.items` (or `customize.price`), + * a new `is_custom: true` price row is written for each affected price. The + * carry-forward path in handleNewProductItems.carryForwardStripeResources + * (which calls copyStripeResourcesToMatchingPrice) MUST populate the new row's + * `stripe_*_id` fields from the matching catalog price so we don't mint + * duplicate Stripe Price objects. + * + * Contract under test: + * - Adding an unrelated boolean entitlement (dashboard) keeps every existing + * price's Stripe IDs intact. + * - Same for the paid feature shapes prepaid / consumable / allocated. + * - Negative: swapping prepaid → consumable on the same feature does NOT + * reuse stripe_price_id (different price.config.type / billing_method). + * - Negative: changing the price amount on a prepaid item does NOT reuse + * stripe_price_id (config differs → pricesAreSame=false → reuse level + * drops below "full"). + * - Negative: changing tier amounts on a tiered prepaid item does NOT reuse + * stripe_price_id. + * + * Implementation surface: + * server/src/internal/products/product-items/productItemUtils/ + * handleNewProductItems.ts — calls carryForwardStripeResources before + * persisting new prices. + * shared/utils/productUtils/priceUtils/match/ + * copyStripeResourcesToMatchingPrice.ts + getPriceStripeReuseLevel.ts — + * the actual matching + copy logic. + * shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts — + * boolean predicate used by the test helpers. + */ + +import { test } from "bun:test"; +import { + type AttachParamsV1Input, + BillingInterval, + BillingMethod, + OnDecrease, + OnIncrease, + RolloverExpiryDurationType, + TierBehavior, + TierInfinite, +} from "@autumn/shared"; +import { + expectAllStripeIdsReused, + expectStripePriceIdNotReused, + loadCustomerAndCatalogPrices, +} from "@tests/integration/billing/misc/utils/findCatalogAndCustomPrices"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: custom plan adds a boolean entitlement, base price reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: add boolean entitlement → base price Stripe IDs reused")}`, async () => { + const customerId = "reuse-custom-boolean"; + + const proPlan = products.pro({ + id: "pro-reuse-boolean", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + itemsV2.monthlyMessages({ included: 100 }), + itemsV2.dashboard(), + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectAllStripeIdsReused({ pairs }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: custom plan keeps prepaid/consumable/allocated items → all reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: paid feature shapes unchanged → all Stripe IDs reused")}`, async () => { + const customerId = "reuse-custom-paid"; + + const proPlan = products.pro({ + id: "pro-reuse-paid", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.prepaidUsers({ billingUnits: 1 }), + items.consumableWords({ includedUsage: 0 }), + items.allocatedWorkflows({ includedUsage: 0 }), + ], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + itemsV2.monthlyMessages({ included: 100 }), + itemsV2.prepaidUsers({ amount: 10, billingUnits: 1 }), + itemsV2.consumableWords({ amount: 0.05 }), + itemsV2.allocatedWorkflows({ amount: 10 }), + itemsV2.dashboard(), + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectAllStripeIdsReused({ pairs }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3 (negative): swap prepaid → consumable on same feature → no reuse +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: prepaid → consumable on same feature → stripe_price_id NOT reused")}`, async () => { + const customerId = "reuse-custom-prepaid-to-consumable"; + + const proPlan = products.pro({ + id: "pro-reuse-prepaid-to-consumable", + items: [items.prepaidMessages({ includedUsage: 0 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [itemsV2.consumableMessages({ amount: 0.5 })], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs, catalogPrices, customerPrices } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectStripePriceIdNotReused({ + pairs, + featureId: TestFeature.Messages, + catalogPrices, + customerPrices, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4 (negative): change prepaid price amount → stripe_price_id not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: prepaid amount change → stripe_price_id NOT reused")}`, async () => { + const customerId = "reuse-custom-prepaid-amount"; + + const proPlan = products.pro({ + id: "pro-reuse-prepaid-amount", + items: [items.prepaidMessages({ includedUsage: 0 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [itemsV2.prepaidMessages({ amount: 25, billingUnits: 100 })], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5 (negative): change tier amounts on tiered prepaid → not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: tier amount change → stripe_price_id NOT reused")}`, async () => { + const customerId = "reuse-custom-tier"; + + const proPlan = products.pro({ + id: "pro-reuse-tier", + items: [items.tieredPrepaidMessages({ includedUsage: 0 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + itemsV2.tieredPrepaidMessages({ + tiers: [ + { to: 600, amount: 20 }, + { to: TierInfinite, amount: 10 }, + ], + }), + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 6 (negative): graduated → volume tier_behavior → stripe_price_id not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: graduated → volume tier_behavior → stripe_price_id NOT reused")}`, async () => { + const customerId = "reuse-custom-tier-behavior"; + + const proPlan = products.pro({ + id: "pro-reuse-tier-behavior", + items: [items.tieredPrepaidMessages({ includedUsage: 0 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + { + feature_id: TestFeature.Messages, + included: 0, + price: { + tiers: [ + { to: 500, amount: 10 }, + { to: TierInfinite, amount: 5 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + billing_units: 100, + }, + }, + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 7 (negative): add flat_amount to a tier → stripe_price_id not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: add flat_amount to tier → stripe_price_id NOT reused")}`, async () => { + const customerId = "reuse-custom-flat-amount"; + + const proPlan = products.pro({ + id: "pro-reuse-flat-amount", + items: [items.volumePrepaidMessages({ includedUsage: 0 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + { + feature_id: TestFeature.Messages, + included: 0, + price: { + tiers: [ + { to: 500, amount: 10, flat_amount: 100 }, + { to: TierInfinite, amount: 5 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + billing_units: 100, + }, + }, + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 8 (negative): change proration_config on allocated → stripe_price_id not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: change proration_config on allocated → stripe_price_id NOT reused")}`, async () => { + const customerId = "reuse-custom-proration"; + + const proPlan = products.pro({ + id: "pro-reuse-proration", + items: [items.allocatedWorkflows({ includedUsage: 0 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + { + feature_id: TestFeature.Workflows, + included: 0, + price: { + amount: 10, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + proration: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.Prorate, + }, + }, + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Workflows }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 9 (negative): change billing_units → stripe_price_id not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: change prepaid billing_units → stripe_price_id NOT reused")}`, async () => { + const customerId = "reuse-custom-billing-units"; + + const proPlan = products.pro({ + id: "pro-reuse-billing-units", + items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 50 })], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 10 (positive): change rollover config on ent (base price unaffected) +// Rollover lives on the entitlement. monthlyMessagesWithRollover has no price, +// so the only paid line on this plan is the $20 base — which has no paired ent +// and thus is unaffected by ent rollover diffs. Asserts the base price still +// reuses all Stripe IDs across the customize. +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: change rollover config → base price Stripe IDs still reused")}`, async () => { + const customerId = "reuse-custom-rollover"; + + const proPlan = products.pro({ + id: "pro-reuse-rollover", + items: [ + items.monthlyMessagesWithRollover({ + includedUsage: 200, + rolloverConfig: { + max: 100, + length: 0, + duration: RolloverExpiryDurationType.Forever, + }, + }), + ], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + { + feature_id: TestFeature.Messages, + included: 200, + rollover: { + max: 500, + expiry_duration_type: RolloverExpiryDurationType.Forever, + expiry_duration_length: 0, + }, + }, + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectAllStripeIdsReused({ pairs }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 11 (positive): prepaid + consumable pair on same feature → both reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("custom plan: prepaid + consumable pair on same feature → both Stripe IDs reused")}`, async () => { + const customerId = "reuse-custom-pair"; + + const proPlan = products.pro({ + id: "pro-reuse-pair", + items: [ + items.prepaidMessages({ includedUsage: 0, billingUnits: 100 }), + items.consumableMessages({ includedUsage: 0, price: 0.5 }), + ], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + plan_id: proPlan.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [ + itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }), + itemsV2.consumableMessages({ amount: 0.5 }), + itemsV2.dashboard(), + ], + }, + }; + + await autumnV2_2.billing.attach(params); + + const { pairs } = await loadCustomerAndCatalogPrices({ + ctx, + customerId, + catalogProductId: proPlan.id, + }); + + expectAllStripeIdsReused({ pairs }); +}); diff --git a/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts b/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts new file mode 100644 index 000000000..262382de7 --- /dev/null +++ b/server/tests/integration/billing/misc/reuse-stripe-prices-versioning.test.ts @@ -0,0 +1,270 @@ +/** + * Plan versioning reuses the previous version's Stripe resources where possible. + * + * When a plan with existing customers is updated with a new items list, the + * V1 update handler (handleUpdatePlanV1) auto-creates a new product version via + * handleVersionProductV2. That path calls handleNewProductItems with + * { curPrices: latestProduct.prices, newVersion: true }. The carry-forward + * inside handleNewProductItems must copy the previous version's + * `stripe_*_id` fields onto each new-version price whose config still matches. + * + * Contract under test: + * - Versioning a plan with the same paid items (just adding a boolean entitlement) + * keeps every paid price's Stripe IDs intact on the new version. + * - Same for paid feature shapes (prepaid / consumable / allocated). + * - Negative: versioning with a changed item (price amount, tier behavior, + * billing_units) does NOT reuse stripe_price_id for that item. + * + * Implementation surface: + * server/src/internal/products/handlers/handleVersionProduct.ts — versioning entry. + * server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts — triggers versioning when customers exist + items differ. + * server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts — calls carryForwardStripeResources. + */ + +import { expect, test } from "bun:test"; +import { + type ApiPlanItemV1, + type CreatePlanItemParamsV1, + BillingInterval, + BillingMethod, + type Price, + priceStripeObjectsMatch, + TierInfinite, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { ProductService } from "@/internal/products/ProductService"; + +const collectStripeIdsByFeatureKey = ( + prices: Price[], +): Map> => { + const map = new Map>(); + for (const price of prices) { + const config = price.config as Record; + const featureId = (config.feature_id as string | undefined) ?? "__fixed__"; + const billWhen = (config.bill_when as string | undefined) ?? "__none__"; + const key = `${featureId}|${billWhen}`; + map.set(key, { + stripe_product_id: (config.stripe_product_id as string | null) ?? null, + stripe_price_id: (config.stripe_price_id as string | null) ?? null, + stripe_empty_price_id: + (config.stripe_empty_price_id as string | null) ?? null, + stripe_meter_id: (config.stripe_meter_id as string | null) ?? null, + stripe_prepaid_price_v2_id: + (config.stripe_prepaid_price_v2_id as string | null) ?? null, + stripe_placeholder_price_id: + (config.stripe_placeholder_price_id as string | null) ?? null, + }); + } + return map; +}; + +const findPriceForFeature = ( + prices: Price[], + featureId: string, +): Price | undefined => + prices.find( + (price) => + (price.config as Record).feature_id === featureId, + ); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: version a plan with same paid items + new boolean → all reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("versioning: add boolean entitlement → all paid Stripe IDs reused on new version")}`, async () => { + const customerId = "reuse-version-add-bool"; + + const proPlan = products.pro({ + id: "pro-version-add-bool", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.prepaidUsers({ billingUnits: 1 }), + items.consumableWords({ includedUsage: 0 }), + items.allocatedWorkflows({ includedUsage: 0 }), + ], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [s.billing.attach({ productId: proPlan.id })], + }); + + const beforeProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: proPlan.id, + orgId: ctx.org.id, + env: ctx.env, + }); + const beforeIds = collectStripeIdsByFeatureKey(beforeProduct.prices); + + const updatedItems = [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 100 }), + items.prepaidUsers({ billingUnits: 1 }), + items.consumableWords({ includedUsage: 0 }), + items.allocatedWorkflows({ includedUsage: 0 }), + items.dashboard(), + ]; + + await autumnV1.products.update(proPlan.id, { items: updatedItems }); + + const afterProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: proPlan.id, + orgId: ctx.org.id, + env: ctx.env, + }); + + expect(afterProduct.version).toBe(beforeProduct.version + 1); + + const afterIds = collectStripeIdsByFeatureKey(afterProduct.prices); + + for (const [key, before] of beforeIds.entries()) { + const after = afterIds.get(key); + expect(after).toBeDefined(); + if (!after) continue; + expect(before.stripe_price_id).not.toBeNull(); + expect(after.stripe_product_id).toBe(before.stripe_product_id); + expect(after.stripe_price_id).toBe(before.stripe_price_id); + expect(after.stripe_empty_price_id).toBe(before.stripe_empty_price_id); + expect(after.stripe_meter_id).toBe(before.stripe_meter_id); + expect(after.stripe_prepaid_price_v2_id).toBe( + before.stripe_prepaid_price_v2_id, + ); + expect(after.stripe_placeholder_price_id).toBe( + before.stripe_placeholder_price_id, + ); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2 (negative): versioning with prepaid amount change → stripe_price_id not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("versioning: prepaid amount change → stripe_price_id NOT reused on new version")}`, async () => { + const customerId = "reuse-version-amount-change"; + + const proPlan = products.pro({ + id: "pro-version-amount-change", + items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [s.billing.attach({ productId: proPlan.id })], + }); + + const beforeProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: proPlan.id, + orgId: ctx.org.id, + env: ctx.env, + }); + const beforeMessages = findPriceForFeature( + beforeProduct.prices, + TestFeature.Messages, + ); + expect(beforeMessages).toBeDefined(); + + const updatedItems = [ + items.monthlyPrice({ price: 20 }), + items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 25 }), + ]; + + await autumnV1.products.update(proPlan.id, { items: updatedItems }); + + const afterProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: proPlan.id, + orgId: ctx.org.id, + env: ctx.env, + }); + expect(afterProduct.version).toBe(beforeProduct.version + 1); + + const afterMessages = findPriceForFeature( + afterProduct.prices, + TestFeature.Messages, + ); + expect(afterMessages).toBeDefined(); + if (!afterMessages || !beforeMessages) return; + + const beforeConfig = beforeMessages.config as Record; + const afterConfig = afterMessages.config as Record; + expect(beforeConfig.stripe_price_id ?? null).not.toBeNull(); + expect(afterConfig.stripe_price_id ?? null).not.toBeNull(); + expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3 (negative): versioning with tier_behavior change → stripe_price_id not reused +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("versioning: graduated → volume tier_behavior → stripe_price_id NOT reused on new version")}`, async () => { + const customerId = "reuse-version-tier-behavior"; + + const proPlan = products.pro({ + id: "pro-version-tier-behavior", + items: [items.tieredPrepaidMessages({ includedUsage: 0 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proPlan] }), + ], + actions: [s.billing.attach({ productId: proPlan.id })], + }); + + const beforeProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: proPlan.id, + orgId: ctx.org.id, + env: ctx.env, + }); + const beforeMessages = findPriceForFeature( + beforeProduct.prices, + TestFeature.Messages, + ); + + const updatedItems = [ + items.monthlyPrice({ price: 20 }), + items.volumePrepaidMessages({ includedUsage: 0 }), + ]; + + await autumnV1.products.update(proPlan.id, { items: updatedItems }); + + const afterProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: proPlan.id, + orgId: ctx.org.id, + env: ctx.env, + }); + expect(afterProduct.version).toBe(beforeProduct.version + 1); + + const afterMessages = findPriceForFeature( + afterProduct.prices, + TestFeature.Messages, + ); + expect(afterMessages).toBeDefined(); + if (!afterMessages || !beforeMessages) return; + + const beforeConfig = beforeMessages.config as Record; + const afterConfig = afterMessages.config as Record; + expect(beforeConfig.stripe_price_id ?? null).not.toBeNull(); + expect(afterConfig.stripe_price_id ?? null).not.toBeNull(); + expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id); +}); diff --git a/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts b/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts new file mode 100644 index 000000000..85c674dbc --- /dev/null +++ b/server/tests/integration/billing/misc/utils/expectNoStripeResources.ts @@ -0,0 +1,70 @@ +import { expect } from "bun:test"; +import { type AppEnv, type Price, prices } from "@autumn/shared"; +import { eq } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { ProductService } from "@/internal/products/ProductService"; + +const stripeIdFields = [ + "stripe_price_id", + "stripe_product_id", + "stripe_empty_price_id", + "stripe_meter_id", + "stripe_prepaid_price_v2_id", +] as const; + +const expectPriceHasNoStripeIds = (price: Price) => { + const config = price.config as Record; + for (const field of stripeIdFields) { + expect(config[field] ?? null).toBeNull(); + } +}; + +export const expectNoStripeResources = async ({ + db, + orgId, + env, + productId, +}: { + db: DrizzleCli; + orgId: string; + env: AppEnv; + productId: string; +}) => { + const fullProduct = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId, + env, + }); + + expect(fullProduct.processor?.id ?? null).toBeNull(); + + for (const price of fullProduct.prices) { + expectPriceHasNoStripeIds(price); + } + + const allPrices = (await db.query.prices.findMany({ + where: eq(prices.internal_product_id, fullProduct.internal_id), + })) as Price[]; + + for (const price of allPrices) { + expectPriceHasNoStripeIds(price); + } +}; + +export const expectNoCustomStripePrices = async ({ + db, + internalProductId, +}: { + db: DrizzleCli; + internalProductId: string; +}) => { + const customPrices = (await db.query.prices.findMany({ + where: eq(prices.internal_product_id, internalProductId), + })) as Price[]; + + for (const price of customPrices) { + if (!price.is_custom) continue; + expectPriceHasNoStripeIds(price); + } +}; diff --git a/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts b/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts new file mode 100644 index 000000000..379e4c7a0 --- /dev/null +++ b/server/tests/integration/billing/misc/utils/findCatalogAndCustomPrices.ts @@ -0,0 +1,181 @@ +import { expect } from "bun:test"; +import { + type AppEnv, + type FullCusProduct, + type Price, + type UsagePriceConfig, + diffPriceStripeObjects, + isFixedPrice, + priceStripeObjectsMatch, +} from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusService } from "@/internal/customers/CusService"; +import { ProductService } from "@/internal/products/ProductService"; + +const priceFeatureId = (price: Price): string | null => { + if (isFixedPrice(price)) return null; + const config = price.config as UsagePriceConfig; + return config.feature_id ?? null; +}; + +const priceMatchKey = (price: Price): string => { + if (isFixedPrice(price)) return "__fixed__"; + const featureId = priceFeatureId(price); + const config = price.config as UsagePriceConfig; + const billWhen = config.bill_when ?? ""; + return `feature:${featureId ?? ""}|bill_when:${billWhen}`; +}; + +/** + * Attach a customer's primary FullCusProduct (custom or otherwise) to the + * matching catalog plan's prices via feature_id (or "fixed" for base prices). + * Returns matched pairs and the catalog plan for further assertions. + */ +export const loadCustomerAndCatalogPrices = async ({ + ctx, + customerId, + catalogProductId, +}: { + ctx: AutumnContext; + customerId: string; + catalogProductId: string; +}): Promise<{ + catalogPrices: Price[]; + customerPrices: Price[]; + pairs: { catalog: Price; customer: Price }[]; + cusProduct: FullCusProduct; +}> => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + const cusProduct = fullCustomer.customer_products[0]; + if (!cusProduct) { + throw new Error(`Customer ${customerId} has no customer_products`); + } + + const fullCatalog = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: catalogProductId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const customerPrices = cusProduct.customer_prices.map( + (customerPrice) => customerPrice.price, + ); + + const pairs: { catalog: Price; customer: Price }[] = []; + for (const customerPrice of customerPrices) { + const key = priceMatchKey(customerPrice); + const catalogMatch = fullCatalog.prices.find( + (price) => priceMatchKey(price) === key, + ); + if (!catalogMatch) continue; + pairs.push({ catalog: catalogMatch, customer: customerPrice }); + } + + return { + catalogPrices: fullCatalog.prices, + customerPrices, + pairs, + cusProduct, + }; +}; + +const formatDiff = (catalog: Price, customer: Price): string => { + const diffs = diffPriceStripeObjects({ + priceA: catalog, + priceB: customer, + }); + return diffs + .map((diff) => `${diff.field}: catalog=${diff.a ?? "null"}, customer=${diff.b ?? "null"}`) + .join("\n "); +}; + +/** + * Assert every (catalog, customer) price pair shares all Stripe-object IDs. + * Each catalog price must also have a non-null stripe_price_id so the + * assertion is meaningful (verifies real reuse, not "both empty"). + */ +export const expectAllStripeIdsReused = ({ + pairs, +}: { + pairs: { catalog: Price; customer: Price }[]; +}) => { + expect(pairs.length).toBeGreaterThan(0); + for (const { catalog, customer } of pairs) { + const catalogConfig = catalog.config as Record; + expect(catalogConfig.stripe_price_id ?? null).not.toBeNull(); + const matches = priceStripeObjectsMatch({ + priceA: catalog, + priceB: customer, + }); + if (!matches) { + throw new Error( + `Expected stripe-object reuse for ${priceMatchKey(catalog)} but got diffs:\n ${formatDiff(catalog, customer)}`, + ); + } + } +}; + +/** + * Assert that the customer price keyed by `featureId` (or fixed base when + * `featureId` is null) does NOT reuse stripe_price_id from the catalog. + * Both prices must have non-null stripe_price_id values for the assertion + * to be meaningful. Falls back to feature-id-only matching when the strict + * (feature + bill_when) pairing misses (e.g. prepaid → consumable swap). + */ +export const expectStripePriceIdNotReused = ({ + pairs, + featureId, + catalogPrices, + customerPrices, +}: { + pairs: { catalog: Price; customer: Price }[]; + featureId: string | null; + catalogPrices?: Price[]; + customerPrices?: Price[]; +}) => { + let catalogPrice: Price | undefined; + let customerPrice: Price | undefined; + + if (featureId === null) { + const pair = pairs.find(({ catalog }) => isFixedPrice(catalog)); + catalogPrice = pair?.catalog; + customerPrice = pair?.customer; + } else { + const pair = pairs.find( + ({ catalog }) => priceFeatureId(catalog) === featureId, + ); + if (pair) { + catalogPrice = pair.catalog; + customerPrice = pair.customer; + } else if (catalogPrices && customerPrices) { + catalogPrice = catalogPrices.find( + (price) => priceFeatureId(price) === featureId, + ); + customerPrice = customerPrices.find( + (price) => priceFeatureId(price) === featureId, + ); + } + } + + expect(catalogPrice).toBeDefined(); + expect(customerPrice).toBeDefined(); + if (!catalogPrice || !customerPrice) return; + const catalogConfig = catalogPrice.config as Record; + const customerConfig = customerPrice.config as Record; + expect(catalogConfig.stripe_price_id ?? null).not.toBeNull(); + expect(customerConfig.stripe_price_id ?? null).not.toBeNull(); + expect(customerConfig.stripe_price_id).not.toBe( + catalogConfig.stripe_price_id, + ); +}; + +export { priceMatchKey }; + +// Re-export for callers +export type { DrizzleCli, AppEnv }; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 53b5d9970..23d5d0423 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -52,6 +52,7 @@ export * from "./productUtils/priceUtils/index"; // Price match utils export * from "./productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice"; export * from "./productUtils/priceUtils/match/getPriceStripeReuseLevel"; +export * from "./productUtils/priceUtils/match/priceStripeObjectsMatch"; export * from "./productV2Utils/mapToProductV2"; export * from "./productV2Utils/productItemUtils/classifyItemUtils"; export * from "./productV2Utils/productItemUtils/getItemType"; diff --git a/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts index 66eae1c8a..06c569cdf 100644 --- a/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts +++ b/shared/utils/productUtils/entUtils/compareEnt/entsAreSame.ts @@ -36,7 +36,7 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => { ent1.allowance_type !== AllowanceType.Unlimited && ent1.allowance != ent2.allowance, carryFromPrevious: ent1.carry_from_previous != ent2.carry_from_previous, - entityFeatureId: ent1.entity_feature_id !== ent2.entity_feature_id, + entityFeatureId: ent1.entity_feature_id != ent2.entity_feature_id, usageLimit: ent1.usage_limit != ent2.usage_limit, rollover: !rolloversAreSame({ rollover1: ent1.rollover, diff --git a/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts index 0677c560c..de5923cc6 100644 --- a/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts +++ b/shared/utils/productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice.ts @@ -49,6 +49,7 @@ export const copyStripeResourcesToMatchingPrice = ({ candidateEntitlements, }: { targetPrice: Price; + // Stripe IDs are copied FROM the best-matching candidate TO targetPrice. candidatePrices: Price[]; targetEntitlements: Entitlement[]; candidateEntitlements: Entitlement[]; diff --git a/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts b/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts new file mode 100644 index 000000000..3e7ab54f9 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts @@ -0,0 +1,69 @@ +import type { Price } from "@autumn/shared"; + +const stripeResourceFields = [ + "stripe_product_id", + "stripe_price_id", + "stripe_empty_price_id", + "stripe_placeholder_price_id", + "stripe_prepaid_price_v2_id", + "stripe_meter_id", + "stripe_event_name", +] as const; + +export type PriceStripeObjectField = (typeof stripeResourceFields)[number]; + +const readField = (price: Price, field: PriceStripeObjectField): string | null => { + const config = price.config as Partial>; + return config[field] ?? null; +}; + +/** + * True iff every Stripe-resource field that initStripeResourcesForBillingPlan + * cares about (`stripe_product_id`, `stripe_price_id`, `stripe_empty_price_id`, + * `stripe_placeholder_price_id`, `stripe_prepaid_price_v2_id`, + * `stripe_meter_id`, `stripe_event_name`) is identical between the two prices. + * + * Used by stripe-reuse coverage to assert that a versioned / custom price + * carried the original plan's Stripe resources forward instead of minting + * fresh ones. + */ +export const priceStripeObjectsMatch = ({ + priceA, + priceB, +}: { + priceA: Price; + priceB: Price; +}): boolean => { + for (const field of stripeResourceFields) { + if (readField(priceA, field) !== readField(priceB, field)) return false; + } + return true; +}; + +/** + * Returns the list of Stripe-resource fields whose values differ between + * `priceA` and `priceB`. Useful for surfacing why a reuse assertion failed. + */ +export const diffPriceStripeObjects = ({ + priceA, + priceB, +}: { + priceA: Price; + priceB: Price; +}): { + field: PriceStripeObjectField; + a: string | null; + b: string | null; +}[] => { + const diffs: { + field: PriceStripeObjectField; + a: string | null; + b: string | null; + }[] = []; + for (const field of stripeResourceFields) { + const a = readField(priceA, field); + const b = readField(priceB, field); + if (a !== b) diffs.push({ field, a, b }); + } + return diffs; +}; diff --git a/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx b/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx index 3076347b5..9fc9ea4cc 100644 --- a/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx +++ b/vite/src/components/forms/shared/admin/AdminPlanIdsTooltip.tsx @@ -1,10 +1,5 @@ import type { ReactNode } from "react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/v2/tooltips/Tooltip"; -import { useAdmin } from "@/views/admin/hooks/useAdmin"; +import { AdminHover } from "@/components/general/AdminHover"; export type AdminPlanIds = { stripe_price_id?: string | null; @@ -12,22 +7,6 @@ export type AdminPlanIds = { internal_product_id?: string | null; }; -const Row = ({ label, value }: { label: string; value: string | null | undefined }) => { - if (!value) return null; - return ( -
- - {label} - - {value} -
- ); -}; - -/** - * Wraps a child with an admin-only hover tooltip showing identifying IDs - * for the displayed plan/price. No-op for non-admin users. - */ export const AdminPlanIdsTooltip = ({ children, ids, @@ -35,28 +14,22 @@ export const AdminPlanIdsTooltip = ({ children: ReactNode; ids: AdminPlanIds; }) => { - const { isAdmin } = useAdmin(); + const texts = [ + ids.stripe_price_id && { + key: "Stripe price id", + value: ids.stripe_price_id, + }, + ids.stripe_product_id && { + key: "Stripe product id", + value: ids.stripe_product_id, + }, + ids.internal_product_id && { + key: "Autumn internal id", + value: ids.internal_product_id, + }, + ].filter(Boolean) as { key: string; value: string }[]; - const hasAnyId = Boolean( - ids.stripe_price_id || ids.stripe_product_id || ids.internal_product_id, - ); + if (texts.length === 0) return <>{children}; - if (!isAdmin || !hasAnyId) { - return <>{children}; - } - - return ( - - {children} - - - - - - - ); + return {children}; }; From 82c34ded1a1bee69df7f04272f5c25fe9a97411f Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Wed, 13 May 2026 10:53:34 +0100 Subject: [PATCH 35/36] rename track response field to deductions --- .../v2.1/contracts/balancesContract.ts | 2 +- server/src/external/tinybird/initTinybird.ts | 2 +- .../tinybird/pipes/listEventsPaginatedPipe.ts | 2 +- .../external/tinybird/sendEvents/mapEvent.ts | 4 +- .../internal/analytics/actions/listEvents.ts | 12 +- .../src/internal/balances/events/initEvent.ts | 8 +- .../balances/track/v3/runPostgresTrackV3.ts | 8 +- .../balances/track/v3/runRedisTrackV3.ts | 16 +- .../balances/utils/deductionV2/index.ts | 2 +- ...projectMutationLogsToTrackDeductionsV2.ts} | 8 +- ...tions.test.ts => track-deductions.test.ts} | 165 ++++++++++++------ ...ctMutationLogsToTrackDeductionsV2.test.ts} | 22 +-- ...s => v20TrackChangeDeductionStrip.test.ts} | 12 +- server/tinybird/datasources/events.datasource | 23 ++- .../events_by_timestamp_mv.datasource | 2 +- .../events_by_timestamp_mv_pipe.pipe | 2 +- .../tinybird/pipes/list_events_paginated.pipe | 2 +- shared/api/balances/track/trackResponseV3.ts | 10 +- shared/api/events/list/eventsListResponse.ts | 12 +- shared/models/eventModels/eventTable.ts | 4 +- 20 files changed, 197 insertions(+), 121 deletions(-) rename server/src/internal/balances/utils/deductionV2/{projectMutationLogsToTrackMutationsV2.ts => projectMutationLogsToTrackDeductionsV2.ts} (93%) rename server/tests/integration/balances/track/basic/{track-mutations.test.ts => track-deductions.test.ts} (62%) rename server/tests/unit/balances/track-v3/{projectMutationLogsToTrackMutationsV2.test.ts => projectMutationLogsToTrackDeductionsV2.test.ts} (91%) rename server/tests/unit/balances/track-v3/{v20TrackChangeMutationStrip.test.ts => v20TrackChangeDeductionStrip.test.ts} (72%) diff --git a/packages/openapi/v2.1/contracts/balancesContract.ts b/packages/openapi/v2.1/contracts/balancesContract.ts index 5129f9dad..9c9dd96f0 100644 --- a/packages/openapi/v2.1/contracts/balancesContract.ts +++ b/packages/openapi/v2.1/contracts/balancesContract.ts @@ -112,7 +112,7 @@ export const balancesTrackContract = oc customer_id: "cus_123", value: 1, balance: API_BALANCE_V1_EXAMPLE, - mutations: [ + deductions: [ { balance_id: "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2", feature_id: "messages", diff --git a/server/src/external/tinybird/initTinybird.ts b/server/src/external/tinybird/initTinybird.ts index 1d11b5d6e..25468e99e 100644 --- a/server/src/external/tinybird/initTinybird.ts +++ b/server/src/external/tinybird/initTinybird.ts @@ -40,7 +40,7 @@ const TinybirdEventSchema = z.object({ internal_entity_id: z.string().nullable(), customer_id: z.string(), properties: z.string().nullable(), - mutations: z.string().nullable(), + deductions: z.string().nullable(), }); /** Pre-built pipe callers */ diff --git a/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts index 2f7d4d62f..f425abe26 100644 --- a/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts +++ b/server/src/external/tinybird/pipes/listEventsPaginatedPipe.ts @@ -13,7 +13,7 @@ export const listEventsPaginatedPipeResponseSchema = z.object({ properties: z.string().nullable(), idempotency_key: z.string().nullable(), entity_id: z.string().nullable(), - mutations: z.string().nullable(), + deductions: z.string().nullable(), }); export type ListEventsPaginatedPipeRow = z.infer< diff --git a/server/src/external/tinybird/sendEvents/mapEvent.ts b/server/src/external/tinybird/sendEvents/mapEvent.ts index 8c482c872..ab35074c3 100644 --- a/server/src/external/tinybird/sendEvents/mapEvent.ts +++ b/server/src/external/tinybird/sendEvents/mapEvent.ts @@ -17,7 +17,7 @@ export interface TinybirdEvent { internal_entity_id: string | null; customer_id: string; properties: string | null; - mutations: string | null; + deductions: string | null; } /** Convert EventInsert to Tinybird schema */ @@ -47,6 +47,6 @@ export const mapToTinybirdEvent = (event: EventInsert): TinybirdEvent => { internal_entity_id: event.internal_entity_id ?? null, customer_id: event.customer_id, properties: event.properties ? JSON.stringify(event.properties) : null, - mutations: event.mutations ? JSON.stringify(event.mutations) : null, + deductions: event.deductions ? JSON.stringify(event.deductions) : null, }; }; diff --git a/server/src/internal/analytics/actions/listEvents.ts b/server/src/internal/analytics/actions/listEvents.ts index eefa07c85..2406ba3d0 100644 --- a/server/src/internal/analytics/actions/listEvents.ts +++ b/server/src/internal/analytics/actions/listEvents.ts @@ -1,4 +1,4 @@ -import type { ApiEventsListItem, TrackMutation } from "@autumn/shared"; +import type { ApiEventsListItem, TrackDeduction } from "@autumn/shared"; import { epochToDateTime } from "@autumn/shared/api/common/epochUtils"; import { getTinybirdPipes } from "@/external/tinybird/initTinybird.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -84,11 +84,11 @@ export const listEvents = async ({ } } - let mutations: TrackMutation[] | null = null; - if (row.mutations) { + let deductions: TrackDeduction[] | null = null; + if (row.deductions) { try { - const parsed = JSON.parse(row.mutations); - if (Array.isArray(parsed)) mutations = parsed as TrackMutation[]; + const parsed = JSON.parse(row.deductions); + if (Array.isArray(parsed)) deductions = parsed as TrackDeduction[]; } catch { // Invalid JSON — leave null so the caller can distinguish missing // vs explicit empty. @@ -102,7 +102,7 @@ export const listEvents = async ({ customer_id: row.customer_id, value: row.value ?? 0, properties, - mutations, + deductions, }; }); diff --git a/server/src/internal/balances/events/initEvent.ts b/server/src/internal/balances/events/initEvent.ts index 9d06c9147..a102a80bc 100644 --- a/server/src/internal/balances/events/initEvent.ts +++ b/server/src/internal/balances/events/initEvent.ts @@ -1,4 +1,4 @@ -import type { EventInsert, TrackMutation, TrackParams } from "@autumn/shared"; +import type { EventInsert, TrackDeduction, TrackParams } from "@autumn/shared"; import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { generateId } from "../../../utils/genUtils.js"; @@ -35,7 +35,7 @@ export const initEvent = (params: { internalEntityId?: string; customerId: string; entityId?: string; - mutations?: TrackMutation[]; + deductions?: TrackDeduction[]; }) => { const { ctx, @@ -44,7 +44,7 @@ export const initEvent = (params: { internalEntityId, customerId, entityId, - mutations, + deductions, } = params; const { org, env } = ctx; @@ -71,7 +71,7 @@ export const initEvent = (params: { properties: eventInfo.properties ?? {}, idempotency_key: eventInfo.idempotency_key ?? null, set_usage: false, - mutations: mutations && mutations.length > 0 ? mutations : null, + deductions: deductions && deductions.length > 0 ? deductions : null, } satisfies EventInsert; return newEvent; diff --git a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts index f4cf4bfa2..8f1c0ce97 100644 --- a/server/src/internal/balances/track/v3/runPostgresTrackV3.ts +++ b/server/src/internal/balances/track/v3/runPostgresTrackV3.ts @@ -9,7 +9,7 @@ import { import { deductionToTrackResponseV2, executePostgresDeductionV2, - projectMutationLogsToTrackMutationsV2, + projectMutationLogsToTrackDeductionsV2, } from "@/internal/balances/utils/deductionV2/index.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import { handlePostgresTrackError } from "../utils/handlePostgresTrackError.js"; @@ -48,7 +48,7 @@ export const runPostgresTrackV3 = async ({ const { fullSubject: updatedFullSubject, updates, mutationLogs } = result; - const mutations = projectMutationLogsToTrackMutationsV2({ + const deductions = projectMutationLogsToTrackDeductionsV2({ fullSubject: updatedFullSubject, mutationLogs, }); @@ -62,7 +62,7 @@ export const runPostgresTrackV3 = async ({ internalEntityId: updatedFullSubject.internalEntityId, customerId: body.customer_id, entityId: body.entity_id, - mutations, + deductions, }); globalEventBatchingManager.addEvent(event); @@ -82,6 +82,6 @@ export const runPostgresTrackV3 = async ({ value: body.value ?? 1, balance, balances, - mutations, + deductions, }; }; diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index ce116da92..74fc2bd91 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -1,6 +1,6 @@ import type { FullSubject, - TrackMutation, + TrackDeduction, TrackParams, TrackResponseV3, } from "@autumn/shared"; @@ -14,7 +14,7 @@ import { import { deductionToTrackResponseV2, executeRedisDeductionV2, - projectMutationLogsToTrackMutationsV2, + projectMutationLogsToTrackDeductionsV2, } from "@/internal/balances/utils/deductionV2/index.js"; import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; @@ -55,12 +55,12 @@ const queueEvent = ({ ctx, body, fullSubject, - mutations, + deductions, }: { ctx: AutumnContext; body: TrackParams; fullSubject: FullSubject; - mutations: TrackMutation[]; + deductions: TrackDeduction[]; }): void => { if (body.skip_event) return; @@ -74,7 +74,7 @@ const queueEvent = ({ internalEntityId: fullSubject.internalEntityId, customerId: body.customer_id, entityId: body.entity_id, - mutations, + deductions, }), ); }; @@ -134,12 +134,12 @@ export const runRedisTrackV3 = async ({ modifiedCusEntIdsByFeatureId, }); - const mutations = projectMutationLogsToTrackMutationsV2({ + const deductions = projectMutationLogsToTrackDeductionsV2({ fullSubject: updatedFullSubject, mutationLogs, }); - queueEvent({ ctx, body, fullSubject, mutations }); + queueEvent({ ctx, body, fullSubject, deductions }); const { balance, balances } = await deductionToTrackResponseV2({ ctx, @@ -155,6 +155,6 @@ export const runRedisTrackV3 = async ({ value: body.value ?? 1, balance, balances, - mutations, + deductions, }; }; diff --git a/server/src/internal/balances/utils/deductionV2/index.ts b/server/src/internal/balances/utils/deductionV2/index.ts index 75a78ec17..25cc39d27 100644 --- a/server/src/internal/balances/utils/deductionV2/index.ts +++ b/server/src/internal/balances/utils/deductionV2/index.ts @@ -8,5 +8,5 @@ export { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js"; export { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js"; export { prepareDeductionOptionsV2 } from "./prepareDeductionOptionsV2.js"; export { prepareFeatureDeductionV2 } from "./prepareFeatureDeductionV2.js"; -export { projectMutationLogsToTrackMutationsV2 } from "./projectMutationLogsToTrackMutationsV2.js"; +export { projectMutationLogsToTrackDeductionsV2 } from "./projectMutationLogsToTrackDeductionsV2.js"; export { rollbackDeductionV2 } from "./rollbackDeductionV2.js"; diff --git a/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.ts b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts similarity index 93% rename from server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.ts rename to server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts index e1dbaca0a..1ff5c65c8 100644 --- a/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.ts +++ b/server/src/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.ts @@ -1,17 +1,17 @@ import { type FullSubject, fullSubjectToCustomerEntitlements, - type TrackMutation, + type TrackDeduction, } from "@autumn/shared"; import type { MutationLogItem } from "../types/mutationLogItem.js"; -export const projectMutationLogsToTrackMutationsV2 = ({ +export const projectMutationLogsToTrackDeductionsV2 = ({ fullSubject, mutationLogs, }: { fullSubject: FullSubject; mutationLogs: MutationLogItem[]; -}): TrackMutation[] => { +}): TrackDeduction[] => { if (mutationLogs.length === 0) return []; const customerEntitlements = fullSubjectToCustomerEntitlements({ @@ -32,7 +32,7 @@ export const projectMutationLogsToTrackMutationsV2 = ({ // cus_ent_* and rollover_* share the same `balance_id` namespace in the // public shape, but their internal types are scoped separately — qualify // with the type when aggregating so the namespaces can't collide. - const aggregated = new Map(); + const aggregated = new Map(); for (const log of mutationLogs) { if (log.balance_delta === 0) continue; diff --git a/server/tests/integration/balances/track/basic/track-mutations.test.ts b/server/tests/integration/balances/track/basic/track-deductions.test.ts similarity index 62% rename from server/tests/integration/balances/track/basic/track-mutations.test.ts rename to server/tests/integration/balances/track/basic/track-deductions.test.ts index 1fbceb3cf..3a5d0f43b 100644 --- a/server/tests/integration/balances/track/basic/track-mutations.test.ts +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -2,22 +2,24 @@ import { expect, test } from "bun:test"; import type { ApiCustomerV3, - TrackMutation, + ApiEventsListResponse, + TrackDeduction, TrackResponseV3, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.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 { timeout } from "@tests/utils/genUtils.js"; import chalk from "chalk"; import { Decimal } from "decimal.js"; import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; -const findMutationByFeature = ( - mutations: TrackMutation[] | undefined, +const findDeductionByFeature = ( + deductions: TrackDeduction[] | undefined, featureId: string, -): TrackMutation | undefined => - mutations?.find((mutation) => mutation.feature_id === featureId); +): TrackDeduction | undefined => + deductions?.find((deduction) => deduction.feature_id === featureId); // ═══════════════════════════════════════════════════════════════════ // A: Track within a feature's own allowance — only the main balance @@ -25,7 +27,7 @@ const findMutationByFeature = ( // stay untouched while allowance remains. // ═══════════════════════════════════════════════════════════════════ test.concurrent( - `${chalk.yellowBright("track-mutations-A: within-allowance track surfaces a single mutation against the main balance")}`, + `${chalk.yellowBright("track-deductions-A: within-allowance track surfaces a single deduction against the main balance")}`, async () => { const action1Item = items.free({ featureId: TestFeature.Action1, @@ -41,7 +43,7 @@ test.concurrent( }); const { customerId, autumnV2_2 } = await initScenario({ - customerId: "track-mutations-a", + customerId: "track-deductions-a", setup: [ s.customer({ testClock: false }), s.products({ list: [freeProd] }), @@ -55,28 +57,28 @@ test.concurrent( value: 10, }); - expect(trackRes.mutations).toBeDefined(); - expect(trackRes.mutations).toHaveLength(1); + expect(trackRes.deductions).toBeDefined(); + expect(trackRes.deductions).toHaveLength(1); - const action1Mutation = findMutationByFeature( - trackRes.mutations, + const action1Deduction = findDeductionByFeature( + trackRes.deductions, TestFeature.Action1, ); - expect(action1Mutation).toBeDefined(); - expect(action1Mutation?.value).toBe(10); + expect(action1Deduction).toBeDefined(); + expect(action1Deduction?.value).toBe(10); expect( - findMutationByFeature(trackRes.mutations, TestFeature.Credits), + findDeductionByFeature(trackRes.deductions, TestFeature.Credits), ).toBeUndefined(); }, ); // ═══════════════════════════════════════════════════════════════════ // B: event_name fans out to two features; each within its own -// allowance → two mutations, no credit-system mutations. +// allowance → two deductions, no credit-system deductions. // ═══════════════════════════════════════════════════════════════════ test.concurrent( - `${chalk.yellowBright("track-mutations-B: event_name across two features surfaces a mutation per touched balance")}`, + `${chalk.yellowBright("track-deductions-B: event_name across two features surfaces a deduction per touched balance")}`, async () => { const action1Item = items.free({ featureId: TestFeature.Action1, @@ -100,7 +102,7 @@ test.concurrent( }); const { customerId, autumnV2_2 } = await initScenario({ - customerId: "track-mutations-b", + customerId: "track-deductions-b", setup: [ s.customer({ testClock: false }), s.products({ list: [freeProd] }), @@ -114,20 +116,20 @@ test.concurrent( value: 5, }); - expect(trackRes.mutations).toBeDefined(); - expect(trackRes.mutations).toHaveLength(2); + expect(trackRes.deductions).toBeDefined(); + expect(trackRes.deductions).toHaveLength(2); - const featureIds = (trackRes.mutations ?? []) - .map((mutation) => mutation.feature_id) + const featureIds = (trackRes.deductions ?? []) + .map((deduction) => deduction.feature_id) .sort(); expect(featureIds).toEqual( [TestFeature.Action1, TestFeature.Action3].sort(), ); expect( - findMutationByFeature(trackRes.mutations, TestFeature.Action1)?.value, + findDeductionByFeature(trackRes.deductions, TestFeature.Action1)?.value, ).toBe(5); expect( - findMutationByFeature(trackRes.mutations, TestFeature.Action3)?.value, + findDeductionByFeature(trackRes.deductions, TestFeature.Action3)?.value, ).toBe(5); }, ); @@ -136,7 +138,7 @@ test.concurrent( // C: Single-feature track, no credit system. // ═══════════════════════════════════════════════════════════════════ test.concurrent( - `${chalk.yellowBright("track-mutations-C: feature with no credit systems surfaces a single mutation")}`, + `${chalk.yellowBright("track-deductions-C: feature with no credit systems surfaces a single deduction")}`, async () => { const messagesItem = items.free({ featureId: TestFeature.Messages, @@ -148,7 +150,7 @@ test.concurrent( }); const { customerId, autumnV2_2 } = await initScenario({ - customerId: "track-mutations-c", + customerId: "track-deductions-c", setup: [ s.customer({ testClock: false }), s.products({ list: [freeProd] }), @@ -162,19 +164,19 @@ test.concurrent( value: 7, }); - expect(trackRes.mutations).toBeDefined(); - expect(trackRes.mutations).toHaveLength(1); - expect(trackRes.mutations?.[0].feature_id).toBe(TestFeature.Messages); - expect(trackRes.mutations?.[0].value).toBe(7); + expect(trackRes.deductions).toBeDefined(); + expect(trackRes.deductions).toHaveLength(1); + expect(trackRes.deductions?.[0].feature_id).toBe(TestFeature.Messages); + expect(trackRes.deductions?.[0].value).toBe(7); }, ); // ═══════════════════════════════════════════════════════════════════ -// D: A negative-value track emits a mutation with a negative value +// D: A negative-value track emits a deduction with a negative value // (refund / restore). // ═══════════════════════════════════════════════════════════════════ test.concurrent( - `${chalk.yellowBright("track-mutations-D: negative track value yields a negative-value mutation")}`, + `${chalk.yellowBright("track-deductions-D: negative track value yields a negative-value deduction")}`, async () => { const messagesItem = items.free({ featureId: TestFeature.Messages, @@ -186,7 +188,7 @@ test.concurrent( }); const { customerId, autumnV2_2 } = await initScenario({ - customerId: "track-mutations-d", + customerId: "track-deductions-d", setup: [ s.customer({ testClock: false }), s.products({ list: [freeProd] }), @@ -203,19 +205,19 @@ test.concurrent( value: -4, }); - expect(refundRes.mutations).toBeDefined(); - expect(refundRes.mutations).toHaveLength(1); - expect(refundRes.mutations?.[0].feature_id).toBe(TestFeature.Messages); - expect(refundRes.mutations?.[0].value).toBe(-4); + expect(refundRes.deductions).toBeDefined(); + expect(refundRes.deductions).toHaveLength(1); + expect(refundRes.deductions?.[0].feature_id).toBe(TestFeature.Messages); + expect(refundRes.deductions?.[0].value).toBe(-4); }, ); // ═══════════════════════════════════════════════════════════════════ // E: A linked credit-system feature exists in the org but the customer -// has no entitlement to it — no mutation emitted for that feature. +// has no entitlement to it — no deduction emitted for that feature. // ═══════════════════════════════════════════════════════════════════ test.concurrent( - `${chalk.yellowBright("track-mutations-E: missing entitlement on credit system is omitted from mutations")}`, + `${chalk.yellowBright("track-deductions-E: missing entitlement on credit system is omitted from deductions")}`, async () => { const action1Item = items.free({ featureId: TestFeature.Action1, @@ -227,7 +229,7 @@ test.concurrent( }); const { customerId, autumnV2_2 } = await initScenario({ - customerId: "track-mutations-e", + customerId: "track-deductions-e", setup: [ s.customer({ testClock: false }), s.products({ list: [freeProd] }), @@ -241,12 +243,12 @@ test.concurrent( value: 5, }); - expect(trackRes.mutations).toBeDefined(); - expect(trackRes.mutations).toHaveLength(1); - expect(trackRes.mutations?.[0].feature_id).toBe(TestFeature.Action1); - expect(trackRes.mutations?.[0].value).toBe(5); + expect(trackRes.deductions).toBeDefined(); + expect(trackRes.deductions).toHaveLength(1); + expect(trackRes.deductions?.[0].feature_id).toBe(TestFeature.Action1); + expect(trackRes.deductions?.[0].value).toBe(5); expect( - findMutationByFeature(trackRes.mutations, TestFeature.Credits), + findDeductionByFeature(trackRes.deductions, TestFeature.Credits), ).toBeUndefined(); }, ); @@ -255,10 +257,10 @@ test.concurrent( // F: Overflow into a linked credit system. This is the load-bearing // scenario for the feature — a single track event depletes BOTH // the main balance AND the credit-system balance, and the response -// surfaces both via `mutations`. +// surfaces both via `deductions`. // ═══════════════════════════════════════════════════════════════════ test.concurrent( - `${chalk.yellowBright("track-mutations-F: track that overflows the main balance surfaces credit-system mutations too")}`, + `${chalk.yellowBright("track-deductions-F: track that overflows the main balance surfaces credit-system deductions too")}`, async () => { const action1Item = items.free({ featureId: TestFeature.Action1, @@ -274,7 +276,7 @@ test.concurrent( }); const { customerId, autumnV2_2, autumnV1, ctx } = await initScenario({ - customerId: "track-mutations-f", + customerId: "track-deductions-f", setup: [ s.customer({ testClock: false }), s.products({ list: [freeProd] }), @@ -313,22 +315,77 @@ test.concurrent( value: overflowAmount, }); - expect(trackRes.mutations).toBeDefined(); - expect(trackRes.mutations).toHaveLength(1); + expect(trackRes.deductions).toBeDefined(); + expect(trackRes.deductions).toHaveLength(1); // Action1 is empty before the overflow event, so the whole 50 - // flows through the credit system and `mutations` has exactly + // flows through the credit system and `deductions` has exactly // the Credits row. - const creditsMutation = findMutationByFeature( - trackRes.mutations, + const creditsDeduction = findDeductionByFeature( + trackRes.deductions, TestFeature.Credits, ); - expect(creditsMutation).toBeDefined(); + expect(creditsDeduction).toBeDefined(); expect( - new Decimal(creditsMutation?.value ?? 0) + new Decimal(creditsDeduction?.value ?? 0) .minus(expectedCreditCost) .abs() .lessThan(1e-9), ).toBe(true); }, ); + +// ═══════════════════════════════════════════════════════════════════ +// G: End-to-end Tinybird round-trip. Track an event, wait for the +// batch flush + Tinybird ingest, then read it back via events.list +// and confirm the `deductions` field round-trips through the column. +// ═══════════════════════════════════════════════════════════════════ +test.concurrent( + `${chalk.yellowBright("track-deductions-G: deductions round-trip via events.list (writes Tinybird, reads it back)")}`, + async () => { + const messagesItem = items.free({ + featureId: TestFeature.Messages, + includedUsage: 100, + }); + const freeProd = products.base({ + id: "free", + items: [messagesItem], + }); + + const { customerId, autumnV1, autumnV2_2 } = await initScenario({ + customerId: "track-deductions-g", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 12, + }); + + // Wait for the EventBatchingManager flush (350ms window) plus Tinybird + // ingest propagation. Existing credit-system tests use ~2s for cache + // sync; ~3s gives Tinybird a bit more slack. + await timeout(3000); + + const eventsList = (await autumnV1.events.list({ + customer_id: customerId, + })) as ApiEventsListResponse; + + expect(eventsList.list.length).toBeGreaterThan(0); + const trackedEvent = eventsList.list.find( + (event) => + event.feature_id === TestFeature.Messages && event.value === 12, + ); + expect(trackedEvent).toBeDefined(); + expect(trackedEvent?.deductions).toBeDefined(); + expect(trackedEvent?.deductions).not.toBeNull(); + expect(trackedEvent?.deductions).toHaveLength(1); + expect(trackedEvent?.deductions?.[0].feature_id).toBe(TestFeature.Messages); + expect(trackedEvent?.deductions?.[0].value).toBe(12); + }, +); diff --git a/server/tests/unit/balances/track-v3/projectMutationLogsToTrackMutationsV2.test.ts b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts similarity index 91% rename from server/tests/unit/balances/track-v3/projectMutationLogsToTrackMutationsV2.test.ts rename to server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts index 29fab5d6f..74b5d56ff 100644 --- a/server/tests/unit/balances/track-v3/projectMutationLogsToTrackMutationsV2.test.ts +++ b/server/tests/unit/balances/track-v3/projectMutationLogsToTrackDeductionsV2.test.ts @@ -6,7 +6,7 @@ import { type FullSubject, SubjectType, } from "@autumn/shared"; -import { projectMutationLogsToTrackMutationsV2 } from "@/internal/balances/utils/deductionV2/projectMutationLogsToTrackMutationsV2.js"; +import { projectMutationLogsToTrackDeductionsV2 } from "@/internal/balances/utils/deductionV2/projectMutationLogsToTrackDeductionsV2.js"; import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js"; const buildFeature = (id: string): Feature => @@ -122,12 +122,12 @@ const buildLog = (overrides: Partial): MutationLogItem => ({ ...overrides, }); -describe("projectMutationLogsToTrackMutationsV2", () => { +describe("projectMutationLogsToTrackDeductionsV2", () => { test("returns an empty array when there are no logs", () => { const fullSubject = buildFullSubject({ customerEntitlements: [] }); expect( - projectMutationLogsToTrackMutationsV2({ fullSubject, mutationLogs: [] }), + projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [] }), ).toEqual([]); }); @@ -139,7 +139,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ @@ -167,7 +167,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ @@ -190,7 +190,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ @@ -223,7 +223,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ @@ -264,7 +264,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ @@ -292,7 +292,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ @@ -315,7 +315,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ @@ -337,7 +337,7 @@ describe("projectMutationLogsToTrackMutationsV2", () => { ], }); - const result = projectMutationLogsToTrackMutationsV2({ + const result = projectMutationLogsToTrackDeductionsV2({ fullSubject, mutationLogs: [ buildLog({ diff --git a/server/tests/unit/balances/track-v3/v20TrackChangeMutationStrip.test.ts b/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts similarity index 72% rename from server/tests/unit/balances/track-v3/v20TrackChangeMutationStrip.test.ts rename to server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts index ba9ceb3f2..f46eb29d8 100644 --- a/server/tests/unit/balances/track-v3/v20TrackChangeMutationStrip.test.ts +++ b/server/tests/unit/balances/track-v3/v20TrackChangeDeductionStrip.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; import type { - TrackMutation, + TrackDeduction, TrackResponseV2, TrackResponseV3, } from "@autumn/shared"; import { V2_0_TrackChange } from "@autumn/shared/api/balances/track/changes/V2.0_TrackChange"; -const buildMutations = (): TrackMutation[] => [ +const buildDeductions = (): TrackDeduction[] => [ { balance_id: "cus_ent_messages", feature_id: "messages", @@ -14,8 +14,8 @@ const buildMutations = (): TrackMutation[] => [ }, ]; -describe("V2_0_TrackChange mutation strip", () => { - test("does not leak the mutations field to V2.0 clients", () => { +describe("V2_0_TrackChange deduction strip", () => { + test("does not leak the deductions field to V2.0 clients", () => { const transform = new V2_0_TrackChange(); const input: TrackResponseV3 = { customer_id: "cus_1", @@ -24,14 +24,14 @@ describe("V2_0_TrackChange mutation strip", () => { value: 4, balance: null, balances: undefined, - mutations: buildMutations(), + deductions: buildDeductions(), }; const transformed = transform.transformResponse({ input, }) as TrackResponseV2; - expect(transformed).not.toHaveProperty("mutations"); + expect(transformed).not.toHaveProperty("deductions"); expect(transformed.customer_id).toBe("cus_1"); expect(transformed.value).toBe(4); expect(transformed.balance).toBeNull(); diff --git a/server/tinybird/datasources/events.datasource b/server/tinybird/datasources/events.datasource index dacbddd31..ecefb94ed 100644 --- a/server/tinybird/datasources/events.datasource +++ b/server/tinybird/datasources/events.datasource @@ -17,8 +17,27 @@ SCHEMA > `internal_entity_id` Nullable(String) `json:$.internal_entity_id`, `customer_id` String `json:$.customer_id`, `properties` JSON `json:$.properties`, - `mutations` Nullable(String) `json:$.mutations` DEFAULT NULL + `deductions` Nullable(String) `json:$.deductions` DEFAULT NULL ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" -ENGINE_SORTING_KEY "org_id, env, customer_id, event_name, timestamp" \ No newline at end of file +ENGINE_SORTING_KEY "org_id, env, customer_id, event_name, timestamp" + +FORWARD_QUERY > + SELECT + id, + org_id, + org_slug, + internal_customer_id, + env, + created_at, + timestamp, + event_name, + idempotency_key, + value, + set_usage, + entity_id, + internal_entity_id, + customer_id, + properties, + defaultValueOfTypeName('Nullable(String)') AS deductions \ No newline at end of file diff --git a/server/tinybird/materializations/events_by_timestamp_mv.datasource b/server/tinybird/materializations/events_by_timestamp_mv.datasource index 286a216a5..f11a642a6 100644 --- a/server/tinybird/materializations/events_by_timestamp_mv.datasource +++ b/server/tinybird/materializations/events_by_timestamp_mv.datasource @@ -14,7 +14,7 @@ SCHEMA > `properties` Nullable(String), `idempotency_key` Nullable(String), `entity_id` String DEFAULT '', - `mutations` Nullable(String) + `deductions` Nullable(String) ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" diff --git a/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe b/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe index 9d4f65d89..4d5a6fd2c 100644 --- a/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe +++ b/server/tinybird/materializations/events_by_timestamp_mv_pipe.pipe @@ -15,7 +15,7 @@ SQL > properties, idempotency_key, coalesce(entity_id, '') as entity_id, - mutations + deductions FROM events ORDER BY timestamp DESC, id DESC diff --git a/server/tinybird/pipes/list_events_paginated.pipe b/server/tinybird/pipes/list_events_paginated.pipe index f21a1acff..f6a88289b 100644 --- a/server/tinybird/pipes/list_events_paginated.pipe +++ b/server/tinybird/pipes/list_events_paginated.pipe @@ -19,7 +19,7 @@ SQL > properties, idempotency_key, entity_id, - mutations + deductions FROM events_by_timestamp_mv WHERE org_id = {{ String(org_id, '') }} diff --git a/shared/api/balances/track/trackResponseV3.ts b/shared/api/balances/track/trackResponseV3.ts index 8e7cdd29b..b32c54a2d 100644 --- a/shared/api/balances/track/trackResponseV3.ts +++ b/shared/api/balances/track/trackResponseV3.ts @@ -1,21 +1,21 @@ import { z } from "zod/v4"; import { ApiBalanceV1Schema } from "../../customers/cusFeatures/apiBalanceV1.js"; -export const TrackMutationSchema = z.object({ +export const TrackDeductionSchema = z.object({ balance_id: z.string().meta({ description: - "ID of the underlying balance row that was mutated (customer_entitlement or rollover).", + "ID of the underlying balance row that was deducted from (customer_entitlement or rollover).", }), feature_id: z.string().meta({ description: "The feature this balance belongs to.", }), value: z.number().meta({ description: - "Amount consumed from this balance. Positive when usage was deducted, negative when credit was restored (e.g. a negative track value).", + "Amount deducted from this balance. Positive when usage was consumed, negative when credit was restored (e.g. a refund via negative track value).", }), }); -export type TrackMutation = z.infer; +export type TrackDeduction = z.infer; /** * Track response V3 - uses ApiBalanceV1 (V2.1 format) @@ -48,7 +48,7 @@ export const TrackResponseV3Schema = z.object({ description: "Map of feature_id to updated balance for the tracked feature and any related features (e.g. linked credit systems). Value is null when the customer has no balance for that feature.", }), - mutations: z.array(TrackMutationSchema).optional().meta({ + deductions: z.array(TrackDeductionSchema).optional().meta({ description: "Per-balance breakdown of what this event deducted. A single event can consume from multiple balance rows when credit systems or rollovers are involved; this surfaces each one so callers can build per-feature usage views without polling.", }), diff --git a/shared/api/events/list/eventsListResponse.ts b/shared/api/events/list/eventsListResponse.ts index 5ecd6695e..a56174efc 100644 --- a/shared/api/events/list/eventsListResponse.ts +++ b/shared/api/events/list/eventsListResponse.ts @@ -1,6 +1,6 @@ import { createPagePaginatedResponseSchema } from "@api/common/pagePaginationSchemas"; import { z } from "zod/v4"; -import { TrackMutationSchema } from "../../balances/track/trackResponseV3"; +import { TrackDeductionSchema } from "../../balances/track/trackResponseV3"; export const EVENTS_LIST_EXAMPLE = { list: [ @@ -11,7 +11,7 @@ export const EVENTS_LIST_EXAMPLE = { customer_id: "0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx", value: 30, properties: {}, - mutations: [ + deductions: [ { balance_id: "cus_ent_3DdSDtFBlvDbjyUuJeUIbQlyN12", feature_id: "credits", @@ -26,7 +26,7 @@ export const EVENTS_LIST_EXAMPLE = { customer_id: "0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx", value: 49, properties: {}, - mutations: null, + deductions: null, }, ], total: 2, @@ -48,11 +48,11 @@ export const ApiEventsListItemSchema = z.object({ properties: z .record(z.string(), z.unknown()) .describe("Event properties (JSON)"), - mutations: z - .array(TrackMutationSchema) + deductions: z + .array(TrackDeductionSchema) .nullable() .describe( - "Per-balance breakdown of what this event consumed. Null for events ingested before mutations were tracked; an empty array means the event was accepted but no balance moved.", + "Per-balance breakdown of what this event deducted. Null for events ingested before deductions were tracked; an empty array means the event was accepted but no balance moved.", ), }); diff --git a/shared/models/eventModels/eventTable.ts b/shared/models/eventModels/eventTable.ts index 210f63011..5f574a2c2 100644 --- a/shared/models/eventModels/eventTable.ts +++ b/shared/models/eventModels/eventTable.ts @@ -11,7 +11,7 @@ import { timestamp, unique, } from "drizzle-orm/pg-core"; -import type { TrackMutation } from "../../api/balances/track/trackResponseV3.js"; +import type { TrackDeduction } from "../../api/balances/track/trackResponseV3.js"; import { customers } from "../cusModels/cusTable.js"; export const events = pgTable( @@ -35,7 +35,7 @@ export const events = pgTable( // Optional stuff... customer_id: text("customer_id").notNull(), properties: jsonb().$type>(), - mutations: jsonb().$type(), + deductions: jsonb().$type(), }, (table) => [ foreignKey({ From 542a0fa7e4eaf518a8e3634832be032f2a961a5b Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 13 May 2026 11:23:51 +0100 Subject: [PATCH 36/36] =?UTF-8?q?fix:=20=F0=9F=90=9B=20address=20pr=20comm?= =?UTF-8?q?ents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/validate-schema.yml | 41 +++++++++++++++++++++------ 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/validate-schema.yml b/.github/workflows/validate-schema.yml index 24ca98076..291f9e806 100644 --- a/.github/workflows/validate-schema.yml +++ b/.github/workflows/validate-schema.yml @@ -4,34 +4,57 @@ on: pull_request: branches: - main - paths: - - "shared/**" - - "server/**" push: branches: - main - paths: - - "shared/**" - - "server/**" jobs: - validate-schema: - name: Validate Schema + changes: + name: Detect schema-relevant changes runs-on: ubuntu-latest - + outputs: + schema: ${{ steps.filter.outputs.schema }} steps: - name: Checkout code uses: actions/checkout@v4 + - name: Filter paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + schema: + - 'shared/**' + - 'server/**' + - 'scripts/migrations/**' + - '.github/workflows/validate-schema.yml' + + validate-schema: + name: Validate Schema + needs: changes + runs-on: ubuntu-latest + + steps: + - name: Skip (no schema-relevant changes) + if: needs.changes.outputs.schema != 'true' + run: echo "No schema-relevant changes detected; skipping validation." + + - name: Checkout code + if: needs.changes.outputs.schema == 'true' + uses: actions/checkout@v4 + - name: Set up Bun + if: needs.changes.outputs.schema == 'true' uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.10 - name: Install dependencies + if: needs.changes.outputs.schema == 'true' run: bun install - name: Validate schema + if: needs.changes.outputs.schema == 'true' env: DATABASE_URL: ${{ secrets.DATABASE_READ_ONLY_URL }} DB_MAX_CONNECTIONS: 1