wip on update subscription custom plan
This commit is contained in:
9
bun.lock
9
bun.lock
@@ -153,6 +153,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@date-fns/utc": "catalog:",
|
"@date-fns/utc": "catalog:",
|
||||||
|
"@owpz/ksuid": "^25.7.20",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"decimal.js": "^10.5.0",
|
"decimal.js": "^10.5.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
@@ -965,6 +966,8 @@
|
|||||||
|
|
||||||
"@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="],
|
"@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="],
|
||||||
|
|
||||||
|
"@owpz/ksuid": ["@owpz/ksuid@25.7.20", "", { "dependencies": { "base-x": "^5.0.0" }, "bin": { "ksuid": "dist/cli.js" } }, "sha512-cTe0yLCXtKvVl7wE3E3r+oisL2vhrMy+OZaVN53gsPGc4oY7eyyobBb/MO7UUkYAbPjt+9n20eS/zudelQlVHA=="],
|
||||||
|
|
||||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||||
|
|
||||||
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
|
"@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="],
|
||||||
@@ -1699,6 +1702,8 @@
|
|||||||
|
|
||||||
"base-convert-int-array": ["base-convert-int-array@1.0.1", "", {}, "sha512-NWqzaoXx8L/SS32R+WmKqnQkVXVYl2PwNJ68QV3RAlRRL1uV+yxJT66abXI1cAvqCXQTyXr7/9NN4Af90/zDVw=="],
|
"base-convert-int-array": ["base-convert-int-array@1.0.1", "", {}, "sha512-NWqzaoXx8L/SS32R+WmKqnQkVXVYl2PwNJ68QV3RAlRRL1uV+yxJT66abXI1cAvqCXQTyXr7/9NN4Af90/zDVw=="],
|
||||||
|
|
||||||
|
"base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
|
||||||
|
|
||||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
@@ -3191,6 +3196,8 @@
|
|||||||
|
|
||||||
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
|
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
|
||||||
|
|
||||||
|
"@autumn/shared/@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||||
|
|
||||||
"@autumn/vite/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
|
"@autumn/vite/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
|
||||||
|
|
||||||
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||||
@@ -3963,6 +3970,8 @@
|
|||||||
|
|
||||||
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
|
"@autumn/shared/@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||||
|
|
||||||
"@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"@aws-crypto/sha256-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=="],
|
"@aws-crypto/sha256-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=="],
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ BUN_PARALLEL_COMPACT \
|
|||||||
'server/tests/balances/track/concurrency' \
|
'server/tests/balances/track/concurrency' \
|
||||||
'server/tests/balances/track/negative' \
|
'server/tests/balances/track/negative' \
|
||||||
'server/tests/balances/update' \
|
'server/tests/balances/update' \
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type Stripe from "stripe";
|
|
||||||
|
|
||||||
export const stripeInvoiceLineItemToPriceId = (
|
|
||||||
lineItem: Stripe.InvoiceLineItem,
|
|
||||||
) => {
|
|
||||||
const priceId = lineItem.pricing?.price_details?.price;
|
|
||||||
if (!priceId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof priceId !== "string") {
|
|
||||||
throw new Error("lineItem.pricing.price_details.price is not a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
return priceId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stripeInvoiceLineItemToProductId = (
|
|
||||||
lineItem: Stripe.InvoiceLineItem,
|
|
||||||
) => {
|
|
||||||
const productId = lineItem.pricing?.price_details?.product;
|
|
||||||
if (!productId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof productId !== "string") {
|
|
||||||
throw new Error("lineItem.pricing.price_details.product is not a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
return productId;
|
|
||||||
};
|
|
||||||
2
server/src/external/stripe/stripeSubUtils.ts
vendored
2
server/src/external/stripe/stripeSubUtils.ts
vendored
@@ -222,7 +222,7 @@ export const getStripeSchedules = async ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (schedule.status == "canceled") {
|
if (schedule.status === "canceled") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
export const stripeSubscriptionItemToStripePriceId = (
|
||||||
|
stripeSubscriptionItem: Stripe.SubscriptionItem,
|
||||||
|
) => {
|
||||||
|
const price = stripeSubscriptionItem.price;
|
||||||
|
|
||||||
|
if (typeof price === "string") {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
|
return price.id;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type Stripe from "stripe";
|
||||||
|
import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils";
|
||||||
|
|
||||||
|
export const findStripeSubscriptionItemByStripePriceId = ({
|
||||||
|
stripePriceId,
|
||||||
|
stripeSubscriptionItems,
|
||||||
|
}: {
|
||||||
|
stripePriceId: string;
|
||||||
|
stripeSubscriptionItems: Stripe.SubscriptionItem[];
|
||||||
|
}) => {
|
||||||
|
return stripeSubscriptionItems.find(
|
||||||
|
(item) => stripeSubscriptionItemToStripePriceId(item) === stripePriceId,
|
||||||
|
);
|
||||||
|
};
|
||||||
22
server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts
vendored
Normal file
22
server/src/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { notNullish } from "@autumn/shared";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
export const isStripeSubscriptionTrialing = (
|
||||||
|
stripeSubscription: Stripe.Subscription,
|
||||||
|
) => {
|
||||||
|
return stripeSubscription.status === "trialing";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isStripeSubscriptionCancelling = (
|
||||||
|
stripeSubscription?: Stripe.Subscription,
|
||||||
|
) => {
|
||||||
|
if (!stripeSubscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
notNullish(stripeSubscription.canceled_at) ||
|
||||||
|
notNullish(stripeSubscription.cancel_at) ||
|
||||||
|
stripeSubscription.cancel_at_period_end
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// import type { ScheduledCusProductAction } from "@autumn/shared";
|
|
||||||
// import type { AutumnContext } from "../../../honoUtils/HonoEnv";
|
|
||||||
// import { CusProductService } from "../../customers/cusProducts/CusProductService";
|
|
||||||
|
|
||||||
// export const executeScheduledCusProductAction = async ({
|
|
||||||
// ctx,
|
|
||||||
// scheduledCusProductAction,
|
|
||||||
// }: {
|
|
||||||
// ctx: AutumnContext;
|
|
||||||
// scheduledCusProductAction?: ScheduledCusProductAction;
|
|
||||||
// }) => {
|
|
||||||
// if (!scheduledCusProductAction) return;
|
|
||||||
|
|
||||||
// const { action, cusProduct } = scheduledCusProductAction;
|
|
||||||
|
|
||||||
// if (action === "delete") {
|
|
||||||
// return await CusProductService.delete({
|
|
||||||
// db: ctx.db,
|
|
||||||
// cusProductId: cusProduct.id,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import {
|
|
||||||
type FullCusProduct,
|
|
||||||
type FullCustomer,
|
|
||||||
filterCusProductsBySubId,
|
|
||||||
isConsumablePrice,
|
|
||||||
isCusProductOngoing,
|
|
||||||
type StripeItemSpec,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
|
||||||
import { cusProductToStripeItemSpecs } from "../cusProductToStripeItemSpecs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize targetItems with current sub state.
|
|
||||||
* - Regular items: set quantity
|
|
||||||
* - Metered items: set undefined (no quantity)
|
|
||||||
*/
|
|
||||||
const initializeTargetItems = ({
|
|
||||||
currentItems,
|
|
||||||
}: {
|
|
||||||
currentItems: Stripe.SubscriptionItem[];
|
|
||||||
}): Map<string, number | undefined> => {
|
|
||||||
const targetItems = new Map<string, number | undefined>();
|
|
||||||
|
|
||||||
for (const item of currentItems) {
|
|
||||||
const priceId = item.price?.id;
|
|
||||||
if (!priceId) continue;
|
|
||||||
|
|
||||||
// Metered items have no quantity (or quantity is irrelevant)
|
|
||||||
const isMetered = item.price?.recurring?.usage_type === "metered";
|
|
||||||
targetItems.set(priceId, isMetered ? undefined : (item.quantity ?? 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds new items to targetItems map.
|
|
||||||
* - Consumable prices: only add if not already in map, use spec.quantity (0 or undefined)
|
|
||||||
* - Regular prices: add to existing quantity in map
|
|
||||||
*/
|
|
||||||
const addNewItems = ({
|
|
||||||
targetItems,
|
|
||||||
itemsToAdd,
|
|
||||||
}: {
|
|
||||||
targetItems: Map<string, number | undefined>;
|
|
||||||
itemsToAdd: StripeItemSpec[];
|
|
||||||
}) => {
|
|
||||||
for (const spec of itemsToAdd) {
|
|
||||||
const isConsumable =
|
|
||||||
spec.autumnPrice && isConsumablePrice(spec.autumnPrice);
|
|
||||||
|
|
||||||
// CONSUMABLE: Only add if not already in map, use quantity from spec
|
|
||||||
if (isConsumable) {
|
|
||||||
if (targetItems.has(spec.stripePriceId)) continue;
|
|
||||||
targetItems.set(spec.stripePriceId, spec.quantity); // Could be 0 or undefined
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// REGULAR: Add to existing quantity in map
|
|
||||||
const existingQty = targetItems.get(spec.stripePriceId) ?? 0;
|
|
||||||
const newQty = (existingQty ?? 0) + (spec.quantity ?? 1);
|
|
||||||
targetItems.set(spec.stripePriceId, newQty);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a cusProduct has a specific stripe price ID
|
|
||||||
*/
|
|
||||||
const cusProductHasStripePriceId = ({
|
|
||||||
cusProduct,
|
|
||||||
stripePriceId,
|
|
||||||
}: {
|
|
||||||
cusProduct: FullCusProduct;
|
|
||||||
stripePriceId: string;
|
|
||||||
}): boolean => {
|
|
||||||
return cusProduct.customer_prices.some(
|
|
||||||
(cp) =>
|
|
||||||
cp.price.config.stripe_price_id === stripePriceId ||
|
|
||||||
cp.price.config.stripe_empty_price_id === stripePriceId,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes old items from targetItems map.
|
|
||||||
* - Consumable: keep if ANY remaining cusProduct needs it, otherwise delete
|
|
||||||
* - Regular: always subtract quantity, delete if <= 0
|
|
||||||
*/
|
|
||||||
const removeOldItems = ({
|
|
||||||
targetItems,
|
|
||||||
itemsToRemove,
|
|
||||||
remainingCusProducts,
|
|
||||||
}: {
|
|
||||||
targetItems: Map<string, number | undefined>;
|
|
||||||
itemsToRemove: StripeItemSpec[];
|
|
||||||
remainingCusProducts: FullCusProduct[]; // All cus products AFTER operation (includes new, excludes old)
|
|
||||||
}) => {
|
|
||||||
for (const spec of itemsToRemove) {
|
|
||||||
const priceId = spec.stripePriceId;
|
|
||||||
const isConsumable =
|
|
||||||
spec.autumnPrice && isConsumablePrice(spec.autumnPrice);
|
|
||||||
|
|
||||||
// CONSUMABLE: Keep if ANY remaining cusProduct needs it
|
|
||||||
if (isConsumable) {
|
|
||||||
const anyNeedsIt = remainingCusProducts.some((cp) =>
|
|
||||||
cusProductHasStripePriceId({ cusProduct: cp, stripePriceId: priceId }),
|
|
||||||
);
|
|
||||||
if (anyNeedsIt) continue;
|
|
||||||
|
|
||||||
// No one needs it, delete
|
|
||||||
targetItems.delete(priceId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// REGULAR: Always subtract quantity
|
|
||||||
const existingQty = targetItems.get(priceId) ?? 0;
|
|
||||||
const quantityToRemove = spec.quantity ?? 1;
|
|
||||||
const newQty = (existingQty ?? 0) - quantityToRemove;
|
|
||||||
|
|
||||||
if (newQty <= 0) {
|
|
||||||
targetItems.delete(priceId);
|
|
||||||
} else {
|
|
||||||
targetItems.set(priceId, newQty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert targetItems map to Stripe subscription update params.
|
|
||||||
* Compares with currentItems to determine add/update/delete operations.
|
|
||||||
*/
|
|
||||||
const toStripeParams = ({
|
|
||||||
targetItems,
|
|
||||||
currentItems,
|
|
||||||
}: {
|
|
||||||
targetItems: Map<string, number | undefined>;
|
|
||||||
currentItems: Stripe.SubscriptionItem[];
|
|
||||||
}): Stripe.SubscriptionUpdateParams.Item[] => {
|
|
||||||
const result: Stripe.SubscriptionUpdateParams.Item[] = [];
|
|
||||||
|
|
||||||
// Handle additions and updates
|
|
||||||
for (const [priceId, quantity] of targetItems) {
|
|
||||||
const existingItem = currentItems.find((si) => si.price?.id === priceId);
|
|
||||||
|
|
||||||
if (existingItem) {
|
|
||||||
// UPDATE existing item (only if quantity changed)
|
|
||||||
const currentQty = existingItem.quantity;
|
|
||||||
if (quantity !== currentQty) {
|
|
||||||
result.push({ id: existingItem.id, quantity });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// ADD new item
|
|
||||||
result.push({ price: priceId, quantity });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle deletions - items in current but NOT in target
|
|
||||||
for (const item of currentItems) {
|
|
||||||
const priceId = item.price?.id;
|
|
||||||
if (!priceId) continue;
|
|
||||||
|
|
||||||
if (!targetItems.has(priceId)) {
|
|
||||||
result.push({ id: item.id, deleted: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildSubItemUpdate = ({
|
|
||||||
ctx,
|
|
||||||
fullCus,
|
|
||||||
stripeSub,
|
|
||||||
paymentMethod,
|
|
||||||
ongoingCusProduct,
|
|
||||||
newCusProducts,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
fullCus: FullCustomer;
|
|
||||||
stripeSub: Stripe.Subscription;
|
|
||||||
paymentMethod?: Stripe.PaymentMethod;
|
|
||||||
ongoingCusProduct?: FullCusProduct;
|
|
||||||
newCusProducts?: FullCusProduct[];
|
|
||||||
}) => {
|
|
||||||
// const { fullCus, stripeSub } = attachContext;
|
|
||||||
const currentItems = stripeSub?.items.data || [];
|
|
||||||
|
|
||||||
const itemsToAdd =
|
|
||||||
newCusProducts?.flatMap((cusProduct) =>
|
|
||||||
cusProductToStripeItemSpecs({
|
|
||||||
ctx,
|
|
||||||
cusProduct,
|
|
||||||
fromVercel: paymentMethod?.type === "custom",
|
|
||||||
}),
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
const itemsToRemove = ongoingCusProduct
|
|
||||||
? cusProductToStripeItemSpecs({
|
|
||||||
ctx,
|
|
||||||
cusProduct: ongoingCusProduct,
|
|
||||||
fromVercel: paymentMethod?.type === "custom", // TODO:
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Cus products that will remain after operation (excludes old, includes existing + new)
|
|
||||||
const existingCusProducts = filterCusProductsBySubId({
|
|
||||||
cusProducts: fullCus.customer_products,
|
|
||||||
subId: stripeSub?.id,
|
|
||||||
})
|
|
||||||
.filter((cp: FullCusProduct) => cp.id !== ongoingCusProduct?.id)
|
|
||||||
.filter((cp: FullCusProduct) => isCusProductOngoing({ cusProduct: cp }));
|
|
||||||
|
|
||||||
const remainingCusProducts = [
|
|
||||||
...existingCusProducts,
|
|
||||||
...(newCusProducts ?? []),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Step 0: Initialize targetItems with current sub state
|
|
||||||
const targetItems = initializeTargetItems({ currentItems });
|
|
||||||
|
|
||||||
// Step 1: Add new items
|
|
||||||
addNewItems({ targetItems, itemsToAdd });
|
|
||||||
|
|
||||||
// Step 2: Remove old items
|
|
||||||
removeOldItems({
|
|
||||||
targetItems,
|
|
||||||
itemsToRemove,
|
|
||||||
remainingCusProducts,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Convert to Stripe params (deletions derived from diff)
|
|
||||||
const finalSubItems = toStripeParams({ targetItems, currentItems });
|
|
||||||
|
|
||||||
return finalSubItems;
|
|
||||||
};
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import {
|
|
||||||
addCusProductToCusEnt,
|
|
||||||
type BillingInterval,
|
|
||||||
cusPriceToCusEnt,
|
|
||||||
cusProductToProduct,
|
|
||||||
entToOptions,
|
|
||||||
type FeatureOptions,
|
|
||||||
type FullCusProduct,
|
|
||||||
intervalsSame,
|
|
||||||
isAllocatedCusEnt,
|
|
||||||
notNullish,
|
|
||||||
type StripeItemSpec,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import { cusEntToInvoiceUsage } from "../../../../../../shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage";
|
|
||||||
import { priceToStripeItem } from "../../../../external/stripe/priceToStripeItem/priceToStripeItem";
|
|
||||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
|
||||||
|
|
||||||
export const cusProductToStripeItemSpecs = ({
|
|
||||||
ctx,
|
|
||||||
cusProduct,
|
|
||||||
fromCheckout = false,
|
|
||||||
fromVercel = false,
|
|
||||||
|
|
||||||
// filter by interval
|
|
||||||
interval,
|
|
||||||
intervalCount = 1,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
cusProduct: FullCusProduct;
|
|
||||||
fromCheckout?: boolean;
|
|
||||||
fromVercel?: boolean;
|
|
||||||
|
|
||||||
// filter by interval
|
|
||||||
interval?: BillingInterval;
|
|
||||||
intervalCount?: number;
|
|
||||||
}) => {
|
|
||||||
const product = cusProductToProduct({ cusProduct });
|
|
||||||
|
|
||||||
const cusPrices = cusProduct.customer_prices;
|
|
||||||
const cusEnts = cusProduct.customer_entitlements;
|
|
||||||
// const fromVercel = attachContext?.paymentMethod?.type === "custom";
|
|
||||||
|
|
||||||
const { org } = ctx;
|
|
||||||
|
|
||||||
const stripeItems: StripeItemSpec[] = [];
|
|
||||||
|
|
||||||
for (const cusPrice of cusPrices) {
|
|
||||||
const price = cusPrice.price;
|
|
||||||
const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts });
|
|
||||||
const ent = cusEnt?.entitlement;
|
|
||||||
|
|
||||||
if (
|
|
||||||
interval &&
|
|
||||||
!intervalsSame({
|
|
||||||
intervalA: { interval, intervalCount },
|
|
||||||
intervalB: price.config,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let options: FeatureOptions | undefined;
|
|
||||||
let existingUsage: number | undefined;
|
|
||||||
if (cusEnt) {
|
|
||||||
const ent = cusEnt.entitlement;
|
|
||||||
options = entToOptions({ ent, options: cusProduct.options ?? [] });
|
|
||||||
|
|
||||||
const cusEntWithCusProduct = addCusProductToCusEnt({
|
|
||||||
cusEnt,
|
|
||||||
cusProduct,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isAllocatedCusEnt(cusEntWithCusProduct)) {
|
|
||||||
existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeItem = priceToStripeItem({
|
|
||||||
price,
|
|
||||||
product,
|
|
||||||
org,
|
|
||||||
options,
|
|
||||||
isCheckout: fromCheckout,
|
|
||||||
relatedEnt: ent,
|
|
||||||
existingUsage,
|
|
||||||
withEntity: notNullish(cusProduct.internal_entity_id),
|
|
||||||
apiVersion: ctx.apiVersion.value,
|
|
||||||
fromVercel,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!stripeItem) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lineItem } = stripeItem;
|
|
||||||
|
|
||||||
// subItems.push(lineItem);
|
|
||||||
stripeItems.push({
|
|
||||||
stripePriceId: lineItem?.price ?? "",
|
|
||||||
quantity: lineItem?.quantity,
|
|
||||||
autumnPrice: price,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return stripeItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
// const {
|
|
||||||
// prices,
|
|
||||||
// entitlements,
|
|
||||||
// optionsList,
|
|
||||||
// cusProducts,
|
|
||||||
// customer,
|
|
||||||
// internalEntityId,
|
|
||||||
// products,
|
|
||||||
// } = attachParams;
|
|
||||||
|
|
||||||
// const subItems: any[] = [];
|
|
||||||
// const invoiceItems: any[] = [];
|
|
||||||
// const usageFeatures: any[] = [];
|
|
||||||
// for (const price of prices) {
|
|
||||||
// const priceEnt = getPriceEntitlement(price, entitlements);
|
|
||||||
// const options = getEntOptions(optionsList, priceEnt);
|
|
||||||
// const prodOptions = priceToProductOptions({
|
|
||||||
// price,
|
|
||||||
// options: attachParams.productsList,
|
|
||||||
// products,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// let existingUsage = getExistingUsageFromCusProducts({
|
|
||||||
// entitlement: priceEnt,
|
|
||||||
// cusProducts,
|
|
||||||
// entities: customer.entities ?? [],
|
|
||||||
// carryExistingUsages: config.carryUsage,
|
|
||||||
// internalEntityId,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const replaceables = priceEnt
|
|
||||||
// ? attachParams.replaceables.filter((r) => r.ent.id === priceEnt.id)
|
|
||||||
// : [];
|
|
||||||
|
|
||||||
// existingUsage += replaceables.length;
|
|
||||||
|
|
||||||
// const product = getProductForPrice(price, attachParams.products)!;
|
|
||||||
|
|
||||||
// if (!product) {
|
|
||||||
// logger.error(
|
|
||||||
// `Couldn't find product for price ${price.internal_product_id}`,
|
|
||||||
// {
|
|
||||||
// data: {
|
|
||||||
// products: attachParams.products,
|
|
||||||
// price,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
// throw new InternalError({
|
|
||||||
// message: `Price internal product ID: ${price.internal_product_id} not found in products`,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const stripeItem = priceToStripeItem({
|
|
||||||
// price,
|
|
||||||
// product,
|
|
||||||
// org: attachParams.org,
|
|
||||||
// options,
|
|
||||||
// isCheckout: config.onlyCheckout,
|
|
||||||
// relatedEnt: priceEnt,
|
|
||||||
// existingUsage,
|
|
||||||
// withEntity: notNullish(internalEntityId),
|
|
||||||
// apiVersion: attachParams.apiVersion,
|
|
||||||
// productOptions: prodOptions,
|
|
||||||
// fromVercel: attachParams.paymentMethod?.type === "custom",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (isUsagePrice({ price })) {
|
|
||||||
// usageFeatures.push(priceEnt.feature.internal_id);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (!stripeItem) {
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const { lineItem } = stripeItem;
|
|
||||||
|
|
||||||
// // subItems.push(lineItem);
|
|
||||||
|
|
||||||
// if (price.config.interval === BillingInterval.OneOff) {
|
|
||||||
// invoiceItems.push(lineItem);
|
|
||||||
// } else {
|
|
||||||
// subItems.push({
|
|
||||||
// ...lineItem,
|
|
||||||
// autumnPrice: price,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return { subItems, invoiceItems, usageFeatures } as ItemSet;
|
|
||||||
// if (price.config.interval === BillingInterval.OneOff) {
|
|
||||||
// invoiceItems.push({
|
|
||||||
// stripe_price_id: lineItem.price,
|
|
||||||
// quantity: lineItem.quantity,
|
|
||||||
// });
|
|
||||||
// } else {
|
|
||||||
// subItems.push({
|
|
||||||
// ...lineItem,
|
|
||||||
// autumnPrice: price,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
@@ -19,15 +19,15 @@ server/src/internal/billing/v2/
|
|||||||
│ ├── compute/ # Shared compute helpers (server-side)
|
│ ├── compute/ # Shared compute helpers (server-side)
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── cusProduct/ # CusProduct initialization
|
│ │ ├── cusProduct/ # CusProduct initialization
|
||||||
│ │ │ ├── initFullCusProduct.ts
|
│ │ │ ├── initFullCustomerProduct.ts
|
||||||
│ │ │ ├── initCusProduct.ts
|
│ │ │ ├── initCustomerProduct.ts
|
||||||
│ │ │ ├── initCusPrice.ts
|
│ │ │ ├── initCustomerPrice.ts
|
||||||
│ │ │ ├── initCusEntitlement/
|
│ │ │ ├── initCustomerEntitlement/
|
||||||
│ │ │ │ ├── initCusEntitlement.ts
|
│ │ │ │ ├── initCustomerEntitlement.ts
|
||||||
│ │ │ │ ├── initCusEntitlementBalance.ts
|
│ │ │ │ ├── initCustomerEntitlementBalance.ts
|
||||||
│ │ │ │ ├── initCusEntitlementEntities.ts
|
│ │ │ │ ├── initCustomerEntitlementEntities.ts
|
||||||
│ │ │ │ ├── initCusEntitlementNextResetAt.ts
|
│ │ │ │ ├── initCustomerEntitlementNextResetAt.ts
|
||||||
│ │ │ │ └── initCusEntUsageAllowed.ts
|
│ │ │ │ └── initCustomerEntitlementUsageAllowed.ts
|
||||||
│ │ │ └── index.ts
|
│ │ │ └── index.ts
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ ├── existingState/ # Handle existing usages/rollovers
|
│ │ ├── existingState/ # Handle existing usages/rollovers
|
||||||
@@ -330,7 +330,7 @@ export const handleAttach = createRoute({
|
|||||||
| `billingUtils/stripeAdapter/buildSubItems/*` | `stripe/subItems/*` |
|
| `billingUtils/stripeAdapter/buildSubItems/*` | `stripe/subItems/*` |
|
||||||
| `billingUtils/stripeAdapter/stripeInvoiceOps/*` | `stripe/invoice/*` |
|
| `billingUtils/stripeAdapter/stripeInvoiceOps/*` | `stripe/invoice/*` |
|
||||||
| `billingUtils/stripeAdapter/applyStripeDiscounts/*` | `stripe/discounts/*` |
|
| `billingUtils/stripeAdapter/applyStripeDiscounts/*` | `stripe/discounts/*` |
|
||||||
| `billingUtils/initFullCusProduct/*` | `cusProduct/*` |
|
| `billingUtils/initFullCustomerProduct/*` | `cusProduct/*` |
|
||||||
|
|
||||||
**Note**: `shared/utils/billingUtils/` stays in place - it's pure calculations used by frontend too.
|
**Note**: `shared/utils/billingUtils/` stays in place - it's pure calculations used by frontend too.
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Create `computeUpdatePlanPlan.ts`:
|
|||||||
**Inputs**: Target cus product, new product definition
|
**Inputs**: Target cus product, new product definition
|
||||||
|
|
||||||
**Compute**:
|
**Compute**:
|
||||||
1. Build new cus product (reuse [`initFullCusProduct`](server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts))
|
1. Build new cus product (reuse [`initFullCustomerProduct`](server/src/internal/billing/billingUtils/initFullCustomerProduct/initFullCustomerProduct.ts))
|
||||||
2. Build `stripe.subscription` update params
|
2. Build `stripe.subscription` update params
|
||||||
3. Build `autumn.expireCusProducts` for old cus product
|
3. Build `autumn.expireCusProducts` for old cus product
|
||||||
4. Build `autumn.insertCusProducts` for new cus product
|
4. Build `autumn.insertCusProducts` for new cus product
|
||||||
|
|||||||
11
server/src/internal/billing/v2/billingContext.ts
Normal file
11
server/src/internal/billing/v2/billingContext.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
export interface BillingContext {
|
||||||
|
fullCustomer: FullCustomer;
|
||||||
|
stripeCustomer: Stripe.Customer;
|
||||||
|
|
||||||
|
stripeSubscription?: Stripe.Subscription;
|
||||||
|
paymentMethod?: Stripe.PaymentMethod;
|
||||||
|
testClockFrozenTime?: number;
|
||||||
|
}
|
||||||
@@ -1,38 +1,56 @@
|
|||||||
import { EntitlementSchema, PriceSchema } from "@autumn/shared";
|
import {
|
||||||
|
CusProductStatus,
|
||||||
|
EntitlementSchema,
|
||||||
|
FreeTrialSchema,
|
||||||
|
PriceSchema,
|
||||||
|
} from "@autumn/shared";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { FullCusProductSchema } from "../../../../../shared/models/cusProductModels/cusProductModels";
|
import { FullCusProductSchema } from "../../../../../shared/models/cusProductModels/cusProductModels";
|
||||||
|
|
||||||
// manualInvoice?: {
|
export const FreeTrialPlanSchema = z.object({
|
||||||
// items: Stripe.InvoiceItemCreateParams[];
|
freeTrial: FreeTrialSchema.nullable().optional(),
|
||||||
// finalize: boolean;
|
trialEndsAt: z.number().optional(),
|
||||||
// chargeAutomatically: boolean;
|
});
|
||||||
// };
|
|
||||||
// subscription?: {
|
export type FreeTrialPlan = z.infer<typeof FreeTrialPlanSchema>;
|
||||||
// action: "create" | "update" | "cancel";
|
|
||||||
// params: SubscriptionParams;
|
export const StripeSubscriptionActionSchema = z.discriminatedUnion("type", [
|
||||||
// };
|
z.object({
|
||||||
// subscriptionItemUpdates?: { itemId: string; quantity: number }[];
|
type: z.literal("create"),
|
||||||
// checkout?: Stripe.Checkout.SessionCreateParams;
|
params: z.custom<import("stripe").Stripe.SubscriptionCreateParams>(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("update"),
|
||||||
|
stripeSubscriptionId: z.string(),
|
||||||
|
params: z.custom<import("stripe").Stripe.SubscriptionUpdateParams>(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("cancel"),
|
||||||
|
stripeSubscriptionId: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type StripeSubscriptionAction = z.infer<
|
||||||
|
typeof StripeSubscriptionActionSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const StripeBillingPlanSchema = z.object({
|
export const StripeBillingPlanSchema = z.object({
|
||||||
subscription: z.object({
|
subscription: StripeSubscriptionActionSchema.optional(),
|
||||||
action: z.enum(["create", "update", "cancel"]),
|
|
||||||
params: z.object({
|
|
||||||
items: z.array(z.object({ id: z.string(), quantity: z.number() })),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AutumnBillingPlanSchema = z.object({
|
export const AutumnBillingPlanSchema = z.object({
|
||||||
insertCusProducts: z.array(FullCusProductSchema),
|
insertCustomerProducts: z.array(FullCusProductSchema),
|
||||||
|
|
||||||
updateCusProduct: z.object({
|
updateCustomerProduct: z.object({
|
||||||
cusProductId: z.string(),
|
customerProduct: FullCusProductSchema,
|
||||||
action: z.enum(["expire"]),
|
updates: z.object({
|
||||||
|
status: z.enum(CusProductStatus),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
insertCustomPrices: z.array(PriceSchema),
|
customPrices: z.array(PriceSchema), // Custom prices to insert
|
||||||
insertCustomEntitlements: z.array(EntitlementSchema),
|
customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert
|
||||||
|
customFreeTrial: FreeTrialSchema.optional(), // Custom free trial to insert
|
||||||
|
|
||||||
// expireCusProducts: z.array(z.string()),
|
// expireCusProducts: z.array(z.string()),
|
||||||
|
|
||||||
@@ -46,11 +64,9 @@ export const AutumnBillingPlanSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const BillingPlanSchema = z.object({
|
export const BillingPlanSchema = z.object({
|
||||||
intent: z.enum(["update_quantity", "update_plan"]),
|
autumn: AutumnBillingPlanSchema,
|
||||||
featureQuantities: z.array(
|
stripe: StripeBillingPlanSchema,
|
||||||
z.object({
|
|
||||||
featureId: z.string(),
|
|
||||||
quantity: z.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
|
||||||
|
export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
|||||||
import type { AttachContext } from "../typesOld";
|
import type { AttachContext } from "../typesOld";
|
||||||
import { buildAutumnLineItems } from "./computeAutumnUtils/buildAutumnLineItems";
|
import { buildAutumnLineItems } from "./computeAutumnUtils/buildAutumnLineItems";
|
||||||
import { buildNewCusProducts } from "./computeAutumnUtils/buildNewCusProducts";
|
import { buildNewCusProducts } from "./computeAutumnUtils/buildNewCusProducts";
|
||||||
import { buildStripeCheckoutAction } from "./computeStripeUtils/buildStripeCheckoutAction";
|
|
||||||
import { buildStripeInvoiceAction } from "./computeStripeUtils/buildStripeInvoiceAction";
|
|
||||||
import { buildStripeSubAction } from "./computeStripeUtils/buildStripeSubAction";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared logic by attach, cancel and
|
* Shared logic by attach, cancel and
|
||||||
@@ -41,7 +38,7 @@ export const computeAttachPlan = async ({
|
|||||||
const autumnLineItems = buildAutumnLineItems({
|
const autumnLineItems = buildAutumnLineItems({
|
||||||
ctx,
|
ctx,
|
||||||
newCusProducts,
|
newCusProducts,
|
||||||
ongoingCusProductAction,
|
ongoingCustomerProduct: ongoingCusProductAction?.cusProduct,
|
||||||
billingCycleAnchor,
|
billingCycleAnchor,
|
||||||
testClockFrozenTime,
|
testClockFrozenTime,
|
||||||
});
|
});
|
||||||
@@ -53,30 +50,16 @@ export const computeAttachPlan = async ({
|
|||||||
// newCusProducts,
|
// newCusProducts,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// 4. Build stripe checkout action
|
|
||||||
const stripeCheckoutAction = buildStripeCheckoutAction({
|
|
||||||
ctx,
|
|
||||||
attachContext,
|
|
||||||
newCusProducts,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Build stripe sub action
|
// 5. Build stripe sub action
|
||||||
const stripeSubAction = buildStripeSubAction({
|
const stripeSubAction = undefined;
|
||||||
ctx,
|
|
||||||
stripeSub: attachContext.stripeSub!,
|
|
||||||
fullCus: attachContext.fullCus,
|
|
||||||
paymentMethod: attachContext.paymentMethod,
|
|
||||||
ongoingCusProductAction,
|
|
||||||
newCusProducts,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Build stripe invoice action
|
// 6. Build stripe invoice action
|
||||||
const stripeInvoiceAction = buildStripeInvoiceAction({
|
// const stripeInvoiceAction = buildStripeInvoiceAction({
|
||||||
attachContext,
|
// attachContext,
|
||||||
autumnLineItems,
|
// autumnLineItems,
|
||||||
stripeSubAction,
|
// stripeSubAction,
|
||||||
newCusProducts,
|
// newCusProducts,
|
||||||
});
|
// });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
autumnLineItems,
|
autumnLineItems,
|
||||||
@@ -86,7 +69,5 @@ export const computeAttachPlan = async ({
|
|||||||
newCusProducts,
|
newCusProducts,
|
||||||
|
|
||||||
stripeSubAction,
|
stripeSubAction,
|
||||||
stripeInvoiceAction,
|
|
||||||
stripeCheckoutAction,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||||
import { cusProductToExistingUsages } from "../../../billingUtils/handleExistingUsages/cusProductToExistingUsages";
|
import { cusProductToExistingUsages } from "../../utils/handleExistingUsages/cusProductToExistingUsages";
|
||||||
import { initFullCustomerProduct } from "../../../billingUtils/initFullCusProduct/initFullCustomerProduct";
|
import { initFullCustomerProduct } from "../../utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||||
import type { AttachContext } from "../../typesOld";
|
import type { AttachContext } from "../../typesOld";
|
||||||
|
|
||||||
export const buildNewCusProducts = ({
|
export const buildNewCusProducts = ({
|
||||||
@@ -28,7 +28,6 @@ export const buildNewCusProducts = ({
|
|||||||
fullCustomer: fullCus,
|
fullCustomer: fullCus,
|
||||||
fullProduct: products[0],
|
fullProduct: products[0],
|
||||||
featureQuantities: [],
|
featureQuantities: [],
|
||||||
replaceables: [],
|
|
||||||
existingUsages,
|
existingUsages,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import {
|
|
||||||
BillingInterval,
|
|
||||||
cusProductsToPrices,
|
|
||||||
type FullCusProduct,
|
|
||||||
getLargestInterval,
|
|
||||||
msToSeconds,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
|
||||||
import { toSuccessUrl } from "../../../../orgs/orgUtils/convertOrgUtils";
|
|
||||||
import { cusProductToStripeItemSpecs } from "../../../billingUtils/stripeAdapter/cusProductToStripeItemSpecs";
|
|
||||||
import type { AttachContext } from "../../typesOld";
|
|
||||||
import { computeShouldCreateStripeCheckout } from "./computeShouldCreateStripeCheckout";
|
|
||||||
|
|
||||||
export const buildCheckoutSessionCreateSubscriptionData = ({
|
|
||||||
isRecurring,
|
|
||||||
trialEndsAt,
|
|
||||||
// freeTrial,
|
|
||||||
// billingCycleAnchorUnixSeconds,
|
|
||||||
}: {
|
|
||||||
isRecurring: boolean;
|
|
||||||
trialEndsAt?: number;
|
|
||||||
// freeTrial: FreeTrial;
|
|
||||||
// billingCycleAnchorUnixSeconds: number;
|
|
||||||
}): Stripe.Checkout.SessionCreateParams.SubscriptionData | undefined => {
|
|
||||||
if (isRecurring && trialEndsAt) {
|
|
||||||
return {
|
|
||||||
trial_end: msToSeconds(trialEndsAt),
|
|
||||||
trial_settings: {
|
|
||||||
end_behavior: {
|
|
||||||
missing_payment_method: "cancel",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildStripeCheckoutAction = ({
|
|
||||||
ctx,
|
|
||||||
attachContext,
|
|
||||||
newCusProducts,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
attachContext: AttachContext;
|
|
||||||
newCusProducts: FullCusProduct[];
|
|
||||||
}) => {
|
|
||||||
const { org, env } = ctx;
|
|
||||||
const { body } = attachContext;
|
|
||||||
|
|
||||||
const { shouldCreate, reason } = computeShouldCreateStripeCheckout({
|
|
||||||
attachContext,
|
|
||||||
newCusProducts,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. Get largest interval
|
|
||||||
const largestInterval = getLargestInterval({
|
|
||||||
prices: cusProductsToPrices({ cusProducts: newCusProducts }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Get params
|
|
||||||
const stripeItemSpecs = newCusProducts.flatMap((cusProduct) =>
|
|
||||||
cusProductToStripeItemSpecs({
|
|
||||||
ctx,
|
|
||||||
cusProduct,
|
|
||||||
fromCheckout: true,
|
|
||||||
fromVercel: attachContext.paymentMethod?.type === "custom",
|
|
||||||
interval: largestInterval?.interval,
|
|
||||||
intervalCount: largestInterval?.intervalCount,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Is recurring:
|
|
||||||
const isRecurring = largestInterval?.interval !== BillingInterval.OneOff;
|
|
||||||
const subscriptionData = buildCheckoutSessionCreateSubscriptionData({
|
|
||||||
isRecurring,
|
|
||||||
trialEndsAt: undefined, // TODO:
|
|
||||||
});
|
|
||||||
|
|
||||||
const metadata: Record<string, string> = {
|
|
||||||
...(body.checkout_session_params?.metadata || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Build checkout session params
|
|
||||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
|
||||||
success_url: toSuccessUrl({ org, env }),
|
|
||||||
subscription_data: subscriptionData,
|
|
||||||
mode: isRecurring ? "subscription" : "payment",
|
|
||||||
line_items: stripeItemSpecs.map((item) => ({
|
|
||||||
price: item.stripePriceId,
|
|
||||||
quantity: item.quantity,
|
|
||||||
})),
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldCreate,
|
|
||||||
reason,
|
|
||||||
params: checkoutSessionCreateParams,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// let billingCycleAnchorUnixSeconds = org.config.anchor_start_of_month
|
|
||||||
// ? Math.floor(
|
|
||||||
// getNextStartOfMonthUnix({
|
|
||||||
// interval: itemSets[0].interval,
|
|
||||||
// intervalCount: itemSets[0].intervalCount,
|
|
||||||
// }) / 1000,
|
|
||||||
// )
|
|
||||||
// : undefined;
|
|
||||||
|
|
||||||
// if (attachParams.billingAnchor) {
|
|
||||||
// billingCycleAnchorUnixSeconds = Math.floor(
|
|
||||||
// attachParams.billingAnchor / 1000,
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
@@ -4,10 +4,10 @@ import {
|
|||||||
isOneOffProduct,
|
isOneOffProduct,
|
||||||
type LineItem,
|
type LineItem,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { applyStripeDiscountsToLineItems } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems";
|
|
||||||
import { subToDiscounts } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts";
|
|
||||||
import { lineItemsToStripeLines } from "../../../billingUtils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines";
|
|
||||||
import type { AttachContext, StripeSubAction } from "../../typesOld";
|
import type { AttachContext, StripeSubAction } from "../../typesOld";
|
||||||
|
import { applyStripeDiscountsToLineItems } from "../../utils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems";
|
||||||
|
import { subToDiscounts } from "../../utils/stripeAdapter/applyStripeDiscounts/subToDiscounts";
|
||||||
|
import { lineItemsToStripeLines } from "../../utils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines";
|
||||||
|
|
||||||
export const buildStripeInvoiceAction = ({
|
export const buildStripeInvoiceAction = ({
|
||||||
attachContext,
|
attachContext,
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import type {
|
|
||||||
FullCusProduct,
|
|
||||||
FullCustomer,
|
|
||||||
OngoingCusProductAction,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
|
||||||
import { buildSubItemUpdate } from "../../../billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate";
|
|
||||||
import type { StripeSubAction } from "../../typesOld";
|
|
||||||
|
|
||||||
export const buildStripeSubAction = ({
|
|
||||||
ctx,
|
|
||||||
stripeSub,
|
|
||||||
fullCus,
|
|
||||||
paymentMethod,
|
|
||||||
ongoingCusProductAction,
|
|
||||||
newCusProducts,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
stripeSub: Stripe.Subscription;
|
|
||||||
fullCus: FullCustomer;
|
|
||||||
paymentMethod?: Stripe.PaymentMethod;
|
|
||||||
ongoingCusProductAction?: OngoingCusProductAction;
|
|
||||||
newCusProducts: FullCusProduct[];
|
|
||||||
}): StripeSubAction => {
|
|
||||||
// const { stripeSub } = attachContext;
|
|
||||||
|
|
||||||
const ongoingCusProduct = ongoingCusProductAction?.cusProduct;
|
|
||||||
|
|
||||||
// Build sub item update (what items should be on the sub after this operation)
|
|
||||||
const subItemUpdate = buildSubItemUpdate({
|
|
||||||
ctx,
|
|
||||||
fullCus,
|
|
||||||
stripeSub,
|
|
||||||
paymentMethod,
|
|
||||||
ongoingCusProduct,
|
|
||||||
newCusProducts,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasNewItems = subItemUpdate.some((item) => !item.deleted);
|
|
||||||
const currentSubItems = stripeSub?.items.data ?? [];
|
|
||||||
|
|
||||||
// 1. If subItems update is empty, no update:
|
|
||||||
if (subItemUpdate.length === 0) {
|
|
||||||
return { type: "none" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 4: Ongoing action is 'cancel' → cancel at period end
|
|
||||||
if (ongoingCusProductAction?.action === "cancel") {
|
|
||||||
return {
|
|
||||||
type: "cancel_at_period_end",
|
|
||||||
subId: stripeSub!.id,
|
|
||||||
items: subItemUpdate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 1: New items but no existing sub → create
|
|
||||||
if (hasNewItems && !stripeSub) {
|
|
||||||
return {
|
|
||||||
type: "create",
|
|
||||||
items: subItemUpdate.map((item) => ({
|
|
||||||
price: item.price,
|
|
||||||
quantity: item.quantity,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: New items and existing sub → update
|
|
||||||
if (hasNewItems && stripeSub) {
|
|
||||||
return {
|
|
||||||
type: "update",
|
|
||||||
subId: stripeSub.id,
|
|
||||||
items: subItemUpdate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: No new items but existing sub has items → cancel immediately
|
|
||||||
if (!hasNewItems && currentSubItems.length > 0 && stripeSub) {
|
|
||||||
return {
|
|
||||||
type: "cancel_immediately",
|
|
||||||
subId: stripeSub.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "none" };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { BillingPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
|
||||||
|
export const addStripeSubscriptionIdToBillingPlan = ({
|
||||||
|
billingPlan,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
}: {
|
||||||
|
billingPlan: BillingPlan;
|
||||||
|
stripeSubscriptionId: string;
|
||||||
|
}) => {
|
||||||
|
for (const customerProduct of billingPlan.autumn.insertCustomerProducts) {
|
||||||
|
customerProduct.subscription_ids = [stripeSubscriptionId];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { AutumnBillingPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts";
|
||||||
|
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||||
|
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
|
||||||
|
import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService";
|
||||||
|
import { PriceService } from "@/internal/products/prices/PriceService";
|
||||||
|
|
||||||
|
export const executeAutumnBillingPlan = async ({
|
||||||
|
ctx,
|
||||||
|
autumnBillingPlan,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
autumnBillingPlan: AutumnBillingPlan;
|
||||||
|
}) => {
|
||||||
|
const { db } = ctx;
|
||||||
|
const {
|
||||||
|
insertCustomerProducts,
|
||||||
|
updateCustomerProduct,
|
||||||
|
customPrices,
|
||||||
|
customEntitlements,
|
||||||
|
customFreeTrial,
|
||||||
|
} = autumnBillingPlan;
|
||||||
|
|
||||||
|
await PriceService.insert({
|
||||||
|
db,
|
||||||
|
data: customPrices,
|
||||||
|
});
|
||||||
|
|
||||||
|
await EntitlementService.insert({
|
||||||
|
db,
|
||||||
|
data: customEntitlements,
|
||||||
|
});
|
||||||
|
if (customFreeTrial) {
|
||||||
|
await FreeTrialService.insert({
|
||||||
|
db,
|
||||||
|
data: customFreeTrial,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Insert new customer products
|
||||||
|
await insertNewCusProducts({
|
||||||
|
ctx,
|
||||||
|
newCusProducts: insertCustomerProducts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Update customer product
|
||||||
|
if (updateCustomerProduct) {
|
||||||
|
await CusProductService.update({
|
||||||
|
db,
|
||||||
|
cusProductId: updateCustomerProduct.customerProduct.id,
|
||||||
|
updates: updateCustomerProduct.updates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createStripeCli } from "../../../../external/connect/createStripeCli";
|
import { createStripeCli } from "../../../../external/connect/createStripeCli";
|
||||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||||
import { createAndPayInvoice } from "../../billingUtils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice";
|
import { createAndPayInvoice } from "../utils/stripeAdapter/stripeInvoiceOps/createAndPayInvoice";
|
||||||
import type {
|
import type {
|
||||||
AttachContext,
|
AttachContext,
|
||||||
StripeCheckoutAction,
|
StripeCheckoutAction,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
|||||||
import { getOrCreateCustomer } from "../../../customers/cusUtils/getOrCreateCustomer";
|
import { getOrCreateCustomer } from "../../../customers/cusUtils/getOrCreateCustomer";
|
||||||
import { getFreeTrialForAttach } from "./fetchAutumnUtils/getFreeTrialForAttach";
|
import { getFreeTrialForAttach } from "./fetchAutumnUtils/getFreeTrialForAttach";
|
||||||
import { getProductsForAttach } from "./fetchAutumnUtils/getProductsForAttach";
|
import { getProductsForAttach } from "./fetchAutumnUtils/getProductsForAttach";
|
||||||
import { overrideProduct } from "./fetchAutumnUtils/overrideProduct";
|
|
||||||
import { resolveAttachActions } from "./fetchAutumnUtils/resolveAttachActions/resolveAttachActions";
|
import { resolveAttachActions } from "./fetchAutumnUtils/resolveAttachActions/resolveAttachActions";
|
||||||
import { fetchStripeCustomerForBilling } from "./fetchStripeUtils/fetchStripeCustomerForBilling";
|
import { fetchStripeCustomerForBilling } from "./fetchStripeUtils/fetchStripeCustomerForBilling";
|
||||||
import { fetchStripeSubscriptionForBilling } from "./fetchStripeUtils/fetchStripeSubscriptionForBilling";
|
import { fetchStripeSubscriptionForBilling } from "./fetchStripeUtils/fetchStripeSubscriptionForBilling";
|
||||||
@@ -42,16 +41,17 @@ export const fetchAttachContext = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Override product
|
// 3. Override product
|
||||||
const {
|
// const {
|
||||||
// customPrices,
|
// // customPrices,
|
||||||
// customEnts,
|
// // customEnts,
|
||||||
fullProducts: newFullProducts,
|
// fullProducts: newFullProducts,
|
||||||
} = await overrideProduct({
|
// } = await overrideProduct({
|
||||||
ctx,
|
// ctx,
|
||||||
body,
|
// body,
|
||||||
products: fullProducts,
|
// products: fullProducts,
|
||||||
fullCustomer: fullCus,
|
// fullCustomer: fullCus,
|
||||||
});
|
// });
|
||||||
|
const newFullProducts = fullProducts;
|
||||||
|
|
||||||
// 4. Get free trial
|
// 4. Get free trial
|
||||||
const {
|
const {
|
||||||
@@ -74,12 +74,11 @@ export const fetchAttachContext = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 6. Get stripe customer
|
// 6. Get stripe customer
|
||||||
const { stripeCus, paymentMethod, now } = await fetchStripeCustomerForBilling(
|
const { stripeCus, paymentMethod, testClockFrozenTime } =
|
||||||
{
|
await fetchStripeCustomerForBilling({
|
||||||
ctx,
|
ctx,
|
||||||
fullCus,
|
fullCus,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const cusProductActions = resolveAttachActions({
|
const cusProductActions = resolveAttachActions({
|
||||||
fullCus,
|
fullCus,
|
||||||
@@ -94,7 +93,7 @@ export const fetchAttachContext = async ({
|
|||||||
stripeSub,
|
stripeSub,
|
||||||
stripeCus,
|
stripeCus,
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
testClockFrozenTime: now,
|
testClockFrozenTime,
|
||||||
|
|
||||||
ongoingCusProductAction: cusProductActions.ongoingCusProductAction,
|
ongoingCusProductAction: cusProductActions.ongoingCusProductAction,
|
||||||
scheduledCusProductAction: cusProductActions.scheduledCusProductAction,
|
scheduledCusProductAction: cusProductActions.scheduledCusProductAction,
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import {
|
|||||||
type FreeTrial,
|
type FreeTrial,
|
||||||
type FullCustomer,
|
type FullCustomer,
|
||||||
type FullProduct,
|
type FullProduct,
|
||||||
|
initFreeTrial,
|
||||||
notNullish,
|
notNullish,
|
||||||
planToDbFreeTrial,
|
planToDbFreeTrial,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||||
import {
|
import { getFreeTrialAfterFingerprint } from "../../../../products/free-trials/freeTrialUtils";
|
||||||
getFreeTrialAfterFingerprint,
|
|
||||||
validateAndInitFreeTrial,
|
|
||||||
} from "../../../../products/free-trials/freeTrialUtils";
|
|
||||||
|
|
||||||
export const getFreeTrialForAttach = async ({
|
export const getFreeTrialForAttach = async ({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -35,8 +33,8 @@ export const getFreeTrialForAttach = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const trial = dbFreeTrial
|
const trial = dbFreeTrial
|
||||||
? validateAndInitFreeTrial({
|
? initFreeTrial({
|
||||||
freeTrial: dbFreeTrial,
|
freeTrialParams: dbFreeTrial,
|
||||||
internalProductId: products[0].internal_id,
|
internalProductId: products[0].internal_id,
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const getUncancelAttachActions = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ongoingCusProductAction,
|
ongoingCustomerProduct: ongoingCusProductAction?.cusProduct,
|
||||||
scheduledCusProductAction,
|
scheduledCusProductAction,
|
||||||
newProductActions: [],
|
newProductActions: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared";
|
import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared";
|
||||||
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
||||||
import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
|
import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
|
||||||
import { executeSubscriptionUpdate } from "../subscriptionUpdate/execute/executeSubscriptionUpdate";
|
|
||||||
import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext";
|
import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext";
|
||||||
|
|
||||||
export const handleApiSubscriptionUpdate = createRoute({
|
export const handleApiSubscriptionUpdate = createRoute({
|
||||||
@@ -21,12 +20,13 @@ export const handleApiSubscriptionUpdate = createRoute({
|
|||||||
params: body,
|
params: body,
|
||||||
});
|
});
|
||||||
|
|
||||||
await executeSubscriptionUpdate({
|
// Execute...
|
||||||
ctx,
|
// await executeApiSubscriptionUpdate({
|
||||||
params: body,
|
// ctx,
|
||||||
updateSubscriptionContext,
|
// params: body,
|
||||||
subscriptionUpdatePlan,
|
// updateSubscriptionContext,
|
||||||
});
|
// subscriptionUpdatePlan,
|
||||||
|
// });
|
||||||
|
|
||||||
return c.json({ success: true }, 200);
|
return c.json({ success: true }, 200);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
|
CusProductStatus,
|
||||||
cusProductToProduct,
|
cusProductToProduct,
|
||||||
type SubscriptionUpdateV0Params,
|
type SubscriptionUpdateV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||||
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||||
|
import type { BillingPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
|
||||||
|
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||||
|
import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
|
||||||
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
||||||
|
import { computeSubscriptionUpdateStripeSubscriptionAction } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateStripeSubscriptionAction";
|
||||||
|
import { logBillingPlan } from "@/internal/billing/v2/utils/logBillingPlan";
|
||||||
|
import { createStripeResourcesForProducts } from "@/internal/billing/v2/utils/stripeAdapter/createStripeResourcesForProduct";
|
||||||
|
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/executeStripeSubscriptionAction";
|
||||||
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
|
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
|
||||||
|
|
||||||
export const computeSubscriptionUpdateCustomPlan = async ({
|
export const computeSubscriptionUpdateCustomPlan = async ({
|
||||||
ctx,
|
ctx,
|
||||||
subscriptionUpdateContext,
|
updateSubscriptionContext,
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: SubscriptionUpdateV0Params;
|
||||||
}) => {
|
}) => {
|
||||||
// 1. Compute the override plan
|
const { customerProduct } = updateSubscriptionContext;
|
||||||
const { customerProduct } = subscriptionUpdateContext;
|
|
||||||
|
|
||||||
const currentFullProduct = cusProductToProduct({
|
const currentFullProduct = cusProductToProduct({
|
||||||
cusProduct: customerProduct,
|
cusProduct: customerProduct,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 1. Compute the custom full product
|
||||||
const {
|
const {
|
||||||
fullProduct: customFullProduct,
|
fullProduct: customFullProduct,
|
||||||
customPrices,
|
customPrices,
|
||||||
@@ -33,21 +42,80 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
customItems: params.items,
|
customItems: params.items,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Compute the new customer product
|
// 2. Compute the custom trial details
|
||||||
|
const { freeTrialPlan, customFreeTrial } =
|
||||||
|
computeSubscriptionUpdateFreeTrialPlan({
|
||||||
|
updateSubscriptionContext,
|
||||||
|
params,
|
||||||
|
fullProduct: customFullProduct,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Compute the new customer product
|
||||||
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
||||||
ctx,
|
ctx,
|
||||||
subscriptionUpdateContext,
|
updateSubscriptionContext,
|
||||||
params,
|
params,
|
||||||
fullProduct: customFullProduct,
|
fullProduct: customFullProduct,
|
||||||
|
freeTrialPlan,
|
||||||
});
|
});
|
||||||
|
|
||||||
// // 2. Compute the invoice action
|
// 4. Create stripe prices
|
||||||
// const invoiceAction = computeSubscriptionUpdateCustomPlanInvoiceAction({
|
const fullCustomer = updateSubscriptionContext.fullCustomer;
|
||||||
// ctx,
|
await createStripeResourcesForProducts({
|
||||||
// updateSubscriptionContext,
|
ctx,
|
||||||
// newFullCustomerProduct,
|
fullCustomer,
|
||||||
// params,
|
fullProducts: [customFullProduct],
|
||||||
// });
|
});
|
||||||
|
|
||||||
return newFullCustomerProduct;
|
// 5. Compute Stripe subscription action
|
||||||
|
const stripeSubscriptionAction =
|
||||||
|
computeSubscriptionUpdateStripeSubscriptionAction({
|
||||||
|
ctx,
|
||||||
|
billingContext: updateSubscriptionContext,
|
||||||
|
newCustomerProduct: newFullCustomerProduct,
|
||||||
|
freeTrialPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const billingPlan: BillingPlan = {
|
||||||
|
stripe: {
|
||||||
|
subscription: stripeSubscriptionAction,
|
||||||
|
},
|
||||||
|
autumn: {
|
||||||
|
insertCustomerProducts: [newFullCustomerProduct],
|
||||||
|
|
||||||
|
updateCustomerProduct: {
|
||||||
|
customerProduct: customerProduct,
|
||||||
|
updates: {
|
||||||
|
status: CusProductStatus.Expired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
customPrices: customPrices,
|
||||||
|
customEntitlements: customEnts,
|
||||||
|
customFreeTrial: customFreeTrial,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
logBillingPlan({ ctx, billingPlan });
|
||||||
|
|
||||||
|
if (stripeSubscriptionAction) {
|
||||||
|
const updatedStripeSubscription = await executeStripeSubscriptionAction({
|
||||||
|
ctx,
|
||||||
|
subscriptionAction: stripeSubscriptionAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedStripeSubscription) {
|
||||||
|
addStripeSubscriptionIdToBillingPlan({
|
||||||
|
billingPlan,
|
||||||
|
stripeSubscriptionId: updatedStripeSubscription.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeAutumnBillingPlan({
|
||||||
|
ctx,
|
||||||
|
autumnBillingPlan: billingPlan.autumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return billingPlan;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import {
|
|
||||||
type FullCusProduct,
|
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
secondsToMs,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
|
||||||
import { buildAutumnLineItems } from "../../../compute/computeAutumnUtils/buildAutumnLineItems";
|
|
||||||
import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema";
|
|
||||||
import { computeSubscriptionUpdateCustomPlanInvoiceRequired } from "./computeSubscriptionUpdateICustomPlanInvoiceRequired";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the invoice action for a custom subscription update.
|
|
||||||
*
|
|
||||||
* Determines what invoice operations (create, prorate, void, etc.) are needed
|
|
||||||
* when a subscription is updated with custom item configurations.
|
|
||||||
*
|
|
||||||
* @param ctx - The Autumn request context
|
|
||||||
* @param updateSubscriptionContext - Context containing customer product and subscription details
|
|
||||||
* @param params - The subscription update parameters from the API request
|
|
||||||
* @returns The computed invoice action to be executed
|
|
||||||
*/
|
|
||||||
export const computeSubscriptionUpdateCustomPlanInvoiceAction = ({
|
|
||||||
ctx,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
params,
|
|
||||||
newFullCustomerProduct,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
|
||||||
params: SubscriptionUpdateV0Params;
|
|
||||||
newFullCustomerProduct: FullCusProduct;
|
|
||||||
}) => {
|
|
||||||
// 1. Early return and don't create an invoice
|
|
||||||
|
|
||||||
const invoiceRequired = computeSubscriptionUpdateCustomPlanInvoiceRequired({
|
|
||||||
ctx,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!invoiceRequired) return undefined;
|
|
||||||
|
|
||||||
const { customerProduct, stripeSubscription, testClockFrozenTime } =
|
|
||||||
updateSubscriptionContext;
|
|
||||||
|
|
||||||
// 2. Calculate line items
|
|
||||||
const lineItems = buildAutumnLineItems({
|
|
||||||
ctx,
|
|
||||||
newCusProducts: [newFullCustomerProduct],
|
|
||||||
ongoingCustomerProduct: customerProduct,
|
|
||||||
billingCycleAnchor: secondsToMs(stripeSubscription?.billing_cycle_anchor),
|
|
||||||
testClockFrozenTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3.
|
|
||||||
|
|
||||||
console.log("New line items", lineItems);
|
|
||||||
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type {
|
||||||
|
FreeTrial,
|
||||||
|
FullProduct,
|
||||||
|
SubscriptionUpdateV0Params,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import {
|
||||||
|
addDuration,
|
||||||
|
initFreeTrial,
|
||||||
|
isProductPaidAndRecurring,
|
||||||
|
secondsToMs,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||||
|
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||||
|
|
||||||
|
interface ComputeSubscriptionUpdateTrialDetailsResult {
|
||||||
|
freeTrialPlan: {
|
||||||
|
freeTrial?: FreeTrial | null;
|
||||||
|
trialEndsAt?: number;
|
||||||
|
};
|
||||||
|
customFreeTrial?: FreeTrial;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeSubscriptionUpdateFreeTrialPlan = ({
|
||||||
|
updateSubscriptionContext,
|
||||||
|
params,
|
||||||
|
fullProduct,
|
||||||
|
}: {
|
||||||
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
|
params: SubscriptionUpdateV0Params;
|
||||||
|
fullProduct: FullProduct;
|
||||||
|
}): ComputeSubscriptionUpdateTrialDetailsResult => {
|
||||||
|
const { stripeSubscription, testClockFrozenTime, customerProduct } =
|
||||||
|
updateSubscriptionContext;
|
||||||
|
|
||||||
|
const now = testClockFrozenTime ?? Date.now();
|
||||||
|
|
||||||
|
const freeTrialParams = params.free_trial;
|
||||||
|
|
||||||
|
// Case 1: If free trial is null (removing free trial)
|
||||||
|
if (freeTrialParams === null) {
|
||||||
|
return { freeTrialPlan: { freeTrial: null } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: If free trial params are passed in
|
||||||
|
if (freeTrialParams) {
|
||||||
|
const dbFreeTrial = initFreeTrial({
|
||||||
|
freeTrialParams,
|
||||||
|
internalProductId: fullProduct.internal_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trialEndsAt = addDuration({
|
||||||
|
now,
|
||||||
|
durationType: dbFreeTrial.duration,
|
||||||
|
durationLength: dbFreeTrial.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
freeTrialPlan: { freeTrial: dbFreeTrial, trialEndsAt },
|
||||||
|
customFreeTrial: dbFreeTrial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: If new product is paid and recurring
|
||||||
|
if (isProductPaidAndRecurring(fullProduct)) {
|
||||||
|
if (
|
||||||
|
stripeSubscription &&
|
||||||
|
isStripeSubscriptionTrialing(stripeSubscription)
|
||||||
|
) {
|
||||||
|
const trialEndsAt = secondsToMs(
|
||||||
|
stripeSubscription.trial_end ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
freeTrialPlan: { freeTrial: null, trialEndsAt },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
freeTrialPlan: { freeTrial: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 4: Return free trial / trial ends at from current customer product
|
||||||
|
return {
|
||||||
|
freeTrialPlan: {
|
||||||
|
freeTrial: customerProduct.free_trial,
|
||||||
|
trialEndsAt: customerProduct.trial_ends_at ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { SubscriptionUpdateV0Params } from "@autumn/shared";
|
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
|
||||||
import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether an invoice is required for a custom subscription update.
|
|
||||||
*
|
|
||||||
* Evaluates the subscription changes to decide if billing adjustments
|
|
||||||
* (prorations, charges, credits) necessitate creating an invoice.
|
|
||||||
*
|
|
||||||
* @param ctx - The Autumn request context
|
|
||||||
* @param updateSubscriptionContext - Context containing customer product and subscription details
|
|
||||||
* @param params - The subscription update parameters from the API request
|
|
||||||
* @returns `true` if an invoice is required, `false` otherwise
|
|
||||||
*/
|
|
||||||
export const computeSubscriptionUpdateCustomPlanInvoiceRequired = ({
|
|
||||||
ctx,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
|
||||||
params: SubscriptionUpdateV0Params;
|
|
||||||
}) => {
|
|
||||||
// 1. When to calculate invoice...?
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
@@ -1,28 +1,35 @@
|
|||||||
import type { FullProduct, SubscriptionUpdateV0Params } from "@autumn/shared";
|
import {
|
||||||
|
type FullProduct,
|
||||||
|
type SubscriptionUpdateV0Params,
|
||||||
|
secondsToMs,
|
||||||
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import { cusProductToExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/cusProductToExistingRollovers";
|
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages";
|
|
||||||
import { initFullCustomerProduct } from "@/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct";
|
|
||||||
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
||||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||||
|
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
|
||||||
|
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
|
||||||
|
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||||
|
|
||||||
export const computeSubscriptionUpdateNewCustomerProduct = async ({
|
export const computeSubscriptionUpdateNewCustomerProduct = ({
|
||||||
ctx,
|
ctx,
|
||||||
subscriptionUpdateContext,
|
|
||||||
params,
|
params,
|
||||||
|
updateSubscriptionContext,
|
||||||
fullProduct,
|
fullProduct,
|
||||||
|
freeTrialPlan,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
|
||||||
params: SubscriptionUpdateV0Params;
|
params: SubscriptionUpdateV0Params;
|
||||||
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
fullProduct: FullProduct;
|
fullProduct: FullProduct;
|
||||||
|
freeTrialPlan: FreeTrialPlan;
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
customerProduct,
|
customerProduct,
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
stripeSubscription,
|
stripeSubscription,
|
||||||
stripeSubscriptionSchedule,
|
stripeSubscriptionSchedule,
|
||||||
} = subscriptionUpdateContext;
|
} = updateSubscriptionContext;
|
||||||
|
|
||||||
// 1. Get feature quantities
|
// 1. Get feature quantities
|
||||||
const existingUsages = cusProductToExistingUsages({
|
const existingUsages = cusProductToExistingUsages({
|
||||||
@@ -41,19 +48,32 @@ export const computeSubscriptionUpdateNewCustomerProduct = async ({
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: Move this to a separate function
|
||||||
|
const billingCycleAnchor =
|
||||||
|
freeTrialPlan.trialEndsAt ??
|
||||||
|
secondsToMs(stripeSubscription?.billing_cycle_anchor);
|
||||||
|
|
||||||
|
const now = updateSubscriptionContext.testClockFrozenTime ?? Date.now();
|
||||||
|
|
||||||
// 1. Compute the new full customer product
|
// 1. Compute the new full customer product
|
||||||
const newFullCustomerProduct = initFullCustomerProduct({
|
const newFullCustomerProduct = initFullCustomerProduct({
|
||||||
ctx,
|
ctx,
|
||||||
|
|
||||||
initContext: {
|
initContext: {
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
fullProduct,
|
fullProduct,
|
||||||
featureQuantities,
|
featureQuantities,
|
||||||
existingUsages,
|
existingUsages,
|
||||||
existingRollovers,
|
existingRollovers,
|
||||||
|
resetCycleAnchor: billingCycleAnchor ?? "now",
|
||||||
|
now,
|
||||||
|
|
||||||
|
freeTrial: freeTrialPlan.freeTrial ?? null,
|
||||||
|
trialEndsAt: freeTrialPlan.trialEndsAt,
|
||||||
},
|
},
|
||||||
|
|
||||||
initOptions: {
|
initOptions: {
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
// resetCycleAnchor,
|
|
||||||
subscriptionId: stripeSubscription?.id,
|
subscriptionId: stripeSubscription?.id,
|
||||||
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|
||||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
|
||||||
|
|
||||||
export const computeSubscriptionUpdateResetCycleAnchor = ({
|
|
||||||
ctx,
|
|
||||||
subscriptionUpdateContext,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
|
||||||
}) => {
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { FullCusProduct } from "@autumn/shared";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||||
|
import { buildStripeSubscriptionItemsUpdate } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/buildStripeSubscriptionItemsUpdate";
|
||||||
|
import { buildStripeSubscriptionCreateAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionCreateAction";
|
||||||
|
import { buildStripeSubscriptionUpdateAction } from "@/internal/billing/v2/utils/stripeAdapter/subscriptions/buildStripeSubscriptionUpdateAction";
|
||||||
|
|
||||||
|
export const computeSubscriptionUpdateStripeSubscriptionAction = ({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
newCustomerProduct,
|
||||||
|
freeTrialPlan,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingContext: UpdateSubscriptionContext;
|
||||||
|
newCustomerProduct: FullCusProduct;
|
||||||
|
freeTrialPlan: FreeTrialPlan;
|
||||||
|
}) => {
|
||||||
|
const { customerProduct, stripeSubscription } = billingContext;
|
||||||
|
|
||||||
|
const subItemsUpdate = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [newCustomerProduct],
|
||||||
|
removeCustomerProducts: [customerProduct],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Compute the action type
|
||||||
|
|
||||||
|
// Case 1: No subscription and sub items update is empty -> no action
|
||||||
|
if (!stripeSubscription && subItemsUpdate.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: No subscription and sub items update not empty -> create subscription
|
||||||
|
if (!stripeSubscription && subItemsUpdate.length > 0) {
|
||||||
|
return buildStripeSubscriptionCreateAction({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
subItemsUpdate,
|
||||||
|
addInvoiceItems: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Cancel subscription
|
||||||
|
if (
|
||||||
|
stripeSubscription &&
|
||||||
|
subItemsUpdate.length === stripeSubscription.items.data.length &&
|
||||||
|
subItemsUpdate.every((item) => item.deleted)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: "cancel" as const,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 4: Update subscription
|
||||||
|
if (stripeSubscription) {
|
||||||
|
return buildStripeSubscriptionUpdateAction({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
subItemsUpdate,
|
||||||
|
freeTrialPlan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|
||||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
|
||||||
|
|
||||||
export const computeSubscriptionUpdateTrialDetails = ({
|
|
||||||
ctx,
|
|
||||||
subscriptionUpdateContext,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
|
||||||
}) => {
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
type SubscriptionUpdateV0Params,
|
type SubscriptionUpdateV0Params,
|
||||||
} from "@shared/index";
|
} from "@shared/index";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan";
|
||||||
|
import type { BillingPlan } from "../../billingPlan";
|
||||||
import type { SubscriptionUpdatePlan } from "../../typesOld";
|
import type { SubscriptionUpdatePlan } from "../../typesOld";
|
||||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||||
import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan";
|
import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan";
|
||||||
@@ -17,7 +19,7 @@ export type ComputeSubscriptionUpdatePlan = ({
|
|||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: SubscriptionUpdateV0Params;
|
||||||
}) => SubscriptionUpdatePlan;
|
}) => SubscriptionUpdatePlan | BillingPlan | Promise<BillingPlan>;
|
||||||
|
|
||||||
export type ComputeSubscriptionUpdatePlanIntentMap = Partial<
|
export type ComputeSubscriptionUpdatePlanIntentMap = Partial<
|
||||||
Record<SubscriptionUpdateIntentEnum, ComputeSubscriptionUpdatePlan>
|
Record<SubscriptionUpdateIntentEnum, ComputeSubscriptionUpdatePlan>
|
||||||
@@ -30,6 +32,8 @@ const computeSubscriptionUpdatePlanIntentMap: ComputeSubscriptionUpdatePlanInten
|
|||||||
{
|
{
|
||||||
[SubscriptionUpdateIntentEnum.UpdateQuantity]:
|
[SubscriptionUpdateIntentEnum.UpdateQuantity]:
|
||||||
computeSubscriptionUpdateQuantityPlan,
|
computeSubscriptionUpdateQuantityPlan,
|
||||||
|
[SubscriptionUpdateIntentEnum.UpdatePlan]:
|
||||||
|
computeSubscriptionUpdateCustomPlan,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getComputeSubscriptionUpdatePlanFunction = (
|
export const getComputeSubscriptionUpdatePlanFunction = (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ExistingRollover, FullCusProduct } from "@shared/index";
|
import type { ExistingRollover, FullCusProduct } from "@autumn/shared";
|
||||||
|
|
||||||
export const applyExistingRollovers = ({
|
export const applyExistingRollovers = ({
|
||||||
customerProduct,
|
customerProduct,
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type ExistingUsages,
|
type ExistingUsages,
|
||||||
type FullCusProduct,
|
type FullCusProduct,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { deductFromCusEntsTypescript } from "../../../balances/track/deductUtils/deductFromCusEntsTypescript";
|
import { deductFromCusEntsTypescript } from "@/internal/balances/track/deductUtils/deductFromCusEntsTypescript";
|
||||||
import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages";
|
import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages";
|
||||||
|
|
||||||
export const applyExistingUsages = ({
|
export const applyExistingUsages = ({
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { addCusProductToCusEnt, type ExistingUsages } from "@autumn/shared";
|
|
||||||
import { Decimal } from "decimal.js";
|
|
||||||
import type { FullCusProduct } from "../../../../../../shared/models/cusProductModels/cusProductModels";
|
|
||||||
import { cusEntsToUsage } from "../../../../../../shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage";
|
|
||||||
import {
|
import {
|
||||||
|
addCusProductToCusEnt,
|
||||||
|
cusEntsToUsage,
|
||||||
|
type ExistingUsages,
|
||||||
|
type FullCusProduct,
|
||||||
isBooleanCusEnt,
|
isBooleanCusEnt,
|
||||||
isEntityScopedCusEnt,
|
isEntityScopedCusEnt,
|
||||||
isUnlimitedCusEnt,
|
isUnlimitedCusEnt,
|
||||||
} from "../../../../../../shared/utils/cusEntUtils/classifyCusEntUtils";
|
} from "@autumn/shared";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
|
||||||
export const cusProductToExistingUsages = ({
|
export const cusProductToExistingUsages = ({
|
||||||
cusProduct,
|
cusProduct,
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
type CustomerEntitlement,
|
||||||
|
type EntitlementWithFeature,
|
||||||
|
type InitFullCustomerProductContext,
|
||||||
|
isBooleanEntitlement,
|
||||||
|
isUnlimitedEntitlement,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { generateId } from "@server/utils/genUtils";
|
||||||
|
import { initCustomerEntitlementNextResetAt } from "./initCustomerEntitlementNextResetAt";
|
||||||
|
import { initCustomerEntitlementBalance } from "./initCustomerEntitlementBalance";
|
||||||
|
import { initCustomerEntitlementUsageAllowed } from "./initCustomerEntitlementUsageAllowed";
|
||||||
|
|
||||||
|
// MAIN FUNCTION
|
||||||
|
export const initCustomerEntitlement = ({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
cusProductId,
|
||||||
|
}: {
|
||||||
|
initContext: InitFullCustomerProductContext;
|
||||||
|
entitlement: EntitlementWithFeature;
|
||||||
|
cusProductId: string;
|
||||||
|
}): CustomerEntitlement => {
|
||||||
|
const { balance, entities } = initCustomerEntitlementBalance({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get unlimited
|
||||||
|
const isBoolean = isBooleanEntitlement({ entitlement });
|
||||||
|
const unlimited = isBoolean ? null : isUnlimitedEntitlement({ entitlement });
|
||||||
|
|
||||||
|
// Usage allowed:
|
||||||
|
const usageAllowed = initCustomerEntitlementUsageAllowed({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextResetAt = initCustomerEntitlementNextResetAt({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fullCustomer } = initContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: generateId("cus_ent"),
|
||||||
|
internal_customer_id: fullCustomer.internal_id,
|
||||||
|
internal_feature_id: entitlement.internal_feature_id,
|
||||||
|
feature_id: entitlement.feature.id,
|
||||||
|
customer_id: fullCustomer.id,
|
||||||
|
entitlement_id: entitlement.id,
|
||||||
|
customer_product_id: cusProductId,
|
||||||
|
created_at: Date.now(),
|
||||||
|
|
||||||
|
// Entitlement fields
|
||||||
|
unlimited,
|
||||||
|
balance,
|
||||||
|
additional_balance: 0,
|
||||||
|
adjustment: 0,
|
||||||
|
entities,
|
||||||
|
usage_allowed: usageAllowed,
|
||||||
|
next_reset_at: nextResetAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// // 3. Define expires at (TODO next time...)
|
||||||
|
// const isBooleanFeature = entitlement.feature.type === FeatureType.Boolean;
|
||||||
|
// let usageAllowed = false;
|
||||||
|
// if (
|
||||||
|
// relatedPrice &&
|
||||||
|
// (getBillingType(relatedPrice.config!) === BillingType.UsageInArrear ||
|
||||||
|
// getBillingType(relatedPrice.config!) === BillingType.InArrearProrated)
|
||||||
|
// ) {
|
||||||
|
// usageAllowed = true;
|
||||||
|
// }
|
||||||
|
// if (notNullish(productOptions?.quantity) && notNullish(newBalance)) {
|
||||||
|
// newBalance = new Decimal(newBalance!)
|
||||||
|
// .mul(productOptions?.quantity || 1)
|
||||||
|
// .toNumber();
|
||||||
|
// }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
type EntitlementWithFeature,
|
||||||
|
type EntityBalance,
|
||||||
|
entToOptions,
|
||||||
|
entToPrice,
|
||||||
|
getStartingBalance,
|
||||||
|
type InitFullCustomerProductContext,
|
||||||
|
isBooleanEntitlement,
|
||||||
|
isUnlimitedEntitlement,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { initCustomerEntitlementEntities } from "./initCustomerEntitlementEntities";
|
||||||
|
|
||||||
|
export interface InitCustomerEntitlementBalanceResult {
|
||||||
|
balance: number;
|
||||||
|
entities: Record<string, EntityBalance> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initCustomerEntitlementBalance = ({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
}: {
|
||||||
|
initContext: InitFullCustomerProductContext;
|
||||||
|
entitlement: EntitlementWithFeature;
|
||||||
|
}): { balance: number; entities: Record<string, EntityBalance> | null } => {
|
||||||
|
// 1. If entitlement is boolean or unlimited, return 0
|
||||||
|
const isBoolean = isBooleanEntitlement({ entitlement });
|
||||||
|
const isUnlimited = isUnlimitedEntitlement({ entitlement });
|
||||||
|
|
||||||
|
if (isBoolean || isUnlimited) {
|
||||||
|
return { balance: 0, entities: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get starting balance
|
||||||
|
const { fullCustomer, featureQuantities } = initContext;
|
||||||
|
|
||||||
|
const price = entToPrice({
|
||||||
|
ent: entitlement,
|
||||||
|
prices: initContext.fullProduct.prices,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = entToOptions({
|
||||||
|
ent: entitlement,
|
||||||
|
options: featureQuantities,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startingBalance = getStartingBalance({
|
||||||
|
entitlement,
|
||||||
|
options,
|
||||||
|
relatedPrice: price,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Get entitlement entities if entity scoped
|
||||||
|
const entities = initCustomerEntitlementEntities({
|
||||||
|
entitlement,
|
||||||
|
customerEntities: fullCustomer.entities,
|
||||||
|
startingBalance,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { balance: startingBalance, entities };
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
type EntitlementWithFeature,
|
||||||
|
type Entity,
|
||||||
|
type EntityBalance,
|
||||||
|
entitlementFeatureMatchesEntityFeature,
|
||||||
|
isEntityScopedEntitlement,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
export const initCustomerEntitlementEntities = ({
|
||||||
|
entitlement,
|
||||||
|
customerEntities,
|
||||||
|
startingBalance,
|
||||||
|
}: {
|
||||||
|
entitlement: EntitlementWithFeature;
|
||||||
|
customerEntities: Entity[];
|
||||||
|
startingBalance: number;
|
||||||
|
}): Record<string, EntityBalance> | null => {
|
||||||
|
if (!isEntityScopedEntitlement({ entitlement })) return null;
|
||||||
|
|
||||||
|
const entities: Record<string, EntityBalance> = {};
|
||||||
|
|
||||||
|
for (const entity of customerEntities) {
|
||||||
|
const featureMatches = entitlementFeatureMatchesEntityFeature({
|
||||||
|
entitlement,
|
||||||
|
entity,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!featureMatches) continue;
|
||||||
|
|
||||||
|
entities[entity.id] = {
|
||||||
|
id: entity.id,
|
||||||
|
balance: startingBalance,
|
||||||
|
adjustment: 0,
|
||||||
|
additional_balance: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
EntInterval,
|
||||||
|
type EntitlementWithFeature,
|
||||||
|
getCycleEnd,
|
||||||
|
type InitFullCustomerProductContext,
|
||||||
|
isBooleanEntitlement,
|
||||||
|
isLifetimeEntitlement,
|
||||||
|
isUnlimitedEntitlement,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
export const initCustomerEntitlementNextResetAt = ({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
}: {
|
||||||
|
initContext: InitFullCustomerProductContext;
|
||||||
|
entitlement: EntitlementWithFeature;
|
||||||
|
}) => {
|
||||||
|
// 1. If entitlement is boolean, or unlimited, or lifetime, then next reset at is null
|
||||||
|
const isLifetime = isLifetimeEntitlement({ entitlement });
|
||||||
|
const isUnlimited = isUnlimitedEntitlement({ entitlement });
|
||||||
|
const isBoolean = isBooleanEntitlement({ entitlement });
|
||||||
|
|
||||||
|
if (isLifetime || isUnlimited || isBoolean) return null;
|
||||||
|
|
||||||
|
const { resetCycleAnchor, now } = initContext;
|
||||||
|
|
||||||
|
const nextResetAt = getCycleEnd({
|
||||||
|
anchor: resetCycleAnchor,
|
||||||
|
interval: entitlement.interval ?? EntInterval.Month,
|
||||||
|
intervalCount: entitlement.interval_count,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextResetAt;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
type EntitlementWithFeature,
|
||||||
|
entToPrice,
|
||||||
|
type InitFullCustomerProductContext,
|
||||||
|
isPayPerUsePrice,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
export const initCustomerEntitlementUsageAllowed = ({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
}: {
|
||||||
|
initContext: InitFullCustomerProductContext;
|
||||||
|
entitlement: EntitlementWithFeature;
|
||||||
|
}) => {
|
||||||
|
const price = entToPrice({
|
||||||
|
ent: entitlement,
|
||||||
|
prices: initContext.fullProduct.prices,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!price) return false;
|
||||||
|
|
||||||
|
return isPayPerUsePrice({ price });
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type CustomerPrice,
|
||||||
|
type FullCustomer,
|
||||||
|
generateId,
|
||||||
|
type Price,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
export const initCustomerPrice = ({
|
||||||
|
price,
|
||||||
|
fullCus,
|
||||||
|
cusProductId,
|
||||||
|
}: {
|
||||||
|
price: Price;
|
||||||
|
fullCus: FullCustomer;
|
||||||
|
cusProductId: string;
|
||||||
|
}): CustomerPrice => {
|
||||||
|
return {
|
||||||
|
id: generateId("cus_price"),
|
||||||
|
internal_customer_id: fullCus.internal_id,
|
||||||
|
customer_product_id: cusProductId,
|
||||||
|
created_at: Date.now(),
|
||||||
|
price_id: price.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
CollectionMethod,
|
||||||
|
type CusProduct,
|
||||||
|
CusProductStatus,
|
||||||
|
generateId,
|
||||||
|
type InitFullCustomerProductContext,
|
||||||
|
type InitFullCustomerProductOptions,
|
||||||
|
notNullish,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
|
||||||
|
export const initCustomerProduct = ({
|
||||||
|
initContext,
|
||||||
|
initOptions,
|
||||||
|
customerProductId,
|
||||||
|
}: {
|
||||||
|
initContext: InitFullCustomerProductContext;
|
||||||
|
initOptions?: InitFullCustomerProductOptions;
|
||||||
|
customerProductId?: string;
|
||||||
|
}): CusProduct => {
|
||||||
|
const {
|
||||||
|
fullCustomer,
|
||||||
|
fullProduct,
|
||||||
|
featureQuantities,
|
||||||
|
freeTrial,
|
||||||
|
trialEndsAt,
|
||||||
|
} = initContext;
|
||||||
|
const {
|
||||||
|
subscriptionId,
|
||||||
|
subscriptionScheduleId,
|
||||||
|
collectionMethod,
|
||||||
|
isCustom,
|
||||||
|
apiSemver,
|
||||||
|
} = initOptions ?? {};
|
||||||
|
|
||||||
|
const internalEntityId = fullCustomer.entity?.internal_id;
|
||||||
|
const entityId = fullCustomer.entity?.id;
|
||||||
|
|
||||||
|
const status = initOptions?.status ?? CusProductStatus.Active;
|
||||||
|
const startsAt = initOptions?.startsAt ?? Date.now();
|
||||||
|
|
||||||
|
const canceled = notNullish(initOptions?.canceledAt);
|
||||||
|
const canceledAt = initOptions?.canceledAt;
|
||||||
|
|
||||||
|
const subscriptionIds = subscriptionId ? [subscriptionId] : undefined;
|
||||||
|
|
||||||
|
const scheduleIds = subscriptionScheduleId
|
||||||
|
? [subscriptionScheduleId]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: customerProductId ?? generateId("cus_prod"),
|
||||||
|
|
||||||
|
internal_customer_id: fullCustomer.internal_id,
|
||||||
|
customer_id: fullCustomer.id,
|
||||||
|
internal_entity_id: internalEntityId,
|
||||||
|
entity_id: entityId,
|
||||||
|
internal_product_id: fullProduct.internal_id,
|
||||||
|
product_id: fullProduct.id,
|
||||||
|
|
||||||
|
created_at: Date.now(),
|
||||||
|
|
||||||
|
status,
|
||||||
|
|
||||||
|
// Legacy
|
||||||
|
// processor: null,
|
||||||
|
|
||||||
|
starts_at: startsAt || Date.now(),
|
||||||
|
|
||||||
|
trial_ends_at: trialEndsAt,
|
||||||
|
free_trial_id: freeTrial?.id,
|
||||||
|
|
||||||
|
options: featureQuantities,
|
||||||
|
|
||||||
|
canceled,
|
||||||
|
canceled_at: canceledAt,
|
||||||
|
|
||||||
|
subscription_ids: subscriptionIds,
|
||||||
|
scheduled_ids: scheduleIds,
|
||||||
|
collection_method: collectionMethod ?? CollectionMethod.ChargeAutomatically,
|
||||||
|
|
||||||
|
quantity: 1,
|
||||||
|
|
||||||
|
is_custom: isCustom ?? false,
|
||||||
|
|
||||||
|
api_semver: apiSemver ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ? subscriptionStatus
|
||||||
|
// : isFuture
|
||||||
|
// ? CusProductStatus.Scheduled
|
||||||
|
// : CusProductStatus.Active
|
||||||
|
|
||||||
|
// {
|
||||||
|
// type: ProcessorType.Stripe,
|
||||||
|
// // subscription_id: subscriptionId,
|
||||||
|
// // subscription_schedule_id: subscriptionScheduleId,
|
||||||
|
// // last_invoice_id: lastInvoiceId,
|
||||||
|
// },
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
type FullCusProduct,
|
||||||
|
formatMs,
|
||||||
|
type InitFullCustomerProductContext,
|
||||||
|
type InitFullCustomerProductOptions,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { applyExistingRollovers } from "../handleExistingRollovers/applyExistingRollovers";
|
||||||
|
import { generateId } from "@/utils/genUtils";
|
||||||
|
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||||
|
import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages";
|
||||||
|
import { initCustomerEntitlement } from "./initCustomerEntitlement/initCustomerEntitlement";
|
||||||
|
import { initCustomerPrice } from "./initCustomerPrice";
|
||||||
|
import { initCustomerProduct } from "./initCustomerProduct";
|
||||||
|
|
||||||
|
export const initFullCustomerProduct = ({
|
||||||
|
// biome-ignore lint/correctness/noUnusedFunctionParameters: will need it at some point
|
||||||
|
ctx,
|
||||||
|
initContext,
|
||||||
|
initOptions,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
initContext: InitFullCustomerProductContext;
|
||||||
|
initOptions?: InitFullCustomerProductOptions;
|
||||||
|
}): FullCusProduct => {
|
||||||
|
const { fullCustomer, fullProduct } = initContext;
|
||||||
|
|
||||||
|
const cusProductId = generateId("cus_prod");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Initializing full customer product, next reset at",
|
||||||
|
formatMs(initContext.resetCycleAnchor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const newFullCusEnts = fullProduct.entitlements.map((entitlement) => ({
|
||||||
|
...initCustomerEntitlement({
|
||||||
|
initContext,
|
||||||
|
entitlement,
|
||||||
|
cusProductId,
|
||||||
|
}),
|
||||||
|
entitlement,
|
||||||
|
replaceables: [],
|
||||||
|
rollovers: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newCusPrices = fullProduct.prices.map((price) => ({
|
||||||
|
...initCustomerPrice({
|
||||||
|
fullCus: fullCustomer,
|
||||||
|
price,
|
||||||
|
cusProductId,
|
||||||
|
}),
|
||||||
|
price,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newCusProduct = initCustomerProduct({
|
||||||
|
initContext,
|
||||||
|
customerProductId: cusProductId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { entitlements: _ents, prices: _prices, ...rawProduct } = fullProduct;
|
||||||
|
|
||||||
|
const newFullCustomerProduct = {
|
||||||
|
...newCusProduct,
|
||||||
|
product: rawProduct,
|
||||||
|
customer_entitlements: newFullCusEnts,
|
||||||
|
customer_prices: newCusPrices,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Finally, apply existing usages to new cus product
|
||||||
|
applyExistingUsages({
|
||||||
|
customerProduct: newFullCustomerProduct,
|
||||||
|
existingUsages: initContext.existingUsages,
|
||||||
|
entities: fullCustomer.entities,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Add rollovers to customer entitlements
|
||||||
|
applyExistingRollovers({
|
||||||
|
customerProduct: newFullCustomerProduct,
|
||||||
|
existingRollovers: initContext.existingRollovers ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return newFullCustomerProduct;
|
||||||
|
};
|
||||||
53
server/src/internal/billing/v2/utils/logBillingPlan.ts
Normal file
53
server/src/internal/billing/v2/utils/logBillingPlan.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { formatMs } from "@shared/utils/common/formatUtils/formatUnix";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { BillingPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
|
||||||
|
export const logBillingPlan = ({
|
||||||
|
ctx,
|
||||||
|
billingPlan,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingPlan: BillingPlan;
|
||||||
|
}) => {
|
||||||
|
ctx.logger.info("Billing plan:", {
|
||||||
|
autumn: {
|
||||||
|
insertCustomerProducts: billingPlan.autumn.insertCustomerProducts.map(
|
||||||
|
(cusProduct) => ({
|
||||||
|
id: cusProduct.product.id,
|
||||||
|
name: cusProduct.product.name,
|
||||||
|
description: cusProduct.product.description,
|
||||||
|
entitlements: cusProduct.customer_entitlements.map((ce) => ({
|
||||||
|
featureName: ce.entitlement.feature.name,
|
||||||
|
nextResetAt: formatMs(ce.next_reset_at),
|
||||||
|
balance: ce.balance,
|
||||||
|
})),
|
||||||
|
|
||||||
|
trialEndsAt: formatMs(cusProduct.trial_ends_at),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
updateCustomerProduct: billingPlan.autumn.updateCustomerProduct
|
||||||
|
? {
|
||||||
|
customerProduct:
|
||||||
|
billingPlan.autumn.updateCustomerProduct.customerProduct?.product
|
||||||
|
.id,
|
||||||
|
updates: billingPlan.autumn.updateCustomerProduct.updates,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
stripe: {
|
||||||
|
subscription: billingPlan.stripe.subscription
|
||||||
|
? {
|
||||||
|
type: billingPlan.stripe.subscription.type,
|
||||||
|
stripeSubscriptionId:
|
||||||
|
billingPlan.stripe.subscription.type !== "create"
|
||||||
|
? billingPlan.stripe.subscription.stripeSubscriptionId
|
||||||
|
: undefined,
|
||||||
|
params:
|
||||||
|
billingPlan.stripe.subscription.type !== "cancel"
|
||||||
|
? billingPlan.stripe.subscription.params
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { FullCustomer, FullProduct } from "@autumn/shared";
|
||||||
|
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||||
|
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import { checkStripeProductExists } from "@/internal/products/productUtils";
|
||||||
|
|
||||||
|
export const createStripeResourcesForProducts = async ({
|
||||||
|
ctx,
|
||||||
|
fullProducts,
|
||||||
|
fullCustomer,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
fullProducts: FullProduct[];
|
||||||
|
fullCustomer: FullCustomer;
|
||||||
|
}) => {
|
||||||
|
const { db, org, env, logger } = ctx;
|
||||||
|
|
||||||
|
const batchProductUpdates = [];
|
||||||
|
for (const product of fullProducts) {
|
||||||
|
batchProductUpdates.push(
|
||||||
|
checkStripeProductExists({
|
||||||
|
db,
|
||||||
|
org,
|
||||||
|
env,
|
||||||
|
product,
|
||||||
|
logger,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(batchProductUpdates);
|
||||||
|
|
||||||
|
const batchPriceUpdates = [];
|
||||||
|
|
||||||
|
const stripeCli = createStripeCli({
|
||||||
|
org,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
|
||||||
|
const internalEntityId = fullCustomer.entity?.internal_id;
|
||||||
|
|
||||||
|
for (const product of fullProducts) {
|
||||||
|
for (const price of product.prices) {
|
||||||
|
batchPriceUpdates.push(
|
||||||
|
createStripePriceIFNotExist({
|
||||||
|
db,
|
||||||
|
stripeCli,
|
||||||
|
price,
|
||||||
|
entitlements: product.entitlements,
|
||||||
|
product,
|
||||||
|
org,
|
||||||
|
logger,
|
||||||
|
internalEntityId,
|
||||||
|
useCheckout: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(batchPriceUpdates);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
|
||||||
|
|
||||||
|
export const findStripeItemSpecByStripePriceId = ({
|
||||||
|
stripePriceId,
|
||||||
|
stripeItemSpecs,
|
||||||
|
}: {
|
||||||
|
stripePriceId: string;
|
||||||
|
stripeItemSpecs: StripeItemSpec[];
|
||||||
|
}) => {
|
||||||
|
return stripeItemSpecs.find((item) => item.stripePriceId === stripePriceId);
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
|
||||||
|
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
||||||
|
import { isCustomerProductOnStripeSubscription } from "@shared/utils";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils";
|
||||||
|
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import { findStripeItemSpecByStripePriceId } from "@/internal/billing/v2/utils/stripeAdapter/stripeItemSpec/findStripeItemSpecUtils";
|
||||||
|
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs";
|
||||||
|
|
||||||
|
const getFinalCustomerProductsState = ({
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts = [],
|
||||||
|
removeCustomerProducts = [],
|
||||||
|
}: {
|
||||||
|
billingContext: BillingContext;
|
||||||
|
addCustomerProducts?: FullCusProduct[];
|
||||||
|
removeCustomerProducts?: FullCusProduct[];
|
||||||
|
}) => {
|
||||||
|
const { fullCustomer, stripeSubscription } = billingContext;
|
||||||
|
|
||||||
|
let customerProducts = stripeSubscription
|
||||||
|
? fullCustomer.customer_products.filter((cp) =>
|
||||||
|
isCustomerProductOnStripeSubscription({
|
||||||
|
customerProduct: cp,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
customerProducts = customerProducts.filter(
|
||||||
|
(cp) =>
|
||||||
|
!removeCustomerProducts.some((cpToRemove) => cpToRemove.id === cp.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
customerProducts = [...customerProducts, ...addCustomerProducts];
|
||||||
|
|
||||||
|
return customerProducts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const customerProductsToRecurringStripeItemSpecs = ({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
customerProducts,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
customerProducts: FullCusProduct[];
|
||||||
|
}) => {
|
||||||
|
const stripeItemSpecs: StripeItemSpec[] = [];
|
||||||
|
|
||||||
|
for (const customerProduct of customerProducts) {
|
||||||
|
const { recurringItems } = customerProductToStripeItemSpecs({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
customerProduct,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const recurringItem of recurringItems) {
|
||||||
|
// 1. If price ID is already in the array, update the quantity
|
||||||
|
const existingItem = stripeItemSpecs.find(
|
||||||
|
(item) => item.stripePriceId === recurringItem.stripePriceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
existingItem.quantity =
|
||||||
|
(existingItem.quantity ?? 0) + (recurringItem.quantity ?? 0);
|
||||||
|
} else {
|
||||||
|
stripeItemSpecs.push(recurringItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripeItemSpecs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripeItemSpecsToSubItemsUpdate = ({
|
||||||
|
billingContext,
|
||||||
|
stripeItemSpecs,
|
||||||
|
}: {
|
||||||
|
billingContext: BillingContext;
|
||||||
|
stripeItemSpecs: StripeItemSpec[];
|
||||||
|
}) => {
|
||||||
|
const { stripeSubscription } = billingContext;
|
||||||
|
const currentSubscriptionItems = stripeSubscription?.items.data ?? [];
|
||||||
|
|
||||||
|
const subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[] = [];
|
||||||
|
for (const stripeItemSpec of stripeItemSpecs) {
|
||||||
|
const existingItem = findStripeSubscriptionItemByStripePriceId({
|
||||||
|
stripePriceId: stripeItemSpec.stripePriceId,
|
||||||
|
stripeSubscriptionItems: currentSubscriptionItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingItem && existingItem.quantity !== stripeItemSpec.quantity) {
|
||||||
|
subItemsUpdate.push({
|
||||||
|
id: existingItem.id,
|
||||||
|
quantity: stripeItemSpec.quantity,
|
||||||
|
});
|
||||||
|
} else if (!existingItem) {
|
||||||
|
subItemsUpdate.push({
|
||||||
|
price: stripeItemSpec.stripePriceId,
|
||||||
|
quantity: stripeItemSpec.quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const subItem of currentSubscriptionItems) {
|
||||||
|
const stripeItemSpec = findStripeItemSpecByStripePriceId({
|
||||||
|
stripePriceId: stripeSubscriptionItemToStripePriceId(subItem),
|
||||||
|
stripeItemSpecs,
|
||||||
|
});
|
||||||
|
if (!stripeItemSpec) {
|
||||||
|
subItemsUpdate.push({ id: subItem.id, deleted: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subItemsUpdate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildStripeSubscriptionItemsUpdate = ({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts = [],
|
||||||
|
removeCustomerProducts = [],
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
addCustomerProducts?: FullCusProduct[];
|
||||||
|
removeCustomerProducts?: FullCusProduct[];
|
||||||
|
}) => {
|
||||||
|
// 1. Get final customer product state
|
||||||
|
const customerProducts = getFinalCustomerProductsState({
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts,
|
||||||
|
removeCustomerProducts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Get recurring subscription item array (doesn't include one off items)
|
||||||
|
const recurringItems = customerProductsToRecurringStripeItemSpecs({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
customerProducts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Diff it with the current subscription items
|
||||||
|
return stripeItemSpecsToSubItemsUpdate({
|
||||||
|
billingContext,
|
||||||
|
stripeItemSpecs: recurringItems,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
addCusProductToCusEnt,
|
||||||
|
cusPriceToCusEnt,
|
||||||
|
cusProductToProduct,
|
||||||
|
entToOptions,
|
||||||
|
type FeatureOptions,
|
||||||
|
type FullCusProduct,
|
||||||
|
isAllocatedCusEnt,
|
||||||
|
isOneOffPrice,
|
||||||
|
notNullish,
|
||||||
|
type StripeItemSpec,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { cusEntToInvoiceUsage } from "@shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage";
|
||||||
|
import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
|
||||||
|
export const customerProductToStripeItemSpecs = ({
|
||||||
|
ctx,
|
||||||
|
customerProduct,
|
||||||
|
billingContext,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
customerProduct: FullCusProduct;
|
||||||
|
billingContext?: BillingContext;
|
||||||
|
}): {
|
||||||
|
recurringItems: StripeItemSpec[];
|
||||||
|
oneOffItems: StripeItemSpec[];
|
||||||
|
} => {
|
||||||
|
const product = cusProductToProduct({ cusProduct: customerProduct });
|
||||||
|
|
||||||
|
const cusPrices = customerProduct.customer_prices;
|
||||||
|
const cusEnts = customerProduct.customer_entitlements;
|
||||||
|
|
||||||
|
const fromVercel = billingContext?.paymentMethod?.type === "custom";
|
||||||
|
|
||||||
|
const { org } = ctx;
|
||||||
|
|
||||||
|
const recurringItems: StripeItemSpec[] = [];
|
||||||
|
const oneOffItems: StripeItemSpec[] = [];
|
||||||
|
|
||||||
|
for (const cusPrice of cusPrices) {
|
||||||
|
const price = cusPrice.price;
|
||||||
|
const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts });
|
||||||
|
const ent = cusEnt?.entitlement;
|
||||||
|
|
||||||
|
let options: FeatureOptions | undefined;
|
||||||
|
let existingUsage: number | undefined;
|
||||||
|
if (cusEnt) {
|
||||||
|
const ent = cusEnt.entitlement;
|
||||||
|
options = entToOptions({ ent, options: customerProduct.options ?? [] });
|
||||||
|
|
||||||
|
const cusEntWithCusProduct = addCusProductToCusEnt({
|
||||||
|
cusEnt,
|
||||||
|
cusProduct: customerProduct,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAllocatedCusEnt(cusEntWithCusProduct)) {
|
||||||
|
existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeItem = priceToStripeItem({
|
||||||
|
price,
|
||||||
|
product,
|
||||||
|
org,
|
||||||
|
options,
|
||||||
|
isCheckout: false, // TODO: Add this back in?
|
||||||
|
relatedEnt: ent,
|
||||||
|
existingUsage,
|
||||||
|
withEntity: notNullish(customerProduct.internal_entity_id),
|
||||||
|
apiVersion: ctx.apiVersion.value,
|
||||||
|
fromVercel,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stripeItem) continue;
|
||||||
|
|
||||||
|
const { lineItem } = stripeItem;
|
||||||
|
|
||||||
|
if (isOneOffPrice(price)) {
|
||||||
|
oneOffItems.push({
|
||||||
|
stripePriceId: lineItem?.price ?? "",
|
||||||
|
quantity: lineItem?.quantity,
|
||||||
|
autumnPrice: price,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
recurringItems.push({
|
||||||
|
stripePriceId: lineItem?.price ?? "",
|
||||||
|
quantity: lineItem?.quantity,
|
||||||
|
autumnPrice: price,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recurringItems, oneOffItems };
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { msToSeconds } from "@autumn/shared";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
|
||||||
|
export const buildStripeSubscriptionCreateAction = ({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
freeTrialPlan,
|
||||||
|
subItemsUpdate,
|
||||||
|
addInvoiceItems,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
freeTrialPlan?: FreeTrialPlan;
|
||||||
|
subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[];
|
||||||
|
addInvoiceItems: Stripe.SubscriptionCreateParams.AddInvoiceItem[];
|
||||||
|
}) => {
|
||||||
|
const { stripeCustomer, paymentMethod } = billingContext;
|
||||||
|
|
||||||
|
const trialEndsAt = freeTrialPlan?.trialEndsAt;
|
||||||
|
const freeTrial = freeTrialPlan?.freeTrial;
|
||||||
|
|
||||||
|
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
|
||||||
|
const isCustomPaymentMethod = paymentMethod?.type === "custom";
|
||||||
|
|
||||||
|
// add_invoice_items: invoiceItems,
|
||||||
|
// collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
|
||||||
|
// days_until_due: invoiceOnly ? 30 : undefined,
|
||||||
|
// billing cycle anchor?
|
||||||
|
// discounts?
|
||||||
|
// expand: ["latest_invoice"],
|
||||||
|
// metadata?
|
||||||
|
|
||||||
|
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
|
||||||
|
customer: stripeCustomer.id,
|
||||||
|
items: subItemsUpdate.map((item) => ({
|
||||||
|
price: item.price,
|
||||||
|
quantity: item.quantity,
|
||||||
|
})),
|
||||||
|
|
||||||
|
billing_mode: { type: "flexible" },
|
||||||
|
|
||||||
|
payment_behavior: isCustomPaymentMethod
|
||||||
|
? "default_incomplete"
|
||||||
|
: "error_if_incomplete",
|
||||||
|
|
||||||
|
add_invoice_items: addInvoiceItems,
|
||||||
|
|
||||||
|
trial_end: trialEndsAt ? msToSeconds(trialEndsAt) : undefined,
|
||||||
|
|
||||||
|
...(isFreeTrialWithCardRequired && {
|
||||||
|
trial_settings: {
|
||||||
|
end_behavior: {
|
||||||
|
missing_payment_method: "cancel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
...(isCustomPaymentMethod && {
|
||||||
|
payment_settings: {
|
||||||
|
save_default_payment_method: "on_subscription",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "create" as const,
|
||||||
|
params: stripeSubscriptionCreateParams,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ...paymentMethodData,
|
||||||
|
// customer: customer.processor.id,
|
||||||
|
// items: sanitizeSubItems(subItems),
|
||||||
|
|
||||||
|
// billing_mode: { type: "flexible" },
|
||||||
|
// // For custom payment methods (e.g., Vercel), start subscription as incomplete
|
||||||
|
// // The subscription will become active after external payment is confirmed via Payment Records API
|
||||||
|
// payment_behavior: isCustomPaymentMethod
|
||||||
|
// ? "default_incomplete"
|
||||||
|
// : "error_if_incomplete",
|
||||||
|
// add_invoice_items: invoiceItems,
|
||||||
|
// collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
|
||||||
|
// days_until_due: invoiceOnly ? 30 : undefined,
|
||||||
|
// billing_cycle_anchor: billingCycleAnchorUnix
|
||||||
|
// ? Math.floor(billingCycleAnchorUnix / 1000)
|
||||||
|
// : undefined,
|
||||||
|
|
||||||
|
// discounts,
|
||||||
|
// expand: ["latest_invoice"],
|
||||||
|
|
||||||
|
// // Pass metadata from attachParams (e.g., Vercel installation/billing plan IDs)
|
||||||
|
// metadata: metadata || undefined,
|
||||||
|
|
||||||
|
// // For custom payment methods, save the payment method on the subscription
|
||||||
|
// // so it's available in webhook handlers and for future renewals
|
||||||
|
// ...(isCustomPaymentMethod && {
|
||||||
|
// payment_settings: {
|
||||||
|
// save_default_payment_method: "on_subscription",
|
||||||
|
// },
|
||||||
|
// }),
|
||||||
|
|
||||||
|
// ...{
|
||||||
|
// trial_settings:
|
||||||
|
// freeTrial && !freeTrial.card_required
|
||||||
|
// ? {
|
||||||
|
// end_behavior: {
|
||||||
|
// missing_payment_method: "cancel",
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// : undefined,
|
||||||
|
|
||||||
|
// trial_end: freeTrialToStripeTimestamp({ freeTrial, now }),
|
||||||
|
// },
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { msToSeconds } from "@shared/utils/common/unixUtils";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { isStripeSubscriptionCancelling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
|
||||||
|
export const buildStripeSubscriptionUpdateAction = ({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
subItemsUpdate,
|
||||||
|
freeTrialPlan,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[];
|
||||||
|
freeTrialPlan?: FreeTrialPlan;
|
||||||
|
}) => {
|
||||||
|
const { stripeSubscription } = billingContext;
|
||||||
|
|
||||||
|
if (!stripeSubscription) {
|
||||||
|
throw new Error(
|
||||||
|
"[buildStripeSubscriptionUpdateAction] Cannot update subscription: no existing subscription",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trialEndsAt = freeTrialPlan?.trialEndsAt;
|
||||||
|
const cancelAtPeriodEnd = isStripeSubscriptionCancelling(stripeSubscription)
|
||||||
|
? false
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const params: Stripe.SubscriptionUpdateParams = {
|
||||||
|
items: subItemsUpdate.length > 0 ? subItemsUpdate : undefined,
|
||||||
|
trial_end: trialEndsAt ? msToSeconds(trialEndsAt) : undefined,
|
||||||
|
proration_behavior: "none",
|
||||||
|
cancel_at_period_end: cancelAtPeriodEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.items === undefined &&
|
||||||
|
params.trial_end === undefined &&
|
||||||
|
params.cancel_at_period_end === undefined
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "update" as const,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// import type Stripe from "stripe";
|
||||||
|
// import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
// import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
// import type { FreeTrialPlan } from "@/internal/billing/v2/billingPlan";
|
||||||
|
|
||||||
|
// export const computeStripeSubscriptionActionType = ({
|
||||||
|
// ctx,
|
||||||
|
// billingContext,
|
||||||
|
// subItemsUpdate,
|
||||||
|
// freeTrialPlan,
|
||||||
|
// }: {
|
||||||
|
// ctx: AutumnContext;
|
||||||
|
// billingContext: BillingContext;
|
||||||
|
// subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[];
|
||||||
|
// freeTrialPlan?: FreeTrialPlan;
|
||||||
|
// }) => {
|
||||||
|
// const { stripeSubscription } = billingContext;
|
||||||
|
|
||||||
|
// // Case 1: No subscription and sub items update is empty -> no action
|
||||||
|
// if (!stripeSubscription && subItemsUpdate.length === 0) return undefined;
|
||||||
|
|
||||||
|
// // Case 2: No subscription and sub items update not empty -> create subscription
|
||||||
|
// if (!stripeSubscription && subItemsUpdate.length > 0) {
|
||||||
|
// return "create";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Case 3: Cancel subscription
|
||||||
|
// if (
|
||||||
|
// subItemsUpdate.length === stripeSubscription?.items.data.length &&
|
||||||
|
// subItemsUpdate.every((item) => item.deleted)
|
||||||
|
// ) {
|
||||||
|
// return "cancel";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (stripeSubscription) {
|
||||||
|
// return "update";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return undefined;
|
||||||
|
// };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { StripeSubscriptionAction } from "@/internal/billing/v2/billingPlan";
|
||||||
|
|
||||||
|
export const executeStripeSubscriptionAction = async ({
|
||||||
|
ctx,
|
||||||
|
subscriptionAction,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
subscriptionAction: StripeSubscriptionAction;
|
||||||
|
}) => {
|
||||||
|
const { org, env } = ctx;
|
||||||
|
const stripeClient = createStripeCli({ org, env });
|
||||||
|
|
||||||
|
switch (subscriptionAction.type) {
|
||||||
|
case "update":
|
||||||
|
return await stripeClient.subscriptions.update(
|
||||||
|
subscriptionAction.stripeSubscriptionId,
|
||||||
|
subscriptionAction.params,
|
||||||
|
);
|
||||||
|
case "create":
|
||||||
|
return await stripeClient.subscriptions.create(subscriptionAction.params);
|
||||||
|
case "cancel":
|
||||||
|
await stripeClient.subscriptions.cancel(
|
||||||
|
subscriptionAction.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
type CreateFreeTrial,
|
type CreateFreeTrial,
|
||||||
CreateFreeTrialSchema,
|
|
||||||
ErrCode,
|
ErrCode,
|
||||||
type FreeTrial,
|
type FreeTrial,
|
||||||
FreeTrialDuration,
|
FreeTrialDuration,
|
||||||
|
initFreeTrial,
|
||||||
type Price,
|
type Price,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { DrizzleCli } from "@server/db/initDrizzle.js";
|
import type { DrizzleCli } from "@server/db/initDrizzle.js";
|
||||||
@@ -12,7 +12,6 @@ import { FreeTrialService } from "@server/internal/products/free-trials/FreeTria
|
|||||||
import { ProductService } from "@server/internal/products/ProductService.js";
|
import { ProductService } from "@server/internal/products/ProductService.js";
|
||||||
import { isOneOff } from "@server/internal/products/productUtils.js";
|
import { isOneOff } from "@server/internal/products/productUtils.js";
|
||||||
import RecaseError from "@server/utils/errorUtils.js";
|
import RecaseError from "@server/utils/errorUtils.js";
|
||||||
import { generateId } from "@server/utils/genUtils.js";
|
|
||||||
import { addDays, addMinutes, addMonths, addYears } from "date-fns";
|
import { addDays, addMinutes, addMonths, addYears } from "date-fns";
|
||||||
|
|
||||||
export const validateOneOffTrial = async ({
|
export const validateOneOffTrial = async ({
|
||||||
@@ -31,28 +30,6 @@ export const validateOneOffTrial = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateAndInitFreeTrial = ({
|
|
||||||
freeTrial,
|
|
||||||
internalProductId,
|
|
||||||
isCustom = false,
|
|
||||||
}: {
|
|
||||||
freeTrial: CreateFreeTrial;
|
|
||||||
internalProductId: string;
|
|
||||||
isCustom?: boolean;
|
|
||||||
}): FreeTrial => {
|
|
||||||
const freeTrialSchema = CreateFreeTrialSchema.parse(freeTrial);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...freeTrialSchema,
|
|
||||||
id: generateId("ft"),
|
|
||||||
created_at: Date.now(),
|
|
||||||
duration: freeTrial.duration || FreeTrialDuration.Day,
|
|
||||||
internal_product_id: internalProductId,
|
|
||||||
is_custom: isCustom,
|
|
||||||
card_required: freeTrial.card_required ?? true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const freeTrialsAreSame = ({
|
export const freeTrialsAreSame = ({
|
||||||
ft1,
|
ft1,
|
||||||
ft2,
|
ft2,
|
||||||
@@ -176,8 +153,8 @@ export const handleNewFreeTrial = async ({
|
|||||||
return curFreeTrial;
|
return curFreeTrial;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdFreeTrial = validateAndInitFreeTrial({
|
const createdFreeTrial = initFreeTrial({
|
||||||
freeTrial: newFreeTrial,
|
freeTrialParams: newFreeTrial,
|
||||||
internalProductId,
|
internalProductId,
|
||||||
isCustom,
|
isCustom,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import {
|
|||||||
ApiVersion,
|
ApiVersion,
|
||||||
CouponDurationType,
|
CouponDurationType,
|
||||||
type CreateReward,
|
type CreateReward,
|
||||||
ProductItemFeatureType,
|
|
||||||
RewardType,
|
RewardType,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||||
import { createReward } from "@tests/utils/productUtils.js";
|
|
||||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
@@ -34,18 +32,6 @@ const freeProd = constructProduct({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const free = constructProduct({
|
|
||||||
type: "free",
|
|
||||||
isDefault: false,
|
|
||||||
isAddOn: true,
|
|
||||||
items: [
|
|
||||||
constructFeatureItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
includedUsage: 12,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const pro = constructProduct({
|
const pro = constructProduct({
|
||||||
type: "pro",
|
type: "pro",
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
@@ -54,28 +40,7 @@ const pro = constructProduct({
|
|||||||
featureId: TestFeature.Messages,
|
featureId: TestFeature.Messages,
|
||||||
includedUsage: 12,
|
includedUsage: 12,
|
||||||
}),
|
}),
|
||||||
// constructArrearProratedItem({
|
|
||||||
// featureId: TestFeature.Users,
|
|
||||||
// includedUsage: 1,
|
|
||||||
// pricePerUnit: 10,
|
|
||||||
// }),
|
|
||||||
// constructArrearProratedItem({
|
|
||||||
// featureId: TestFeature.Workflows,
|
|
||||||
// includedUsage: 1,
|
|
||||||
// pricePerUnit: 25,
|
|
||||||
// }),
|
|
||||||
// constructArrearItem({
|
|
||||||
// featureId: TestFeature.Words,
|
|
||||||
// billingUnits: 1,
|
|
||||||
// price: 0.1,
|
|
||||||
// }),
|
|
||||||
// constructPrepaidItem({
|
|
||||||
// featureId: TestFeature.Messages,
|
|
||||||
// billingUnits: 100,
|
|
||||||
// price: 8,
|
|
||||||
// }),
|
|
||||||
],
|
],
|
||||||
// intervalCount: 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const premium = constructProduct({
|
const premium = constructProduct({
|
||||||
@@ -86,24 +51,7 @@ const premium = constructProduct({
|
|||||||
featureId: TestFeature.Messages,
|
featureId: TestFeature.Messages,
|
||||||
includedUsage: 500,
|
includedUsage: 500,
|
||||||
}),
|
}),
|
||||||
// constructArrearItem({
|
|
||||||
// featureId: TestFeature.Words,
|
|
||||||
// billingUnits: 1,
|
|
||||||
// price: 0.1,
|
|
||||||
// }),
|
|
||||||
|
|
||||||
// constructArrearProratedItem({
|
|
||||||
// featureId: TestFeature.Users,
|
|
||||||
// includedUsage: 1,
|
|
||||||
// pricePerUnit: 15,
|
|
||||||
// }),
|
|
||||||
// constructPrepaidItem({
|
|
||||||
// featureId: TestFeature.Messages,
|
|
||||||
// billingUnits: 100,
|
|
||||||
// price: 12,
|
|
||||||
// }),
|
|
||||||
],
|
],
|
||||||
// intervalCount: 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const freeAddOn = constructRawProduct({
|
const freeAddOn = constructRawProduct({
|
||||||
@@ -117,19 +65,6 @@ const freeAddOn = constructRawProduct({
|
|||||||
isAddOn: true,
|
isAddOn: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const oneOffAddOn = constructRawProduct({
|
|
||||||
id: "oneOffAddOn",
|
|
||||||
items: [
|
|
||||||
constructPrepaidItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
billingUnits: 100,
|
|
||||||
price: 10,
|
|
||||||
isOneOff: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
isAddOn: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oneOffCredits = constructRawProduct({
|
const oneOffCredits = constructRawProduct({
|
||||||
id: "oneOffCredits",
|
id: "oneOffCredits",
|
||||||
items: [
|
items: [
|
||||||
@@ -157,17 +92,6 @@ const monthlyAddOn = constructRawProduct({
|
|||||||
isAddOn: true,
|
isAddOn: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const entities = [
|
|
||||||
{
|
|
||||||
id: "entity1",
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "entity2",
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 50% off reward that only applies to pro product
|
// 50% off reward that only applies to pro product
|
||||||
const rewardId = "50_percent_off";
|
const rewardId = "50_percent_off";
|
||||||
const promoCode = "50OFF";
|
const promoCode = "50OFF";
|
||||||
@@ -186,17 +110,6 @@ const reward: CreateReward = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const superProd = constructRawProduct({
|
|
||||||
id: "super",
|
|
||||||
items: [
|
|
||||||
constructPrepaidItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
billingUnits: 12,
|
|
||||||
price: 8,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
|
describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
|
||||||
const customerId = "temp";
|
const customerId = "temp";
|
||||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
@@ -218,66 +131,22 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
|
|||||||
freeAddOn,
|
freeAddOn,
|
||||||
monthlyAddOn,
|
monthlyAddOn,
|
||||||
oneOffCredits,
|
oneOffCredits,
|
||||||
superProd,
|
|
||||||
],
|
],
|
||||||
prefix: customerId,
|
prefix: customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await createReward({
|
|
||||||
db: ctx.db,
|
|
||||||
orgId: ctx.org.id,
|
|
||||||
env: ctx.env,
|
|
||||||
autumn: autumnV1,
|
|
||||||
reward,
|
|
||||||
// productId: pro.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await autumnV1.attach({
|
await autumnV1.attach({
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
product_id: superProd.id,
|
product_id: pro.id,
|
||||||
options: [
|
|
||||||
{
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
quantity: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// await autumnV1.entities.create(customerId, entities);
|
|
||||||
|
|
||||||
// await autumnV1.track({
|
|
||||||
// customer_id: customerId,
|
|
||||||
// feature_id: TestFeature.Workflows,
|
|
||||||
// value: 4,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// await autumnV1.track({
|
|
||||||
// customer_id: customerId,
|
|
||||||
// feature_id: TestFeature.Words,
|
|
||||||
// value: 1000,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const customer = await CusService.getFull({
|
|
||||||
// db: ctx.db,
|
|
||||||
// idOrInternalId: customerId,
|
|
||||||
// orgId: ctx.org.id,
|
|
||||||
// env: ctx.env,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// await attachFailedPaymentMethod({
|
|
||||||
// stripeCli: ctx.stripeCli,
|
|
||||||
// customer,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// await advanceTestClock({
|
|
||||||
// stripeCli: ctx.stripeCli,
|
|
||||||
// testClockId: result.testClockId,
|
|
||||||
// advanceTo: toUnix({
|
|
||||||
// year: 2025,
|
|
||||||
// month: 12,
|
|
||||||
// day: 22,
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// await createReward({
|
||||||
|
// db: ctx.db,
|
||||||
|
// orgId: ctx.org.id,
|
||||||
|
// env: ctx.env,
|
||||||
|
// autumn: autumnV1,
|
||||||
|
// reward,
|
||||||
|
// // productId: pro.id,
|
||||||
|
// });
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { OrgService } from "@/internal/orgs/OrgService.js";
|
|||||||
import {
|
import {
|
||||||
initEntitlement,
|
initEntitlement,
|
||||||
initFeature,
|
initFeature,
|
||||||
initFreeTrial,
|
|
||||||
initPrice,
|
initPrice,
|
||||||
initProduct,
|
initProduct,
|
||||||
} from "../utils/init.js";
|
} from "../utils/init.js";
|
||||||
@@ -188,6 +187,7 @@ export const alexProducts = {
|
|||||||
interval: EntInterval.Lifetime,
|
interval: EntInterval.Lifetime,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
freeTrial: null,
|
||||||
prices: [
|
prices: [
|
||||||
initPrice({
|
initPrice({
|
||||||
amount: 20.0, // $20.00
|
amount: 20.0, // $20.00
|
||||||
@@ -195,10 +195,6 @@ export const alexProducts = {
|
|||||||
type: "monthly",
|
type: "monthly",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
freeTrial: initFreeTrial({
|
|
||||||
length: 7,
|
|
||||||
uniqueFingerprint: true,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
topUpMessages: initProduct({
|
topUpMessages: initProduct({
|
||||||
@@ -305,10 +301,7 @@ export const alexProducts = {
|
|||||||
amount: 50,
|
amount: 50,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
freeTrial: initFreeTrial({
|
freeTrial: null,
|
||||||
length: 7,
|
|
||||||
uniqueFingerprint: true,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
proTeam: initProduct({
|
proTeam: initProduct({
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const newCusProduct = createMockCustomerProduct({
|
const newCusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEnt],
|
customerEntitlements: [cusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingRollovers: ExistingRollover[] = [
|
const existingRollovers: ExistingRollover[] = [
|
||||||
@@ -35,7 +35,10 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingRollovers({ newCusProduct, existingRollovers });
|
applyExistingRollovers({
|
||||||
|
customerProduct: newCusProduct,
|
||||||
|
existingRollovers,
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: rollover should be added to the cusEnt
|
// Assert: rollover should be added to the cusEnt
|
||||||
const updatedCusEnt = newCusProduct.customer_entitlements.find(
|
const updatedCusEnt = newCusProduct.customer_entitlements.find(
|
||||||
|
|||||||
@@ -5,37 +5,43 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||||
|
|
||||||
describe(chalk.yellowBright("applyExistingRollovers (no matching cusEnt)"), () => {
|
describe(
|
||||||
test("skips rollover when no matching cusEnt exists", () => {
|
chalk.yellowBright("applyExistingRollovers (no matching cusEnt)"),
|
||||||
const cusEnt = createMockCusEntitlement({
|
() => {
|
||||||
internalFeatureId: "internal_feature_a",
|
test("skips rollover when no matching cusEnt exists", () => {
|
||||||
featureId: "feature_a",
|
const cusEnt = createMockCusEntitlement({
|
||||||
featureName: "Feature A",
|
internalFeatureId: "internal_feature_a",
|
||||||
allowance: 100,
|
featureId: "feature_a",
|
||||||
balance: 100,
|
featureName: "Feature A",
|
||||||
|
allowance: 100,
|
||||||
|
balance: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCusProduct = createMockCustomerProduct({
|
||||||
|
customerEntitlements: [cusEnt],
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingRollovers: ExistingRollover[] = [
|
||||||
|
{
|
||||||
|
id: "rollover_1",
|
||||||
|
cus_ent_id: "old_cus_ent_id",
|
||||||
|
balance: 500,
|
||||||
|
usage: 0,
|
||||||
|
expires_at: null,
|
||||||
|
entities: {},
|
||||||
|
internal_feature_id: "internal_nonexistent_feature",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
applyExistingRollovers({
|
||||||
|
customerProduct: newCusProduct,
|
||||||
|
existingRollovers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: no rollovers added since feature doesn't match
|
||||||
|
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
||||||
|
expect(updatedCusEnt.rollovers.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
const newCusProduct = createMockCustomerProduct({
|
);
|
||||||
cusEntitlements: [cusEnt],
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingRollovers: ExistingRollover[] = [
|
|
||||||
{
|
|
||||||
id: "rollover_1",
|
|
||||||
cus_ent_id: "old_cus_ent_id",
|
|
||||||
balance: 500,
|
|
||||||
usage: 0,
|
|
||||||
expires_at: null,
|
|
||||||
entities: {},
|
|
||||||
internal_feature_id: "internal_nonexistent_feature",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Act
|
|
||||||
applyExistingRollovers({ newCusProduct, existingRollovers });
|
|
||||||
|
|
||||||
// Assert: no rollovers added since feature doesn't match
|
|
||||||
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
|
||||||
expect(updatedCusEnt.rollovers.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,50 +5,58 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||||
|
|
||||||
describe(chalk.yellowBright("applyExistingRollovers (multiple rollovers same feature)"), () => {
|
describe(
|
||||||
test("applies multiple rollovers to the same feature", () => {
|
chalk.yellowBright(
|
||||||
const internalFeatureId = "internal_words";
|
"applyExistingRollovers (multiple rollovers same feature)",
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
test("applies multiple rollovers to the same feature", () => {
|
||||||
|
const internalFeatureId = "internal_words";
|
||||||
|
|
||||||
const cusEnt = createMockCusEntitlement({
|
const cusEnt = createMockCusEntitlement({
|
||||||
internalFeatureId,
|
internalFeatureId,
|
||||||
featureId: "words",
|
featureId: "words",
|
||||||
featureName: "Words",
|
featureName: "Words",
|
||||||
allowance: 5000,
|
allowance: 5000,
|
||||||
balance: 5000,
|
balance: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCusProduct = createMockCustomerProduct({
|
||||||
|
customerEntitlements: [cusEnt],
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingRollovers: ExistingRollover[] = [
|
||||||
|
{
|
||||||
|
id: "rollover_1",
|
||||||
|
cus_ent_id: "old_cus_ent_id",
|
||||||
|
balance: 1000,
|
||||||
|
usage: 0,
|
||||||
|
expires_at: null,
|
||||||
|
entities: {},
|
||||||
|
internal_feature_id: internalFeatureId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rollover_2",
|
||||||
|
cus_ent_id: "old_cus_ent_id",
|
||||||
|
balance: 500,
|
||||||
|
usage: 0,
|
||||||
|
expires_at: null,
|
||||||
|
entities: {},
|
||||||
|
internal_feature_id: internalFeatureId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
applyExistingRollovers({
|
||||||
|
customerProduct: newCusProduct,
|
||||||
|
existingRollovers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: both rollovers should be added to the same cusEnt
|
||||||
|
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
||||||
|
expect(updatedCusEnt.rollovers.length).toBe(2);
|
||||||
|
expect(updatedCusEnt.rollovers[0].balance).toBe(1000);
|
||||||
|
expect(updatedCusEnt.rollovers[1].balance).toBe(500);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
const newCusProduct = createMockCustomerProduct({
|
);
|
||||||
cusEntitlements: [cusEnt],
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingRollovers: ExistingRollover[] = [
|
|
||||||
{
|
|
||||||
id: "rollover_1",
|
|
||||||
cus_ent_id: "old_cus_ent_id",
|
|
||||||
balance: 1000,
|
|
||||||
usage: 0,
|
|
||||||
expires_at: null,
|
|
||||||
entities: {},
|
|
||||||
internal_feature_id: internalFeatureId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "rollover_2",
|
|
||||||
cus_ent_id: "old_cus_ent_id",
|
|
||||||
balance: 500,
|
|
||||||
usage: 0,
|
|
||||||
expires_at: null,
|
|
||||||
entities: {},
|
|
||||||
internal_feature_id: internalFeatureId,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Act
|
|
||||||
applyExistingRollovers({ newCusProduct, existingRollovers });
|
|
||||||
|
|
||||||
// Assert: both rollovers should be added to the same cusEnt
|
|
||||||
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
|
||||||
expect(updatedCusEnt.rollovers.length).toBe(2);
|
|
||||||
expect(updatedCusEnt.rollovers[0].balance).toBe(1000);
|
|
||||||
expect(updatedCusEnt.rollovers[1].balance).toBe(500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,53 +5,59 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||||
|
|
||||||
describe(chalk.yellowBright("applyExistingRollovers (multiple cusEnts, one match)"), () => {
|
describe(
|
||||||
test("applies rollover only to matching feature when multiple cusEnts exist", () => {
|
chalk.yellowBright("applyExistingRollovers (multiple cusEnts, one match)"),
|
||||||
const cusEntA = createMockCusEntitlement({
|
() => {
|
||||||
internalFeatureId: "internal_feature_a",
|
test("applies rollover only to matching feature when multiple cusEnts exist", () => {
|
||||||
featureId: "feature_a",
|
const cusEntA = createMockCusEntitlement({
|
||||||
featureName: "Feature A",
|
internalFeatureId: "internal_feature_a",
|
||||||
allowance: 100,
|
featureId: "feature_a",
|
||||||
balance: 100,
|
featureName: "Feature A",
|
||||||
|
allowance: 100,
|
||||||
|
balance: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEntB = createMockCusEntitlement({
|
||||||
|
internalFeatureId: "internal_feature_b",
|
||||||
|
featureId: "feature_b",
|
||||||
|
featureName: "Feature B",
|
||||||
|
allowance: 200,
|
||||||
|
balance: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCusProduct = createMockCustomerProduct({
|
||||||
|
customerEntitlements: [cusEntA, cusEntB],
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingRollovers: ExistingRollover[] = [
|
||||||
|
{
|
||||||
|
id: "rollover_1",
|
||||||
|
cus_ent_id: "old_cus_ent_id",
|
||||||
|
balance: 50,
|
||||||
|
usage: 0,
|
||||||
|
expires_at: null,
|
||||||
|
entities: {},
|
||||||
|
internal_feature_id: "internal_feature_a",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
applyExistingRollovers({
|
||||||
|
customerProduct: newCusProduct,
|
||||||
|
existingRollovers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: only feature_a gets the rollover
|
||||||
|
const updatedCusEntA = newCusProduct.customer_entitlements.find(
|
||||||
|
(ce) => ce.feature_id === "feature_a",
|
||||||
|
);
|
||||||
|
const updatedCusEntB = newCusProduct.customer_entitlements.find(
|
||||||
|
(ce) => ce.feature_id === "feature_b",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updatedCusEntA?.rollovers.length).toBe(1);
|
||||||
|
expect(updatedCusEntA?.rollovers[0].balance).toBe(50);
|
||||||
|
expect(updatedCusEntB?.rollovers.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
const cusEntB = createMockCusEntitlement({
|
);
|
||||||
internalFeatureId: "internal_feature_b",
|
|
||||||
featureId: "feature_b",
|
|
||||||
featureName: "Feature B",
|
|
||||||
allowance: 200,
|
|
||||||
balance: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newCusProduct = createMockCustomerProduct({
|
|
||||||
cusEntitlements: [cusEntA, cusEntB],
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingRollovers: ExistingRollover[] = [
|
|
||||||
{
|
|
||||||
id: "rollover_1",
|
|
||||||
cus_ent_id: "old_cus_ent_id",
|
|
||||||
balance: 50,
|
|
||||||
usage: 0,
|
|
||||||
expires_at: null,
|
|
||||||
entities: {},
|
|
||||||
internal_feature_id: "internal_feature_a",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Act
|
|
||||||
applyExistingRollovers({ newCusProduct, existingRollovers });
|
|
||||||
|
|
||||||
// Assert: only feature_a gets the rollover
|
|
||||||
const updatedCusEntA = newCusProduct.customer_entitlements.find(
|
|
||||||
(ce) => ce.feature_id === "feature_a",
|
|
||||||
);
|
|
||||||
const updatedCusEntB = newCusProduct.customer_entitlements.find(
|
|
||||||
(ce) => ce.feature_id === "feature_b",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(updatedCusEntA?.rollovers.length).toBe(1);
|
|
||||||
expect(updatedCusEntA?.rollovers[0].balance).toBe(50);
|
|
||||||
expect(updatedCusEntB?.rollovers.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,57 +5,63 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||||
|
|
||||||
describe(chalk.yellowBright("applyExistingRollovers (duplicate internal_feature_id)"), () => {
|
describe(
|
||||||
test("applies rollover only to first cusEnt when multiple have same internal_feature_id", () => {
|
chalk.yellowBright("applyExistingRollovers (duplicate internal_feature_id)"),
|
||||||
const internalFeatureId = "internal_words";
|
() => {
|
||||||
|
test("applies rollover only to first cusEnt when multiple have same internal_feature_id", () => {
|
||||||
|
const internalFeatureId = "internal_words";
|
||||||
|
|
||||||
const cusEntFirst = createMockCusEntitlement({
|
const cusEntFirst = createMockCusEntitlement({
|
||||||
id: "cus_ent_first",
|
id: "cus_ent_first",
|
||||||
internalFeatureId,
|
internalFeatureId,
|
||||||
featureId: "words",
|
featureId: "words",
|
||||||
featureName: "Words",
|
featureName: "Words",
|
||||||
allowance: 100,
|
allowance: 100,
|
||||||
balance: 100,
|
balance: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEntSecond = createMockCusEntitlement({
|
||||||
|
id: "cus_ent_second",
|
||||||
|
internalFeatureId,
|
||||||
|
featureId: "words",
|
||||||
|
featureName: "Words",
|
||||||
|
allowance: 200,
|
||||||
|
balance: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCusProduct = createMockCustomerProduct({
|
||||||
|
customerEntitlements: [cusEntFirst, cusEntSecond],
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingRollovers: ExistingRollover[] = [
|
||||||
|
{
|
||||||
|
id: "rollover_1",
|
||||||
|
cus_ent_id: "old_cus_ent_id",
|
||||||
|
balance: 50,
|
||||||
|
usage: 0,
|
||||||
|
expires_at: null,
|
||||||
|
entities: {},
|
||||||
|
internal_feature_id: internalFeatureId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Act
|
||||||
|
applyExistingRollovers({
|
||||||
|
customerProduct: newCusProduct,
|
||||||
|
existingRollovers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert: only the FIRST cusEnt gets the rollover (due to .find() behavior)
|
||||||
|
const firstCusEnt = newCusProduct.customer_entitlements.find(
|
||||||
|
(ce) => ce.id === "cus_ent_first",
|
||||||
|
);
|
||||||
|
const secondCusEnt = newCusProduct.customer_entitlements.find(
|
||||||
|
(ce) => ce.id === "cus_ent_second",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstCusEnt?.rollovers.length).toBe(1);
|
||||||
|
expect(firstCusEnt?.rollovers[0].balance).toBe(50);
|
||||||
|
expect(secondCusEnt?.rollovers.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
const cusEntSecond = createMockCusEntitlement({
|
);
|
||||||
id: "cus_ent_second",
|
|
||||||
internalFeatureId,
|
|
||||||
featureId: "words",
|
|
||||||
featureName: "Words",
|
|
||||||
allowance: 200,
|
|
||||||
balance: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newCusProduct = createMockCustomerProduct({
|
|
||||||
cusEntitlements: [cusEntFirst, cusEntSecond],
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingRollovers: ExistingRollover[] = [
|
|
||||||
{
|
|
||||||
id: "rollover_1",
|
|
||||||
cus_ent_id: "old_cus_ent_id",
|
|
||||||
balance: 50,
|
|
||||||
usage: 0,
|
|
||||||
expires_at: null,
|
|
||||||
entities: {},
|
|
||||||
internal_feature_id: internalFeatureId,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Act
|
|
||||||
applyExistingRollovers({ newCusProduct, existingRollovers });
|
|
||||||
|
|
||||||
// Assert: only the FIRST cusEnt gets the rollover (due to .find() behavior)
|
|
||||||
const firstCusEnt = newCusProduct.customer_entitlements.find(
|
|
||||||
(ce) => ce.id === "cus_ent_first",
|
|
||||||
);
|
|
||||||
const secondCusEnt = newCusProduct.customer_entitlements.find(
|
|
||||||
(ce) => ce.id === "cus_ent_second",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(firstCusEnt?.rollovers.length).toBe(1);
|
|
||||||
expect(firstCusEnt?.rollovers[0].balance).toBe(50);
|
|
||||||
expect(secondCusEnt?.rollovers.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,45 +5,53 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||||
|
|
||||||
describe(chalk.yellowBright("applyExistingRollovers (zero balance, positive entity balance)"), () => {
|
describe(
|
||||||
test("applies rollover when top-level balance is 0 but entity has positive balance", () => {
|
chalk.yellowBright(
|
||||||
const internalFeatureId = "internal_seats";
|
"applyExistingRollovers (zero balance, positive entity balance)",
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
test("applies rollover when top-level balance is 0 but entity has positive balance", () => {
|
||||||
|
const internalFeatureId = "internal_seats";
|
||||||
|
|
||||||
const cusEnt = createMockCusEntitlement({
|
const cusEnt = createMockCusEntitlement({
|
||||||
internalFeatureId,
|
internalFeatureId,
|
||||||
featureId: "seats",
|
featureId: "seats",
|
||||||
featureName: "Seats",
|
featureName: "Seats",
|
||||||
allowance: 10,
|
allowance: 10,
|
||||||
balance: 10,
|
balance: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newCusProduct = createMockCustomerProduct({
|
const newCusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEnt],
|
customerEntitlements: [cusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingRollovers: ExistingRollover[] = [
|
const existingRollovers: ExistingRollover[] = [
|
||||||
{
|
{
|
||||||
id: "rollover_1",
|
id: "rollover_1",
|
||||||
cus_ent_id: "old_cus_ent_id",
|
cus_ent_id: "old_cus_ent_id",
|
||||||
balance: 0, // Top-level balance is 0
|
balance: 0, // Top-level balance is 0
|
||||||
usage: 0,
|
usage: 0,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
entities: {
|
entities: {
|
||||||
entity1: { id: "entity1", balance: 5, usage: 0 },
|
entity1: { id: "entity1", balance: 5, usage: 0 },
|
||||||
entity2: { id: "entity2", balance: 3, usage: 0 },
|
entity2: { id: "entity2", balance: 3, usage: 0 },
|
||||||
|
},
|
||||||
|
internal_feature_id: internalFeatureId,
|
||||||
},
|
},
|
||||||
internal_feature_id: internalFeatureId,
|
];
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingRollovers({ newCusProduct, existingRollovers });
|
applyExistingRollovers({
|
||||||
|
customerProduct: newCusProduct,
|
||||||
|
existingRollovers,
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: rollover SHOULD be applied because entity has balance > 0
|
// Assert: rollover SHOULD be applied because entity has balance > 0
|
||||||
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
||||||
expect(updatedCusEnt.rollovers.length).toBe(1);
|
expect(updatedCusEnt.rollovers.length).toBe(1);
|
||||||
expect(updatedCusEnt.rollovers[0].balance).toBe(0);
|
expect(updatedCusEnt.rollovers[0].balance).toBe(0);
|
||||||
expect(updatedCusEnt.rollovers[0].entities.entity1.balance).toBe(5);
|
expect(updatedCusEnt.rollovers[0].entities.entity1.balance).toBe(5);
|
||||||
expect(updatedCusEnt.rollovers[0].entities.entity2.balance).toBe(3);
|
expect(updatedCusEnt.rollovers[0].entities.entity2.balance).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,42 +5,50 @@ import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMock
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||||
|
|
||||||
describe(chalk.yellowBright("applyExistingRollovers (zero balance, all-zero entity balances)"), () => {
|
describe(
|
||||||
test("skips rollover when balance is 0 and all entity balances are 0", () => {
|
chalk.yellowBright(
|
||||||
const internalFeatureId = "internal_seats";
|
"applyExistingRollovers (zero balance, all-zero entity balances)",
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
test("skips rollover when balance is 0 and all entity balances are 0", () => {
|
||||||
|
const internalFeatureId = "internal_seats";
|
||||||
|
|
||||||
const cusEnt = createMockCusEntitlement({
|
const cusEnt = createMockCusEntitlement({
|
||||||
internalFeatureId,
|
internalFeatureId,
|
||||||
featureId: "seats",
|
featureId: "seats",
|
||||||
featureName: "Seats",
|
featureName: "Seats",
|
||||||
allowance: 10,
|
allowance: 10,
|
||||||
balance: 10,
|
balance: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newCusProduct = createMockCustomerProduct({
|
const newCusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEnt],
|
customerEntitlements: [cusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingRollovers: ExistingRollover[] = [
|
const existingRollovers: ExistingRollover[] = [
|
||||||
{
|
{
|
||||||
id: "rollover_1",
|
id: "rollover_1",
|
||||||
cus_ent_id: "old_cus_ent_id",
|
cus_ent_id: "old_cus_ent_id",
|
||||||
balance: 0,
|
balance: 0,
|
||||||
usage: 10,
|
usage: 10,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
entities: {
|
entities: {
|
||||||
entity1: { id: "entity1", balance: 0, usage: 5 },
|
entity1: { id: "entity1", balance: 0, usage: 5 },
|
||||||
entity2: { id: "entity2", balance: 0, usage: 5 },
|
entity2: { id: "entity2", balance: 0, usage: 5 },
|
||||||
|
},
|
||||||
|
internal_feature_id: internalFeatureId,
|
||||||
},
|
},
|
||||||
internal_feature_id: internalFeatureId,
|
];
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingRollovers({ newCusProduct, existingRollovers });
|
applyExistingRollovers({
|
||||||
|
customerProduct: newCusProduct,
|
||||||
|
existingRollovers,
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: rollover should NOT be applied (balance 0, all entity balances 0)
|
// Assert: rollover should NOT be applied (balance 0, all entity balances 0)
|
||||||
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
const updatedCusEnt = newCusProduct.customer_entitlements[0];
|
||||||
expect(updatedCusEnt.rollovers.length).toBe(0);
|
expect(updatedCusEnt.rollovers.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe(chalk.yellowBright("applyExistingUsages"), () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEnt],
|
customerEntitlements: [cusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingUsages: ExistingUsages = {
|
const existingUsages: ExistingUsages = {
|
||||||
@@ -28,7 +28,11 @@ describe(chalk.yellowBright("applyExistingUsages"), () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({
|
||||||
|
customerProduct: cusProduct,
|
||||||
|
existingUsages,
|
||||||
|
entities: [],
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: balance should be 5000 - 1500 = 3500
|
// Assert: balance should be 5000 - 1500 = 3500
|
||||||
const updatedCusEnt = cusProduct.customer_entitlements.find(
|
const updatedCusEnt = cusProduct.customer_entitlements.find(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEntA, cusEntB],
|
customerEntitlements: [cusEntA, cusEntB],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2 entities on feature A, 3 entities on feature B
|
// 2 entities on feature A, 3 entities on feature B
|
||||||
@@ -66,7 +66,11 @@ describe(
|
|||||||
const existingUsages: ExistingUsages = {};
|
const existingUsages: ExistingUsages = {};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities });
|
applyExistingUsages({
|
||||||
|
customerProduct: cusProduct,
|
||||||
|
existingUsages,
|
||||||
|
entities,
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: Feature A balance = 10 - 2 = 8, Feature B balance = 10 - 3 = 7
|
// Assert: Feature A balance = 10 - 2 = 8, Feature B balance = 10 - 3 = 7
|
||||||
const updatedCusEntA = cusProduct.customer_entitlements.find(
|
const updatedCusEntA = cusProduct.customer_entitlements.find(
|
||||||
@@ -90,8 +94,8 @@ describe(
|
|||||||
balance: 10,
|
balance: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const customerProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEntA],
|
customerEntitlements: [cusEntA],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2 entities on feature A
|
// 2 entities on feature A
|
||||||
@@ -114,10 +118,10 @@ describe(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities });
|
applyExistingUsages({ customerProduct, existingUsages, entities });
|
||||||
|
|
||||||
// Assert: Entity count (2) takes priority, balance = 10 - 2 = 8
|
// Assert: Entity count (2) takes priority, balance = 10 - 2 = 8
|
||||||
const updatedCusEntA = cusProduct.customer_entitlements.find(
|
const updatedCusEntA = customerProduct.customer_entitlements.find(
|
||||||
(ce) => ce.feature_id === "feature_a",
|
(ce) => ce.feature_id === "feature_a",
|
||||||
);
|
);
|
||||||
expect(updatedCusEntA?.balance).toBe(8);
|
expect(updatedCusEntA?.balance).toBe(8);
|
||||||
@@ -143,8 +147,8 @@ describe(
|
|||||||
balance: 2,
|
balance: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const customerProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEntA1, cusEntA2],
|
customerEntitlements: [cusEntA1, cusEntA2],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3 entities on feature A
|
// 3 entities on feature A
|
||||||
@@ -169,10 +173,10 @@ describe(
|
|||||||
const existingUsages: ExistingUsages = {};
|
const existingUsages: ExistingUsages = {};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities });
|
applyExistingUsages({ customerProduct, existingUsages, entities });
|
||||||
|
|
||||||
// Total usage = 3, distributed: first cusEnt uses 2, second cusEnt uses 1
|
// Total usage = 3, distributed: first cusEnt uses 2, second cusEnt uses 1
|
||||||
const updatedCusEnts = cusProduct.customer_entitlements.filter(
|
const updatedCusEnts = customerProduct.customer_entitlements.filter(
|
||||||
(ce) => ce.feature_id === "feature_a",
|
(ce) => ce.feature_id === "feature_a",
|
||||||
);
|
);
|
||||||
expect(updatedCusEnts[0]?.balance).toBe(0);
|
expect(updatedCusEnts[0]?.balance).toBe(0);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [monthlyCusEnt, lifetimeCusEnt], // Monthly first in array
|
customerEntitlements: [monthlyCusEnt, lifetimeCusEnt], // Monthly first in array
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply 7 usage (should deplete lifetime first, then take 2 from monthly)
|
// Apply 7 usage (should deplete lifetime first, then take 2 from monthly)
|
||||||
@@ -43,7 +43,11 @@ describe(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({
|
||||||
|
customerProduct: cusProduct,
|
||||||
|
existingUsages,
|
||||||
|
entities: [],
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: Lifetime should be depleted first (0), then monthly should have 3 remaining
|
// Assert: Lifetime should be depleted first (0), then monthly should have 3 remaining
|
||||||
const updatedLifetime = cusProduct.customer_entitlements.find(
|
const updatedLifetime = cusProduct.customer_entitlements.find(
|
||||||
@@ -82,8 +86,8 @@ describe(
|
|||||||
usageAllowed: true,
|
usageAllowed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const customerProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [payPerUseCusEnt, prepaidCusEnt], // Pay-per-use first in array
|
customerEntitlements: [payPerUseCusEnt, prepaidCusEnt], // Pay-per-use first in array
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply 7 usage (should deplete prepaid first, then take 2 from pay-per-use)
|
// Apply 7 usage (should deplete prepaid first, then take 2 from pay-per-use)
|
||||||
@@ -92,13 +96,13 @@ describe(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||||
|
|
||||||
// Assert: Prepaid should be depleted first (0), then pay-per-use should have 3 remaining
|
// Assert: Prepaid should be depleted first (0), then pay-per-use should have 3 remaining
|
||||||
const updatedPrepaid = cusProduct.customer_entitlements.find(
|
const updatedPrepaid = customerProduct.customer_entitlements.find(
|
||||||
(ce) => ce.usage_allowed === false,
|
(ce) => ce.usage_allowed === false,
|
||||||
);
|
);
|
||||||
const updatedPayPerUse = cusProduct.customer_entitlements.find(
|
const updatedPayPerUse = customerProduct.customer_entitlements.find(
|
||||||
(ce) => ce.usage_allowed === true,
|
(ce) => ce.usage_allowed === true,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,8 +139,8 @@ describe(
|
|||||||
nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const customerProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [payPerUseMonthly, prepaidMonthly], // Random order
|
customerEntitlements: [payPerUseMonthly, prepaidMonthly], // Random order
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply 12 usage (5 from prepaid, 7 from pay-per-use -> goes to -2)
|
// Apply 12 usage (5 from prepaid, 7 from pay-per-use -> goes to -2)
|
||||||
@@ -145,12 +149,12 @@ describe(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||||
|
|
||||||
const updatedPrepaid = cusProduct.customer_entitlements.find(
|
const updatedPrepaid = customerProduct.customer_entitlements.find(
|
||||||
(ce) => ce.usage_allowed === false,
|
(ce) => ce.usage_allowed === false,
|
||||||
);
|
);
|
||||||
const updatedPayPerUse = cusProduct.customer_entitlements.find(
|
const updatedPayPerUse = customerProduct.customer_entitlements.find(
|
||||||
(ce) => ce.usage_allowed === true,
|
(ce) => ce.usage_allowed === true,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,8 +203,12 @@ describe(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add in random order
|
// Add in random order
|
||||||
const cusProduct = createMockCustomerProduct({
|
const customerProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [payPerUseMonthly, prepaidLifetime, prepaidMonthly],
|
customerEntitlements: [
|
||||||
|
payPerUseMonthly,
|
||||||
|
prepaidLifetime,
|
||||||
|
prepaidMonthly,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply 5 usage (should take 2 from prepaid monthly, 2 from prepaid lifetime, 1 from pay-per-use monthly)
|
// Apply 5 usage (should take 2 from prepaid monthly, 2 from prepaid lifetime, 1 from pay-per-use monthly)
|
||||||
@@ -209,11 +217,11 @@ describe(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||||
|
|
||||||
// Find each cusEnt by their unique characteristics
|
// Find each cusEnt by their unique characteristics
|
||||||
const findCusEnt = (usageAllowed: boolean, interval: EntInterval) =>
|
const findCusEnt = (usageAllowed: boolean, interval: EntInterval) =>
|
||||||
cusProduct.customer_entitlements.find(
|
customerProduct.customer_entitlements.find(
|
||||||
(ce) =>
|
(ce) =>
|
||||||
ce.usage_allowed === usageAllowed &&
|
ce.usage_allowed === usageAllowed &&
|
||||||
ce.entitlement.interval === interval,
|
ce.entitlement.interval === interval,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [entityScopedCusEnt],
|
customerEntitlements: [entityScopedCusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply entity usages: entity1: 50, entity2: 100, entity3: 25
|
// Apply entity usages: entity1: 50, entity2: 100, entity3: 25
|
||||||
@@ -42,7 +42,11 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({
|
||||||
|
customerProduct: cusProduct,
|
||||||
|
existingUsages,
|
||||||
|
entities: [],
|
||||||
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
||||||
@@ -68,7 +72,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [nonEntityScopedCusEnt],
|
customerEntitlements: [nonEntityScopedCusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to apply entity usages to non-entity-scoped cusEnt
|
// Try to apply entity usages to non-entity-scoped cusEnt
|
||||||
@@ -83,7 +87,11 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({
|
||||||
|
customerProduct: cusProduct,
|
||||||
|
existingUsages,
|
||||||
|
entities: [],
|
||||||
|
});
|
||||||
|
|
||||||
// Assert: Balance should remain unchanged since cusEnt is not entity-scoped
|
// Assert: Balance should remain unchanged since cusEnt is not entity-scoped
|
||||||
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
||||||
@@ -111,8 +119,8 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const customerProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [entityScopedCusEnt],
|
customerEntitlements: [entityScopedCusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply top-level usage (no targetEntityId) - should aggregate across entities
|
// Apply top-level usage (no targetEntityId) - should aggregate across entities
|
||||||
@@ -126,10 +134,10 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||||
|
|
||||||
// Assert: Deduction should flow through entities
|
// Assert: Deduction should flow through entities
|
||||||
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
const updatedCusEnt = customerProduct.customer_entitlements[0];
|
||||||
expect(updatedCusEnt.entities).not.toBeNull();
|
expect(updatedCusEnt.entities).not.toBeNull();
|
||||||
|
|
||||||
// The total deducted should be 80, distributed across entities
|
// The total deducted should be 80, distributed across entities
|
||||||
@@ -161,8 +169,8 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const customerProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [entityScopedCusEnt],
|
customerEntitlements: [entityScopedCusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply entity usages that exceed balances
|
// Apply entity usages that exceed balances
|
||||||
@@ -177,10 +185,10 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
|
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||||
|
|
||||||
// Assert: Entity balances should go negative
|
// Assert: Entity balances should go negative
|
||||||
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
const updatedCusEnt = customerProduct.customer_entitlements[0];
|
||||||
expect(updatedCusEnt.entities).not.toBeNull();
|
expect(updatedCusEnt.entities).not.toBeNull();
|
||||||
expect(updatedCusEnt.entities?.entity1.balance).toBe(-20); // 50 - 70 = -20
|
expect(updatedCusEnt.entities?.entity1.balance).toBe(-20); // 50 - 70 = -20
|
||||||
expect(updatedCusEnt.entities?.entity2.balance).toBe(-20); // 30 - 50 = -20
|
expect(updatedCusEnt.entities?.entity2.balance).toBe(-20); // 30 - 50 = -20
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [lifetimeCusEnt, monthlyCusEnt],
|
customerEntitlements: [lifetimeCusEnt, monthlyCusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -74,7 +74,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [topLevelCusEnt, entityScopedCusEnt],
|
customerEntitlements: [topLevelCusEnt, entityScopedCusEnt],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -126,7 +126,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({
|
const cusProduct = createMockCustomerProduct({
|
||||||
cusEntitlements: [cusEntWithRollover],
|
customerEntitlements: [cusEntWithRollover],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import {
|
|
||||||
AllowanceType,
|
|
||||||
type AttachReplaceable,
|
|
||||||
type EntitlementWithFeature,
|
|
||||||
type Feature,
|
|
||||||
type FeatureOptions,
|
|
||||||
FeatureType,
|
|
||||||
type InitFullCusProductContext,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import { initCusEntitlementBalance } from "@/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance";
|
|
||||||
|
|
||||||
const createMockEntitlement = ({
|
|
||||||
feature,
|
|
||||||
featureType,
|
|
||||||
allowanceType,
|
|
||||||
}: {
|
|
||||||
feature: Feature;
|
|
||||||
featureType: FeatureType;
|
|
||||||
allowanceType: AllowanceType;
|
|
||||||
}): EntitlementWithFeature => ({
|
|
||||||
id: "ent_test",
|
|
||||||
created_at: Date.now(),
|
|
||||||
internal_feature_id: "feat_internal",
|
|
||||||
internal_product_id: "prod_internal",
|
|
||||||
is_custom: false,
|
|
||||||
allowance_type: allowanceType,
|
|
||||||
allowance: 100,
|
|
||||||
interval: null,
|
|
||||||
interval_count: 1,
|
|
||||||
carry_from_previous: false,
|
|
||||||
entity_feature_id: null,
|
|
||||||
feature_id: "feat_test",
|
|
||||||
usage_limit: null,
|
|
||||||
rollover: null,
|
|
||||||
feature,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMockInsertContext = (): InitFullCusProductContext => ({
|
|
||||||
fullCus: {} as InitFullCusProductContext["fullCus"],
|
|
||||||
product: {} as InitFullCusProductContext["product"],
|
|
||||||
featureQuantities: [] as FeatureOptions[],
|
|
||||||
replaceables: [] as AttachReplaceable[],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(chalk.yellowBright("initCusEntitlementBalance"), () => {
|
|
||||||
test("returns { newBalance: 0, newEntities: null } for boolean entitlements", () => {
|
|
||||||
const booleanFeature = ctx.features.find(
|
|
||||||
(f) => f.type === FeatureType.Boolean,
|
|
||||||
)!;
|
|
||||||
|
|
||||||
const entitlement = createMockEntitlement({
|
|
||||||
feature: booleanFeature,
|
|
||||||
featureType: FeatureType.Boolean,
|
|
||||||
allowanceType: AllowanceType.Fixed,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = initCusEntitlementBalance({
|
|
||||||
insertContext: createMockInsertContext(),
|
|
||||||
entitlement,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ balance: 0, entities: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { FullCusProduct } from "@autumn/shared";
|
||||||
|
import {
|
||||||
|
createMockFullCustomer,
|
||||||
|
createMockStripeCustomer,
|
||||||
|
} from "@tests/utils/mockUtils/customerMocks";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
|
||||||
|
export const createMockBillingContext = ({
|
||||||
|
customerProducts = [],
|
||||||
|
stripeSubscription,
|
||||||
|
}: {
|
||||||
|
customerProducts?: FullCusProduct[];
|
||||||
|
stripeSubscription?: Stripe.Subscription;
|
||||||
|
}): BillingContext => ({
|
||||||
|
fullCustomer: createMockFullCustomer({ customerProducts }),
|
||||||
|
stripeCustomer: createMockStripeCustomer(),
|
||||||
|
stripeSubscription,
|
||||||
|
});
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
|
||||||
|
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||||
|
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||||
|
import { createMockFeature } from "@tests/utils/mockUtils/featureMocks";
|
||||||
|
import {
|
||||||
|
createMockAllocatedPrice,
|
||||||
|
createMockConsumablePrice,
|
||||||
|
createMockCustomerPrice,
|
||||||
|
createMockFixedPrice,
|
||||||
|
createMockOneOffPrice,
|
||||||
|
createMockPrepaidPrice,
|
||||||
|
} from "@tests/utils/mockUtils/priceMocks";
|
||||||
|
import { createMockFullProduct } from "@tests/utils/mockUtils/productMocks";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { buildStripeSubscriptionItemsUpdate } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/buildStripeSubscriptionItemsUpdate";
|
||||||
|
import { createMockBillingContext } from "./billingContextMocks";
|
||||||
|
import { createMockStripeSubscription } from "./stripeSubscriptionMocks";
|
||||||
|
|
||||||
|
// ============ TESTS ============
|
||||||
|
|
||||||
|
describe(chalk.yellowBright("buildStripeSubscriptionItemsUpdate"), () => {
|
||||||
|
describe("no existing subscription", () => {
|
||||||
|
test("1. new customer product with fixed price", () => {
|
||||||
|
const fixedPrice = createMockFixedPrice({
|
||||||
|
id: "price_fixed",
|
||||||
|
stripePriceId: "stripe_price_fixed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_pro",
|
||||||
|
prices: [fixedPrice],
|
||||||
|
stripeProductId: "stripe_prod_pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_pro",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: fixedPrice })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [customerProduct],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
price: "stripe_price_fixed",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2. new customer product with prepaid price", () => {
|
||||||
|
const feature = createMockFeature({
|
||||||
|
id: "credits",
|
||||||
|
name: "Credits",
|
||||||
|
});
|
||||||
|
|
||||||
|
const prepaidPrice = createMockPrepaidPrice({
|
||||||
|
id: "price_prepaid",
|
||||||
|
featureId: "credits",
|
||||||
|
stripePriceId: "stripe_price_prepaid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_pro",
|
||||||
|
prices: [prepaidPrice],
|
||||||
|
stripeProductId: "stripe_prod_pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt = createMockCusEntitlement({
|
||||||
|
featureId: "credits",
|
||||||
|
featureName: "Credits",
|
||||||
|
allowance: 100,
|
||||||
|
balance: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_pro",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: prepaidPrice })],
|
||||||
|
customerEntitlements: [cusEnt],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: "credits",
|
||||||
|
internal_feature_id: "internal_credits",
|
||||||
|
quantity: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [feature] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [customerProduct],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
price: "stripe_price_prepaid",
|
||||||
|
quantity: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3. consumable price with entity uses stripe_empty_price_id", () => {
|
||||||
|
const feature = createMockFeature({
|
||||||
|
id: "api_calls",
|
||||||
|
name: "API Calls",
|
||||||
|
});
|
||||||
|
|
||||||
|
const consumablePrice = createMockConsumablePrice({
|
||||||
|
id: "price_usage",
|
||||||
|
featureId: "api_calls",
|
||||||
|
stripePriceId: "stripe_price_usage",
|
||||||
|
stripeEmptyPriceId: "stripe_empty_price_usage",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_usage",
|
||||||
|
prices: [consumablePrice],
|
||||||
|
stripeProductId: "stripe_prod_usage",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt = createMockCusEntitlement({
|
||||||
|
featureId: "api_calls",
|
||||||
|
featureName: "API Calls",
|
||||||
|
allowance: 1000,
|
||||||
|
balance: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_usage",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: consumablePrice })],
|
||||||
|
customerEntitlements: [cusEnt],
|
||||||
|
internalEntityId: "entity_123", // Has entity
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [feature] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [customerProduct],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
price: "stripe_empty_price_usage",
|
||||||
|
quantity: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("existing subscription", () => {
|
||||||
|
test("4. remove one customer product, add another", () => {
|
||||||
|
const oldPrice = createMockFixedPrice({
|
||||||
|
id: "price_old",
|
||||||
|
stripePriceId: "stripe_price_old",
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPrice = createMockFixedPrice({
|
||||||
|
id: "price_new",
|
||||||
|
stripePriceId: "stripe_price_new",
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldProduct = createMockFullProduct({
|
||||||
|
id: "prod_old",
|
||||||
|
prices: [oldPrice],
|
||||||
|
stripeProductId: "stripe_prod_old",
|
||||||
|
});
|
||||||
|
|
||||||
|
const newProduct = createMockFullProduct({
|
||||||
|
id: "prod_new",
|
||||||
|
prices: [newPrice],
|
||||||
|
stripeProductId: "stripe_prod_new",
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldCustomerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_old",
|
||||||
|
productId: "prod_old",
|
||||||
|
product: oldProduct,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
||||||
|
subscriptionIds: ["sub_123"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCustomerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_new",
|
||||||
|
productId: "prod_new",
|
||||||
|
product: newProduct,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: newPrice })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeSubscription = createMockStripeSubscription({
|
||||||
|
id: "sub_123",
|
||||||
|
items: [{ id: "si_old", priceId: "stripe_price_old", quantity: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [oldCustomerProduct],
|
||||||
|
stripeSubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [newCustomerProduct],
|
||||||
|
removeCustomerProducts: [oldCustomerProduct],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
|
||||||
|
// Should delete old item
|
||||||
|
const deletedItem = result.find((item) => item.deleted === true);
|
||||||
|
expect(deletedItem).toBeDefined();
|
||||||
|
expect(deletedItem?.id).toBe("si_old");
|
||||||
|
|
||||||
|
// Should add new item
|
||||||
|
const newItem = result.find((item) => item.price === "stripe_price_new");
|
||||||
|
expect(newItem).toBeDefined();
|
||||||
|
expect(newItem?.quantity).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5. update quantity of existing subscription item", () => {
|
||||||
|
const feature = createMockFeature({
|
||||||
|
id: "seats",
|
||||||
|
name: "Seats",
|
||||||
|
});
|
||||||
|
|
||||||
|
const prepaidPrice = createMockPrepaidPrice({
|
||||||
|
id: "price_seats",
|
||||||
|
featureId: "seats",
|
||||||
|
stripePriceId: "stripe_price_seats",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_team",
|
||||||
|
prices: [prepaidPrice],
|
||||||
|
stripeProductId: "stripe_prod_team",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt = createMockCusEntitlement({
|
||||||
|
featureId: "seats",
|
||||||
|
featureName: "Seats",
|
||||||
|
allowance: 10,
|
||||||
|
balance: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current: 5 seats, updating to 10 seats
|
||||||
|
const customerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_team",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: prepaidPrice })],
|
||||||
|
customerEntitlements: [cusEnt],
|
||||||
|
subscriptionIds: ["sub_123"],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: "seats",
|
||||||
|
internal_feature_id: "internal_seats",
|
||||||
|
quantity: 10, // Updated quantity
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeSubscription = createMockStripeSubscription({
|
||||||
|
id: "sub_123",
|
||||||
|
items: [
|
||||||
|
{ id: "si_seats", priceId: "stripe_price_seats", quantity: 5 }, // Old quantity
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [feature] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [customerProduct],
|
||||||
|
stripeSubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
id: "si_seats",
|
||||||
|
quantity: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
test("6. no changes needed returns empty array", () => {
|
||||||
|
const fixedPrice = createMockFixedPrice({
|
||||||
|
id: "price_fixed",
|
||||||
|
stripePriceId: "stripe_price_fixed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_pro",
|
||||||
|
prices: [fixedPrice],
|
||||||
|
stripeProductId: "stripe_prod_pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_pro",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: fixedPrice })],
|
||||||
|
subscriptionIds: ["sub_123"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeSubscription = createMockStripeSubscription({
|
||||||
|
id: "sub_123",
|
||||||
|
items: [{ id: "si_fixed", priceId: "stripe_price_fixed", quantity: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [customerProduct],
|
||||||
|
stripeSubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7. multiple products with same price ID aggregates quantities", () => {
|
||||||
|
const feature = createMockFeature({
|
||||||
|
id: "seats",
|
||||||
|
name: "Seats",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sharedPrice = createMockPrepaidPrice({
|
||||||
|
id: "price_seats",
|
||||||
|
featureId: "seats",
|
||||||
|
stripePriceId: "stripe_price_seats",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product1 = createMockFullProduct({
|
||||||
|
id: "prod_team_1",
|
||||||
|
prices: [sharedPrice],
|
||||||
|
stripeProductId: "stripe_prod_team_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product2 = createMockFullProduct({
|
||||||
|
id: "prod_team_2",
|
||||||
|
prices: [sharedPrice],
|
||||||
|
stripeProductId: "stripe_prod_team_2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt1 = createMockCusEntitlement({
|
||||||
|
id: "cus_ent_1",
|
||||||
|
featureId: "seats",
|
||||||
|
featureName: "Seats",
|
||||||
|
allowance: 5,
|
||||||
|
balance: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt2 = createMockCusEntitlement({
|
||||||
|
id: "cus_ent_2",
|
||||||
|
featureId: "seats",
|
||||||
|
featureName: "Seats",
|
||||||
|
allowance: 3,
|
||||||
|
balance: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct1 = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_team_1",
|
||||||
|
product: product1,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: sharedPrice })],
|
||||||
|
customerEntitlements: [cusEnt1],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: "seats",
|
||||||
|
internal_feature_id: "internal_seats",
|
||||||
|
quantity: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct2 = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_2",
|
||||||
|
productId: "prod_team_2",
|
||||||
|
product: product2,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: sharedPrice })],
|
||||||
|
customerEntitlements: [cusEnt2],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
feature_id: "seats",
|
||||||
|
internal_feature_id: "internal_seats",
|
||||||
|
quantity: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [feature] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [customerProduct1, customerProduct2],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
price: "stripe_price_seats",
|
||||||
|
quantity: 8, // 5 + 3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("8. empty customer products marks all current items as deleted", () => {
|
||||||
|
const stripeSubscription = createMockStripeSubscription({
|
||||||
|
id: "sub_123",
|
||||||
|
items: [
|
||||||
|
{ id: "si_1", priceId: "stripe_price_1", quantity: 1 },
|
||||||
|
{ id: "si_2", priceId: "stripe_price_2", quantity: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result).toContainEqual({ id: "si_1", deleted: true });
|
||||||
|
expect(result).toContainEqual({ id: "si_2", deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("9. no stripe subscription and no products returns empty array", () => {
|
||||||
|
const ctx = createMockCtx({ features: [] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("10. allocated price uses stripe_empty_price_id with quantity 0", () => {
|
||||||
|
const feature = createMockFeature({
|
||||||
|
id: "storage",
|
||||||
|
name: "Storage",
|
||||||
|
});
|
||||||
|
|
||||||
|
const allocatedPrice = createMockAllocatedPrice({
|
||||||
|
id: "price_storage",
|
||||||
|
featureId: "storage",
|
||||||
|
stripePriceId: "stripe_price_storage",
|
||||||
|
stripeEmptyPriceId: "stripe_empty_price_storage",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_storage",
|
||||||
|
prices: [allocatedPrice],
|
||||||
|
stripeProductId: "stripe_prod_storage",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt = createMockCusEntitlement({
|
||||||
|
featureId: "storage",
|
||||||
|
featureName: "Storage",
|
||||||
|
allowance: 100,
|
||||||
|
balance: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_storage",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: allocatedPrice })],
|
||||||
|
customerEntitlements: [cusEnt],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [feature] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [customerProduct],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
price: "stripe_empty_price_storage",
|
||||||
|
quantity: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("11. one-off prices are NOT included in subscription items", () => {
|
||||||
|
const fixedPrice = createMockFixedPrice({
|
||||||
|
id: "price_fixed",
|
||||||
|
stripePriceId: "stripe_price_fixed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const oneOffPrice = createMockOneOffPrice({
|
||||||
|
id: "price_oneoff",
|
||||||
|
stripePriceId: "stripe_price_oneoff",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_pro",
|
||||||
|
prices: [fixedPrice, oneOffPrice],
|
||||||
|
stripeProductId: "stripe_prod_pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerProduct = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_pro",
|
||||||
|
product,
|
||||||
|
customerPrices: [
|
||||||
|
createMockCustomerPrice({ price: fixedPrice }),
|
||||||
|
createMockCustomerPrice({ price: oneOffPrice }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [],
|
||||||
|
stripeSubscription: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [customerProduct],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should only include the fixed price, not the one-off
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
price: "stripe_price_fixed",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("12. adding second entity customer product with consumable price keeps quantity at 0", () => {
|
||||||
|
const feature = createMockFeature({
|
||||||
|
id: "api_calls",
|
||||||
|
name: "API Calls",
|
||||||
|
});
|
||||||
|
|
||||||
|
const consumablePrice = createMockConsumablePrice({
|
||||||
|
id: "price_usage",
|
||||||
|
featureId: "api_calls",
|
||||||
|
stripePriceId: "stripe_price_usage",
|
||||||
|
stripeEmptyPriceId: "stripe_empty_price_usage",
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = createMockFullProduct({
|
||||||
|
id: "prod_usage",
|
||||||
|
prices: [consumablePrice],
|
||||||
|
stripeProductId: "stripe_prod_usage",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt1 = createMockCusEntitlement({
|
||||||
|
id: "cus_ent_1",
|
||||||
|
featureId: "api_calls",
|
||||||
|
featureName: "API Calls",
|
||||||
|
allowance: 1000,
|
||||||
|
balance: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnt2 = createMockCusEntitlement({
|
||||||
|
id: "cus_ent_2",
|
||||||
|
featureId: "api_calls",
|
||||||
|
featureName: "API Calls",
|
||||||
|
allowance: 1000,
|
||||||
|
balance: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First entity customer product (already exists on subscription)
|
||||||
|
const customerProduct1 = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_1",
|
||||||
|
productId: "prod_usage",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: consumablePrice })],
|
||||||
|
customerEntitlements: [cusEnt1],
|
||||||
|
internalEntityId: "entity_1",
|
||||||
|
subscriptionIds: ["sub_123"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second entity customer product (being added)
|
||||||
|
const customerProduct2 = createMockCustomerProduct({
|
||||||
|
id: "cus_prod_2",
|
||||||
|
productId: "prod_usage",
|
||||||
|
product,
|
||||||
|
customerPrices: [createMockCustomerPrice({ price: consumablePrice })],
|
||||||
|
customerEntitlements: [cusEnt2],
|
||||||
|
internalEntityId: "entity_2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeSubscription = createMockStripeSubscription({
|
||||||
|
id: "sub_123",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "si_usage",
|
||||||
|
priceId: "stripe_empty_price_usage",
|
||||||
|
quantity: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createMockCtx({ features: [feature] });
|
||||||
|
const billingContext = createMockBillingContext({
|
||||||
|
customerProducts: [customerProduct1],
|
||||||
|
stripeSubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildStripeSubscriptionItemsUpdate({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
addCustomerProducts: [customerProduct2],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quantity should still be 0 (consumable prices aggregate to 0)
|
||||||
|
// Either no update needed (empty array) or quantity stays at 0
|
||||||
|
const usageItem = result.find(
|
||||||
|
(item) =>
|
||||||
|
item.price === "stripe_empty_price_usage" || item.id === "si_usage",
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there's an update, quantity should be 0
|
||||||
|
if (usageItem && "quantity" in usageItem) {
|
||||||
|
expect(usageItem.quantity).toBe(0);
|
||||||
|
}
|
||||||
|
// Otherwise no update needed is also valid (means quantity unchanged at 0)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
export const createMockStripeSubscriptionItem = ({
|
||||||
|
id,
|
||||||
|
priceId,
|
||||||
|
quantity,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
priceId: string;
|
||||||
|
quantity: number;
|
||||||
|
}): Stripe.SubscriptionItem =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
object: "subscription_item",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
quantity,
|
||||||
|
price: {
|
||||||
|
id: priceId,
|
||||||
|
object: "price",
|
||||||
|
active: true,
|
||||||
|
currency: "usd",
|
||||||
|
type: "recurring",
|
||||||
|
} as Stripe.Price,
|
||||||
|
}) as Stripe.SubscriptionItem;
|
||||||
|
|
||||||
|
export const createMockStripeSubscription = ({
|
||||||
|
id,
|
||||||
|
items = [],
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
items?: { id: string; priceId: string; quantity: number }[];
|
||||||
|
}): Stripe.Subscription => {
|
||||||
|
const subscriptionItems = items.map((item) =>
|
||||||
|
createMockStripeSubscriptionItem({
|
||||||
|
id: item.id,
|
||||||
|
priceId: item.priceId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
object: "subscription",
|
||||||
|
status: "active",
|
||||||
|
items: {
|
||||||
|
object: "list",
|
||||||
|
data: subscriptionItems,
|
||||||
|
has_more: false,
|
||||||
|
url: `/v1/subscription_items?subscription=${id}`,
|
||||||
|
},
|
||||||
|
} as Stripe.Subscription;
|
||||||
|
};
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
type Feature,
|
type Feature,
|
||||||
FeatureType,
|
FeatureType,
|
||||||
FeatureUsageType,
|
FeatureUsageType,
|
||||||
FreeTrialDuration,
|
|
||||||
type Organization,
|
type Organization,
|
||||||
PriceType,
|
PriceType,
|
||||||
type ProductItem,
|
type ProductItem,
|
||||||
@@ -223,23 +222,6 @@ export const initPrice = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initFreeTrial = ({
|
|
||||||
length,
|
|
||||||
uniqueFingerprint = false,
|
|
||||||
cardRequired = true,
|
|
||||||
}: {
|
|
||||||
length: number;
|
|
||||||
uniqueFingerprint?: boolean;
|
|
||||||
cardRequired?: boolean;
|
|
||||||
}): CreateFreeTrial => {
|
|
||||||
return {
|
|
||||||
length,
|
|
||||||
unique_fingerprint: uniqueFingerprint,
|
|
||||||
duration: FreeTrialDuration.Day,
|
|
||||||
card_required: cardRequired,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initProduct = ({
|
export const initProduct = ({
|
||||||
id,
|
id,
|
||||||
isDefault = false,
|
isDefault = false,
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import type { Feature } from "@autumn/shared";
|
import {
|
||||||
|
ApiVersion,
|
||||||
|
ApiVersionClass,
|
||||||
|
AppEnv,
|
||||||
|
type Feature,
|
||||||
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
|
||||||
|
export const createMockOrg = () => ({
|
||||||
|
id: "org_test",
|
||||||
|
name: "Test Organization",
|
||||||
|
slug: "test-org",
|
||||||
|
default_currency: "usd",
|
||||||
|
stripe_account_id: "acct_test",
|
||||||
|
});
|
||||||
|
|
||||||
export const createMockCtx = ({
|
export const createMockCtx = ({
|
||||||
features,
|
features = [],
|
||||||
|
org,
|
||||||
}: {
|
}: {
|
||||||
features: Feature[];
|
features?: Feature[];
|
||||||
|
org?: ReturnType<typeof createMockOrg>;
|
||||||
}): AutumnContext =>
|
}): AutumnContext =>
|
||||||
({
|
({
|
||||||
features,
|
features,
|
||||||
}) as AutumnContext;
|
org: org ?? createMockOrg(),
|
||||||
|
apiVersion: new ApiVersionClass(ApiVersion.V1_2),
|
||||||
|
env: AppEnv.Sandbox,
|
||||||
|
}) as unknown as AutumnContext;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createMockEntitlement } from "./entitlementMocks";
|
|||||||
|
|
||||||
export const createMockCusEntitlement = ({
|
export const createMockCusEntitlement = ({
|
||||||
id,
|
id,
|
||||||
|
entitlementId,
|
||||||
featureId,
|
featureId,
|
||||||
internalFeatureId,
|
internalFeatureId,
|
||||||
featureName,
|
featureName,
|
||||||
@@ -22,6 +23,7 @@ export const createMockCusEntitlement = ({
|
|||||||
entityFeatureId = null,
|
entityFeatureId = null,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
entitlementId?: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
internalFeatureId?: string;
|
internalFeatureId?: string;
|
||||||
featureName: string;
|
featureName: string;
|
||||||
@@ -34,32 +36,36 @@ export const createMockCusEntitlement = ({
|
|||||||
nextResetAt?: number | null;
|
nextResetAt?: number | null;
|
||||||
entities?: Record<string, EntityBalance> | null;
|
entities?: Record<string, EntityBalance> | null;
|
||||||
entityFeatureId?: string | null;
|
entityFeatureId?: string | null;
|
||||||
}): FullCustomerEntitlement => ({
|
}): FullCustomerEntitlement => {
|
||||||
id: id ?? `cus_ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`,
|
const entId = entitlementId ?? `ent_${featureId}`;
|
||||||
internal_customer_id: "cus_internal",
|
return {
|
||||||
internal_feature_id: internalFeatureId ?? `internal_${featureId}`,
|
id: id ?? `cus_ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`,
|
||||||
customer_id: "cus_test",
|
internal_customer_id: "cus_internal",
|
||||||
feature_id: featureId,
|
internal_feature_id: internalFeatureId ?? `internal_${featureId}`,
|
||||||
customer_product_id: "cus_prod_test",
|
customer_id: "cus_test",
|
||||||
entitlement_id: `ent_${featureId}`,
|
feature_id: featureId,
|
||||||
created_at: Date.now(),
|
customer_product_id: "cus_prod_test",
|
||||||
unlimited: false,
|
entitlement_id: entId,
|
||||||
balance,
|
created_at: Date.now(),
|
||||||
additional_balance: 0,
|
unlimited: false,
|
||||||
usage_allowed: usageAllowed,
|
balance,
|
||||||
next_reset_at: nextResetAt,
|
additional_balance: 0,
|
||||||
adjustment: 0,
|
usage_allowed: usageAllowed,
|
||||||
entities,
|
next_reset_at: nextResetAt,
|
||||||
entitlement: createMockEntitlement({
|
adjustment: 0,
|
||||||
featureId,
|
entities,
|
||||||
internalFeatureId,
|
entitlement: createMockEntitlement({
|
||||||
featureName,
|
id: entId,
|
||||||
allowance,
|
featureId,
|
||||||
featureType,
|
internalFeatureId,
|
||||||
interval,
|
featureName,
|
||||||
intervalCount,
|
allowance,
|
||||||
entityFeatureId,
|
featureType,
|
||||||
}),
|
interval,
|
||||||
replaceables: [],
|
intervalCount,
|
||||||
rollovers: [],
|
entityFeatureId,
|
||||||
});
|
}),
|
||||||
|
replaceables: [],
|
||||||
|
rollovers: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,24 +5,35 @@ import {
|
|||||||
type FullCusProduct,
|
type FullCusProduct,
|
||||||
type FullCustomerEntitlement,
|
type FullCustomerEntitlement,
|
||||||
type FullCustomerPrice,
|
type FullCustomerPrice,
|
||||||
|
type FullProduct,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { createMockProduct } from "./productMocks";
|
import { createMockFullProduct } from "./productMocks";
|
||||||
|
|
||||||
export const createMockCustomerProduct = ({
|
export const createMockCustomerProduct = ({
|
||||||
|
id = "cus_prod_test",
|
||||||
|
productId = "prod_test",
|
||||||
customerEntitlements = [],
|
customerEntitlements = [],
|
||||||
customerPrices = [],
|
customerPrices = [],
|
||||||
options = [],
|
options = [],
|
||||||
|
subscriptionIds = [],
|
||||||
|
internalEntityId,
|
||||||
|
product,
|
||||||
}: {
|
}: {
|
||||||
|
id?: string;
|
||||||
|
productId?: string;
|
||||||
customerEntitlements?: FullCustomerEntitlement[];
|
customerEntitlements?: FullCustomerEntitlement[];
|
||||||
customerPrices?: FullCustomerPrice[];
|
customerPrices?: FullCustomerPrice[];
|
||||||
options?: FeatureOptions[];
|
options?: FeatureOptions[];
|
||||||
|
subscriptionIds?: string[];
|
||||||
|
internalEntityId?: string;
|
||||||
|
product?: FullProduct;
|
||||||
}): FullCusProduct => ({
|
}): FullCusProduct => ({
|
||||||
id: "cus_prod_test",
|
id,
|
||||||
internal_product_id: "prod_internal",
|
internal_product_id: `internal_${productId}`,
|
||||||
product_id: "prod_test",
|
product_id: productId,
|
||||||
internal_customer_id: "cus_internal",
|
internal_customer_id: "cus_internal",
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
internal_entity_id: null,
|
internal_entity_id: internalEntityId ?? null,
|
||||||
entity_id: null,
|
entity_id: null,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
status: CusProductStatus.Active,
|
status: CusProductStatus.Active,
|
||||||
@@ -34,13 +45,13 @@ export const createMockCustomerProduct = ({
|
|||||||
options,
|
options,
|
||||||
free_trial_id: null,
|
free_trial_id: null,
|
||||||
collection_method: CollectionMethod.ChargeAutomatically,
|
collection_method: CollectionMethod.ChargeAutomatically,
|
||||||
subscription_ids: [],
|
subscription_ids: subscriptionIds,
|
||||||
scheduled_ids: [],
|
scheduled_ids: [],
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
api_semver: null,
|
api_semver: null,
|
||||||
is_custom: false,
|
is_custom: false,
|
||||||
customer_prices: customerPrices,
|
customer_prices: customerPrices,
|
||||||
customer_entitlements: customerEntitlements,
|
customer_entitlements: customerEntitlements,
|
||||||
product: createMockProduct(),
|
product: product ?? (createMockFullProduct({ id: productId }) as FullProduct),
|
||||||
free_trial: null,
|
free_trial: null,
|
||||||
});
|
});
|
||||||
|
|||||||
35
server/tests/utils/mockUtils/customerMocks.ts
Normal file
35
server/tests/utils/mockUtils/customerMocks.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { FullCusProduct, FullCustomer } from "@autumn/shared";
|
||||||
|
import { AppEnv } from "@autumn/shared";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
export const createMockFullCustomer = ({
|
||||||
|
customerProducts = [],
|
||||||
|
}: {
|
||||||
|
customerProducts?: FullCusProduct[];
|
||||||
|
}): FullCustomer => ({
|
||||||
|
id: "cus_test",
|
||||||
|
name: "Test Customer",
|
||||||
|
email: "test@example.com",
|
||||||
|
fingerprint: null,
|
||||||
|
internal_id: "cus_internal_test",
|
||||||
|
org_id: "org_test",
|
||||||
|
created_at: Date.now(),
|
||||||
|
env: AppEnv.Sandbox,
|
||||||
|
processor: { type: "stripe", id: "cus_stripe_test" },
|
||||||
|
processors: null,
|
||||||
|
metadata: {},
|
||||||
|
customer_products: customerProducts,
|
||||||
|
entities: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createMockStripeCustomer = ({
|
||||||
|
id = "cus_stripe_test",
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
} = {}): Stripe.Customer =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
object: "customer",
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test Customer",
|
||||||
|
}) as Stripe.Customer;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user