From ed48ab3bd7cc46193cfd4d9cbf66bb76a1fcb6e1 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 23 Jun 2026 16:20:34 -0700 Subject: [PATCH 01/10] feat: add "View usage" button to analytics limit banner and rename Usage page (#1660) --- .../projects/[projectId]/analytics/shared.tsx | 26 ++++++++++++++----- .../usage/page-client.test.tsx | 4 +-- .../project-settings/usage/page-client.tsx | 2 +- .../projects/[projectId]/sidebar-layout.tsx | 2 +- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index eb2bf54e3..9e80840fb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -15,6 +15,7 @@ import { WarningCircleIcon } from "@phosphor-icons/react"; import { Alert, AlertDescription, Button } from "@/components/ui"; +import { Link } from "@/components/link"; import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { PLAN_LIMITS, resolvePlanId } from "@hexclave/shared/dist/plans"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; @@ -340,7 +341,7 @@ export function AnalyticsEventLimitBanner() { return null; } - return ; + return ; } /** @@ -395,7 +396,7 @@ function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: str ); } -function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise } }) { +function AnalyticsEventLimitBannerInner({ team, projectId }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise }, projectId: string }) { const eventsItem = team.useItem("analytics_events"); const products = team.useProducts(); const planId = resolvePlanId(products); @@ -433,15 +434,26 @@ function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: st } {canUpgrade && !isExhausted && " Consider upgrading your plan."} - {canUpgrade && ( +
- )} + {canUpgrade && ( + + )} +
); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx index 48a999652..4ec7a3934 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx @@ -106,8 +106,8 @@ describe("Usage settings page", () => { it("renders the plan, usage rows, and overage state", () => { render(); - // The page title and usage card share this label. - expect(screen.getAllByText("Usage").length).toBeGreaterThan(0); + // The page title + expect(screen.getAllByText("Billing & Usage").length).toBeGreaterThan(0); expect(screen.getAllByText("Free").length).toBeGreaterThan(0); expect(screen.getAllByText("Owner").length).toBeGreaterThan(0); expect(screen.getAllByText("Dashboard admins").length).toBeGreaterThan(0); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx index d7780dbb5..cfdac5213 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx @@ -362,7 +362,7 @@ export default function PageClient() { return ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 4b4059751..e2bf5154c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -139,7 +139,7 @@ const projectSettingsItem: AppSection = { match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/?$/.test(fullUrl.pathname), }, { - name: "Usage", + name: "Billing & Usage", href: "/project-settings/usage", match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/usage(\/.*)?$/.test(fullUrl.pathname), }, From bf265abf8876bf817c6734c79374ed57922a85d5 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 23 Jun 2026 16:23:04 -0700 Subject: [PATCH 02/10] refactor: move esbuild utils from stack-shared to shared-backend (#1653) --- packages/js/package.json | 2 +- packages/next/package.json | 2 +- packages/react/package.json | 2 +- packages/tanstack-start/package.json | 2 +- packages/template/package-template.json | 2 +- packages/template/package.json | 2 +- pnpm-lock.yaml | 30 ++++++++++++------------- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/js/package.json b/packages/js/package.json index e3e6510b8..efc9cb775 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -76,7 +76,6 @@ "@oslojs/otp": "^1.1.0", "qrcode": "^1.5.4", "rrweb": "^1.1.3", - "tsx": "^4.21.0", "yup": "^1.7.1" }, "devDependencies": { @@ -97,6 +96,7 @@ "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", "tsdown": "^0.20.3", + "tsx": "^4.21.0", "convex": "^1.27.0" } } diff --git a/packages/next/package.json b/packages/next/package.json index 3c1a82174..394929b9b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -93,7 +93,6 @@ "react-hook-form": "^7.70.0", "tailwindcss-animate": "^1.0.7", "rrweb": "^1.1.3", - "tsx": "^4.21.0", "yup": "^1.7.1" }, "peerDependencies": { @@ -132,6 +131,7 @@ "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", "tsdown": "^0.20.3", + "tsx": "^4.21.0", "convex": "^1.27.0" } } diff --git a/packages/react/package.json b/packages/react/package.json index ecf8d634f..d247fa458 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -92,7 +92,6 @@ "react-hook-form": "^7.70.0", "tailwindcss-animate": "^1.0.7", "rrweb": "^1.1.3", - "tsx": "^4.21.0", "yup": "^1.7.1" }, "peerDependencies": { @@ -129,6 +128,7 @@ "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", "tsdown": "^0.20.3", + "tsx": "^4.21.0", "convex": "^1.27.0" } } diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index c021d9845..9991d2490 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -103,7 +103,6 @@ "react-hook-form": "^7.70.0", "tailwindcss-animate": "^1.0.7", "rrweb": "^1.1.3", - "tsx": "^4.21.0", "yup": "^1.7.1" }, "peerDependencies": { @@ -144,6 +143,7 @@ "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", "tsdown": "^0.20.3", + "tsx": "^4.21.0", "convex": "^1.27.0" } } diff --git a/packages/template/package-template.json b/packages/template/package-template.json index e18be6d11..88d79114d 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -146,7 +146,6 @@ "//": "NEXT_LINE_PLATFORM react-like", "tailwindcss-animate": "^1.0.7", "rrweb": "^1.1.3", - "tsx": "^4.21.0", "yup": "^1.7.1" }, "//": "IF_PLATFORM react-like", @@ -202,6 +201,7 @@ "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", "tsdown": "^0.20.3", + "tsx": "^4.21.0", "convex": "^1.27.0" } } diff --git a/packages/template/package.json b/packages/template/package.json index 82e74b530..0b885f070 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -109,7 +109,6 @@ "react-hook-form": "^7.70.0", "tailwindcss-animate": "^1.0.7", "rrweb": "^1.1.3", - "tsx": "^4.21.0", "yup": "^1.7.1" }, "peerDependencies": { @@ -152,6 +151,7 @@ "rimraf": "^6.1.2", "tailwindcss": "^3.4.4", "tsdown": "^0.20.3", + "tsx": "^4.21.0", "convex": "^1.27.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 130066a41..1153bb90f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1981,9 +1981,6 @@ importers: rrweb: specifier: ^1.1.3 version: 1.1.3 - tsx: - specifier: ^4.21.0 - version: 4.21.0 yup: specifier: ^1.7.1 version: 1.7.1 @@ -2042,6 +2039,9 @@ importers: tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 packages/next: dependencies: @@ -2117,9 +2117,6 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.0)) - tsx: - specifier: ^4.21.0 - version: 4.21.0 yup: specifier: ^1.7.1 version: 1.7.1 @@ -2187,6 +2184,9 @@ importers: tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 packages/react: dependencies: @@ -2259,9 +2259,6 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) - tsx: - specifier: ^4.21.0 - version: 4.21.0 yup: specifier: ^1.7.1 version: 1.7.1 @@ -2326,6 +2323,9 @@ importers: tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 packages/sc: dependencies: @@ -2536,9 +2536,6 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.0)) - tsx: - specifier: ^4.21.0 - version: 4.21.0 yup: specifier: ^1.7.1 version: 1.7.1 @@ -2609,6 +2606,9 @@ importers: tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 packages/template: dependencies: @@ -2684,9 +2684,6 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) - tsx: - specifier: ^4.21.0 - version: 4.21.0 yup: specifier: ^1.7.1 version: 1.7.1 @@ -2760,6 +2757,9 @@ importers: tsdown: specifier: ^0.20.3 version: 0.20.3(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 packages/ui: dependencies: From 09c9df410a75cbdc9b0b81ba51e1e9c7d0e325c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 01:55:58 +0000 Subject: [PATCH 03/10] chore: update package versions --- apps/backend/package.json | 2 +- apps/dashboard/package.json | 2 +- apps/dev-launchpad/package.json | 2 +- apps/e2e/package.json | 2 +- apps/hosted-components/package.json | 2 +- apps/internal-tool/package.json | 2 +- apps/mcp/package.json | 2 +- apps/mock-oauth-server/package.json | 2 +- apps/skills/package.json | 2 +- docs-mintlify/package.json | 2 +- docs/package.json | 2 +- examples/cjs-test/package.json | 2 +- examples/convex/package.json | 2 +- examples/demo/package.json | 2 +- examples/docs-examples/package.json | 2 +- examples/e-commerce/package.json | 2 +- examples/js-example/package.json | 2 +- examples/lovable-react-18-example/package.json | 2 +- examples/middleware/package.json | 2 +- examples/react-example/package.json | 2 +- examples/supabase/package.json | 2 +- examples/tanstack-start-demo/package.json | 2 +- packages/cli/package.json | 2 +- packages/dashboard-ui-components/package.json | 2 +- packages/js/package.json | 2 +- packages/next/package.json | 2 +- packages/react/package.json | 2 +- packages/sc/package.json | 2 +- packages/shared-backend/package.json | 2 +- packages/shared/package.json | 2 +- packages/tanstack-start/package.json | 2 +- packages/template/package-template.json | 2 +- packages/template/package.json | 2 +- packages/ui/package.json | 2 +- sdks/implementations/swift/package.json | 2 +- sdks/spec/package.json | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 69466c868..0d5b9b6c1 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/backend", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "type": "module", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 01aa3f2df..3e57365e6 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/dashboard", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "scripts": { diff --git a/apps/dev-launchpad/package.json b/apps/dev-launchpad/package.json index f31dce60f..98d94122b 100644 --- a/apps/dev-launchpad/package.json +++ b/apps/dev-launchpad/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/dev-launchpad", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "scripts": { diff --git a/apps/e2e/package.json b/apps/e2e/package.json index e08782e2d..db8185264 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/e2e-tests", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "type": "module", diff --git a/apps/hosted-components/package.json b/apps/hosted-components/package.json index 319712d2c..7a99926b9 100644 --- a/apps/hosted-components/package.json +++ b/apps/hosted-components/package.json @@ -1,7 +1,7 @@ { "name": "@hexclave/hosted-components", "private": true, - "version": "1.0.28", + "version": "1.0.29", "type": "module", "scripts": { "dev": "vite dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}09", diff --git a/apps/internal-tool/package.json b/apps/internal-tool/package.json index 68488913c..706d9382f 100644 --- a/apps/internal-tool/package.json +++ b/apps/internal-tool/package.json @@ -1,7 +1,7 @@ { "name": "@hexclave/internal-tool", "private": true, - "version": "1.0.28", + "version": "1.0.29", "type": "module", "scripts": { "dev": "node scripts/pre-dev.mjs && next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}41", diff --git a/apps/mcp/package.json b/apps/mcp/package.json index f91334db8..d932188e7 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/mcp", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "type": "module", diff --git a/apps/mock-oauth-server/package.json b/apps/mock-oauth-server/package.json index d5afa89a2..2012b7b87 100644 --- a/apps/mock-oauth-server/package.json +++ b/apps/mock-oauth-server/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/mock-oauth-server", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "main": "index.js", diff --git a/apps/skills/package.json b/apps/skills/package.json index b65ee4f9b..d338ed361 100644 --- a/apps/skills/package.json +++ b/apps/skills/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/skills", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "type": "module", diff --git a/docs-mintlify/package.json b/docs-mintlify/package.json index 133520ce0..f0b85d48c 100644 --- a/docs-mintlify/package.json +++ b/docs-mintlify/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/docs-mintlify", - "version": "1.0.28", + "version": "1.0.29", "private": true, "scripts": { "dev": "mint dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}04 --no-open", diff --git a/docs/package.json b/docs/package.json index 73a7561d5..2e5fc5b10 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/docs", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "description": "", "main": "index.js", diff --git a/examples/cjs-test/package.json b/examples/cjs-test/package.json index 3828a2865..4d930d893 100644 --- a/examples/cjs-test/package.json +++ b/examples/cjs-test/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/example-cjs-test", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "scripts": { diff --git a/examples/convex/package.json b/examples/convex/package.json index 115581711..e7d0dda88 100644 --- a/examples/convex/package.json +++ b/examples/convex/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/convex-example", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "scripts": { diff --git a/examples/demo/package.json b/examples/demo/package.json index ca92d552c..3a0c4b777 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/example-demo-app", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "description": "", "private": true, diff --git a/examples/docs-examples/package.json b/examples/docs-examples/package.json index 3c276cf9a..5aea6bb36 100644 --- a/examples/docs-examples/package.json +++ b/examples/docs-examples/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/docs-examples", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "description": "", "private": true, diff --git a/examples/e-commerce/package.json b/examples/e-commerce/package.json index bd5e1737c..523f40dcb 100644 --- a/examples/e-commerce/package.json +++ b/examples/e-commerce/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/e-commerce-demo", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "scripts": { diff --git a/examples/js-example/package.json b/examples/js-example/package.json index efd34679b..e059084d0 100644 --- a/examples/js-example/package.json +++ b/examples/js-example/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/js-example", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "description": "", diff --git a/examples/lovable-react-18-example/package.json b/examples/lovable-react-18-example/package.json index e4cd5d20c..07290aec2 100644 --- a/examples/lovable-react-18-example/package.json +++ b/examples/lovable-react-18-example/package.json @@ -1,7 +1,7 @@ { "name": "@hexclave/lovable-react-18-example", "private": true, - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "type": "module", "scripts": { diff --git a/examples/middleware/package.json b/examples/middleware/package.json index 6b01872bd..3c6a395f2 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/example-middleware-demo", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "scripts": { diff --git a/examples/react-example/package.json b/examples/react-example/package.json index 8f43d3407..1e81ddca1 100644 --- a/examples/react-example/package.json +++ b/examples/react-example/package.json @@ -1,7 +1,7 @@ { "name": "react-example", "private": true, - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "type": "module", "scripts": { diff --git a/examples/supabase/package.json b/examples/supabase/package.json index 3c693cd58..9fc7f710c 100644 --- a/examples/supabase/package.json +++ b/examples/supabase/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/example-supabase", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "private": true, "scripts": { diff --git a/examples/tanstack-start-demo/package.json b/examples/tanstack-start-demo/package.json index 58c245662..0a78d7916 100644 --- a/examples/tanstack-start-demo/package.json +++ b/examples/tanstack-start-demo/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/example-tanstack-start-demo", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "description": "TanStack Start demo app for Hexclave", "private": true, diff --git a/packages/cli/package.json b/packages/cli/package.json index 48f723746..a209ae99a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/cli", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "description": "The CLI for Hexclave. https://hexclave.com", "main": "dist/index.js", diff --git a/packages/dashboard-ui-components/package.json b/packages/dashboard-ui-components/package.json index 4bbe34364..0ee261b06 100644 --- a/packages/dashboard-ui-components/package.json +++ b/packages/dashboard-ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/dashboard-ui-components", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/js/package.json b/packages/js/package.json index efc9cb775..53000fe09 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@hexclave/js", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/next/package.json b/packages/next/package.json index 394929b9b..bb63db643 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@hexclave/next", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/react/package.json b/packages/react/package.json index d247fa458..89798a4db 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@hexclave/react", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/sc/package.json b/packages/sc/package.json index f7444e06d..da35c2825 100644 --- a/packages/sc/package.json +++ b/packages/sc/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/sc", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "exports": { "./force-react-server": { diff --git a/packages/shared-backend/package.json b/packages/shared-backend/package.json index eca4ddb3a..ad381b494 100644 --- a/packages/shared-backend/package.json +++ b/packages/shared-backend/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/shared-backend", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/shared/package.json b/packages/shared/package.json index 250007529..d11bd437c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/shared", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "scripts": { "build": "rimraf dist && tsdown", diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index 9991d2490..2b1d045dc 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -1,7 +1,7 @@ { "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@hexclave/tanstack-start", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/template/package-template.json b/packages/template/package-template.json index 88d79114d..aedd853ec 100644 --- a/packages/template/package-template.json +++ b/packages/template/package-template.json @@ -13,7 +13,7 @@ "//": "NEXT_LINE_PLATFORM template", "private": true, - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/template/package.json b/packages/template/package.json index 0b885f070..27f09e422 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -2,7 +2,7 @@ "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)", "name": "@hexclave/template", "private": true, - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 25d1f54c8..10bbb81d1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/ui", - "version": "1.0.28", + "version": "1.0.29", "repository": "https://github.com/hexclave/hexclave", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/sdks/implementations/swift/package.json b/sdks/implementations/swift/package.json index d8ebb4c5e..3ecc377e8 100644 --- a/sdks/implementations/swift/package.json +++ b/sdks/implementations/swift/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/swift-sdk", - "version": "1.0.28", + "version": "1.0.29", "private": true, "description": "Hexclave Swift SDK", "scripts": { diff --git a/sdks/spec/package.json b/sdks/spec/package.json index 8a44e48cb..1c8f7e815 100644 --- a/sdks/spec/package.json +++ b/sdks/spec/package.json @@ -1,6 +1,6 @@ { "name": "@hexclave/sdk-spec", - "version": "1.0.28", + "version": "1.0.29", "private": true, "description": "Hexclave SDK specification files", "scripts": {} From 0c8f5e33ed2b093a21fbce07a01db1aa4c1f5ac9 Mon Sep 17 00:00:00 2001 From: Aman Ganapathy <84686202+nams1570@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:30:38 -0700 Subject: [PATCH 04/10] feat(payments): quick-ack + idempotent webhooks (#1664) ### Context Stripe recommends acking webhook events ASAP with a 200. Stripe also recommends employing event idempotency on your end. By responding quickly, you prevent stripe from thinking the webhook failed and retrying the event. Retrying the event in the past used to be responsible for people getting multiple payment receipt emails. Note that even in the case where an event processing genuinely fails, we have a new table to let us recover from it. Currently, recovery will be manual, but since it will be logged to sentry we will be notified. --- ## Summary by cubic Quick-ack Stripe webhooks with 200 and add atomic idempotency to stop duplicate processing and emails. Events are persisted and processed in the background with clear status and error tracking. - **New Features** - Persist each webhook in `StripeWebhookEvent` keyed by `event.id` with full `payload` and `stripeAccountId` for recovery. - Return 200 immediately; process in the background and track status as `PENDING`, `PROCESSED`, or `FAILED`. - Single-flight claim deduplicates redeliveries while `PENDING` and after `PROCESSED`; only `FAILED` events reprocess on redelivery. - Store `lastError` on failures; unknown webhook types ack with 200 and are handled asynchronously. - Webhook response includes `deduplicated: true` when a redelivery is skipped. - **Migration** - Run Prisma migrations to create the `StripeWebhookEvent` table, enum, and unique index on `stripeEventId`. Written for commit 59456a36e81074ddd8c4d2a6d3228cf37908aa42. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **New Features** * Added persistent, idempotent Stripe webhook handling with event-level deduplication keyed by the webhook event id. * Webhooks are acknowledged immediately and processed asynchronously, with automatic retry capability for failed events. * **Bug Fixes** * Reduced duplicate side effects from redeliveries (including preventing repeated receipt emails) by ensuring only one successful processing per event. * **Tests** * Updated and expanded integration and end-to-end coverage for asynchronous processing, deduplication, and failure recovery behavior. --- .../migration.sql | 21 ++ .../tests/unique-stripe-event-id.ts | 60 +++++ apps/backend/prisma/schema.prisma | 26 ++ .../integrations/stripe/webhooks/route.tsx | 28 ++- .../src/lib/stripe-webhook-events.test.ts | 122 ++++++++++ apps/backend/src/lib/stripe-webhook-events.ts | 60 +++++ .../endpoints/api/v1/stripe-webhooks.test.ts | 227 ++++++++++++++---- 7 files changed, 494 insertions(+), 50 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/migration.sql create mode 100644 apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/tests/unique-stripe-event-id.ts create mode 100644 apps/backend/src/lib/stripe-webhook-events.test.ts create mode 100644 apps/backend/src/lib/stripe-webhook-events.ts diff --git a/apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/migration.sql b/apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/migration.sql new file mode 100644 index 000000000..151ff73c0 --- /dev/null +++ b/apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +CREATE TYPE "StripeWebhookEventStatus" AS ENUM ('PENDING', 'PROCESSED', 'FAILED'); + +-- CreateTable +CREATE TABLE "StripeWebhookEvent" ( + "id" UUID NOT NULL, + "stripeEventId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "stripeAccountId" TEXT, + "payload" JSONB NOT NULL, + "status" "StripeWebhookEventStatus" NOT NULL DEFAULT 'PENDING', + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "processedAt" TIMESTAMP(3), + + CONSTRAINT "StripeWebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "StripeWebhookEvent_stripeEventId_key" ON "StripeWebhookEvent"("stripeEventId"); diff --git a/apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/tests/unique-stripe-event-id.ts b/apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/tests/unique-stripe-event-id.ts new file mode 100644 index 000000000..8bca6123e --- /dev/null +++ b/apps/backend/prisma/migrations/20260622000000_add_stripe_webhook_event/tests/unique-stripe-event-id.ts @@ -0,0 +1,60 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + // Table does not exist before the migration, so nothing to seed. + return {}; +}; + +export const postMigration = async (sql: Sql) => { + const tables = await sql<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'StripeWebhookEvent' + `; + expect(Array.from(tables)).toMatchInlineSnapshot(` + [ + { + "table_name": "StripeWebhookEvent", + }, + ] + `); + + const eventId = `evt_${randomUUID()}`; + + await sql` + INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "updatedAt") + VALUES (${randomUUID()}::uuid, ${eventId}, 'invoice.payment_succeeded', '{"id":"evt"}'::jsonb, NOW()) + `; + + // Status defaults to PENDING. + const inserted = await sql` + SELECT "status" FROM "StripeWebhookEvent" WHERE "stripeEventId" = ${eventId} + `; + expect(Array.from(inserted)).toMatchInlineSnapshot(` + [ + { + "status": "PENDING", + }, + ] + `); + + // The same Stripe event id cannot be inserted twice (idempotency guarantee). + await expect(sql` + INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "updatedAt") + VALUES (${randomUUID()}::uuid, ${eventId}, 'invoice.payment_succeeded', '{"id":"evt2"}'::jsonb, NOW()) + `).rejects.toThrow(/StripeWebhookEvent_stripeEventId_key/); + + // A different event id is fine, and the status enum rejects invalid values. + await sql` + INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "status", "updatedAt") + VALUES (${randomUUID()}::uuid, ${`evt_${randomUUID()}`}, 'invoice.paid', '{}'::jsonb, 'PROCESSED', NOW()) + `; + + await expect(sql` + INSERT INTO "StripeWebhookEvent" ("id", "stripeEventId", "eventType", "payload", "status", "updatedAt") + VALUES (${randomUUID()}::uuid, ${`evt_${randomUUID()}`}, 'invoice.paid', '{}'::jsonb, 'NOT_A_STATUS', NOW()) + `).rejects.toThrow(); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 24afecea6..7a9421c90 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1427,6 +1427,32 @@ model OutgoingRequest { @@index([startedFulfillingAt, deduplicationKey]) } +enum StripeWebhookEventStatus { + PENDING + PROCESSED + FAILED +} + +// Idempotency + recovery log for incoming Stripe webhook events. Each event is +// persisted synchronously (keyed on the Stripe `event.id`) before we ack 200 to +// Stripe, so redeliveries are deduped and the full `payload` of any +// PENDING/FAILED row can be replayed manually. Not tenancy-scoped: the Stripe +// account -> tenancy resolution happens during processing, not here. +model StripeWebhookEvent { + id String @id @default(uuid()) @db.Uuid + + stripeEventId String @unique + eventType String + stripeAccountId String? + payload Json + status StripeWebhookEventStatus @default(PENDING) + lastError String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + processedAt DateTime? +} + // BulldozerStorageEngine is managed externally (see prisma.config.ts // `tables.external`). It's created by migrations and interacted with // via raw SQL — not through the Prisma client. Keeping it out of the diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 0388a0e80..7656826eb 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -1,5 +1,7 @@ import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails"; import { bulldozerWriteOneTimePurchase } from "@/lib/payments/bulldozer-dual-write"; +import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "@/lib/stripe-webhook-events"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import { listPermissions } from "@/lib/permissions"; import { getHexclaveStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; import type { StripeOverridesMap } from "@/lib/stripe-proxy"; @@ -466,13 +468,29 @@ export const POST = createSmartRouteHandler({ throw new StatusError(400, "Invalid stripe-signature header"); } - try { - await processStripeWebhookEvent(event); - } catch (error) { - captureError("stripe-webhook-receiver", error); - throw error; + // Persist the event for idempotency + recovery BEFORE acking. Stripe + // delivers at-least-once + const { shouldProcess } = await claimStripeEvent(event); + if (!shouldProcess) { + return { + statusCode: 200, + bodyType: "json", + body: { received: true, deduplicated: true }, + }; } + // Ack Stripe immediately and process in the background. + // Stripe recommends ACKing ASAP to avoid timeouts and redeliveries + runAsynchronouslyAndWaitUntil(async () => { + try { + await processStripeWebhookEvent(event); + await markStripeEventProcessed(event.id); + } catch (error) { + captureError("stripe-webhook-receiver", error); + await markStripeEventFailed(event.id, error); + } + }); + return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/lib/stripe-webhook-events.test.ts b/apps/backend/src/lib/stripe-webhook-events.test.ts new file mode 100644 index 000000000..5918d01c6 --- /dev/null +++ b/apps/backend/src/lib/stripe-webhook-events.test.ts @@ -0,0 +1,122 @@ +import { randomUUID } from "node:crypto"; +import type Stripe from "stripe"; +import { describe, expect, it } from "vitest"; +import { StripeWebhookEventStatus } from "@/generated/prisma/client"; +import { globalPrismaClient } from "@/prisma-client"; +import { claimStripeEvent, markStripeEventFailed, markStripeEventProcessed } from "./stripe-webhook-events"; + +// Test fixtures only need the fields the helper reads (id/type/account) plus a +// JSON-serializable body. Building a full Stripe.Event is impractical, so we +// cast a minimal object — any drift in the fields we actually use is still +// caught because the helper reads them directly. +function makeEvent(): Stripe.Event { + return { + id: `evt_${randomUUID()}`, + type: "invoice.payment_succeeded", + account: "acct_test_123", + data: { object: { id: "in_test", note: "fixture" } }, + } as unknown as Stripe.Event; +} + +describe("stripe webhook event idempotency (real DB)", () => { + it("claims a brand new event and persists it as PENDING", async ({ expect }) => { + const event = makeEvent(); + + const { shouldProcess } = await claimStripeEvent(event); + expect(shouldProcess).toBe(true); + + const row = await globalPrismaClient.stripeWebhookEvent.findUnique({ + where: { stripeEventId: event.id }, + }); + expect(row).not.toBeNull(); + expect(row?.status).toBe(StripeWebhookEventStatus.PENDING); + expect(row?.eventType).toBe(event.type); + expect(row?.stripeAccountId).toBe(event.account); + expect(row?.processedAt).toBeNull(); + expect(row?.lastError).toBeNull(); + // The full event payload is stored so dropped/failed events can be replayed. + expect(row?.payload).toMatchObject({ id: event.id, type: event.type }); + }); + + it("skips a redelivery while the prior delivery is still in-flight (PENDING)", async ({ expect }) => { + const event = makeEvent(); + + const first = await claimStripeEvent(event); + expect(first.shouldProcess).toBe(true); + + // Single-flight: a redelivery that arrives while the first attempt is still + // PENDING must not spin up a second processor (that would double the fan-out). + const second = await claimStripeEvent(event); + expect(second.shouldProcess).toBe(false); + }); + + it("deduplicates once the event has been fully PROCESSED", async ({ expect }) => { + const event = makeEvent(); + + await claimStripeEvent(event); + await markStripeEventProcessed(event.id); + + const processedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({ + where: { stripeEventId: event.id }, + }); + expect(processedRow?.status).toBe(StripeWebhookEventStatus.PROCESSED); + expect(processedRow?.processedAt).not.toBeNull(); + expect(processedRow?.lastError).toBeNull(); + + // A Stripe redelivery of an already-processed event must be a no-op. + const redelivery = await claimStripeEvent(event); + expect(redelivery.shouldProcess).toBe(false); + }); + + it("records the error on failure and allows recovery via redelivery", async ({ expect }) => { + const event = makeEvent(); + + await claimStripeEvent(event); + await markStripeEventFailed(event.id, new Error("boom while processing")); + + const failedRow = await globalPrismaClient.stripeWebhookEvent.findUnique({ + where: { stripeEventId: event.id }, + }); + expect(failedRow?.status).toBe(StripeWebhookEventStatus.FAILED); + expect(failedRow?.lastError).toContain("boom while processing"); + + // FAILED rows must reprocess so a manual Stripe "Resend" can recover them. + const recovery = await claimStripeEvent(event); + expect(recovery.shouldProcess).toBe(true); + + // ...but reclaiming a FAILED row flips it back to in-flight (PENDING), so a + // further redelivery during that retry is once again skipped (single-flight). + const concurrentRetry = await claimStripeEvent(event); + expect(concurrentRetry.shouldProcess).toBe(false); + }); + + it("scrubs a stale processedAt when a row leaves the PROCESSED state", async ({ expect }) => { + const event = makeEvent(); + + await claimStripeEvent(event); + await markStripeEventProcessed(event.id); + + // markStripeEventFailed must clear processedAt so a recovered/re-failed row is + // never readable as "completed at