fix: latest

This commit is contained in:
John Yeo
2026-04-20 17:46:07 +01:00
18 changed files with 297 additions and 262 deletions

View File

@@ -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"
}

View File

@@ -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=="],

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)

View File

@@ -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",
],
};

View File

@@ -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] });

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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),
});
});

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(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),
});
});

View File

@@ -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<string, InvoiceLineItem[]>();
@@ -194,7 +193,7 @@ export function InvoiceDetailSheet() {
<div className="flex items-center gap-2">
<span>Invoice</span>
<CustomerInvoiceStatus
status={invoice.status ?? "paid"}
status={invoice.status ?? InvoiceStatus.Paid}
amountPaid={invoice.amount_paid}
total={invoice.total}
refundedAmount={invoice.refunded_amount}
@@ -237,33 +236,15 @@ export function InvoiceDetailSheet() {
{formatSignedAmount(invoice.total, invoice.currency)}
</span>
</div>
{invoice.amount_paid != null && invoice.amount_paid !== invoice.total && (
<div className="flex items-center justify-between">
<span className="text-sm text-t2">Amount Paid</span>
<span className="text-sm tabular-nums text-t2">
{formatSignedAmount(invoice.amount_paid, invoice.currency)}
</span>
</div>
)}
{invoice.refunded_amount > 0 && (
<>
{invoice.amount_paid != null &&
invoice.amount_paid !== invoice.total && (
<div className="flex items-center justify-between">
<span className="text-sm text-t3">Refunded</span>
<span className="text-sm text-amber-500 tabular-nums">
-{formatAmount(invoice.refunded_amount, invoice.currency)}
<span className="text-sm text-t2">Amount Paid</span>
<span className="text-sm tabular-nums text-t2">
{formatSignedAmount(invoice.amount_paid, invoice.currency)}
</span>
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-sm text-t3">Net</span>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatSignedAmount(
invoice.total - invoice.refunded_amount,
invoice.currency,
)}
</span>
</div>
</>
)}
)}
</div>
</SheetSection>

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -110,8 +102,7 @@ export function RefundInvoiceDialog({
<DialogHeader>
<DialogTitle>Refund Invoice</DialogTitle>
<DialogDescription>
Amount paid: {fmt({ amount: refundableAmount })}{" "}
{invoice.currency.toUpperCase()}
Amount paid: {formattedRefundable} {invoice.currency.toUpperCase()}
</DialogDescription>
</DialogHeader>
@@ -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)}
/>
</div>
)}
{/* Refund summary */}
<div className="flex flex-col gap-1 border-t border-border/40 pt-3">
{alreadyRefunded > 0 && (
<div className="flex justify-between text-xs text-t3">
<span>Previously refunded</span>
<span>{fmt({ amount: alreadyRefunded })}</span>
</div>
)}
<div className="flex justify-between text-sm font-medium text-foreground">
<span>Refund amount</span>
<span>{refundDisplay}</span>
</div>
</div>
</div>
<DialogFooter>

View File

@@ -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;

View File

@@ -32,11 +32,10 @@ export const CustomerInvoicesColumns = [
accessorKey: "total",
cell: ({ row }: { row: Row<CustomerInvoice> }) => {
const invoice = row.original;
const displayTotal = invoice.amount_paid ?? invoice.total;
const discountAmount = getTotalDiscountAmount(invoice);
return (
<div>
{displayTotal.toFixed(2)} {invoice.currency.toUpperCase()}
{invoice.total.toFixed(2)} {invoice.currency.toUpperCase()}
{discountAmount > 0 && (
<span className="text-t3"> (-{discountAmount.toFixed(2)})</span>
)}
@@ -52,8 +51,8 @@ export const CustomerInvoicesColumns = [
return (
<CustomerInvoiceStatus
status={invoice.status}
amountPaid={invoice.amount_paid}
total={invoice.total}
amountPaid={invoice.amount_paid}
refundedAmount={invoice.refunded_amount}
/>
);

View File

@@ -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 <AttachProductSheet />;
case "attach-product-v2":
return sheetData?.scheduleEditMode ? (
<CreateScheduleSheet />
) : (
<AttachProductSheetV3 />
);
return <AttachProductSheetV3 />;
case "subscription-detail":
return <SubscriptionDetailSheet />;
case "subscription-update":
@@ -67,8 +64,12 @@ export function CustomerSheets() {
return <BalanceDeleteSheet />;
case "balance-create":
return <BalanceCreateSheet />;
case "invoice-detail":
return <InvoiceDetailSheet />;
case "invoice-detail": {
const invoice = sheetData?.invoice as Invoice | undefined;
const lineItems = (sheetData?.lineItems as InvoiceLineItem[]) ?? [];
if (!invoice) return null;
return <InvoiceDetailSheet invoice={invoice} lineItems={lineItems} />;
}
case "sync-stripe":
return <SyncStripeSheet />;
case "billing-auto-topup-add":