wip on update subscription custom plan

This commit is contained in:
John Yeo
2025-12-22 18:50:24 +00:00
parent 0eacdb33ad
commit 8a6b78c868
136 changed files with 3180 additions and 1712 deletions

View File

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

View File

@@ -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' \

View File

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

View File

@@ -222,7 +222,7 @@ export const getStripeSchedules = async ({
}, },
); );
if (schedule.status == "canceled") { if (schedule.status === "canceled") {
return null; return null;
} }

View File

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

View File

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

View 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
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ export const getUncancelAttachActions = ({
: undefined; : undefined;
return { return {
ongoingCusProductAction, ongoingCustomerProduct: ongoingCusProductAction?.cusProduct,
scheduledCusProductAction, scheduledCusProductAction,
newProductActions: [], newProductActions: [],
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = (

View File

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

View File

@@ -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 = ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
},
});
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }),
// },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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