fix: latest
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
37
bun.lock
37
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=="],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user