diff --git a/apps/website/package.json b/apps/website/package.json index 67cc6b74b..7d22d1f51 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -10,9 +10,12 @@ }, "dependencies": { "@gsap/react": "^2.1.2", + "@vercel/analytics": "^2.0.1", + "framer-motion": "^12.38.0", "gray-matter": "^4.0.3", - "gsap": "^3.14.2", + "gsap": "^3.15.0", "lottie-react": "^2.4.1", + "lottie-web": "^5.13.0", "matter-js": "^0.20.0", "motion": "^12.38.0", "next": "16.2.4", @@ -25,7 +28,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.2.2", "@tailwindcss/typography": "^0.5.19", - "eslint": "^9", + "eslint": "^10.2.0", "eslint-config-next": "16.2.1", "tailwindcss": "^4.2.2" } diff --git a/bun.lock b/bun.lock index f785973bc..10cdd8015 100644 --- a/bun.lock +++ b/bun.lock @@ -108,9 +108,12 @@ "version": "0.1.0", "dependencies": { "@gsap/react": "^2.1.2", + "@vercel/analytics": "^2.0.1", + "framer-motion": "^12.38.0", "gray-matter": "^4.0.3", - "gsap": "^3.14.2", + "gsap": "^3.15.0", "lottie-react": "^2.4.1", + "lottie-web": "^5.13.0", "matter-js": "^0.20.0", "motion": "^12.38.0", "next": "16.2.4", @@ -123,7 +126,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.2.2", "@tailwindcss/typography": "^0.5.19", - "eslint": "^9", + "eslint": "^10.2.0", "eslint-config-next": "16.2.1", "tailwindcss": "^4.2.2", }, @@ -2160,6 +2163,8 @@ "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -2354,6 +2359,8 @@ "@vdemedes/prettier-config": ["@vdemedes/prettier-config@2.0.1", "", {}, "sha512-lcHyyLfS2ro282qsXKpxw+canUkOlFIGoanxt3BaNCm5K1NR8k4hGvYbFO54/+QWq12d0y/EYRz68yNQkqWFrw=="], + "@vercel/analytics": ["@vercel/analytics@2.0.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "nuxt": ">= 3", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "nuxt", "react", "svelte", "vue", "vue-router"] }, "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vercel/sdk": ["@vercel/sdk@1.19.37", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "zod": "^3.25.0 || ^4.0.0" }, "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-/y1rX2RI0f8bkj1yN0g4fcOmDb0MxY28Z29RHNLtxbz/2glFSR/BsIyCV/02EPPHFn1AqjVvQjQhGOE+LvssSQ=="], @@ -5614,6 +5621,8 @@ "@autumn/vite/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + "@autumn/website/eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], + "@autumn/website/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "@autumn/website/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -7272,6 +7281,24 @@ "@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=="], + + "@autumn/website/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + + "@autumn/website/eslint/@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@autumn/website/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + + "@autumn/website/eslint/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "@autumn/website/eslint/eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "@autumn/website/eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@autumn/website/eslint/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "@autumn/website/eslint/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@autumn/website/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -8686,6 +8713,10 @@ "@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/website/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@autumn/website/eslint/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -9394,6 +9425,8 @@ "@artilleryio/int-core/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + "@autumn/website/eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@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=="], diff --git a/server/src/external/stripe/webhookHandlers/common/trackCustomerProductUpdate.ts b/server/src/external/stripe/webhookHandlers/common/trackCustomerProductUpdate.ts index 55d914032..182c5d872 100644 --- a/server/src/external/stripe/webhookHandlers/common/trackCustomerProductUpdate.ts +++ b/server/src/external/stripe/webhookHandlers/common/trackCustomerProductUpdate.ts @@ -11,6 +11,10 @@ type SubscriptionEventContext = * - Adds to updatedCustomerProducts list for logging/audit * - Updates customerProducts array in place so subsequent tasks see the change * - Updates fullCustomer.customer_products so actions can see the change + * + * Note: callers iterating `customerProducts` while this helper (or + * `trackCustomerProductDeletion`) may run must iterate over a snapshot, e.g. + * `for (const cp of [...customerProducts])`, to avoid iterator invalidation. */ export const trackCustomerProductUpdate = ({ eventContext, @@ -24,19 +28,15 @@ export const trackCustomerProductUpdate = ({ const { customerProducts, fullCustomer, updatedCustomerProducts } = eventContext; - // Track the update for logging updatedCustomerProducts.push({ customerProduct, updates }); - // Create updated product const updatedProduct = { ...customerProduct, ...updates } as FullCusProduct; - // Update in customerProducts array const idx = customerProducts.findIndex((cp) => cp.id === customerProduct.id); if (idx >= 0) { customerProducts[idx] = updatedProduct; } - // Also update in fullCustomer.customer_products so actions can see the change const fullCustomerIdx = fullCustomer.customer_products.findIndex( (cp) => cp.id === customerProduct.id, ); @@ -49,9 +49,13 @@ export const trackCustomerProductUpdate = ({ /** * Tracks a customer product deletion for subscription event workflows. - * - Adds to deletedCustomerProducts list for logging/audit (only for deleted context) + * - Adds to deletedCustomerProducts list for logging/audit * - Removes from customerProducts array in place so subsequent tasks see the change * - Removes from fullCustomer.customer_products so actions can see the change + * + * Note: callers iterating `customerProducts` while this helper (or + * `trackCustomerProductUpdate`) may run must iterate over a snapshot, e.g. + * `for (const cp of [...customerProducts])`, to avoid iterator invalidation. */ export const trackCustomerProductDeletion = ({ eventContext, @@ -65,16 +69,13 @@ export const trackCustomerProductDeletion = ({ const { customerProducts, fullCustomer, deletedCustomerProducts } = eventContext; - // Track the deletion for logging deletedCustomerProducts.push(customerProduct); - // Remove from customerProducts array const idx = customerProducts.findIndex((cp) => cp.id === customerProduct.id); if (idx >= 0) { customerProducts.splice(idx, 1); } - // Also remove from fullCustomer.customer_products so actions can see the change const fullCustomerIdx = fullCustomer.customer_products.findIndex( (cp) => cp.id === customerProduct.id, ); @@ -100,10 +101,8 @@ export const trackCustomerProductInsertion = ({ }): void => { const { customerProducts, insertedCustomerProducts } = eventContext; - // Track the insertion for logging insertedCustomerProducts.push(customerProduct); - // Add to customerProducts array if not already present const exists = customerProducts.some((cp) => cp.id === customerProduct.id); if (!exists) { customerProducts.push(customerProduct); diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts index 59ae0b613..fd8f3f67b 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/setupStripeSubscriptionDeletedContext.ts @@ -22,7 +22,13 @@ export interface StripeSubscriptionDeletedContext { stripeSubscription: ExpandedStripeSubscription; stripeCustomer: ExpandedStripeCustomer; fullCustomer: FullCustomer; - /** Customer products that are on this subscription */ + /** + * Mutable list of customer products on this subscription. Updated in place + * by the `trackCustomerProduct{Update,Deletion,Insertion}` helpers. + * Callers iterating this array while those helpers may run (directly or + * transitively) must iterate over a snapshot, e.g. + * `for (const cp of [...customerProducts])`, to avoid iterator invalidation. + */ customerProducts: FullCusProduct[]; /** Current time in ms, respecting test clocks */ nowMs: number; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts index 220f93ced..6e80d3bc2 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts @@ -36,7 +36,13 @@ export const expireAndActivateCustomerProducts = async ({ ); const expiredCustomerProducts: FullCusProduct[] = []; - for (const customerProduct of customerProducts) { + // Iterate over a snapshot: `expireAndActivateWithTracking` may insert a + // default product, and `trackCustomerProductDeletion` below splices the + // paid scheduled product out. Both mutate `customerProducts` in place, + // which would otherwise invalidate the for-of cursor and cause elements to + // be skipped or re-iterated (see the add-on skip bug in the renewal + // handler). + for (const customerProduct of [...customerProducts]) { // 1. If not on stripe subscription, skip const onStripeSubscription = isCustomerProductOnStripeSubscription({ customerProduct, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts index fddb56983..f80a933ec 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts @@ -1,4 +1,3 @@ -import { formatMs } from "@autumn/shared"; import type Stripe from "stripe"; import { handleStripeSubscriptionCanceled } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionCanceled/handleStripeSubscriptionCanceled.js"; import { syncAutumnSubscription } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/syncAutumnSubscription.js"; @@ -23,10 +22,6 @@ export const handleStripeSubscriptionUpdated = async ({ event, }); - ctx.logger.debug( - `Received subscription updated event, now: ${formatMs(subscriptionUpdatedContext?.nowMs)}`, - ); - if (!subscriptionUpdatedContext) { ctx.logger.warn( "[sub.updated] subscriptionUpdatedContext not found, skipping", diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts index 5840f212f..a0c1ade0d 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext.ts @@ -22,7 +22,14 @@ export interface StripeSubscriptionUpdatedContext { stripeSubscription: ExpandedStripeSubscription; previousAttributes: SubscriptionPreviousAttributes; fullCustomer: FullCustomer; - /** Mutable list of customer products - can be updated in place by tasks */ + /** + * Mutable list of customer products. Updated in place by the + * `trackCustomerProduct{Update,Deletion,Insertion}` helpers so subsequent + * tasks see the current state. Callers iterating this array while those + * helpers may run (directly or transitively) must iterate over a snapshot, + * e.g. `for (const cp of [...customerProducts])`, to avoid iterator + * invalidation. + */ customerProducts: FullCusProduct[]; /** Current time in ms, respecting test clocks */ nowMs: number; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts index 068f2e27c..ede369cfc 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/expireEndedCustomerProducts.ts @@ -21,7 +21,11 @@ export const expireEndedCustomerProducts = async ({ const expiredCustomerProducts: FullCusProduct[] = []; - for (const customerProduct of customerProducts) { + // Iterate over a snapshot: `expireAndActivateWithTracking` may insert a + // default product (via `trackCustomerProductInsertion`), which `push`es + // onto `customerProducts`. Without the snapshot the for-of would then + // iterate the newly inserted default product as an extra pass. + for (const customerProduct of [...customerProducts]) { const shouldExpire = hasCustomerProductEnded(customerProduct, { nowMs }); if (!shouldExpire) continue; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionRenewed/handleStripeSubscriptionRenewed.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionRenewed/handleStripeSubscriptionRenewed.ts index 09e462c0a..e5ee4764a 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionRenewed/handleStripeSubscriptionRenewed.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleStripeSubscriptionRenewed/handleStripeSubscriptionRenewed.ts @@ -61,8 +61,13 @@ export const handleStripeSubscriptionRenewed = async ({ return; } - // PASS 1: Update customer products and handle scheduled products - for (const customerProduct of customerProducts) { + // PASS 1: Update customer products and handle scheduled products. + // Iterate over a snapshot: `trackCustomerProductDeletion` (called below when + // a scheduled default product is deleted alongside the main product's + // renewal) splices `customerProducts` in place, which would otherwise + // invalidate the for-of cursor and skip subsequent entries (e.g. an add-on + // that sits after the main product in the array). + for (const customerProduct of [...customerProducts]) { // Skip if not active or not on this subscription const { valid } = cp(customerProduct) diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index dcf55f502..6d4a64be6 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -5,10 +5,13 @@ export const temp: TestGroup = { description: "Create schedule unit tests", tier: "domain", paths: [ - "unit/billing/create-schedule/create-schedule-params.spec.ts", - "unit/billing/create-schedule/compute-create-schedule-plan.spec.ts", - "unit/billing/create-schedule/normalize-create-schedule-phases.spec.ts", - "unit/billing/create-schedule/validate-create-schedule-phase-plans.spec.ts", - "integration/billing/create-schedule/create-schedule-basic.test.ts", + "integration/billing/update-subscription/free-trial/update-paid-trials.test.ts", + "integration/billing/update-subscription/free-trial/update-paid-to-free-trials.test.ts", + + "integration/billing/legacy/attach/downgrade/legacy-downgrade-merged-clock.test.ts", + "integration/billing/legacy/attach/downgrade/legacy-downgrade-merged-schedule.test.ts", + "integration/billing/stripe-webhooks/subscription-updated/subscription-updated-uncancel.test.ts", + "integration/billing/stripe-webhooks/subscription-updated/subscription-updated-past-due.test.ts", + "integration/billing/stripe-webhooks/invoice-created/invoice-created-entity-consumable.test.ts", ], }; diff --git a/server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts b/server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts index d4da5f53c..3710e5848 100644 --- a/server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts +++ b/server/tests/integration/billing/legacy/attach/downgrade/legacy-downgrade.test.ts @@ -30,6 +30,7 @@ import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { addWeeks } from "date-fns"; +import { constructPriceItem } from "@/internal/products/product-items/productItemUtils"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts"; import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils"; @@ -288,14 +289,17 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: pro-quarter -> premiu const customerId = "legacy-downgrade-4"; const wordsItem = items.consumableWords(); + const quarterlyPrice = constructPriceItem({ + price: 500, + interval: BillingInterval.Quarter, + }); - // Quarterly pro product - use constructProduct directly with interval parameter const proQuarter = constructProduct({ id: "pro-quarter", - items: [wordsItem], - type: "pro", - interval: BillingInterval.Quarter, + items: [wordsItem, quarterlyPrice], + type: "free", isDefault: false, + interval: BillingInterval.Quarter, }); const premium = products.premium({ id: "premium", items: [wordsItem] }); diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-paid-to-free-trials.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-paid-to-free-trials.test.ts new file mode 100644 index 000000000..2976936bc --- /dev/null +++ b/server/tests/integration/billing/update-subscription/free-trial/update-paid-to-free-trials.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Paid-to-Free with Trial Tests + * + * Split out from update-paid-trials.test.ts to avoid rate-limit races when + * running alongside the six p2p-trial tests. + */ + +// 1. Paid (no trial) -> Free with trial +test.concurrent(`${chalk.yellowBright("p2f-trial: paid no trial -> free with trial")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice(); + + const pro = products.base({ + items: [messagesItem, priceItem], + id: "pro-no-trial", + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "p2f-no-trial-to-trial", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Verify initially NOT trialing + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: pro.id, + }); + + // Update to free (remove price) but add trial + const updateParams = { + customer_id: customerId, + product_id: pro.id, + items: [messagesItem], // No price item = free + free_trial: { + length: 14, + duration: FreeTrialDuration.Day, + card_required: false, + unique_fingerprint: false, + }, + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should be refunded for the removed price (-$20) + expect(preview.total).toEqual(-20); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Product should now be trialing + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(14), + toleranceMs: ms.hours(1) + ms.minutes(10), + }); + + // Usage should be preserved, reset should follow new trial end + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage, + usage: 0, + resetsAt: advancedTo + ms.days(14), + }); +}); + +// 2. Paid with trial (mid-cycle after trial ended) -> Free (no trial) +test.concurrent(`${chalk.yellowBright("p2f-trial: paid with trial -> free no trial")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const proTrial = products.proWithTrial({ + items: [messagesItem], + id: "pro-trial", + trialDays: 7, + }); + + const { customerId, autumnV1, advancedTo } = await initScenario({ + customerId: "p2f-trial-to-no-trial", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [ + s.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 10 }), // Advance past 7-day trial to mid-cycle + ], + }); + + // Track some usage before update + const messagesUsage = 30; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Verify no longer trialing (trial has ended, now paying) + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer: customerBefore, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Update to free (remove price), no trial specified + const updateParams = { + customer_id: customerId, + product_id: proTrial.id, + items: [messagesItem], // No price item = free + }; + + const preview = await autumnV1.subscriptions.previewUpdate(updateParams); + + // Should credit for the removed price + expect(preview.total).toBeLessThanOrEqual(0); + + await autumnV1.subscriptions.update(updateParams); + + const customer = await autumnV1.customers.get(customerId); + + // Product should NOT be trialing + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedTo, + }); + + // Usage should be preserved, reset should be from advancedTo + 1 month + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage - messagesUsage, + usage: messagesUsage, + // resetsAt: advancedTo + ms.days(30), + }); +}); diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts index d6a1c4537..eb32ace6d 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-paid-trials.test.ts @@ -538,150 +538,3 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")} }); }); -// ═══════════════════════════════════════════════════════════════════════════════ -// PAID-TO-FREE WITH TRIAL -// ═══════════════════════════════════════════════════════════════════════════════ - -// 7. Paid (no trial) -> Free with trial -test.concurrent(`${chalk.yellowBright("p2f-trial: paid no trial -> free with trial")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const priceItem = items.monthlyPrice(); - - const pro = products.base({ - items: [messagesItem, priceItem], - id: "pro-no-trial", - }); - - const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ - customerId: "p2f-no-trial-to-trial", - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [s.attach({ productId: pro.id })], - }); - - // Verify initially NOT trialing - const customerBefore = - await autumnV1.customers.get(customerId); - await expectProductNotTrialing({ - customer: customerBefore, - productId: pro.id, - }); - - // Update to free (remove price) but add trial - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [messagesItem], // No price item = free - free_trial: { - length: 14, - duration: FreeTrialDuration.Day, - card_required: false, - unique_fingerprint: false, - }, - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should be refunded for the removed price (-$20) - expect(preview.total).toEqual(-20); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Product should now be trialing - await expectProductTrialing({ - customer, - productId: pro.id, - trialEndsAt: advancedTo + ms.days(14), - toleranceMs: ms.hours(1) + ms.minutes(10), - }); - - // Usage should be preserved, reset should follow new trial end - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: messagesItem.included_usage, - balance: messagesItem.included_usage, - usage: 0, - resetsAt: advancedTo + ms.days(14), - }); -}); - -// 8. Paid with trial (mid-cycle after trial ended) -> Free (no trial) -test.concurrent(`${chalk.yellowBright("p2f-trial: paid with trial -> free no trial")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - - const proTrial = products.proWithTrial({ - items: [messagesItem], - id: "pro-trial", - trialDays: 7, - }); - - const { customerId, autumnV1, ctx, advancedTo } = await initScenario({ - customerId: "p2f-trial-to-no-trial", - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [proTrial] }), - ], - actions: [ - s.attach({ productId: proTrial.id }), - s.advanceTestClock({ days: 10 }), // Advance past 7-day trial to mid-cycle - ], - }); - - // Track some usage before update - const messagesUsage = 30; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Verify no longer trialing (trial has ended, now paying) - const customerBefore = - await autumnV1.customers.get(customerId); - await expectProductNotTrialing({ - customer: customerBefore, - productId: proTrial.id, - nowMs: advancedTo, - }); - - // Update to free (remove price), no trial specified - const updateParams = { - customer_id: customerId, - product_id: proTrial.id, - items: [messagesItem], // No price item = free - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should credit for the removed price - expect(preview.total).toBeLessThanOrEqual(0); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Product should NOT be trialing - await expectProductNotTrialing({ - customer, - productId: proTrial.id, - nowMs: advancedTo, - }); - - // Usage should be preserved, reset should be from advancedTo + 1 month - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: messagesItem.included_usage, - balance: messagesItem.included_usage - messagesUsage, - usage: messagesUsage, - // resetsAt: advancedTo + ms.days(30), - }); -}); diff --git a/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx index 1ff943e43..f6e284115 100644 --- a/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx @@ -70,7 +70,6 @@ export function InvoiceDetailSheet() { const [refundDialogOpen, setRefundDialogOpen] = useState(false); const { customer } = useCusQuery(); - const productGroups = useMemo(() => { // Step 1: bucket line items by product_id const byProduct = new Map(); @@ -194,7 +193,7 @@ export function InvoiceDetailSheet() {
Invoice
- {invoice.amount_paid != null && invoice.amount_paid !== invoice.total && ( -
- Amount Paid - - {formatSignedAmount(invoice.amount_paid, invoice.currency)} - -
- )} - {invoice.refunded_amount > 0 && ( - <> + {invoice.amount_paid != null && + invoice.amount_paid !== invoice.total && (
- Refunded - - -{formatAmount(invoice.refunded_amount, invoice.currency)} + Amount Paid + + {formatSignedAmount(invoice.amount_paid, invoice.currency)}
-
- Net - - {formatSignedAmount( - invoice.total - invoice.refunded_amount, - invoice.currency, - )} - -
- - )} + )} diff --git a/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx b/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx index 657e4723e..a504383e9 100644 --- a/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx +++ b/vite/src/views/customers2/components/sheets/RefundInvoiceDialog.tsx @@ -70,9 +70,7 @@ export function RefundInvoiceDialog({ }, }); - const alreadyRefunded = invoice.refunded_amount ?? 0; const refundableAmount = Math.abs(invoice.amount_paid ?? invoice.total); - const remainingRefundable = refundableAmount - alreadyRefunded; const handleSubmit = () => { if (mode === "partial") { @@ -81,28 +79,22 @@ export function RefundInvoiceDialog({ toast.error("Please enter a valid refund amount"); return; } - if (parsed > remainingRefundable) { - toast.error("Refund amount cannot exceed remaining refundable balance"); + if (parsed > refundableAmount) { + toast.error("Refund amount cannot exceed the amount paid"); return; } } refundMutation.mutate(); }; - const fmt = ({ amount: amt }: { amount: number }) => - formatAmount({ - amount: amt, - currency: invoice.currency, - minFractionDigits: 2, - amountFormatOptions: { currencyDisplay: "narrowSymbol" }, - }); - - const refundDisplay = - mode === "full" - ? fmt({ amount: remainingRefundable }) - : amount - ? fmt({ amount: Number.parseFloat(amount) || 0 }) - : fmt({ amount: 0 }); + const formattedRefundable = formatAmount({ + amount: refundableAmount, + currency: invoice.currency, + minFractionDigits: 2, + amountFormatOptions: { + currencyDisplay: "narrowSymbol", + }, + }); return ( @@ -110,8 +102,7 @@ export function RefundInvoiceDialog({ Refund Invoice - Amount paid: {fmt({ amount: refundableAmount })}{" "} - {invoice.currency.toUpperCase()} + Amount paid: {formattedRefundable} {invoice.currency.toUpperCase()} @@ -140,27 +131,13 @@ export function RefundInvoiceDialog({ type="number" min="0.01" step="0.01" - max={remainingRefundable} + max={refundableAmount} placeholder="0.00" value={amount} onChange={(e) => setAmount(e.target.value)} /> )} - - {/* Refund summary */} -
- {alreadyRefunded > 0 && ( -
- Previously refunded - {fmt({ amount: alreadyRefunded })} -
- )} -
- Refund amount - {refundDisplay} -
-
diff --git a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx index 5c416703d..15aa4298c 100644 --- a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx +++ b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx @@ -24,16 +24,13 @@ const statusConfig = { }; const getRefundStatus = ({ - amountPaid, - total, + refundableAmount, refundedAmount, }: { - amountPaid: number; - total: number; + refundableAmount: number; refundedAmount: number; }): { color: string; label: string } | null => { if (refundedAmount <= 0) return null; - const refundableAmount = Math.abs(amountPaid ?? total); if (refundedAmount >= refundableAmount) { return { color: "bg-amber-500 dark:bg-amber-600", @@ -48,13 +45,13 @@ const getRefundStatus = ({ export function CustomerInvoiceStatus({ status, - amountPaid, total, + amountPaid, refundedAmount, }: { status: InvoiceStatus | null | undefined; - amountPaid?: number | null; total?: number; + amountPaid?: number | null; refundedAmount?: number; }) { if (!status) return null; @@ -65,8 +62,7 @@ export function CustomerInvoiceStatus({ total !== undefined && refundedAmount !== undefined ? getRefundStatus({ - amountPaid: amountPaid ?? total, - total, + refundableAmount: Math.abs(amountPaid ?? total), refundedAmount, }) : null; diff --git a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesColumns.tsx b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesColumns.tsx index 6e683ca4a..2605dba30 100644 --- a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesColumns.tsx @@ -32,11 +32,10 @@ export const CustomerInvoicesColumns = [ accessorKey: "total", cell: ({ row }: { row: Row }) => { const invoice = row.original; - const displayTotal = invoice.amount_paid ?? invoice.total; const discountAmount = getTotalDiscountAmount(invoice); return (
- {displayTotal.toFixed(2)} {invoice.currency.toUpperCase()} + {invoice.total.toFixed(2)} {invoice.currency.toUpperCase()} {discountAmount > 0 && ( (-{discountAmount.toFixed(2)}) )} @@ -52,8 +51,8 @@ export const CustomerInvoicesColumns = [ return ( ); diff --git a/vite/src/views/customers2/customer/CustomerSheets.tsx b/vite/src/views/customers2/customer/CustomerSheets.tsx index d2fdbfe50..a5b452d87 100644 --- a/vite/src/views/customers2/customer/CustomerSheets.tsx +++ b/vite/src/views/customers2/customer/CustomerSheets.tsx @@ -1,3 +1,4 @@ +import type { Invoice, InvoiceLineItem } from "@autumn/shared"; import { AnimatePresence, motion } from "motion/react"; import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; @@ -11,7 +12,6 @@ import { SubscriptionCancelSheet } from "@/views/customers2/components/sheets/Su import { SubscriptionUncancelSheet } from "@/views/customers2/components/sheets/SubscriptionUncancelSheet"; import { SubscriptionUpdateSheet2 } from "@/views/customers2/components/sheets/SubscriptionUpdateSheet2"; import { AttachProductSheet } from "../components/sheets/AttachProductSheet"; -import { AttachProductSheetV2 } from "../components/sheets/AttachProductSheetV2"; import { AttachProductSheetV3 } from "../components/sheets/AttachProductSheetV3"; import { BalanceCreateSheet } from "../components/sheets/BalanceCreateSheet"; import { BalanceDeleteSheet } from "../components/sheets/BalanceDeleteSheet"; @@ -32,6 +32,7 @@ import { SHEET_ANIMATION } from "./customerAnimations"; export function CustomerSheets() { const isMobile = useIsMobile(); const sheetType = useSheetStore((s) => s.type); + const sheetData = useSheetStore((s) => s.data); const closeSheet = useSheetStore((s) => s.closeSheet); const closeBalanceSheet = useCustomerBalanceSheetStore((s) => s.closeSheet); useSheetEscapeHandler(); @@ -46,11 +47,7 @@ export function CustomerSheets() { case "attach-product": return ; case "attach-product-v2": - return sheetData?.scheduleEditMode ? ( - - ) : ( - - ); + return ; case "subscription-detail": return ; case "subscription-update": @@ -67,8 +64,12 @@ export function CustomerSheets() { return ; case "balance-create": return ; - case "invoice-detail": - return ; + case "invoice-detail": { + const invoice = sheetData?.invoice as Invoice | undefined; + const lineItems = (sheetData?.lineItems as InvoiceLineItem[]) ?? []; + if (!invoice) return null; + return ; + } case "sync-stripe": return ; case "billing-auto-topup-add":