From a099903259f087de2539ed581ea2ea64dfb55107 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 15 May 2026 19:55:41 +0100 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=F0=9F=90=9B=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handleMarketplaceInvoiceNotPaid.ts | 43 ++++++++++--------- .../cusProducts/repos/getByStripeSubId.ts | 4 +- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts index 31d46225f..093acc80b 100644 --- a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts +++ b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts @@ -1,3 +1,4 @@ +import type { FullCustomer } from "@autumn/shared"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { isFirstSubscriptionInvoice } from "@/external/stripe/invoices/utils/classifyStripeInvoice.js"; @@ -49,6 +50,7 @@ export const handleMarketplaceInvoiceNotPaid = async ({ ); let customPaymentMethod: Stripe.PaymentMethod | null = null; + let customer: FullCustomer | null = null; try { const partialCustomer = await CusService.getByStripeId({ @@ -63,7 +65,7 @@ export const handleMarketplaceInvoiceNotPaid = async ({ throw new Error("Customer not found"); } - const customer = await CusService.getFull({ + customer = await CusService.getFull({ ctx, idOrInternalId: partialCustomer.internal_id, }); @@ -105,26 +107,6 @@ export const handleMarketplaceInvoiceNotPaid = async ({ }); } - // If this is the first invoice for the subscription and it failed, expire the - // optimistically-provisioned cus_product so the customer falls back to the default plan. - // Renewals are handled by Stripe dunning + customer.subscription.deleted webhook. - if (isFirstSubscriptionInvoice(invoice)) { - const existingCusProducts = await customerProductRepo.getByStripeSubId({ - db, - stripeSubId: subscription.id, - orgId: org.id, - env, - }); - - if (existingCusProducts.length > 0) { - await customerProductActions.expireAndActivateDefault({ - ctx, - customerProduct: existingCusProducts[0], - fullCustomer: customer, - }); - } - } - const product = await ProductService.getFull({ db, orgId: org.id, @@ -145,6 +127,25 @@ export const handleMarketplaceInvoiceNotPaid = async ({ // Continue anyway - we still need to report payment } + // Expire optimistically-provisioned cus_product on first-invoice failure. + // Must run outside the broad catch — swallowing this leaves active access after payment failure. + if (customer && isFirstSubscriptionInvoice(invoice)) { + const existingCusProducts = await customerProductRepo.getByStripeSubId({ + db, + stripeSubId: subscription.id, + orgId: org.id, + env, + }); + + if (existingCusProducts.length > 0) { + await customerProductActions.expireAndActivateDefault({ + ctx, + customerProduct: existingCusProducts[0], + fullCustomer: customer, + }); + } + } + if (!customPaymentMethod) { throw new Error( "Cannot resolve custom payment method for failed-invoice payment record", diff --git a/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts b/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts index 7f4954bde..3abdd940f 100644 --- a/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts +++ b/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts @@ -21,9 +21,9 @@ export const getByStripeSubId = async ({ inStatuses?: string[]; }): Promise => { const data = await db.query.customerProducts.findMany({ - where: (_table, { and: dAnd, or: dOr, inArray: dInArray }) => + where: (_table, { and: dAnd, inArray: dInArray }) => dAnd( - dOr(arrayContains(customerProducts.subscription_ids, [stripeSubId])), + arrayContains(customerProducts.subscription_ids, [stripeSubId]), inStatuses ? dInArray(customerProducts.status, inStatuses) : undefined, ), with: { From 1bd2277f3577f3c8c84f9aafc39ce6e60a50d2b5 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Mon, 18 May 2026 18:37:10 +0100 Subject: [PATCH 02/10] chore: cleanup migration ui --- bun.lock | 52 --- vite/src/hooks/queries/useMigrationsQuery.tsx | 1 + .../migration-list/MigrationListTable.tsx | 4 +- .../hooks/useRealtimeSubscriptions.ts | 1 + .../migration/live/MigrationLiveView.tsx | 300 +++++++++++++++++- .../migration/operations/OperationsForm.tsx | 72 ++--- .../migration/operations/UpdatePlanOpForm.tsx | 19 +- 7 files changed, 346 insertions(+), 103 deletions(-) diff --git a/bun.lock b/bun.lock index 02161d9ec..fe8140660 100644 --- a/bun.lock +++ b/bun.lock @@ -5884,14 +5884,8 @@ "@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], - "@autumn/openapi/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@autumn/openapi/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - "@autumn/scripts/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@autumn/server/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@autumn/server/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="], @@ -5902,8 +5896,6 @@ "@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="], - "@autumn/shared/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@autumn/vite/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], "@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], @@ -6884,8 +6876,6 @@ "atmn/@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], - "atmn/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "atmn/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "atmn/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="], @@ -6900,8 +6890,6 @@ "atmn/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "atmn-tests/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "autumn-js/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], "autumn-js/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -6964,8 +6952,6 @@ "checkout/@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], - "checkout/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "checkout/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "checkout/@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -7674,12 +7660,6 @@ "@asyncapi/parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@autumn/openapi/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "@autumn/scripts/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "@autumn/server/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "@autumn/server/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "@autumn/server/@typescript/native-preview/@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw=="], @@ -7720,8 +7700,6 @@ "@autumn/server/ink/widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], - "@autumn/shared/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@autumn/website/eslint/@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], @@ -8564,12 +8542,8 @@ "artillery/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "atmn-tests/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "atmn/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], - "atmn/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "atmn/@typescript/native-preview/@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw=="], "atmn/@typescript/native-preview/@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260511.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zIe31OYgBvkgTIQEwJtKim6SYyuVTkr+9fK/87hVwKN15X3Ikjeh0C0g2W/Vl4rXeMvy95wBGDN1jpW11DIvgg=="], @@ -8656,8 +8630,6 @@ "checkout/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], - "checkout/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "checkout/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "cli-table3/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -9210,16 +9182,10 @@ "@artilleryio/int-core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], - "@autumn/openapi/@types/bun/bun-types/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - - "@autumn/scripts/@types/bun/bun-types/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - "@autumn/server/ink/@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "@autumn/server/ink/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "@autumn/shared/@types/bun/bun-types/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - "@autumn/website/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], "@aws-sdk/client-sso-oidc/@aws-sdk/core/@smithy/signature-v4/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="], @@ -9686,10 +9652,6 @@ "artillery/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "atmn-tests/@types/bun/bun-types/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - - "atmn/@types/bun/bun-types/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - "atmn/eslint-plugin-react-hooks/eslint/@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], "atmn/eslint-plugin-react-hooks/eslint/@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], @@ -9726,8 +9688,6 @@ "ava/cli-truncate/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "checkout/@types/bun/bun-types/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "concurrently/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -9916,12 +9876,6 @@ "@artilleryio/int-core/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "@autumn/openapi/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - - "@autumn/scripts/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - - "@autumn/shared/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@4.1.3", "", { "dependencies": { "@smithy/protocol-http": "^4.1.8", "@smithy/querystring-builder": "^3.0.11", "@smithy/types": "^3.7.2", "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA=="], "@aws-sdk/client-sso-oidc/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from": ["@smithy/util-buffer-from@3.0.0", "", { "dependencies": { "@smithy/is-array-buffer": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA=="], @@ -10058,10 +10012,6 @@ "artillery/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "atmn-tests/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - - "atmn/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "atmn/eslint-plugin-react-hooks/eslint/@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "atmn/eslint-plugin-react-hooks/eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -10078,8 +10028,6 @@ "atmn/eslint-plugin-react-hooks/eslint/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "checkout/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "find-cache-dir/pkg-dir/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], "ink-confirm-input/ink-text-input/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], diff --git a/vite/src/hooks/queries/useMigrationsQuery.tsx b/vite/src/hooks/queries/useMigrationsQuery.tsx index 36d819228..e26122959 100644 --- a/vite/src/hooks/queries/useMigrationsQuery.tsx +++ b/vite/src/hooks/queries/useMigrationsQuery.tsx @@ -96,6 +96,7 @@ export const useMigrationsQuery = () => { limit?: number; only?: string[]; concurrency?: number; + lazy_run?: boolean; }) => { const { data } = await axiosInstance.post<{ migration_id: string; diff --git a/vite/src/views/migrations/migration-list/MigrationListTable.tsx b/vite/src/views/migrations/migration-list/MigrationListTable.tsx index 44c9db333..a687ef7a7 100644 --- a/vite/src/views/migrations/migration-list/MigrationListTable.tsx +++ b/vite/src/views/migrations/migration-list/MigrationListTable.tsx @@ -23,12 +23,10 @@ export function MigrationListTable() { }, }); - const hasRows = table.getRowModel().rows.length > 0; - const getRowHref = (row: Migration) => pushPage({ path: `/migrations/${row.id}` }); - if (!isLoading && !hasRows) { + if (!isLoading && migrations.length === 0) { return ( [ diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index de8381518..f380510eb 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -8,6 +8,7 @@ import { CaretDownIcon, CaretLeftIcon, CaretRightIcon, + CheckIcon, EyeIcon, ListMagnifyingGlassIcon, PlayIcon, @@ -23,6 +24,7 @@ import { Table } from "@/components/general/table"; import { Badge } from "@/components/v2/badges/Badge"; import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { Dialog, DialogContent, @@ -165,6 +167,13 @@ export function MigrationLiveView({ const [dismissedError, setDismissedError] = useState(null); const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [sample, setSample] = useState({ + open: false, + mode: "limit" as "limit" | "select", + limit: "10", + customerIds: [] as string[], + running: null as "dry" | "live" | null, + }); const { cancelRun, isCanceling } = useMigrationsQuery(); const debouncedSetSearch = useMemo( @@ -384,7 +393,7 @@ export function MigrationLiveView({ + + +
+ {sample.mode === "limit" ? ( +
+ + + setSample((s) => ({ + ...s, + limit: e.target.value, + })) + } + placeholder="10" + /> + + + {Math.min( + Number(sample.limit) || 0, + enrichedCustomers.filter((c) => !c._event).length, + )}{" "} + customers + +
+ ) : ( +
+ + + setSample((s) => ({ ...s, customerIds: ids })) + } + /> +
+ )} +
+ + + { + setSample((s) => ({ ...s, running: "dry" })); + if (sample.mode === "limit") { + const topIds = enrichedCustomers + .filter((c) => !c._event) + .slice(0, Number(sample.limit)) + .map((c) => c.id ?? c.internal_id); + await triggerRun({ + dryRun: true, + only: topIds, + }); + } else { + await triggerRun({ + dryRun: true, + only: sample.customerIds, + }); + } + setSample((s) => ({ ...s, running: null, open: false })); + }} + > + + Dry Run{" "} + {sample.mode === "limit" + ? `(${sample.limit || 0})` + : `(${sample.customerIds.length})`} + + { + setSample((s) => ({ ...s, running: "live" })); + if (sample.mode === "limit") { + await triggerRun({ + dryRun: false, + limit: Number(sample.limit), + }); + } else { + await triggerRun({ + dryRun: false, + only: sample.customerIds, + }); + } + setSample((s) => ({ ...s, running: null, open: false })); + }} + > + + Run Live{" "} + {sample.mode === "limit" + ? `(${sample.limit || 0})` + : `(${sample.customerIds.length})`} + + + +
@@ -596,3 +766,127 @@ export function MigrationLiveView({
); } + +function SampleCustomerPreview({ + customers, + limit, +}: { + customers: CustomerRow[]; + limit: number; +}) { + const unrun = customers.filter((c) => !c._event); + const previewed = unrun.slice(0, limit); + if (limit === 0) return null; + return ( +
+ {previewed.length === 0 ? ( +
+ No customers to preview +
+ ) : ( + previewed.map((c) => ( +
+ + {c.name || c.id || c.internal_id} + + {c.email && ( + + {c.email} + + )} +
+ )) + )} +
+ ); +} + +function SampleCustomerPicker({ + customers, + selectedIds, + onChange, +}: { + customers: CustomerRow[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}) { + const [search, setSearch] = useState(""); + const filtered = useMemo(() => { + if (!search) return customers; + const q = search.toLowerCase(); + return customers.filter( + (c) => + c.name?.toLowerCase().includes(q) || + c.id?.toLowerCase().includes(q) || + c.email?.toLowerCase().includes(q), + ); + }, [customers, search]); + + const toggle = (id: string) => { + onChange( + selectedIds.includes(id) + ? selectedIds.filter((v) => v !== id) + : [...selectedIds, id], + ); + }; + + return ( +
+ setSearch(e.target.value)} + placeholder="Search customers..." + className="text-sm" + /> +
+ {filtered.length === 0 ? ( +
+ No customers found +
+ ) : ( + filtered.map((c) => { + const isSelected = selectedIds.includes(c.id ?? c.internal_id); + return ( + + ); + }) + )} +
+ + {selectedIds.length} selected + +
+ ); +} diff --git a/vite/src/views/migrations/migration/operations/OperationsForm.tsx b/vite/src/views/migrations/migration/operations/OperationsForm.tsx index 43beb28a3..361d94faf 100644 --- a/vite/src/views/migrations/migration/operations/OperationsForm.tsx +++ b/vite/src/views/migrations/migration/operations/OperationsForm.tsx @@ -10,6 +10,7 @@ import { CheckIcon, PackageIcon, PencilSimpleIcon, + PlusIcon, } from "@phosphor-icons/react"; import { @@ -19,6 +20,7 @@ import { DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { ActionCard } from "../shared/ActionCard"; +import { DASHED_BUTTON_CLASS } from "../shared/AddButton"; import { AutumnMark, StripeMark } from "../shared/BillingScopeMarks"; import { AddPlansSection } from "./AddPlanOpForm"; import { UpdatePlanOpForm } from "./UpdatePlanOpForm"; @@ -184,45 +186,41 @@ export function OperationsForm({ )} -
- Add Operation -
- - } - heading="Update Plan" - subheading="Modify existing customer plans" - onClick={() => - setOperations([...operations, DEFAULT_UPDATE_PLAN]) - } - className="flex-1" - /> - {!hasAddPlans && ( - - } - heading="Add Plan" - subheading="Assign a new plan to customers" +
+ + + + Add Operation + + + - setOperations([ - ...operations, - { type: "add_plan", plan_id: "" }, - ]) + setOperations([...operations, DEFAULT_UPDATE_PLAN]) } - className="flex-1" - /> - )} -
+ > + + Update Plan + + {!hasAddPlans && ( + + setOperations([ + ...operations, + { type: "add_plan", plan_id: "" }, + ]) + } + > + + Add Plan + + )} + +
)} diff --git a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx index d2bc70415..596dfb2a6 100644 --- a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx +++ b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx @@ -42,18 +42,21 @@ import { import { RemoveItemRows } from "./RemoveItemRows"; function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) { - const { products } = useProductsQuery(); + const { products } = useProductsQuery({ allVersions: true }); - const targetPlanId = - typeof planFilter.plan_id === "string" && planFilter.plan_id - ? planFilter.plan_id - : null; + const targetIds = extractPlanIds(planFilter.plan_id); + const idSet = new Set(targetIds); - const matchingProducts = targetPlanId - ? products.filter((p) => p.id === targetPlanId) - : []; + const matchingProducts = + idSet.size > 0 ? products.filter((p) => idSet.has(p.id)) : []; + const seen = new Set(); return matchingProducts + .filter((p) => { + if (seen.has(p.version)) return false; + seen.add(p.version); + return true; + }) .map((p) => ({ value: String(p.version), label: `v${p.version}`, From e3b5e238bbf5581647d3c52db06cec352991ad47 Mon Sep 17 00:00:00 2001 From: Charlie Lamb <76403782+charlietlamb@users.noreply.github.com> Date: Mon, 18 May 2026 18:48:52 +0100 Subject: [PATCH 03/10] chore: fix invoice refund select z-index in dialog (#1601) Replace SearchableSelect (Popover-based) with Select (base-ui Select) in RefundInvoiceDialog to fix dropdown rendering behind the dialog overlay. --- .../components/sheets/InvoiceDetailSheet.tsx | 16 +++++----- .../components/sheets/RefundInvoiceDialog.tsx | 32 ++++++++++++------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx index 683e9caa8..e7b7e9657 100644 --- a/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx @@ -389,14 +389,14 @@ export function InvoiceDetailSheet({ )}
- {canRefund && ( - - )} - + {canRefund && ( + + )} + ); } diff --git a/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx b/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx index eac11643d..e3f1b2fab 100644 --- a/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx +++ b/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx @@ -12,7 +12,13 @@ import { DialogTitle, } from "@/components/v2/dialogs/Dialog"; import { Input } from "@/components/v2/inputs/Input"; -import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/v2/selects/Select"; import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; @@ -20,8 +26,6 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; type RefundMode = "full" | "partial"; -const REFUND_MODE_OPTIONS: RefundMode[] = ["full", "partial"]; - export function RefundInvoiceDialog({ open, onOpenChange, @@ -112,15 +116,19 @@ export function RefundInvoiceDialog({ Refund type - - value={mode} - onValueChange={(value) => setMode(value as RefundMode)} - options={REFUND_MODE_OPTIONS} - getOptionValue={(option) => option} - getOptionLabel={(option) => - option === "full" ? "Full" : "Partial" - } - /> + {mode === "partial" && ( From 0b8f8140bea16fa5869d2b6026ec273c6c528040 Mon Sep 17 00:00:00 2001 From: Charlie Lamb <76403782+charlietlamb@users.noreply.github.com> Date: Mon, 18 May 2026 18:53:49 +0100 Subject: [PATCH 04/10] chore: fix chart height (#1603) --- .../views/customers/customer/analytics/AnalyticsView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx index 0c311f76f..a9abd41f9 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx @@ -322,7 +322,7 @@ export const AnalyticsView = () => { {showRevenueMetrics && } -
+
@@ -338,9 +338,9 @@ export const AnalyticsView = () => {
)} -
+
{chartData && chartData.data.length > 0 && ( -
+
Date: Mon, 18 May 2026 19:42:14 +0100 Subject: [PATCH 05/10] Revert "Tinybird reads cutover us east" --- scripts/setup/AGENT_README.md | 2 +- scripts/setup/writeAgentEnv.ts | 2 +- .../src/external/tinybird/initClickhouse.ts | 4 +-- .../src/external/tinybird/initTinybirdV2.ts | 22 +++++++------- .../tinybird/sendEvents/sendEvents.ts | 29 ++++++++++--------- server/src/external/tinybird/tinybirdUtils.ts | 4 +-- 6 files changed, 33 insertions(+), 30 deletions(-) diff --git a/scripts/setup/AGENT_README.md b/scripts/setup/AGENT_README.md index 5e9de40de..aabcf0130 100644 --- a/scripts/setup/AGENT_README.md +++ b/scripts/setup/AGENT_README.md @@ -58,7 +58,7 @@ Set these in the process environment before running `bun dev:agent` and they wil | PostgreSQL | 5432 | Database: `autumn`, user: `postgres`, password: `postgres` | | Redis Stack | 6379 | Used for `CACHE_URL` and `CACHE_URL_US_EAST` (RedisJSON required) | | ElasticMQ | 9324 | Local SQS replacement, queue: `autumn.fifo` | -| ClickHouse | 8123 | Used for `TINYBIRD_US_EAST_CLICKHOUSE_URL` | +| ClickHouse | 8123 | Used for `TINYBIRD_CLICKHOUSE_URL` | | Server | 8080 | Autumn API server | | Vite | 3000 | Frontend dev server | | Checkout | 3001 | Checkout app dev server | diff --git a/scripts/setup/writeAgentEnv.ts b/scripts/setup/writeAgentEnv.ts index 082f40e35..8a09837fb 100644 --- a/scripts/setup/writeAgentEnv.ts +++ b/scripts/setup/writeAgentEnv.ts @@ -67,7 +67,7 @@ AWS_ACCESS_KEY_ID=x AWS_SECRET_ACCESS_KEY=x # ClickHouse (local) -TINYBIRD_US_EAST_CLICKHOUSE_URL=http://localhost:8123 +TINYBIRD_CLICKHOUSE_URL=http://localhost:8123 # App URLs BETTER_AUTH_URL=http://localhost:${serverPort} diff --git a/server/src/external/tinybird/initClickhouse.ts b/server/src/external/tinybird/initClickhouse.ts index d05f3454b..5afb4f595 100644 --- a/server/src/external/tinybird/initClickhouse.ts +++ b/server/src/external/tinybird/initClickhouse.ts @@ -3,8 +3,8 @@ import { type ClickHouseClient, createClient } from "@clickhouse/client"; // ClickHouse URL is different from API URL // API: https://api.europe-west2.gcp.tinybird.co // ClickHouse: https://europe-west2.gcp.clickhouse.tinybird.co -const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_US_EAST_CLICKHOUSE_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; +const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_CLICKHOUSE_URL; +const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; // Debug logging if (TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN) { diff --git a/server/src/external/tinybird/initTinybirdV2.ts b/server/src/external/tinybird/initTinybirdV2.ts index 4248732f7..e2984697d 100644 --- a/server/src/external/tinybird/initTinybirdV2.ts +++ b/server/src/external/tinybird/initTinybirdV2.ts @@ -1,19 +1,19 @@ import { createTinybirdApi } from "@tinybirdco/sdk"; -// Dual-write safety net during the us-east cutover. Reads the legacy -// us-west env vars; delete with the dual-write logic in `sendEvents.ts` -// once us-east is stable. -const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; +const TINYBIRD_US_EAST_API_URL = process.env.TINYBIRD_US_EAST_API_URL; +const TINYBIRD_US_EAST_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; -/** Secondary Tinybird API client for dual-write during region cutover. */ -export const tinybirdSecondaryApi = - TINYBIRD_API_URL && TINYBIRD_TOKEN - ? createTinybirdApi({ baseUrl: TINYBIRD_API_URL, token: TINYBIRD_TOKEN }) +/** Secondary Tinybird API client (us-east region) for dual-write during migration. */ +export const tinybirdUsEastApi = + TINYBIRD_US_EAST_API_URL && TINYBIRD_US_EAST_TOKEN + ? createTinybirdApi({ + baseUrl: TINYBIRD_US_EAST_API_URL, + token: TINYBIRD_US_EAST_TOKEN, + }) : null; -if (tinybirdSecondaryApi) { +if (tinybirdUsEastApi) { console.log( - `[Tinybird] secondary dual-write configured with URL: ${TINYBIRD_API_URL}`, + `[Tinybird] us-east dual-write configured with URL: ${TINYBIRD_US_EAST_API_URL}`, ); } diff --git a/server/src/external/tinybird/sendEvents/sendEvents.ts b/server/src/external/tinybird/sendEvents/sendEvents.ts index 60a32f93c..a30a31137 100644 --- a/server/src/external/tinybird/sendEvents/sendEvents.ts +++ b/server/src/external/tinybird/sendEvents/sendEvents.ts @@ -3,7 +3,7 @@ import type { EventInsert } from "@autumn/shared"; import * as Sentry from "@sentry/bun"; import type { Logger } from "@/external/logtail/logtailUtils.js"; import { tinybirdIngest } from "../initTinybird.js"; -import { tinybirdSecondaryApi } from "../initTinybirdV2.js"; +import { tinybirdUsEastApi } from "../initTinybirdV2.js"; import { isTinybirdConfigured } from "../tinybirdUtils.js"; import { mapToTinybirdEvent } from "./mapEvent.js"; @@ -35,7 +35,7 @@ export const sendEventsToTinybird = async ({ const tinybirdEvents = events.map(mapToTinybirdEvent); - const reportFailure = (error: unknown, region: "primary" | "secondary") => { + const reportFailure = (error: unknown, region: "primary" | "us-east") => { const errorId = generateErrorId(); const errorMessage = error instanceof Error ? error.message : String(error); @@ -81,21 +81,24 @@ export const sendEventsToTinybird = async ({ }) .catch((error: unknown) => reportFailure(error, "primary")); - const secondaryWrite = tinybirdSecondaryApi - ? tinybirdSecondaryApi + const usEastWrite = tinybirdUsEastApi + ? tinybirdUsEastApi .ingestBatch("events", tinybirdEvents) .then((result) => { - logger?.info(`Sent ${events.length} events to Tinybird (secondary)`, { - data: { - region: "secondary", - eventCount: events.length, - successfulRows: result?.successful_rows, - quarantinedRows: result?.quarantined_rows, + logger?.info( + `Sent ${events.length} events to Tinybird (us-east)`, + { + data: { + region: "us-east", + eventCount: events.length, + successfulRows: result?.successful_rows, + quarantinedRows: result?.quarantined_rows, + }, }, - }); + ); }) - .catch((error: unknown) => reportFailure(error, "secondary")) + .catch((error: unknown) => reportFailure(error, "us-east")) : Promise.resolve(); - await Promise.all([primaryWrite, secondaryWrite]); + await Promise.all([primaryWrite, usEastWrite]); }; diff --git a/server/src/external/tinybird/tinybirdUtils.ts b/server/src/external/tinybird/tinybirdUtils.ts index 1af3e38e2..b5d10c720 100644 --- a/server/src/external/tinybird/tinybirdUtils.ts +++ b/server/src/external/tinybird/tinybirdUtils.ts @@ -1,8 +1,8 @@ import { ErrCode, RecaseError } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -const TINYBIRD_API_URL = process.env.TINYBIRD_US_EAST_API_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; +const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL; +const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; export type TinybirdConfig = { baseUrl: string; From 6f48b4724706506fe682ce4771bbfcbb81417fd5 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 18 May 2026 20:14:59 +0100 Subject: [PATCH 06/10] fix: sync entity ID --- .../sync/compute/computeSyncFuturePhases.ts | 1 + .../initImmediateSyncCustomerProduct.ts | 1 + .../initCustomerProduct.ts | 10 +- .../initScheduledCustomerProduct.ts | 3 + .../_temp/sync-v2-entity-binding.test.ts | 98 +++++++++++++++++++ .../initFullCustomerProductContext.ts | 3 + 6 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 server/tests/_temp/sync-v2-entity-binding.test.ts diff --git a/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts index 33621f116..6b869629f 100644 --- a/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts +++ b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts @@ -83,6 +83,7 @@ export const computeSyncFuturePhases = ({ accessStartsAt: productContext.accessStartsAt, subscriptionId: stripeSubscription?.id, subscriptionScheduleId: stripeSchedule?.id, + internalEntityId: productContext.plan.internal_entity_id, }); insertCustomerProducts.push(cusProduct); phaseIds.push(cusProduct.id); diff --git a/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts index 7e2424a24..cfad5f3ee 100644 --- a/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts @@ -67,6 +67,7 @@ export const initImmediateSyncCustomerProduct = ({ status: stripeSubscriptionToAutumnStatus({ stripeStatus: stripeSubscription.status, }), + internalEntityId: plan.internal_entity_id, }, }); }; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index 45b066894..aa5ae0e43 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -40,8 +40,14 @@ export const initCustomerProduct = ({ onTrialEnd, } = initOptions ?? {}; - const internalEntityId = fullCustomer.entity?.internal_id; - const entityId = fullCustomer.entity?.id; + const internalEntityId = + initOptions?.internalEntityId ?? fullCustomer.entity?.internal_id; + const entityId = + initOptions?.internalEntityId && initOptions.internalEntityId !== fullCustomer.entity?.internal_id + ? fullCustomer.entities?.find( + (e) => e.internal_id === initOptions.internalEntityId, + )?.id + : fullCustomer.entity?.id; const startsAt = initOptions?.startsAt ?? now; const endedAt = initOptions?.endedAt; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts index 01ffc2995..866554adc 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts @@ -28,6 +28,7 @@ export const initScheduledCustomerProduct = ({ externalId, subscriptionId, subscriptionScheduleId, + internalEntityId, }: { ctx: AutumnContext; fullCustomer: FullCustomer; @@ -44,6 +45,7 @@ export const initScheduledCustomerProduct = ({ * Stripe linkage and downstream actions (cancel, restore) can find it. */ subscriptionId?: string; subscriptionScheduleId?: string; + internalEntityId?: string; }): FullCusProduct => { return initFullCustomerProduct({ ctx, @@ -64,6 +66,7 @@ export const initScheduledCustomerProduct = ({ externalId, subscriptionId, subscriptionScheduleId, + internalEntityId, }, }); }; diff --git a/server/tests/_temp/sync-v2-entity-binding.test.ts b/server/tests/_temp/sync-v2-entity-binding.test.ts new file mode 100644 index 000000000..195c0e55d --- /dev/null +++ b/server/tests/_temp/sync-v2-entity-binding.test.ts @@ -0,0 +1,98 @@ +/** + * TDD repro for syncV2 entity binding bug. + * + * Bug: When the caller passes `phases[].plans[].internal_entity_id` to + * `billing.sync_v2`, the inserted customer product is NOT bound to that + * entity. `initCustomerProduct` sources `internal_entity_id` only from + * `fullCustomer.entity` (the customer's currently-set entity context), + * ignoring the plan's intent. The data DOES flow through SyncPlanInstance → + * SyncProductContext.plan (it's even logged in logSyncContext), but + * `initImmediateSyncCustomerProduct` never threads it down to + * `initFullCustomerProduct`, and `InitFullCustomerProductOptions` has no + * field to receive it. + * + * Red (current): cusProduct.internal_entity_id is null after sync. + * Green (after fix): cusProduct.internal_entity_id === plan.internal_entity_id. + */ + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { createStripeSubscriptionFromProduct } from "@tests/integration/billing/sync/utils/syncTestUtils"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { CusService } from "@/internal/customers/CusService"; +import { EntityService } from "@/internal/api/entities/EntityService"; + +test( + chalk.yellowBright( + "sync-v2 entity binding: plan.internal_entity_id must propagate to inserted customer product", + ), + async () => { + const customerId = "sync-v2-entity-bind"; + + const pro = products.pro({ + id: "sync-v2-entity-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const fullCustomerBefore = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + const entityList = await EntityService.list({ + db: ctx.db, + internalCustomerId: fullCustomerBefore.internal_id, + }); + expect(entityList.length).toBeGreaterThan(0); + const targetEntity = entityList[0]; + const targetEntityInternalId = targetEntity.internal_id; + + const stripeSubscription = await createStripeSubscriptionFromProduct({ + ctx, + customerId, + productId: pro.id, + }); + expect(stripeSubscription.status).toBe("active"); + + await autumnV1.post("/billing.sync_v2", { + customer_id: customerId, + stripe_subscription_id: stripeSubscription.id, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: pro.id, + internal_entity_id: targetEntityInternalId, + }, + ], + }, + ], + }); + + const fullCustomerAfter = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + const cusProduct = fullCustomerAfter.customer_products.find( + (cp) => cp.product_id === pro.id, + ); + expect(cusProduct).toBeDefined(); + expect(cusProduct!.internal_entity_id).toBe(targetEntityInternalId); + }, +); diff --git a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts index 806252f7c..f468b5287 100644 --- a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts @@ -80,6 +80,9 @@ export interface InitFullCustomerProductOptions { /** When true, preserve subscription_ids even for non-paid-recurring products (used by sync). */ keepSubscriptionIds?: boolean; + /** Override the entity the customer product is bound to. Used by sync to honor `plan.internal_entity_id` instead of falling back to `fullCustomer.entity`. */ + internalEntityId?: string; + previousCustomerProductId?: string; onTrialEnd?: TrialOnEnd; } From f9e475ef087e126221bc9bb81635c63698e486c7 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 18 May 2026 20:18:29 +0100 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20unlimited=20decuti?= =?UTF-8?q?ons=20+=20collapse=20plan=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analytics/actions/listByCursor.ts | 22 +- .../internal/analytics/actions/listEvents.ts | 22 +- .../handleInternalAggregateEvents.ts | 33 ++- .../utils/collapsePlanIdGroups.ts | 42 ++++ .../deductionV2/executePostgresDeductionV2.ts | 20 ++ .../deductionV2/executeRedisDeductionV2.ts | 20 ++ .../deductionV2/prepareFeatureDeductionV2.ts | 25 +++ .../balances/utils/types/deductionTypes.ts | 5 + .../src/internal/products/ProductService.ts | 31 ++- .../internal/products/productCacheUtils.ts | 28 ++- server/tests/clearMasterOrg.ts | 16 ++ ...track-unlimited-cusEnt-attribution.test.ts | 204 ++++++++++++++++++ .../customer/analytics/AnalyticsView.tsx | 82 ++----- .../components/SelectGroupByDropdown.tsx | 123 +++++++++-- .../analytics/hooks/useAnalyticsData.tsx | 1 + 15 files changed, 568 insertions(+), 106 deletions(-) create mode 100644 server/src/internal/analytics/internalHandlers/utils/collapsePlanIdGroups.ts create mode 100644 server/tests/integration/crud/events/track-unlimited-cusEnt-attribution.test.ts diff --git a/server/src/internal/analytics/actions/listByCursor.ts b/server/src/internal/analytics/actions/listByCursor.ts index 652f34986..dc7ca5b43 100644 --- a/server/src/internal/analytics/actions/listByCursor.ts +++ b/server/src/internal/analytics/actions/listByCursor.ts @@ -105,15 +105,21 @@ export const listByCursor = async ({ if (row.deductions) { try { const parsed = JSON.parse(row.deductions); - if (Array.isArray(parsed)) { - deductions = parsed as TrackDeduction[]; - } else if (parsed && Array.isArray(parsed.list)) { - deductions = parsed.list as TrackDeduction[]; + // Tinybird's JSON column re-encodes nested-object array items + // as strings; second parse brings them back to TrackDeduction. + const rawList = Array.isArray(parsed) + ? parsed + : parsed && Array.isArray(parsed.list) + ? parsed.list + : null; + if (rawList) { + deductions = rawList.map((item: unknown) => + typeof item === "string" + ? (JSON.parse(item) as TrackDeduction) + : (item as TrackDeduction), + ); } - } catch { - // Invalid JSON — leave null so the caller can distinguish missing - // vs explicit empty. - } + } catch {} } lastRowMicros = tinybirdTimestampToEpochMicros(row.timestamp); diff --git a/server/src/internal/analytics/actions/listEvents.ts b/server/src/internal/analytics/actions/listEvents.ts index 4408c1213..d59944431 100644 --- a/server/src/internal/analytics/actions/listEvents.ts +++ b/server/src/internal/analytics/actions/listEvents.ts @@ -91,15 +91,21 @@ export const listEvents = async ({ if (row.deductions) { try { const parsed = JSON.parse(row.deductions); - if (Array.isArray(parsed)) { - deductions = parsed as TrackDeduction[]; - } else if (parsed && Array.isArray(parsed.list)) { - deductions = parsed.list as TrackDeduction[]; + // Tinybird's JSON column re-encodes nested-object array items + // as strings; second parse brings them back to TrackDeduction. + const rawList = Array.isArray(parsed) + ? parsed + : parsed && Array.isArray(parsed.list) + ? parsed.list + : null; + if (rawList) { + deductions = rawList.map((item: unknown) => + typeof item === "string" + ? (JSON.parse(item) as TrackDeduction) + : (item as TrackDeduction), + ); } - } catch { - // Invalid JSON — leave null so the caller can distinguish missing - // vs explicit empty. - } + } catch {} } return { diff --git a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts index f1aeadc5c..3acbf9b67 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts @@ -14,8 +14,10 @@ import { assertTinybirdAvailable } from "@/external/tinybird/tinybirdUtils.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { getCustomerNames } from "@/internal/analytics/actions/getCustomerNames.js"; import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js"; +import { ProductService } from "@/internal/products/ProductService.js"; import { CusService } from "@/internal/customers/CusService.js"; import { eventActions } from "../actions/eventActions.js"; +import { collapsePlanIdGroups } from "./utils/collapsePlanIdGroups.js"; const STANDARD_INTERVAL_DAYS: Record = { "24h": 1, @@ -129,6 +131,30 @@ export const handleInternalAggregateEvents = createRoute({ return { start: aligned.getTime(), end: now.getTime() }; })(); + const isPlanIdGrouping = group_by === "plan_id" || group_by === "$plan_id"; + let internalIdToPublicId: Record | undefined; + let planNames: Record | undefined; + let effectiveMaxGroups: number | undefined; + + if (isPlanIdGrouping) { + const allProductRows = await ProductService.listCachedAllVersions({ + db, + orgId: org.id, + env, + }); + effectiveMaxGroups = Math.max( + allProductRows.length + 1, + max_groups ?? 0, + 10, + ); + internalIdToPublicId = {}; + planNames = {}; + for (const p of allProductRows) { + internalIdToPublicId[p.internal_id] = p.id; + planNames[p.id] = p.name ?? p.id; + } + } + const [{ formatted: events, truncated }, totals] = await Promise.all([ eventActions.aggregate({ ctx, @@ -143,7 +169,7 @@ export const handleInternalAggregateEvents = createRoute({ group_by: group_by, customer, timezone: timezone, - max_groups, + max_groups: isPlanIdGrouping ? effectiveMaxGroups : max_groups, }, }), eventActions.getCountAndSum({ @@ -161,6 +187,10 @@ export const handleInternalAggregateEvents = createRoute({ }), ]); + if (isPlanIdGrouping && events?.data && internalIdToPublicId) { + collapsePlanIdGroups({ events, internalIdToPublicId }); + } + // When grouping by entity_id, resolve entity names from ClickHouse let entityNames: Record | undefined; if (group_by === "entity_id" && events?.data) { @@ -214,6 +244,7 @@ export const handleInternalAggregateEvents = createRoute({ truncated, entityNames, customerNames, + planNames, }); }, }); diff --git a/server/src/internal/analytics/internalHandlers/utils/collapsePlanIdGroups.ts b/server/src/internal/analytics/internalHandlers/utils/collapsePlanIdGroups.ts new file mode 100644 index 000000000..e89d915ef --- /dev/null +++ b/server/src/internal/analytics/internalHandlers/utils/collapsePlanIdGroups.ts @@ -0,0 +1,42 @@ +import { Decimal } from "decimal.js"; +import type { ClickHouseResult } from "@autumn/shared"; + +export const collapsePlanIdGroups = ({ + events, + internalIdToPublicId, +}: { + events: ClickHouseResult; + internalIdToPublicId: Record; +}) => { + const collapsed = new Map>(); + + for (const row of events.data) { + const planId = String(row.plan_id ?? ""); + const collapsedId = + planId === "" || planId === "AUTUMN_RESERVED" + ? planId + : internalIdToPublicId[planId] ?? planId; + + const key = `${row.period}|${collapsedId}`; + const existing = collapsed.get(key); + + if (!existing) { + collapsed.set(key, { + ...row, + plan_id: collapsedId, + }); + continue; + } + + for (const [k, v] of Object.entries(row)) { + if (k === "period" || k === "plan_id") continue; + existing[k] = new Decimal(existing[k] ?? 0) + .plus(new Decimal(v as number)) + .toDecimalPlaces(10) + .toNumber(); + } + } + + events.data = Array.from(collapsed.values()); + events.rows = events.data.length; +}; diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 95f010401..9f025e7fb 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -109,6 +109,7 @@ export const executePostgresDeductionV2 = async ({ rollovers, customerEntitlements, unlimitedFeatureIds, + unlimitedCusEnt, lock: preparedLock, } = prepareFeatureDeductionV2({ ctx, @@ -129,6 +130,25 @@ export const executePostgresDeductionV2 = async ({ redisInstance: ctx.redisV2, }); } + // Attribute the event to the unlimited plan even though we skip + // the actual deduction. Without this, resolveInternalProductIdForEvent + // gets an empty mutation log and the event lands in "No plan". + if (unlimitedCusEnt) { + const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1); + if (syntheticDelta !== 0) { + allMutationLogs.push({ + target_type: "customer_entitlement", + customer_entitlement_id: unlimitedCusEnt.id, + rollover_id: null, + entity_id: entityId ?? null, + credit_cost: 1, + balance_delta: syntheticDelta, + adjustment_delta: 0, + usage_delta: 0, + value_delta: 0, + }); + } + } continue; } diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 8cffcfbf1..a9501a6e4 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -120,6 +120,7 @@ export const executeRedisDeductionV2 = async ({ rollovers, customerEntitlements, unlimitedFeatureIds, + unlimitedCusEnt, lock: preparedLock, } = prepareFeatureDeductionV2({ ctx, @@ -140,6 +141,25 @@ export const executeRedisDeductionV2 = async ({ redisInstance: redisInstance ?? ctx.redisV2, }); } + // Attribute the event to the unlimited plan even though we skip + // the actual deduction. Without this, resolveInternalProductIdForEvent + // gets an empty mutation log and the event lands in "No plan". + if (unlimitedCusEnt) { + const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1); + if (syntheticDelta !== 0) { + allMutationLogs.push({ + target_type: "customer_entitlement", + customer_entitlement_id: unlimitedCusEnt.id, + rollover_id: null, + entity_id: entityId ?? null, + credit_cost: 1, + balance_delta: syntheticDelta, + adjustment_delta: 0, + usage_delta: 0, + value_delta: 0, + }); + } + } continue; } diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index fb0fc2eac..4b550de10 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -1,5 +1,7 @@ import { + AllowanceType, cusEntToStartingBalance, + type FullCusEntWithFullCusProduct, type FullSubject, fullSubjectToCustomerEntitlements, fullSubjectToOverageAllowedByFeatureId, @@ -58,6 +60,15 @@ export const prepareFeatureDeductionV2 = ({ }); const unlimitedFeatureIds: string[] = []; + // Track the chosen unlimited cusEnt so the deduction short-circuit can + // still attribute the event to a plan. Prefer cusEnts whose feature + // matches the tracked one (over credit-system parents); within that, + // take the first match in the already-sorted customerEntitlements list. + let unlimitedCusEntPrimary: FullCusEntWithFullCusProduct | undefined; + let unlimitedCusEntFallback: FullCusEntWithFullCusProduct | undefined; + const isUnlimitedCusEnt = (ce: FullCusEntWithFullCusProduct): boolean => + ce.entitlement.allowance_type === AllowanceType.Unlimited || + Boolean(ce.unlimited); for (const relevantFeature of relevantFeatures) { const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({ @@ -67,9 +78,22 @@ export const prepareFeatureDeductionV2 = ({ if (featureUnlimited) { unlimitedFeatureIds.push(relevantFeature.id); + const matchingCusEnt = customerEntitlements.find( + (ce) => + ce.internal_feature_id === relevantFeature.internal_id && + isUnlimitedCusEnt(ce), + ); + if (!matchingCusEnt) continue; + if (relevantFeature.id === feature.id) { + unlimitedCusEntPrimary ??= matchingCusEnt; + } else { + unlimitedCusEntFallback ??= matchingCusEnt; + } } } + const unlimitedCusEnt = unlimitedCusEntPrimary ?? unlimitedCusEntFallback; + const effectiveFeatureIds = relevantFeatures.map((candidate) => candidate.id); const spendLimitByFeatureId = fullSubjectToSpendLimitByFeatureId({ fullSubject, @@ -194,6 +218,7 @@ export const prepareFeatureDeductionV2 = ({ credit_cost: rollover.credit_cost, })), unlimitedFeatureIds, + unlimitedCusEnt, lock: preparedLock, }; }; diff --git a/server/src/internal/balances/utils/types/deductionTypes.ts b/server/src/internal/balances/utils/types/deductionTypes.ts index 033aea91b..58a4c5e5d 100644 --- a/server/src/internal/balances/utils/types/deductionTypes.ts +++ b/server/src/internal/balances/utils/types/deductionTypes.ts @@ -45,6 +45,11 @@ export type PreparedFeatureDeduction = { // rolloverIds: string[]; rollovers: RolloverDeduction[]; unlimitedFeatureIds: string[]; + // Chosen unlimited cusEnt to attribute events to when the deduction + // short-circuits via unlimitedFeatureIds. Prefers a cusEnt matching the + // tracked feature over a credit-system parent. Undefined when no + // unlimited cusEnt is present. + unlimitedCusEnt?: FullCusEntWithFullCusProduct; lock?: { enabled: true; lock_id?: string; diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 159baae9c..455b34139 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -28,7 +28,11 @@ import { import { StatusCodes } from "http-status-codes"; import type { Logger } from "@/external/logtail/logtailUtils"; import { queryWithCache } from "@/utils/cacheUtils/queryWithCache"; -import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils"; +import { + buildAllVersionsProductsCacheKey, + buildProductsCacheKey, + PRODUCTS_CACHE_TTL, +} from "./productCacheUtils"; import { getLatestProducts, isFreeProduct } from "./productUtils"; import { sortFullProducts } from "./productUtils/sortProductUtils"; @@ -297,6 +301,31 @@ export class ProductService { }); } + static async listCachedAllVersions({ + db, + orgId, + env, + }: { + db: DrizzleCli; + orgId: string; + env: AppEnv; + }): Promise> { + return queryWithCache({ + key: buildAllVersionsProductsCacheKey({ orgId, env }), + ttl: PRODUCTS_CACHE_TTL, + fn: () => + db + .select({ + internal_id: products.internal_id, + id: products.id, + name: products.name, + version: products.version, + }) + .from(products) + .where(and(eq(products.org_id, orgId), eq(products.env, env))), + }); + } + static async listFull({ db, orgId, diff --git a/server/src/internal/products/productCacheUtils.ts b/server/src/internal/products/productCacheUtils.ts index fcbba7803..ce2e33ea9 100644 --- a/server/src/internal/products/productCacheUtils.ts +++ b/server/src/internal/products/productCacheUtils.ts @@ -57,6 +57,17 @@ export const buildProductsCacheKey = ({ return `${prefix}:${hash}`; }; +/** Builds the cache key for all product versions (unfiltered, no joins) */ +export const buildAllVersionsProductsCacheKey = ({ + orgId, + env, +}: { + orgId: string; + env: AppEnv; +}) => { + return `${buildProductsCacheKeyPrefix({ orgId, env })}:all_versions`; +}; + /** All possible archived query param values that can be cached */ const ARCHIVED_VARIANTS = [undefined, false, true] as const; @@ -71,13 +82,16 @@ export const invalidateProductsCache = async ({ if (redis.status !== "ready") return; // Build all possible cache keys (deterministic based on archived param variants) - const keysToDelete = ARCHIVED_VARIANTS.map((archived) => - buildProductsCacheKey({ - orgId, - env, - queryParams: archived !== undefined ? { archived } : undefined, - }), - ); + const keysToDelete = [ + ...ARCHIVED_VARIANTS.map((archived) => + buildProductsCacheKey({ + orgId, + env, + queryParams: archived !== undefined ? { archived } : undefined, + }), + ), + buildAllVersionsProductsCacheKey({ orgId, env }), + ]; const regions = getConfiguredRegions(); diff --git a/server/tests/clearMasterOrg.ts b/server/tests/clearMasterOrg.ts index 2083a3c5f..84a32d624 100644 --- a/server/tests/clearMasterOrg.ts +++ b/server/tests/clearMasterOrg.ts @@ -12,7 +12,9 @@ import { initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { redis } from "@/external/redis/initRedis.js"; import { redisV2 } from "@/external/redis/initRedisV2.js"; +import { getSqsClient } from "@/queue/initSqs.js"; import { deletePlatformSubOrg } from "@/internal/orgs/deleteOrg/deletePlatformSubOrg.js"; +import { PurgeQueueCommand } from "@aws-sdk/client-sqs"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { clearOrg } from "./utils/setup/clearOrg.js"; import { setupOrg } from "./utils/setup/setupOrg.js"; @@ -138,6 +140,20 @@ export const clearMasterOrg = async () => { ); } } + const purgeQueue = async (queueUrl: string | undefined, label: string) => { + if (!queueUrl) return; + try { + const sqs = getSqsClient({ queueUrl }); + await sqs.send(new PurgeQueueCommand({ QueueUrl: queueUrl })); + console.log(chalk.green(`✅ Purged ${label} SQS queue.`)); + } catch (err) { + console.log(chalk.yellow(`⚠️ Skipped ${label} SQS purge: ${err}`)); + } + }; + + await purgeQueue(process.env.SQS_QUEUE_URL_V2, "primary"); + await purgeQueue(process.env.TRACK_SQS_QUEUE_URL, "track"); + console.log(chalk.green("\n✅ Master org setup complete!\n")); } catch (error) { console.error(chalk.red("\n❌ Error:"), error); diff --git a/server/tests/integration/crud/events/track-unlimited-cusEnt-attribution.test.ts b/server/tests/integration/crud/events/track-unlimited-cusEnt-attribution.test.ts new file mode 100644 index 000000000..580edc71a --- /dev/null +++ b/server/tests/integration/crud/events/track-unlimited-cusEnt-attribution.test.ts @@ -0,0 +1,204 @@ +/** + * TDD test: when a customer has both an unlimited and a limited cusEnt for the + * same feature, a track event currently short-circuits in + * executePostgresDeductionV2 / executeRedisDeductionV2 (`unlimitedFeatureIds.length > 0`) + * with zero mutation logs. resolveInternalProductIdForEvent then returns null, + * so the persisted event has `internal_product_id = null` and an empty + * `deductions` array — exactly the "No plan" bucket we see in Mintlify's + * analytics for Anthropic. + * + * Red-failure mode (current behavior): + * - event.internal_product_id IS NULL + * - event.deductions IS NULL (or empty) + * - track response `deductions` is empty/undefined + * + * Green-success criteria (after fix): + * - event.internal_product_id === unlimited plan's internal_id + * - event.deductions contains a synthetic entry pointing at the unlimited + * cusEnt with the track call's value (echoed entity_id when present) + * - track response `deductions` contains the same synthetic entry + * - the limited cusEnt's balance is NOT mutated (we only attribute, never + * actually deduct on the unlimited path) + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type 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 { timeout } from "@tests/utils/genUtils.js"; +import { + cleanupOrgRollout, + setOrgRolloutPercent, +} from "@tests/utils/rolloutTestUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { and, desc, eq } from "drizzle-orm"; +import { db } from "@/db/initDrizzle.js"; +import { + customerEntitlements, + events, + products as productsTable, +} from "@autumn/shared"; + +const TINYBIRD_INGEST_WAIT_MS = 3000; + +test( + `${chalk.yellowBright("unlimited-cusent-attribution: track on customer with mixed unlimited+limited entitlements attributes the event to the unlimited plan")}`, + async () => { + const customerId = `unlimited-cusent-attribution-${Date.now()}`; + + // Base plan: limited messages (100/month). + const baseProd = products.base({ + id: "base-limited", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + // Add-on plan: unlimited messages. Anthropic's analogue is enterprise v1 + // with allowance_type=unlimited stacked alongside enterprise v2. + const unlimitedAddon = products.base({ + id: "unlimited-addon", + items: [items.unlimitedMessages()], + isAddOn: true, + }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [baseProd, unlimitedAddon] }), + ], + actions: [ + s.attach({ productId: baseProd.id }), + s.attach({ productId: unlimitedAddon.id }), + ], + }); + + const orgId = ctx.org.id; + + try { + // Force V3 path (executeRedisDeductionV2 → resolveInternalProductIdForEvent). + await setOrgRolloutPercent({ orgId, percent: 100 }); + + // initProductsV0 mutates each product's `id` in-place to suffix it + // with productPrefix (defaults to customerId), so `unlimitedAddon.id` + // is already the final stored id. + const unlimitedPlanId = unlimitedAddon.id; + const unlimitedProductRow = await db + .select({ internal_id: productsTable.internal_id }) + .from(productsTable) + .where( + and( + eq(productsTable.org_id, orgId), + eq(productsTable.env, ctx.env), + eq(productsTable.id, unlimitedPlanId), + ), + ) + .limit(1); + const expectedUnlimitedInternalId = + unlimitedProductRow[0]?.internal_id ?? null; + expect(expectedUnlimitedInternalId).not.toBeNull(); + + // Track usage. Anthropic's calls are AI_CREDITS with value 1..N; we + // use value=7 here so the synthetic deduction is unambiguous. + const TRACK_VALUE = 7; + const trackResponse = (await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: TRACK_VALUE, + })) as TrackResponseV3; + + // ---- Synchronous track response assertions ---------------------- + // After the fix, the synthetic mutation log flows through + // projectMutationLogsToTrackDeductionsV2 → response.deductions. + expect(trackResponse.deductions).toBeDefined(); + expect(trackResponse.deductions ?? []).toHaveLength(1); + const responseDeduction = (trackResponse.deductions ?? [])[0]; + expect(responseDeduction?.feature_id).toBe(TestFeature.Messages); + expect(responseDeduction?.plan_id).toBe(unlimitedPlanId); + expect(responseDeduction?.value).toBe(TRACK_VALUE); + + // ---- Persisted event assertions --------------------------------- + // Wait for the event batch flush (globalEventBatchingManager). + await timeout(TINYBIRD_INGEST_WAIT_MS); + + const customer = (await autumnV2_2.customers.get(customerId, { + with_autumn_id: true, + })) as ApiCustomerV5 & { autumn_id?: string }; + const internalCustomerId = customer.autumn_id; + expect(internalCustomerId).toBeDefined(); + + const eventRows = await db + .select({ + id: events.id, + event_name: events.event_name, + value: events.value, + entity_id: events.entity_id, + internal_product_id: events.internal_product_id, + deductions: events.deductions, + }) + .from(events) + .where( + and( + eq(events.org_id, orgId), + eq(events.env, ctx.env), + eq(events.internal_customer_id, internalCustomerId as string), + ), + ) + .orderBy(desc(events.created_at)) + .limit(5); + + expect(eventRows.length).toBeGreaterThan(0); + const latestEvent = eventRows[0]; + + // The fix: the event is attributed to the unlimited plan. + expect(latestEvent.internal_product_id).toBe( + expectedUnlimitedInternalId, + ); + + // The deductions array on the persisted event mirrors the synthetic + // mutation log: one entry with the unlimited plan's id and the + // tracked value. + expect(latestEvent.deductions).not.toBeNull(); + expect(latestEvent.deductions ?? []).toHaveLength(1); + const persistedDeduction = (latestEvent.deductions ?? [])[0]; + expect(persistedDeduction?.feature_id).toBe(TestFeature.Messages); + expect(persistedDeduction?.plan_id).toBe(unlimitedPlanId); + expect(persistedDeduction?.value).toBe(TRACK_VALUE); + + // The track call had no entity_id, so the synthetic mutation log + // should echo null and the persisted event row should have entity_id + // = null. (See "Echo the track calls entity ID" requirement.) + expect(latestEvent.entity_id).toBeNull(); + + // ---- Non-mutation invariant ------------------------------------- + // The synthetic mutation log is in-memory only; no cusEnt row should + // be touched. Query both cusEnt balances directly: the limited + // one stays at 100 (its starting allowance), and the unlimited one + // stays at whatever it started at. + const cusEntRows = await db + .select({ + id: customerEntitlements.id, + balance: customerEntitlements.balance, + unlimited: customerEntitlements.unlimited, + }) + .from(customerEntitlements) + .where( + eq( + customerEntitlements.internal_customer_id, + internalCustomerId as string, + ), + ); + const limitedRow = cusEntRows.find((r) => !r.unlimited); + const unlimitedRow = cusEntRows.find((r) => r.unlimited); + expect(limitedRow?.balance).toBe(100); + expect(unlimitedRow?.unlimited).toBe(true); + } finally { + await cleanupOrgRollout({ orgId }); + } + }, + { timeout: 120_000 }, +); diff --git a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx index a9abd41f9..c696665ca 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { PageContainer } from "@/components/general/PageContainer"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useFeatureFlags } from "@/hooks/useFeatureFlags"; import { useEnv } from "@/utils/envUtils"; import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide"; @@ -32,6 +31,7 @@ export const AnalyticsView = () => { const [clickHouseDisabled, setClickHouseDisabled] = useState(false); const [hasCleared, setHasCleared] = useState(false); const [groupFilter, setGroupFilter] = useState(null); + const [planDeselected, setPlanDeselected] = useState>(new Set()); const navigate = useNavigate(); const env = useEnv(); @@ -50,61 +50,11 @@ export const AnalyticsView = () => { truncated, entityNames, customerNames, + planNames, totals, eventNames: responseEventNames, } = useAnalyticsData({ hasCleared }); - // Build internal_product_id → display name from the products cache so the - // chart can label plan_id groups (backend ships raw internal ids). - // `/products/products` only returns the latest version per public id, so - // historical versions (e.g. a customer still on v1 after we ship v2) get - // merged in from `customer.customer_products`. When the same public id has - // multiple versions in scope, suffix with ` v{version}`. - const { products } = useProductsQuery({ allVersions: true }); - const planNames = useMemo(() => { - type Entry = { - internal_id: string; - id: string; - name: string; - version: number; - }; - const entries: Entry[] = []; - const seen = new Set(); - const push = (e: Partial & { internal_id?: string | null }) => { - if (!e.internal_id || seen.has(e.internal_id)) return; - seen.add(e.internal_id); - entries.push({ - internal_id: e.internal_id, - id: e.id ?? e.internal_id, - name: e.name ?? e.id ?? e.internal_id, - version: e.version ?? 1, - }); - }; - - for (const p of products) push(p); - for (const cp of customer?.customer_products ?? []) { - push({ - internal_id: cp.product?.internal_id, - id: cp.product?.id, - name: cp.product?.name, - version: cp.product?.version, - }); - } - - // Count public id occurrences so we only suffix when there's ambiguity. - const idCount = new Map(); - for (const e of entries) { - idCount.set(e.id, (idCount.get(e.id) ?? 0) + 1); - } - - const map: Record = {}; - for (const e of entries) { - const showVersion = (idCount.get(e.id) ?? 0) >= 2; - map[e.internal_id] = showVersion ? `${e.name} v${e.version}` : e.name; - } - return map; - }, [products, customer]); - // Show toast when data is truncated due to too many unique group values const hasShownTruncationToast = useRef(false); useEffect(() => { @@ -119,9 +69,9 @@ export const AnalyticsView = () => { } }, [truncated, groupBy]); - // Clear the filter when groupBy changes useEffect(() => { setGroupFilter(null); + setPlanDeselected(new Set()); }, [groupBy]); // Extract unique group values from events data for filtering @@ -165,16 +115,20 @@ export const AnalyticsView = () => { return { chartData: null, chartConfig: null }; } - // Apply frontend filter if a group filter is selected. Use an - // explicit null check because empty string is a meaningful filter - // value for plan_id ("no plan"). let filteredEvents = events; - if (groupBy && groupFilter !== null) { - // Handle special case for column-based operators (not a property) + if (groupBy === "plan_id" && planDeselected.size > 0) { + const filteredData = events.data.filter( + (row: Record) => + !planDeselected.has(String(row.plan_id ?? "")), + ); + filteredEvents = { + ...events, + data: filteredData, + rows: filteredData.length, + }; + } else if (groupBy && groupBy !== "plan_id" && groupFilter !== null) { const groupByColumn = - groupBy === "customer_id" || - groupBy === "entity_id" || - groupBy === "plan_id" + groupBy === "customer_id" || groupBy === "entity_id" ? groupBy : `properties.${groupBy}`; const filteredData = events.data.filter( @@ -206,7 +160,7 @@ export const AnalyticsView = () => { }); return { chartData: transformed, chartConfig: config }; - }, [events, features, groupBy, groupFilter, entityNames, customerNames, planNames]); + }, [events, features, groupBy, groupFilter, planDeselected, entityNames, customerNames, planNames]); // Build legend entries (sorted desc, zero-values filtered). The // width-aware overflow logic lives in ChartLegend. @@ -313,6 +267,8 @@ export const AnalyticsView = () => { propertyKeys, groupFilter, setGroupFilter, + planDeselected, + setPlanDeselected, availableGroupValues, entityNames, customerNames, @@ -356,7 +312,7 @@ export const AnalyticsView = () => {
)} - {!chartData && !queryLoading && ( + {(!chartData || chartData.data.length === 0) && !queryLoading && (

No events found. Please widen your filters.{" "} diff --git a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx index 86c36cbfd..1461080ff 100644 --- a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx +++ b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx @@ -7,13 +7,16 @@ import { Check } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router"; import { IconButton } from "@/components/v2/buttons/IconButton"; +import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { cn } from "@/lib/utils"; @@ -34,12 +37,30 @@ export const SelectGroupByDropdown = ({ const { groupFilter, setGroupFilter, + planDeselected, + setPlanDeselected, availableGroupValues, entityNames, customerNames, planNames, } = useAnalyticsContext(); + const togglePlanDeselected = (value: string) => { + setPlanDeselected((prev: Set) => { + const next = new Set(prev); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }); + }; + + const allPlansSelected = (planDeselected?.size ?? 0) === 0; + const handlePlanSelectAll = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setPlanDeselected(new Set()); + }; + const currentGroupBy = searchParams.get("group_by") || ""; const customerId = searchParams.get("customer_id"); const showCustomerIdOption = !customerId; @@ -244,11 +265,82 @@ export const SelectGroupByDropdown = ({ )} - {/* Filter section - only shown when a groupBy is selected */} - {currentGroupBy && availableGroupValues.length > 0 && ( - <> - - + {currentGroupBy === "plan_id" && + availableGroupValues.length > 0 && ( + <> + + + + Filter plans + {!allPlansSelected && ( + + {availableGroupValues.length - + (planDeselected?.size ?? 0)} + + )} + + +

+ +
+ +
+ {availableGroupValues.map((value: string) => { + const displayValue = + value === "AUTUMN_RESERVED" + ? "Other values" + : value === "" + ? "No plan" + : (planNames?.[value] ?? value); + const isChecked = !planDeselected?.has(value); + const wouldDeselectLast = + isChecked && + availableGroupValues.length - + (planDeselected?.size ?? 0) === + 1; + return ( + { + e.preventDefault(); + if (wouldDeselectLast) return; + togglePlanDeselected(value); + }} + className="flex items-center gap-2 cursor-pointer" + > + + + {displayValue} + + + ); + })} +
+ + + + )} + + {currentGroupBy && + currentGroupBy !== "plan_id" && + availableGroupValues.length > 0 && ( + <> + Filter by value @@ -266,15 +358,11 @@ export const SelectGroupByDropdown = ({ const displayValue = value === "AUTUMN_RESERVED" ? "Other values" - : value === "" && currentGroupBy === "plan_id" - ? "No plan" - : currentGroupBy === "entity_id" - ? (entityNames?.[value] ?? value) - : currentGroupBy === "customer_id" - ? (customerNames?.[value] ?? value) - : currentGroupBy === "plan_id" - ? (planNames?.[value] ?? value) - : value; + : currentGroupBy === "entity_id" + ? (entityNames?.[value] ?? value) + : currentGroupBy === "customer_id" + ? (customerNames?.[value] ?? value) + : value; return ( ); })} - - - )} + + )}
diff --git a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx index e6662fa90..3190bde09 100644 --- a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx +++ b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx @@ -100,6 +100,7 @@ export const useAnalyticsData = ({ truncated: data?.truncated ?? false, entityNames: (data?.entityNames as Record) ?? undefined, customerNames: (data?.customerNames as Record) ?? undefined, + planNames: (data?.planNames as Record) ?? undefined, totals: (data?.totals as | Record From 92f74573e8ae2437e492c37ca2d72b248b01040d Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 18 May 2026 20:25:22 +0100 Subject: [PATCH 08/10] =?UTF-8?q?fix(analytics):=20=F0=9F=90=9B=20derive?= =?UTF-8?q?=20plan=20selection=20count=20from=20active=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/SelectGroupByDropdown.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx index 1461080ff..7f85fd37e 100644 --- a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx +++ b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx @@ -54,7 +54,13 @@ export const SelectGroupByDropdown = ({ }); }; - const allPlansSelected = (planDeselected?.size ?? 0) === 0; + const activeDeselectedCount = availableGroupValues.reduce( + (acc: number, v: string) => acc + (planDeselected?.has(v) ? 1 : 0), + 0, + ); + const selectedPlanCount = + availableGroupValues.length - activeDeselectedCount; + const allPlansSelected = activeDeselectedCount === 0; const handlePlanSelectAll = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -274,8 +280,7 @@ export const SelectGroupByDropdown = ({ Filter plans {!allPlansSelected && ( - {availableGroupValues.length - - (planDeselected?.size ?? 0)} + {selectedPlanCount} )} @@ -304,10 +309,7 @@ export const SelectGroupByDropdown = ({ : (planNames?.[value] ?? value); const isChecked = !planDeselected?.has(value); const wouldDeselectLast = - isChecked && - availableGroupValues.length - - (planDeselected?.size ?? 0) === - 1; + isChecked && selectedPlanCount === 1; return ( Date: Mon, 18 May 2026 20:35:49 +0100 Subject: [PATCH 09/10] =?UTF-8?q?fix(analytics):=20=F0=9F=90=9B=20normaliz?= =?UTF-8?q?e=20$plan=5Fid=20alias=20in=20internal=20aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handleInternalAggregateEvents.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts index 3acbf9b67..e7ea75a10 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts @@ -131,7 +131,16 @@ export const handleInternalAggregateEvents = createRoute({ return { start: aligned.getTime(), end: now.getTime() }; })(); - const isPlanIdGrouping = group_by === "plan_id" || group_by === "$plan_id"; + let resolvedGroupBy = group_by; + if (group_by === "$customer_id") { + resolvedGroupBy = "customer_id"; + } else if (group_by === "$entity_id") { + resolvedGroupBy = "entity_id"; + } else if (group_by === "$plan_id") { + resolvedGroupBy = "plan_id"; + } + + const isPlanIdGrouping = resolvedGroupBy === "plan_id"; let internalIdToPublicId: Record | undefined; let planNames: Record | undefined; let effectiveMaxGroups: number | undefined; @@ -166,7 +175,7 @@ export const handleInternalAggregateEvents = createRoute({ event_names, bin_size: binSize, aggregateAll, - group_by: group_by, + group_by: resolvedGroupBy, customer, timezone: timezone, max_groups: isPlanIdGrouping ? effectiveMaxGroups : max_groups, @@ -193,7 +202,7 @@ export const handleInternalAggregateEvents = createRoute({ // When grouping by entity_id, resolve entity names from ClickHouse let entityNames: Record | undefined; - if (group_by === "entity_id" && events?.data) { + if (resolvedGroupBy === "entity_id" && events?.data) { const entityIds = [ ...new Set( events.data @@ -214,7 +223,7 @@ export const handleInternalAggregateEvents = createRoute({ } let customerNames: Record | undefined; - if (group_by === "customer_id" && events?.data) { + if (resolvedGroupBy === "customer_id" && events?.data) { const customerIds = [ ...new Set( events.data From e2016ca59e8d23e94a6437ec9f293f164f0589a0 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 18 May 2026 20:36:03 +0100 Subject: [PATCH 10/10] =?UTF-8?q?fix(analytics):=20=F0=9F=90=9B=20preserve?= =?UTF-8?q?=20valid=20deductions=20on=20per-item=20parse=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/internal/analytics/actions/listByCursor.ts | 13 ++++++++----- server/src/internal/analytics/actions/listEvents.ts | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/server/src/internal/analytics/actions/listByCursor.ts b/server/src/internal/analytics/actions/listByCursor.ts index dc7ca5b43..3952ba826 100644 --- a/server/src/internal/analytics/actions/listByCursor.ts +++ b/server/src/internal/analytics/actions/listByCursor.ts @@ -113,11 +113,14 @@ export const listByCursor = async ({ ? parsed.list : null; if (rawList) { - deductions = rawList.map((item: unknown) => - typeof item === "string" - ? (JSON.parse(item) as TrackDeduction) - : (item as TrackDeduction), - ); + deductions = rawList.map((item: unknown) => { + if (typeof item !== "string") return item as TrackDeduction; + try { + return JSON.parse(item) as TrackDeduction; + } catch { + return item as unknown as TrackDeduction; + } + }); } } catch {} } diff --git a/server/src/internal/analytics/actions/listEvents.ts b/server/src/internal/analytics/actions/listEvents.ts index d59944431..2fe69ec70 100644 --- a/server/src/internal/analytics/actions/listEvents.ts +++ b/server/src/internal/analytics/actions/listEvents.ts @@ -99,11 +99,14 @@ export const listEvents = async ({ ? parsed.list : null; if (rawList) { - deductions = rawList.map((item: unknown) => - typeof item === "string" - ? (JSON.parse(item) as TrackDeduction) - : (item as TrackDeduction), - ); + deductions = rawList.map((item: unknown) => { + if (typeof item !== "string") return item as TrackDeduction; + try { + return JSON.parse(item) as TrackDeduction; + } catch { + return item as unknown as TrackDeduction; + } + }); } } catch {} }