wip, built cycle utils

This commit is contained in:
John Yeo
2025-12-13 12:18:06 +00:00
parent 5888dc4994
commit 3d604a9043
102 changed files with 4854 additions and 960 deletions

View File

@@ -23,6 +23,15 @@
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
## Import Conventions
- **DO NOT use `.js` extensions** in import paths. Example:
-`import { foo } from "@autumn/shared"`
-`import { foo } from "@autumn/shared/index.js"`
- **Use aliased paths** where possible (e.g., `@autumn/shared`, `@api/`, `@models/`, `@/`)
- **Import from the full path**, not barrel files (index.ts). Import directly from the source file:
-`import { CusProduct } from "@models/cusProductModels/cusProductModels"`
-`import { CusProduct } from "@models/cusProductModels"` (via index.ts re-export)
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`

View File

@@ -152,6 +152,7 @@
"name": "@autumn/shared",
"version": "1.0.0",
"dependencies": {
"@date-fns/utc": "catalog:",
"date-fns": "^4.1.0",
"decimal.js": "^10.5.0",
"dotenv": "^16.5.0",
@@ -281,6 +282,7 @@
},
},
"catalog": {
"@date-fns/utc": "2.1.0",
"@sentry/bun": "10.25.0",
"drizzle-kit": "^0.31.1",
"drizzle-orm": "0.43.1",
@@ -3187,7 +3189,7 @@
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/shared/@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
"@autumn/vite/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
@@ -3961,8 +3963,6 @@
"@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.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"@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=="],

View File

@@ -12,7 +12,8 @@
"stripe": "19.3.0-beta.1",
"drizzle-orm": "0.43.1",
"drizzle-kit": "^0.31.1",
"@sentry/bun": "10.25.0"
"@sentry/bun": "10.25.0",
"@date-fns/utc": "2.1.0"
}
},
"type": "module",

View File

@@ -1,414 +0,0 @@
// import { config } from "dotenv";
// config();
// import assert from "node:assert";
// import {
// AppEnv,
// CusProductStatus,
// cusProductToPrices,
// type Entity,
// type FullCusProduct,
// type FullCustomer,
// type Organization,
// } from "@autumn/shared";
// import type Stripe from "stripe";
// import { initDrizzle } from "@/db/initDrizzle.js";
// import { createStripeCli } from "@/external/connect/createStripeCli.js";
// import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
// import { createSupabaseClient } from "@/external/supabaseUtils.js";
// import { CusService } from "@/internal/customers/CusService.js";
// import { OrgService } from "@/internal/orgs/OrgService.js";
// import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
// import { notNullish } from "@/utils/genUtils.js";
// import {
// getAllEntities,
// getAllFullCustomers,
// } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js";
// import {
// getAllStripeSchedules,
// getAllStripeSubscriptions,
// } from "@/utils/scriptUtils/getAll/getAllStripeSubs.js";
// import { EntityService } from "./internal/api/entities/EntityService.js";
// import { getRelatedCusPrice } from "./internal/customers/cusProducts/cusEnts/cusEntUtils.js";
// import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
// const { db } = initDrizzle({ maxConnections: 5 });
// let orgSlugs = process.env.ORG_SLUGS!.split(",");
// const skipEmails = process.env.SKIP_EMAILS!.split(",");
// const skipIds = [
// "cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx",
// "DxG668K7uDd0Vahk54YWjvCGVgf2",
// ];
// orgSlugs = ["athenahq"];
// // let customerId = null;
// const customerId = null;
// const getSingleCustomer = async ({
// stripeCli,
// customerId,
// orgId,
// env,
// }: {
// stripeCli: Stripe;
// customerId: string;
// orgId: string;
// env: AppEnv;
// }) => {
// const customers = [
// await CusService.getFull({
// db,
// idOrInternalId: customerId,
// orgId,
// env,
// }),
// ];
// const stripeCusId = customers[0].processor?.id;
// const stripeSubs = stripeCusId
// ? (
// await stripeCli.subscriptions.list({
// customer: stripeCusId,
// expand: ["data.discounts.coupon"],
// })
// ).data
// : [];
// // const stripeSubs = await getStripeSubs({
// // stripeCli,
// // subIds: customers[0].customer_products.flatMap(
// // (cp) => cp.subscription_ids || []
// // ),
// // });
// let scheduleIds = customers[0].customer_products.flatMap(
// (cp) => cp.scheduled_ids || [],
// );
// scheduleIds = Array.from(new Set(scheduleIds));
// const stripeSchedules = await getStripeSchedules({
// stripeCli,
// scheduleIds,
// });
// const entities = await EntityService.list({
// db,
// internalCustomerId: customers[0].internal_id,
// });
// return { customers, stripeSubs, stripeSchedules, entities };
// };
// const checkCustomerCorrect = async ({
// fullCus,
// subs,
// schedules,
// org,
// entities,
// }: {
// fullCus: FullCustomer;
// subs: Stripe.Subscription[];
// schedules: Stripe.SubscriptionSchedule[];
// org: Organization;
// entities: Entity[];
// }) => {
// if (skipIds.includes(fullCus.internal_id!)) return;
// if (skipEmails.some((skipEmail) => skipEmail === fullCus.email)) {
// return;
// }
// fullCus.entities = entities.filter(
// (entity) => entity.internal_customer_id === fullCus.internal_id,
// );
// // console.log(`Checking ${fullCus.email} (${fullCus.id})`);
// const cusProducts = fullCus.customer_products;
// await checkCusSubCorrect({
// db,
// fullCus,
// subs,
// schedules,
// org,
// env: AppEnv.Live,
// });
// for (const cusProduct of cusProducts) {
// if (!cusProduct.subscription_ids) continue;
// if (cusProduct.status === CusProductStatus.Scheduled) {
// // Check if there's a main product elsewhere
// const mainCusProd = cusProducts.find(
// (cp: FullCusProduct) =>
// cp.product.group === cusProduct.product.group &&
// cp.id !== cusProduct.id &&
// cp.status !== CusProductStatus.Scheduled &&
// (cusProduct.internal_entity_id
// ? cusProduct.internal_entity_id === cp.internal_entity_id
// : true),
// );
// assert(
// mainCusProd,
// `Found scheduled cus product with no main product (${cusProduct.product.name})`,
// );
// }
// if (
// !cusProduct.product.is_add_on &&
// cusProduct.status !== CusProductStatus.Scheduled
// ) {
// const group = cusProduct.product.group;
// const otherCusProd = cusProducts.find(
// (cp: FullCusProduct) =>
// cp.product.group === group &&
// cp.id !== cusProduct.id &&
// !cp.product.is_add_on &&
// cp.status !== CusProductStatus.Scheduled &&
// cp.internal_entity_id === cusProduct.internal_entity_id,
// );
// assert(
// !otherCusProd,
// `found two cus products from the same group: ${otherCusProd?.product.name} and ${cusProduct.product.name}`,
// );
// }
// const stripeSubs = subs.filter((sub: any) =>
// cusProduct.subscription_ids!.some((id: string) => id === sub.id),
// );
// assert(
// stripeSubs.length === cusProduct.subscription_ids!.length,
// "number of stripe subs should be the same as number of subscription ids",
// );
// // let subItems = stripeSubs.flatMap((sub: any) => sub.items.data);
// const prices = cusProductToPrices({ cusProduct });
// if (
// isOneOff(prices) ||
// isFreeProduct(prices) ||
// cusProduct.status === CusProductStatus.Scheduled
// ) {
// continue;
// }
// for (const cusEnt of cusProduct.customer_entitlements) {
// const cusPrice = getRelatedCusPrice(cusEnt, cusProduct.customer_prices);
// if (cusEnt.usage_allowed && !cusPrice) {
// assert.fail(
// `Feature ${cusEnt.feature_id} has usage allowed but no related cus price`,
// );
// }
// }
// }
// // Other checks to perform
// };
// const checkCustomerHandleError = async ({
// fullCus,
// subs,
// org,
// schedules,
// entities,
// }: {
// fullCus: FullCustomer;
// subs: Stripe.Subscription[];
// org: Organization;
// schedules: Stripe.SubscriptionSchedule[];
// entities: Entity[];
// }) => {
// try {
// await checkCustomerCorrect({
// fullCus,
// subs,
// org,
// schedules,
// entities,
// });
// return undefined;
// } catch (error: any) {
// return {
// id: fullCus.id,
// name: fullCus.name,
// email: fullCus.email,
// error: error.message,
// };
// }
// };
// export const check = async () => {
// const env = AppEnv.Live;
// const sb = createSupabaseClient();
// const today = new Date().toISOString().slice(0, 16);
// for (const slug of orgSlugs) {
// const org = await OrgService.getBySlug({
// db,
// slug,
// });
// if (!org) {
// console.log(`Org ${slug} not found`);
// continue;
// }
// const fileName = `errors/${today}-${org.slug}.json`;
// const stripeCli = createStripeCli({
// org,
// env,
// });
// console.log("--------------------------------");
// console.log(`Running error check for ${org.name}`);
// let customers: FullCustomer[] = [];
// let stripeSubs: Stripe.Subscription[] = [];
// let stripeSchedules: Stripe.SubscriptionSchedule[] = [];
// let entities: Entity[] = [];
// if (customerId) {
// const res = await getSingleCustomer({
// stripeCli,
// customerId,
// orgId: org.id,
// env,
// });
// customers = res.customers;
// stripeSubs = res.stripeSubs;
// entities = res.entities;
// } else {
// const [customersRes, stripeSubsRes, stripeSchedulesRes, entitiesRes] =
// await Promise.all([
// getAllFullCustomers({
// db,
// orgId: org.id,
// env,
// }),
// getAllStripeSubscriptions({
// stripeCli,
// waitForSeconds: 1,
// }),
// getAllStripeSchedules({
// stripeCli,
// waitForSeconds: 1,
// }),
// getAllEntities({
// db,
// orgId: org.id,
// env,
// }),
// ]);
// customers = customersRes;
// stripeSubs = stripeSubsRes.subscriptions;
// stripeSchedules = stripeSchedulesRes.schedules;
// entities = entitiesRes;
// }
// const batchSize = 1;
// const allErrors = [];
// for (let i = 0; i < customers.length; i += batchSize) {
// const batch = customers.slice(i, i + batchSize);
// const batchCheck: any = [];
// for (const customer of batch) {
// batchCheck.push(
// checkCustomerHandleError({
// fullCus: customer,
// subs: stripeSubs,
// schedules: stripeSchedules,
// org,
// entities,
// }),
// );
// }
// let results = await Promise.all(batchCheck);
// results = results.filter(notNullish);
// allErrors.push(...results);
// }
// console.log(`Found ${allErrors.length} errors`);
// if (allErrors.length > 0 && customers.length > 1) {
// await sb.storage
// .from("autumn")
// .upload(fileName, JSON.stringify(allErrors, null, 2));
// if (allErrors.length > 0) {
// const slackBody = {
// text: `Error check for ${org.name}`,
// blocks: [
// {
// type: "section",
// text: {
// type: "mrkdwn",
// text: `*Error check for ${org.name}*: found ${allErrors.length} errors\nSee results at ${process.env.SUPABASE_URL}/storage/v1/object/public/autumn/${fileName}`,
// },
// },
// ],
// };
// await fetch(process.env.SLACK_WEBHOOK_URL!, {
// method: "POST",
// body: JSON.stringify(slackBody),
// });
// }
// } else {
// console.log(allErrors);
// }
// }
// console.log(
// `COMPLETED ERROR CHECK FOR ${new Date().toISOString().slice(0, 16)}`,
// );
// if (process.env.NODE_ENV === "production") {
// const slackBody = {
// text: `Completed error check for ${new Date().toISOString().slice(0, 16)}`,
// blocks: [
// {
// type: "section",
// text: {
// type: "mrkdwn",
// text: `Error check completed for ${new Date().toISOString().slice(0, 16)}`,
// },
// },
// ],
// };
// await fetch(process.env.SLACK_WEBHOOK_URL!, {
// method: "POST",
// body: JSON.stringify(slackBody),
// });
// }
// };
// check()
// .catch((error) => {
// console.error(error);
// process.exit(1);
// })
// .finally(() => {
// process.exit(0);
// });
import { initInfisical } from "./external/infisical/initInfisical.js";
await initInfisical();
await import("./scan/runScan.js");

View File

@@ -1,7 +1,7 @@
import { type ClickHouseClient, createClient } from "@clickhouse/client";
export const clickhouseClient: ClickHouseClient = createClient({
url: process.env.CLICKHOUSE_URL!,
url: process.env.CLICKHOUSE_URL || undefined,
username: process.env.CLICKHOUSE_USERNAME!,
password: process.env.CLICKHOUSE_PASSWORD!,
max_open_connections: 10,

View File

@@ -2,13 +2,14 @@ import {
AttachScenario,
CusProductStatus,
cusProductToProduct,
isFreeProduct,
isOneOffProduct,
} from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { activateFutureProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
@@ -87,8 +88,8 @@ export const handleSchedulePhaseCompleted = async ({
});
if (
!isFreeProduct(fullFutureProduct.prices) &&
!isOneOff(fullFutureProduct.prices)
!isFreeProduct({ prices: fullFutureProduct.prices }) &&
!isOneOffProduct({ prices: fullFutureProduct.prices })
) {
await CusProductService.update({
db,

View File

@@ -112,6 +112,7 @@ export function createRoute<
middlewares.push(validator("json", opts.body));
}
// Same for query
if (opts.versionedQuery && opts.resource) {
middlewares.push(
@@ -130,28 +131,7 @@ export function createRoute<
middlewares.push(validator("param", opts.params));
}
// if (
// opts.assertIdempotence !== undefined &&
// opts.assertIdempotence !== null &&
// opts.assertIdempotence?.trim() !== ""
// ) {
// middlewares.push(async (c, next) => {
// const db = c.get("ctx").db;
// const id = c.req.header(opts.assertIdempotence as string);
// if (!id) {
// throw new RecaseError({
// message: "Idempotency key not found",
// code: ErrCode.IdempotencyKeyNotFound,
// statusCode: StatusCodes.NOT_FOUND,
// });
// }
// await IdempotencyService.validate({
// db,
// id,
// });
// return await next();
// });
// }
// Add expand middleware after query validation
if (opts.query || opts.versionedQuery) {
middlewares.push(expandMiddleware());

View File

@@ -3,7 +3,7 @@ import {
type Feature,
type FreeTrial,
type FullCusProduct,
isTrialing,
isCusProductTrialing,
type ProductItem,
UsageModel,
} from "@autumn/shared";
@@ -63,7 +63,8 @@ export const getOptions = ({
});
if (
(freeTrial || (cusProduct && isTrialing({ cusProduct, now }))) &&
(freeTrial ||
(cusProduct && isCusProductTrialing({ cusProduct, now }))) &&
notNullish(i.interval)
) {
priceData = {

View File

@@ -2,7 +2,6 @@ import {
type AppEnv,
BillingType,
type Customer,
type Entitlement,
type Entity,
EntityExpand,
ErrCode,
@@ -62,16 +61,6 @@ export const entityMatchesFeature = ({
return feature.id === entity.feature_id;
};
export const entitlementLinkedToEntity = ({
entitlement,
entity,
}: {
entitlement: Entitlement;
entity: Entity;
}) => {
return entitlement.entity_feature_id === entity.feature_id;
};
export const isLinkedToEntity = ({
cusEnt,
entity,

View File

@@ -3,9 +3,11 @@ import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleAttach } from "./attach/handleAttach.js";
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
import { handleAttachV2 } from "./v2/attach/handleAttachV2.js";
export const billingRouter = new Hono<HonoEnv>();
billingRouter.post("/setup_payment", ...handleSetupPayment);
billingRouter.post("/checkout", ...handleCheckoutV2);
billingRouter.post("/attach", ...handleAttach);
billingRouter.post("/attach_v2", ...handleAttachV2);

View File

@@ -0,0 +1,28 @@
import type {
FullCustomer,
InsertFullCusProductContext,
NewProductAction,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { insertFullCusProduct } from "../insertFullCusProduct/insertFullCusProduct";
export const applyNewProductAction = async ({
ctx,
fullCus,
newProductAction,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
newProductAction: NewProductAction;
}) => {
const insertContext: InsertFullCusProductContext = {
fullCus,
product: newProductAction.product,
featureQuantities: [],
replaceables: [],
};
if (newProductAction.timing === "scheduled") {
return await insertFullCusProduct({ ctx, fullCus, insertContext });
}
};

View File

@@ -0,0 +1,60 @@
import {
type AttachContext,
type CusProductActions,
type FullCustomer,
formatMs,
getCycleStart,
getLargestInterval,
secondsToMs,
} from "@autumn/shared";
import { createStripeCli } from "../../../../external/connect/createStripeCli";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
export const enrichAttachActions = async ({
ctx,
fullCus,
actions,
attachContext,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
actions: CusProductActions;
attachContext: AttachContext;
}) => {
const { org, env } = ctx;
const { newProductActions, ongoingCusProductAction } = actions;
const stripeCli = createStripeCli({ org, env });
const { sub, testClockFrozenTime } = attachContext;
const billingCycleAnchor = secondsToMs(sub?.billing_cycle_anchor);
const now = testClockFrozenTime ?? Date.now();
const product = attachContext.products[0];
console.log(
`Sub: ${sub?.id}, Billing cycle anchor: ${formatMs(billingCycleAnchor)}, Now: ${formatMs(now)}`,
);
// Get latest cycle end for each product
const largestInterval = getLargestInterval({
prices: product.prices,
excludeOneOff: true,
});
console.log("Largest interval:", largestInterval);
// From billing cycle anchor, now, and interval, calculate latest cycle start:
if (largestInterval && billingCycleAnchor) {
const cycleStart = getCycleStart({
anchor: billingCycleAnchor,
interval: largestInterval.interval,
intervalCount: largestInterval.intervalCount,
now,
});
console.log(`Now: ${formatMs(now)}`);
console.log(`Billing cycle anchor: ${formatMs(billingCycleAnchor)}`);
console.log(`Cycle start: ${formatMs(cycleStart)}`);
}
return actions;
};

View File

@@ -0,0 +1,49 @@
// import { CusProductStatus, type CusProductActions } from "@autumn/shared";
// import type { AutumnContext } from "../../../honoUtils/HonoEnv";
// import { CusProductService } from "../../customers/cusProducts/CusProductService";
// export const executeActiveCusProductAction = async ({
// ctx,
// ongoingCusProductAction,
// }: {
// ctx: AutumnContext;
// ongoingCusProductAction?: OngoingCusProductAction;
// }) => {
// if (!activeCusProductAction) return;
// const { action, cusProduct } = activeCusProductAction;
// if (action === "expire") {
// return await CusProductService.update({
// db: ctx.db,
// cusProductId: cusProduct.id,
// updates: {
// status: CusProductStatus.Expired,
// },
// });
// }
// if (action === "cancel") {
// return await CusProductService.update({
// db: ctx.db,
// cusProductId: cusProduct.id,
// updates: {
// canceled: true,
// canceled_at: Date.now(),
// // TODO: add ended_at
// },
// });
// }
// if (action === "uncancel") {
// return await CusProductService.update({
// db: ctx.db,
// cusProductId: cusProduct.id,
// updates: {
// canceled: false,
// canceled_at: null,
// ended_at: null,
// },
// });
// }
// };

View File

@@ -0,0 +1,22 @@
// 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

@@ -0,0 +1,34 @@
import {
type FullCustomer,
getTargetSubscriptionCusProduct,
type Product,
} from "@autumn/shared";
import { createStripeCli } from "../../../external/connect/createStripeCli";
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
export const getAttachSub = async ({
ctx,
fullCus,
products,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
products: Product[];
}) => {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
const cusProductWithSub = getTargetSubscriptionCusProduct({
fullCus,
productId: products[0].id,
productGroup: products[0].group,
});
const subId = cusProductWithSub?.subscription_ids?.[0];
if (!subId) return { sub: undefined };
const sub = await stripeCli.subscriptions.retrieve(subId);
return { sub };
};

View File

@@ -0,0 +1,24 @@
import {
type EntitlementWithFeature,
entToPrice,
type InsertFullCusProductContext,
isPayPerUsePrice,
} from "@autumn/shared";
import { isArrearPrice } from "../../../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice";
export const initCusEntUsageAllowed = ({
insertContext,
entitlement,
}: {
insertContext: InsertFullCusProductContext;
entitlement: EntitlementWithFeature;
}) => {
const price = entToPrice({
ent: entitlement,
prices: insertContext.product.prices,
});
if (!price) return false;
return isArrearPrice({ price }) || isPayPerUsePrice({ price });
};

View File

@@ -0,0 +1,95 @@
import {
type CustomerEntitlement,
type EntitlementWithFeature,
type InsertFullCusProductContext,
isBooleanEntitlement,
isUnlimitedEntitlement,
} from "@autumn/shared";
import { generateId } from "@server/utils/genUtils";
import { initCusEntitlementBalance } from "./initCusEntitlementBalance";
import { initCusEntUsageAllowed } from "./initCusEntUsageAllowed";
// Init cus ent context
export interface InitCusEntContext {
insertContext: InsertFullCusProductContext;
}
// MAIN FUNCTION
export const initCusEntitlement = ({
insertContext,
entitlement,
cusProductId,
}: {
insertContext: InsertFullCusProductContext;
entitlement: EntitlementWithFeature;
cusProductId: string;
}): CustomerEntitlement => {
const { balance, entities } = initCusEntitlementBalance({
insertContext,
entitlement,
});
// Get unlimited
const isBoolean = isBooleanEntitlement({ entitlement });
const unlimited = isBoolean ? null : isUnlimitedEntitlement({ entitlement });
// Usage allowed:
const usageAllowed = initCusEntUsageAllowed({
insertContext,
entitlement,
});
// 1. Initialize balance / entities column
// now = now || Date.now();
// const nextResetAtValue = initNextResetAt({
// entitlement,
// nextResetAt,
// // keepResetIntervals,
// // existingCusEnt,
// trialEndsAt,
// freeTrial,
// anchorToUnix,
// now,
// });
const nextResetAt = Date.now();
const { fullCus, product } = insertContext;
return {
id: generateId("cus_ent"),
internal_customer_id: fullCus.internal_id,
internal_feature_id: entitlement.internal_feature_id,
feature_id: entitlement.feature.id,
customer_id: fullCus.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,69 @@
import {
type EntitlementWithFeature,
type EntityBalance,
entToOptions,
entToPrice,
getStartingBalance,
type InsertFullCusProductContext,
isBooleanEntitlement,
isUnlimitedEntitlement,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { initCusEntitlementEntities } from "./initCusEntitlementEntities";
export interface InitCusEntitlementBalanceResult {
balance: number;
entities: Record<string, EntityBalance> | null;
}
export const initCusEntitlementBalance = ({
insertContext,
entitlement,
}: {
insertContext: InsertFullCusProductContext;
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 { fullCus, featureQuantities, replaceables } = insertContext;
const price = entToPrice({
ent: entitlement,
prices: insertContext.product.prices,
});
const options = entToOptions({
ent: entitlement,
options: featureQuantities,
});
let startingBalance = getStartingBalance({
entitlement,
options,
relatedPrice: price,
});
// 3. Get entitlement entities if entity scoped
const entities = initCusEntitlementEntities({
entitlement,
customerEntities: fullCus.entities,
startingBalance,
});
// Subtract replaceables from starting balance
const entReplaceables = replaceables.filter(
(r) => r.ent.id === entitlement.id,
);
startingBalance = new Decimal(startingBalance)
.sub(entReplaceables.length)
.toNumber();
return { balance: startingBalance, entities };
};

View File

@@ -0,0 +1,39 @@
import {
type EntitlementWithFeature,
type Entity,
type EntityBalance,
entitlementFeatureMatchesEntityFeature,
isEntityScopedEntitlement,
} from "@autumn/shared";
export const initCusEntitlementEntities = ({
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,77 @@
import {
type EntitlementWithFeature,
type InsertFullCusProductContext,
isBooleanEntitlement,
isLifetimeEntitlement,
isUnlimitedEntitlement,
} from "@autumn/shared";
export const initCusEntitlementNextResetAt = ({
insertContext,
entitlement,
}: {
insertContext: InsertFullCusProductContext;
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;
// 2. If next reset at override is provided, return it?
// // 3. Get next reset at:
// let nextResetAtCalculated = null;
// const trialEndTimestamp = trialEndsAt
// ? Math.round(trialEndsAt / 1000)
// : freeTrial
// ? freeTrialToStripeTimestamp({ freeTrial, now })
// : null;
// const shouldApplyTrial = applyTrialToEntitlement(entitlement, freeTrial);
// // console.log(
// // "Trial end timestamp: ",
// // formatUnixToDateTime(trialEndTimestamp! * 1000),
// // );
// if (freeTrial && shouldApplyTrial && trialEndTimestamp) {
// nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000);
// }
// const resetInterval = entitlement.interval as EntInterval;
// const startDate = nextResetAtCalculated || new UTCDate(now);
// nextResetAtCalculated = getNextEntitlementReset(
// startDate,
// resetInterval,
// entitlement.interval_count || 1,
// ).getTime();
// // console.log(
// // "Next reset at calculated: ",
// // formatUnixToDateTime(nextResetAtCalculated),
// // );
// // If anchorToUnix, align next reset at to anchorToUnix...
// if (
// anchorToUnix &&
// nextResetAtCalculated &&
// Object.values(BillingInterval).includes(
// entitlement.interval as unknown as BillingInterval,
// ) &&
// !shouldApplyTrial
// ) {
// nextResetAtCalculated = getAlignedUnix({
// anchor: anchorToUnix,
// intervalConfig: {
// interval: entitlement.interval as unknown as BillingInterval,
// intervalCount: entitlement.interval_count || 1,
// },
// now,
// });
// }
// return nextResetAtCalculated;
};

View File

@@ -0,0 +1,20 @@
import type { CustomerPrice, FullCustomer, Price } from "@autumn/shared";
import { generateId } from "../../../../utils/genUtils";
export const initCusPrice = ({
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,94 @@
import {
CollectionMethod,
type CusProduct,
CusProductStatus,
type InsertCusProductOptions,
type InsertFullCusProductContext,
notNullish,
} from "@autumn/shared";
export const initCusProduct = ({
insertContext,
insertOptions,
cusProductId,
}: {
insertContext: InsertFullCusProductContext;
insertOptions?: InsertCusProductOptions;
cusProductId: string;
}): CusProduct => {
const { fullCus, product, featureQuantities } = insertContext;
const internalEntityId = fullCus.entity?.internal_id;
const entityId = fullCus.entity?.id;
const status = insertOptions?.status ?? CusProductStatus.Active;
const startsAt = insertOptions?.startsAt ?? Date.now();
const canceled = notNullish(insertOptions?.canceledAt);
const canceledAt = insertOptions?.canceledAt;
const subscriptionIds = insertOptions?.subscriptionId
? [insertOptions.subscriptionId]
: undefined;
const scheduleIds = insertOptions?.subscriptionScheduleId
? [insertOptions.subscriptionScheduleId]
: undefined;
const collectionMethod =
insertOptions?.collectionMethod ?? CollectionMethod.ChargeAutomatically;
const isCustom = insertOptions?.isCustom ?? false;
const apiSemver = insertOptions?.apiSemver ?? null;
return {
id: cusProductId,
internal_customer_id: fullCus.internal_id,
customer_id: fullCus.id,
internal_entity_id: internalEntityId,
entity_id: entityId,
internal_product_id: product.internal_id,
product_id: product.id,
created_at: Date.now(),
status,
// Legacy
// processor: null,
starts_at: startsAt || Date.now(),
trial_ends_at: null,
free_trial_id: null,
options: featureQuantities,
canceled,
canceled_at: canceledAt,
subscription_ids: subscriptionIds,
scheduled_ids: scheduleIds,
collection_method: collectionMethod,
quantity: 1,
is_custom: isCustom,
api_semver: apiSemver,
};
};
// ? 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,75 @@
import type {
FullCustomer,
InsertCusProductOptions,
InsertFullCusProductContext,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { generateId } from "@/utils/genUtils";
import { CusProductService } from "../../../customers/cusProducts/CusProductService";
import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService";
import { CusPriceService } from "../../../customers/cusProducts/cusPrices/CusPriceService";
import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement";
import { initCusPrice } from "./initCusPrice";
import { initCusProduct } from "./initCusProduct";
export const insertFullCusProduct = async ({
ctx,
fullCus,
insertContext,
insertOptions,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
insertContext: InsertFullCusProductContext;
insertOptions?: InsertCusProductOptions;
}) => {
const { db } = ctx;
const { product } = insertContext;
const cusProductId = generateId("cus_prod");
const newCusEnts = product.entitlements.map((entitlement) =>
initCusEntitlement({
insertContext,
entitlement,
cusProductId,
}),
);
const newCusPrices = product.prices.map((price) =>
initCusPrice({
fullCus,
price,
cusProductId,
}),
);
// TODO: Add existing usage to customer entitlements
// TODO: Add rollovers to customer entitlements
const newCusProduct = initCusProduct({
insertContext,
cusProductId,
});
ctx.logger.info(
`[insertFullCusProduct] inserting new cus product ${product.id}`,
);
await CusProductService.insert({
db,
data: newCusProduct,
});
await Promise.all([
CusEntService.insert({
db,
data: newCusEnts,
}),
CusPriceService.insert({
db,
data: newCusPrices,
}),
]);
};

View File

@@ -1,15 +1,28 @@
import { AffectedResource, AttachBodyV1Schema } from "@autumn/shared";
import {
AffectedResource,
ApiVersion,
AttachBodyV0Schema,
AttachBodyV1Schema,
} from "@autumn/shared";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { createAttachContext } from "../common/createAttachContext/createAttachContext";
export const handleAttachV2 = createRoute({
body: AttachBodyV1Schema,
versionedBody: {
latest: AttachBodyV1Schema,
[ApiVersion.V2_0]: AttachBodyV0Schema,
},
resource: AffectedResource.Attach,
handler: async (c) => {
const ctx = c.get("ctx");
const attachBody = c.req.valid("json");
const body = c.req.valid("json");
// Step 1: Create attach params.
// Step 1: Create attach context
const attachContext = await createAttachContext({
ctx,
body,
});
return c.json({ success: true });
return c.json({ success: true }, 400);
},
});

View File

@@ -1,7 +1,17 @@
import { type AttachBodyV1, RELEVANT_STATUSES } from "@autumn/shared";
import {
type AttachBodyV1,
type AttachContext,
type CusProductActions,
type FullCustomer,
RELEVANT_STATUSES,
resolveAttachActions,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { getStripeCusData } from "../../../../customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData";
import { getOrCreateCustomer } from "../../../../customers/cusUtils/getOrCreateCustomer";
import { enrichAttachActions } from "../../../billingUtils/enrichAttachActions/enrichAttachActions";
import { getAttachSub } from "../../../billingUtils/getAttachSub";
import { getFeatureQuantitiesForAttach } from "./getFeatureQuantitiesForAttach";
import { getFreeTrialForAttach } from "./getFreeTrialForAttach";
import { getProductsForAttach } from "./getProductsForAttach";
@@ -14,7 +24,6 @@ export const createAttachContext = async ({
ctx: AutumnContext;
body: AttachBodyV1;
}) => {
const { org, env } = ctx;
const {
customer_id: customerId,
customer_data: customerData,
@@ -42,8 +51,8 @@ export const createAttachContext = async ({
// 3. Override product
const {
fullProducts: newFullProducts,
// customPrices,
// customEnts,
customPrices,
customEnts,
} = await overrideProduct({
ctx,
body,
@@ -67,9 +76,118 @@ export const createAttachContext = async ({
fullCus,
});
// 6. Get stripe customer data
const { stripeCus, paymentMethod, now } = await getStripeCusData({
const {
stripeCus,
paymentMethod,
now: testClockFrozenTime,
} = await getStripeCusData({
ctx,
customer: fullCus,
});
const { sub } = await getAttachSub({
ctx,
fullCus,
products: newFullProducts,
});
const attachContext: AttachContext = {
fullCus,
products: newFullProducts,
freeTrial: freeTrial ?? undefined,
featureQuantities: featureQuantities ?? [],
sub,
testClockFrozenTime,
};
const actions = resolveAttachActions({
fullCus,
products: newFullProducts,
});
const logAttachActions = (cusProductActions: CusProductActions) => {
const {
ongoingCusProductAction,
scheduledCusProductAction,
newProductActions,
} = cusProductActions;
console.log("Cus product actions:", {
ongoing: ongoingCusProductAction
? `${ongoingCusProductAction?.action} ${ongoingCusProductAction?.cusProduct.product.id}`
: "none",
scheduled: scheduledCusProductAction
? `${scheduledCusProductAction?.action} ${scheduledCusProductAction?.cusProduct.product.id}`
: "none",
new:
newProductActions.length > 0
? newProductActions.map(
(newProductAction) =>
`insert ${newProductAction.product.id} (${newProductAction.timing})`,
)
: "none",
});
};
logAttachActions(actions);
// Get cus product to merge subscription with
await enrichAttachActions({
ctx,
fullCus,
actions,
attachContext,
});
// Enrich actions
// 1. Fetch current subscription
// 2. Fetch current schedule
// 2. Add to new product actions
// 3. For scheduling a product, need to figure out when it starts
// 4. For cancelling a product, need to figure out when it ends
// 5. Figure out free trial stuff
// 6. Determine reset anchor (should be same as billing anchor for now)
// 7. If expiring a product, need to figure out carrying usage over
// 8. If expiring a product, need to figure out carrying rollovers over
// 9. Updating one time product?
// NEXT: execute the actions
const applyCusProductActions = async ({
ctx,
fullCus,
cusProductActions,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
cusProductActions: CusProductActions;
}) => {
// // 1. Execute new product actions
// for (const newProductAction of newProductActions) {
// // await executeNewProductAction({
// // ctx,
// // newProductAction,
// // });
// }
// // 2. Execute active cus product action
// if (ongoingCusProductAction) {
// await executeActiveCusProductAction({
// ctx,
// ongoingCusProductAction,
// });
// }
// // 3. Execute scheduled cus product action
// if (scheduledCusProductAction) {
// await executeScheduledCusProductAction({
// ctx,
// scheduledCusProductAction,
// });
// }
};
await applyCusProductActions({
ctx,
fullCus,
cusProductActions: actions,
});
};

View File

@@ -6,6 +6,7 @@ import {
type EntitlementWithFeature,
type Entity,
type EntityBalance,
entitlementFeatureMatchesEntityFeature,
type FeatureOptions,
FeatureType,
type FreeTrial,
@@ -16,7 +17,6 @@ import {
type ProductOptions,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { entitlementLinkedToEntity } from "@/internal/api/entities/entityUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { initNextResetAt } from "../cusProducts/insertCusProduct/initCusEnt/initNextResetAt.js";
@@ -39,7 +39,8 @@ export const initCusEntEntities = ({
: null;
for (const entity of entities) {
if (!entitlementLinkedToEntity({ entitlement, entity })) continue;
if (!entitlementFeatureMatchesEntityFeature({ entitlement, entity }))
continue;
if (existingCusEnt?.entities?.[entity.id]) {
continue;
@@ -153,8 +154,6 @@ export const initCusEntitlement = ({
const nextResetAtValue = initNextResetAt({
entitlement,
nextResetAt,
// keepResetIntervals,
// existingCusEnt,
trialEndsAt,
freeTrial,
anchorToUnix,

View File

@@ -5,7 +5,7 @@ import {
AttachFunctionResponseSchema,
AttachScenario,
ErrCode,
isTrialing,
isCusProductTrialing,
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -80,7 +80,7 @@ export const handlePaidProduct = async ({
if (mergeSub && !config.disableMerge) {
if (mergeCusProduct?.free_trial) {
trialEndsAt = isTrialing({
trialEndsAt = isCusProductTrialing({
cusProduct: mergeCusProduct,
now: attachParams.now,
})

View File

@@ -5,7 +5,7 @@ import {
AttachFunctionResponseSchema,
AttachScenario,
CusProductStatus,
isTrialing,
isCusProductTrialing,
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -188,7 +188,8 @@ export const handleMultiAttachFlow = async ({
logger,
productOptions,
trialEndsAt:
mergeCusProduct && isTrialing({ cusProduct: mergeCusProduct })
mergeCusProduct &&
isCusProductTrialing({ cusProduct: mergeCusProduct })
? mergeCusProduct?.trial_ends_at || undefined
: undefined,
}),

View File

@@ -4,7 +4,7 @@ import {
type FullCustomer,
formatAmount,
getTotalCusProdQuantity,
isTrialing,
isCusProductTrialing,
type Organization,
type Price,
type UsagePriceConfig,
@@ -66,7 +66,7 @@ export const priceToUnusedPreviewItem = ({
anchor?: number;
}) => {
now = now || Date.now();
const onTrial = isTrialing({ cusProduct, now });
const onTrial = isCusProductTrialing({ cusProduct, now });
const subItem = findStripeItemForPrice({
price,

View File

@@ -1,4 +1,4 @@
import { type FullCusProduct, isTrialing } from "@autumn/shared";
import { type FullCusProduct, isCusProductTrialing } from "@autumn/shared";
import type Stripe from "stripe";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
@@ -66,7 +66,9 @@ export const isMainTrialBranch = ({
attachParams: AttachParams;
}) => {
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
if (!isTrialing({ cusProduct: curCusProduct!, now: attachParams.now }))
if (
!isCusProductTrialing({ cusProduct: curCusProduct!, now: attachParams.now })
)
return false;
const subId = curCusProduct?.subscription_ids?.[0];

View File

@@ -3,7 +3,7 @@ import {
type AttachConfig,
BillingInterval,
type FullProduct,
isTrialing,
isCusProductTrialing,
} from "@autumn/shared";
import { getOptions } from "@/internal/api/check/checkUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
@@ -108,7 +108,12 @@ export const getNewProductPreview = async ({
let anchor: number | undefined;
if (mergeSub && !config.disableMerge) {
if (mergeCusProduct?.free_trial) {
if (isTrialing({ cusProduct: mergeCusProduct, now: attachParams.now })) {
if (
isCusProductTrialing({
cusProduct: mergeCusProduct,
now: attachParams.now,
})
) {
trialEnds = mergeCusProduct.trial_ends_at || undefined;
attachParams.freeTrial = mergeCusProduct.free_trial;
} else {

View File

@@ -5,7 +5,7 @@ import {
cusProductToProduct,
type FreeTrial,
type FullCusProduct,
isTrialing,
isCusProductTrialing,
OnDecrease,
OnIncrease,
type PreviewLineItem,
@@ -53,7 +53,7 @@ const getNextCycleAt = ({
if (
branch === AttachBranch.NewVersion &&
curCusProduct &&
isTrialing({ cusProduct: curCusProduct, now })
isCusProductTrialing({ cusProduct: curCusProduct, now })
) {
return curCusProduct.trial_ends_at;
}
@@ -170,7 +170,7 @@ export const getUpgradeProductPreview = async ({
if (
config?.carryTrial &&
curCusProduct?.free_trial &&
isTrialing({ cusProduct: curCusProduct, now })
isCusProductTrialing({ cusProduct: curCusProduct, now })
) {
freeTrial = curCusProduct.free_trial;
}

View File

@@ -1,5 +0,0 @@
import { FullCusProduct } from "@autumn/shared";
export const isCanceled = ({ cusProduct }: { cusProduct: FullCusProduct }) => {
return cusProduct.canceled;
};

View File

@@ -4,30 +4,7 @@ import type {
} from "@autumn/shared";
import type Stripe from "stripe";
import {
getStripeSchedules,
getStripeSubs,
} from "@/external/stripe/stripeSubUtils.js";
export const cusProductsToSchedules = ({
cusProducts,
stripeCli,
}: {
cusProducts: (FullCusProduct | undefined)[];
stripeCli: Stripe;
}) => {
const scheduleIds: string[] = [];
for (const cusProduct of cusProducts) {
if (cusProduct) {
scheduleIds.push(...(cusProduct.scheduled_ids || []));
}
}
return getStripeSchedules({
stripeCli,
scheduleIds,
});
};
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
export const cusProductToSchedule = async ({
cusProduct,

View File

@@ -8,7 +8,7 @@ import {
expandIncludes,
type FullCusProduct,
type FullCustomer,
isTrialing,
isCusProductTrialing,
type Subscription,
} from "@autumn/shared";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
@@ -93,7 +93,9 @@ export const getApiSubscription = async ({
canceled_at: cusProduct.canceled_at || null,
expires_at: cusProduct.ended_at || null,
trial_ends_at: isTrialing({ cusProduct }) ? cusProduct.trial_ends_at : null,
trial_ends_at: isCusProductTrialing({ cusProduct })
? cusProduct.trial_ends_at
: null,
started_at: cusProduct.starts_at,
quantity: cusProduct.quantity,
current_period_start: stripeSubData?.current_period_start || null,

View File

@@ -1,162 +0,0 @@
// import {
// BillingType,
// ErrCode,
// ProductNotFoundError,
// type UsagePriceConfig,
// } from "@autumn/shared";
// import express, { type Router } from "express";
// import { MigrationService } from "@/internal/migrations/MigrationService.js";
// import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js";
// import { ProductService } from "@/internal/products/ProductService.js";
// import {
// getBillingType,
// pricesOnlyOneOff,
// } from "@/internal/products/prices/priceUtils.js";
// import { isFreeProduct } from "@/internal/products/productUtils.js";
// import { JobName } from "@/queue/JobName.js";
// import { addTaskToQueue } from "@/queue/queueUtils.js";
// import RecaseError from "@/utils/errorUtils.js";
// import type {
// ExtendedRequest,
// ExtendedResponse,
// } from "@/utils/models/Request.js";
// import { routeHandler } from "@/utils/routerUtils.js";
// import { findPrepaidPrice } from "../products/prices/priceUtils/findPriceUtils.js";
// export const migrationRouter: Router = express.Router();
// export const handleMigrate = async (
// req: ExtendedRequest,
// res?: ExtendedResponse,
// ) => {
// const { orgId, env, db, features } = req;
// const { from_product_id, from_version, to_product_id, to_version } = req.body;
// const fromProduct = await ProductService.getFull({
// db,
// env,
// orgId,
// idOrInternalId: from_product_id,
// version: from_version,
// });
// const toProduct = await ProductService.getFull({
// db,
// env,
// orgId,
// idOrInternalId: to_product_id,
// version: to_version,
// });
// if (isFreeProduct(fromProduct.prices) && !isFreeProduct(toProduct.prices)) {
// throw new RecaseError({
// message: `Cannot migrate customers from free product to paid product`,
// code: ErrCode.InvalidRequest,
// statusCode: 400,
// });
// }
// // Check if from product is one off, or to product is one off
// if (
// pricesOnlyOneOff(fromProduct.prices) ||
// pricesOnlyOneOff(toProduct.prices)
// ) {
// const fromIsOneOff = pricesOnlyOneOff(fromProduct.prices);
// const msg = fromIsOneOff
// ? `${fromProduct.name} is a one off product, cannot migrate customers on it`
// : `${toProduct.name} is a one off product, cannot migrate customers to this product`;
// throw new RecaseError({
// message: msg,
// code: ErrCode.InvalidRequest,
// statusCode: 400,
// });
// }
// // if (fromProduct.is_add_on || toProduct.is_add_on) {
// // throw new RecaseError({
// // message: `Cannot migrate customers for add on products`,
// // code: ErrCode.InvalidRequest,
// // statusCode: 400,
// // });
// // }
// for (const price of toProduct.prices) {
// const billingType = getBillingType(price.config);
// if (billingType !== BillingType.UsageInAdvance) continue;
// const config = price.config as UsagePriceConfig;
// const internalFeatureId = config.internal_feature_id;
// const feature = features.find((f) => f.internal_id === internalFeatureId);
// const prepaidPrice = findPrepaidPrice({
// prices: fromProduct.prices,
// internalFeatureId,
// });
// if (!prepaidPrice) {
// throw new RecaseError({
// message: `New product has prepaid price for feature ${feature?.name}, but old product does not, can't perform migration`,
// code: ErrCode.InvalidRequest,
// statusCode: 400,
// });
// }
// }
// if (!isFreeProduct(fromProduct.prices) && isFreeProduct(toProduct.prices)) {
// throw new RecaseError({
// message: `Cannot migrate customers from paid product to free product`,
// code: ErrCode.InvalidRequest,
// statusCode: 400,
// });
// }
// if (isFreeProduct(fromProduct.prices) && !isFreeProduct(toProduct.prices)) {
// throw new RecaseError({
// message: `Cannot migrate customers from free product to paid product`,
// code: ErrCode.InvalidRequest,
// statusCode: 400,
// });
// }
// // 1. Create migration JOB
// const migrationJob = constructMigrationJob({
// fromProduct,
// toProduct,
// });
// await MigrationService.createJob({
// db,
// data: migrationJob,
// });
// if (!fromProduct || !toProduct) {
// throw new ProductNotFoundError({
// productId: !fromProduct ? from_product_id : to_product_id,
// version: !fromProduct ? from_version : to_version,
// });
// }
// await addTaskToQueue({
// jobName: JobName.Migration,
// payload: {
// migrationJobId: migrationJob.id,
// },
// });
// if (res) {
// res.status(200).json(migrationJob);
// }
// };
// migrationRouter.post("", async (req: any, res: any) => {
// return routeHandler({
// req,
// res,
// action: "migrate",
// handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
// await handleMigrate(req, res);
// },
// });
// });

View File

@@ -6,24 +6,8 @@ import {
} from "@autumn/shared";
import { nullish } from "@server/utils/genUtils.js";
const BillingIntervalOrder = [
BillingInterval.Year,
BillingInterval.SemiAnnual,
BillingInterval.Quarter,
BillingInterval.Month,
BillingInterval.OneOff,
];
const ReversedBillingIntervalOrder = [
// BillingInterval.OneOff,
BillingInterval.Month,
BillingInterval.Quarter,
BillingInterval.SemiAnnual,
BillingInterval.Year,
];
const entToBillingInterval = (entInterval: EntInterval | null | undefined) => {
if (entInterval == EntInterval.Lifetime || !entInterval) {
if (entInterval === EntInterval.Lifetime || !entInterval) {
return BillingInterval.OneOff;
} else return entInterval as unknown as BillingInterval;
};
@@ -94,16 +78,6 @@ export const getLargestInterval = ({
interval: sortedPrices[0].config.interval,
intervalCount: sortedPrices[0].config.interval_count ?? 1,
};
// return BillingIntervalOrder.find((interval) =>
// prices.some((price) => {
// let intervalMatch = price.config.interval === interval;
// let oneOffMatch = excludeOneOff
// ? price.config.interval !== BillingInterval.OneOff
// : true;
// return intervalMatch && oneOffMatch;
// })
// )!;
};
export const getSmallestInterval = ({
@@ -153,22 +127,6 @@ export const getSmallestInterval = ({
interval: smallestInterval.interval,
intervalCount: smallestInterval.intervalCount,
};
// if (!smallestIntervalPrice) {
// return null;
// }
// return {
// interval: smallestIntervalPrice.config.interval,
// intervalCount: smallestIntervalPrice.config.interval_count ?? 1,
// };
// return ReversedBillingIntervalOrder.find(
// (interval) =>
// prices.some((price) => price.config.interval === interval) ||
// (ents &&
// ents?.some((ent) => entToBillingInterval(ent.interval) === interval))
// )!;
};
export const sortPricesByInterval = (prices: Price[]) => {

View File

@@ -33,14 +33,6 @@ export const isPrepaidPrice = ({ price }: { price: Price }) => {
return billingType === BillingType.UsageInAdvance;
};
export const isPayPerUse = ({ price }: { price: Price }) => {
const billingType = getBillingType(price.config);
return (
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated
);
};
export const isFixedPrice = ({ price }: { price: Price }) => {
const billingType = getBillingType(price.config);

View File

@@ -1,9 +1,10 @@
import type {
CreateProductV2Params,
FullProduct,
Price,
ProductItem,
ProductV2,
import {
BillingInterval,
type CreateProductV2Params,
type FullProduct,
type Price,
type ProductItem,
type ProductV2,
} from "@autumn/shared";
import { pricesOnlyOneOff } from "../prices/priceUtils.js";
import { isFeatureItem } from "../product-items/productItemUtils/getItemType.js";

View File

@@ -1,12 +1,12 @@
import { cusProductToProduct } from "@autumn/shared";
import { AttachScenario, FullCustomer, FullProduct } from "@autumn/shared";
import {
isFreeProduct,
isOneOff,
isProductUpgrade,
} from "../../productUtils.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { isCanceled } from "@/internal/customers/cusProducts/cusProductUtils/classifyCusProduct.js";
AttachScenario,
cusProductToProduct,
type FullCustomer,
type FullProduct,
isCusProductCanceled,
} from "@autumn/shared";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
import { isFreeProduct, isOneOff, isProductUpgrade } from "../../productUtils";
export const getAttachScenario = ({
fullCus,
@@ -17,7 +17,7 @@ export const getAttachScenario = ({
}) => {
if (!fullCus) return AttachScenario.New;
let { curMainProduct, curScheduledProduct } = getExistingCusProducts({
const { curMainProduct, curScheduledProduct } = getExistingCusProducts({
product: fullProduct,
cusProducts: fullCus?.customer_products || [],
internalEntityId: fullCus?.entity?.internal_id,
@@ -30,17 +30,17 @@ export const getAttachScenario = ({
}
// 1. If current product is the same as the product, return active
if (curMainProduct?.product.id == fullProduct.id) {
if (isCanceled({ cusProduct: curMainProduct })) {
if (curMainProduct?.product.id === fullProduct.id) {
if (isCusProductCanceled({ cusProduct: curMainProduct })) {
return AttachScenario.Renew;
} else return AttachScenario.Active;
}
if (curScheduledProduct?.product.id == fullProduct.id) {
if (curScheduledProduct?.product.id === fullProduct.id) {
return AttachScenario.Scheduled;
}
let curFullProduct = cusProductToProduct({ cusProduct: curMainProduct });
const curFullProduct = cusProductToProduct({ cusProduct: curMainProduct });
if (
isFreeProduct(curFullProduct.prices) &&
@@ -49,7 +49,7 @@ export const getAttachScenario = ({
return AttachScenario.New;
}
let isUpgrade = isProductUpgrade({
const isUpgrade = isProductUpgrade({
prices1: curFullProduct.prices,
prices2: fullProduct.prices,
});

View File

@@ -71,6 +71,14 @@ export const formatUnixToDateTime = (
);
};
export const formatUnixSecondsToDateTime = (unixSeconds?: number | null) => {
if (!unixSeconds) {
return "undefined";
}
return formatUnixToDateTime(unixSeconds * 1000);
};
export const formatUnixToUTCDateTime = (
unixDate?: number | null,
withTimezone?: boolean,

View File

@@ -2,17 +2,20 @@ import { beforeAll, describe } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearItem,
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import {
constructProduct,
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { CusService } from "../../src/internal/customers/CusService";
import { advanceTestClock } from "../../src/utils/scriptUtils/testClockUtils";
import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0";
const freeProd = constructProduct({
type: "free",
@@ -34,59 +37,104 @@ const proProd = constructProduct({
includedUsage: 300,
}),
],
intervalCount: 2,
});
const proWithUsage = constructProduct({
type: "pro",
id: "pro-with-usage",
const premium = constructProduct({
type: "premium",
isDefault: false,
items: [
constructArrearItem({
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 0,
billingUnits: 1,
price: 0.5,
includedUsage: 1000,
}),
],
});
const proWithPrepaid = constructProduct({
type: "pro",
id: "pro-with-prepaid",
isDefault: false,
const freeAddOn = constructRawProduct({
id: "freeAddOn",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 1000,
}),
],
isAddOn: true,
});
const oneOffAddOn = constructRawProduct({
id: "oneOffAddOn",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 10,
includedUsage: 100,
isOneOff: true,
}),
],
isAddOn: true,
});
const monthlyAddOn = constructRawProduct({
id: "monthlyAddOn",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 10,
}),
],
isAddOn: true,
});
const testCase = "temp";
describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
const customerId = "temp";
const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 });
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await CusService.deleteByOrgId({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
});
await initCustomerV3({
const result = await initCustomerV3({
ctx,
customerId,
withTestClock: false,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [freeProd, proProd],
// prefix: testCase,
products: [freeProd, proProd, premium, freeAddOn, monthlyAddOn],
prefix: customerId,
});
await autumnV1.attach({
customer_id: customerId,
product_id: freeAddOn.id,
});
await autumnV1.attach({
customer_id: customerId,
product_id: proProd.id,
});
await autumnV1.attach({
customer_id: customerId,
product_id: monthlyAddOn.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 100,
},
],
});
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: result.testClockId,
advanceTo: toUnix({
year: 2026,
month: 1,
day: 15,
}),
});
await autumnV1.attach({

View File

@@ -6,16 +6,16 @@ import {
OnIncrease,
type Organization,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import type Stripe from "stripe";
import { setupBefore } from "@tests/before.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
import { createProducts } from "@tests/utils/productUtils.js";
import { expect } from "chai";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
@@ -50,7 +50,6 @@ describe(`${chalk.yellowBright(`attach/${testCase}: update quantity, no proratio
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
const curUnix = new Date().getTime();
let customer: Customer;
before(async function () {

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import {
AllowanceType,
type AttachReplaceable,
type EntitlementWithFeature,
type Feature,
type FeatureOptions,
FeatureType,
type InsertFullCusProductContext,
} from "@autumn/shared";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import chalk from "chalk";
import { initCusEntitlementBalance } from "@/internal/billing/billingUtils/insertFullCusProduct/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 = (): InsertFullCusProductContext => ({
fullCus: {} as InsertFullCusProductContext["fullCus"],
product: {} as InsertFullCusProductContext["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,211 @@
// import { describe, expect, test } from "bun:test";
// import { addInterval, BillingInterval, EntInterval } from "@autumn/shared";
// import { fromUnix, toUnix } from "./test-interval-utils.test";
// describe("add-interval1: adding interval for basic cases", () => {
// describe("BillingInterval.Week", () => {
// test("adds 1 week", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 }); // Jan 15
// const result = addInterval({
// from,
// interval: BillingInterval.Week,
// intervalCount: 1,
// });
// const { month, day } = fromUnix(result);
// expect(month).toBe(1);
// expect(day).toBe(22); // Jan 22
// });
// test("adds 2 weeks", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: BillingInterval.Week,
// intervalCount: 2,
// });
// const { month, day } = fromUnix(result);
// expect(month).toBe(1);
// expect(day).toBe(29);
// });
// test("adds 1 week crossing month boundary (Jan 28 -> Feb 4)", () => {
// const from = toUnix({ year: 2024, month: 1, day: 28 }); // Jan 28
// const result = addInterval({
// from,
// interval: BillingInterval.Week,
// intervalCount: 1,
// });
// const { month, day } = fromUnix(result);
// expect(month).toBe(2);
// expect(day).toBe(4); // Feb 4
// });
// test("adds 1 week crossing year boundary (Dec 28 -> Jan 4)", () => {
// const from = toUnix({ year: 2024, month: 12, day: 28 }); // Dec 28
// const result = addInterval({
// from,
// interval: BillingInterval.Week,
// intervalCount: 1,
// });
// const { year, month, day } = fromUnix(result);
// expect(year).toBe(2025);
// expect(month).toBe(1);
// expect(day).toBe(4); // Jan 4, 2025
// });
// });
// describe("BillingInterval.Month", () => {
// test("adds 1 month to a normal date", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 }); // Jan 15
// const result = addInterval({
// from,
// interval: BillingInterval.Month,
// intervalCount: 1,
// });
// const { month, day } = fromUnix(result);
// expect(month).toBe(2);
// expect(day).toBe(15); // Feb 15
// });
// test("adds 3 months", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: BillingInterval.Month,
// intervalCount: 3,
// });
// const { month, day } = fromUnix(result);
// expect(month).toBe(4);
// expect(day).toBe(15); // Apr 15
// });
// test("adds 1 month crossing year boundary (Dec 28 -> Jan 28)", () => {
// const from = toUnix({ year: 2024, month: 12, day: 28 }); // Dec 28
// const result = addInterval({
// from,
// interval: BillingInterval.Month,
// intervalCount: 1,
// });
// const { year, month, day } = fromUnix(result);
// expect(year).toBe(2025);
// expect(month).toBe(1);
// expect(day).toBe(28);
// });
// });
// describe("BillingInterval.Quarter", () => {
// test("adds 1 quarter (3 months)", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: BillingInterval.Quarter,
// intervalCount: 1,
// });
// const { month, day } = fromUnix(result);
// expect(month).toBe(4);
// expect(day).toBe(15);
// });
// });
// describe("BillingInterval.SemiAnnual", () => {
// test("adds 1 semi-annual (6 months)", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: BillingInterval.SemiAnnual,
// intervalCount: 1,
// });
// const { month, day } = fromUnix(result);
// expect(month).toBe(7);
// expect(day).toBe(15); // Jul 15
// });
// });
// describe("BillingInterval.Year", () => {
// test("adds 1 year", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: BillingInterval.Year,
// intervalCount: 1,
// });
// const { year, month, day } = fromUnix(result);
// expect(year).toBe(2025);
// expect(month).toBe(1);
// expect(day).toBe(15);
// });
// });
// describe("BillingInterval.OneOff", () => {
// test("returns unchanged timestamp", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: BillingInterval.OneOff,
// intervalCount: 1,
// });
// expect(result).toBe(from);
// });
// });
// describe("EntInterval fine-grained intervals", () => {
// test("adds minutes", () => {
// const from = toUnix({
// year: 2024,
// month: 1,
// day: 15,
// hour: 12,
// minute: 0,
// second: 0,
// });
// const result = addInterval({
// from,
// interval: EntInterval.Minute,
// intervalCount: 30,
// });
// const { minute } = fromUnix(result);
// expect(minute).toBe(30);
// });
// test("adds hours", () => {
// const from = toUnix({
// year: 2024,
// month: 1,
// day: 15,
// hour: 12,
// minute: 0,
// second: 0,
// });
// const result = addInterval({
// from,
// interval: EntInterval.Hour,
// intervalCount: 5,
// });
// const { hour } = fromUnix(result);
// expect(hour).toBe(17);
// });
// test("adds days", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: EntInterval.Day,
// intervalCount: 10,
// });
// const { day } = fromUnix(result);
// expect(day).toBe(25);
// });
// });
// describe("EntInterval.Lifetime", () => {
// test("returns unchanged timestamp", () => {
// const from = toUnix({ year: 2024, month: 1, day: 15 });
// const result = addInterval({
// from,
// interval: EntInterval.Lifetime,
// intervalCount: 1,
// });
// expect(result).toBe(from);
// });
// });
// });

View File

@@ -0,0 +1,177 @@
import { describe, expect, test } from "bun:test";
import { addInterval, BillingInterval } from "@autumn/shared";
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
describe("add-interval2: adding interval for edge cases", () => {
describe("End-of-month: Jan 31 anchor (the classic edge case)", () => {
test("Jan 31 + 1 month = Feb 28/29 (NOT Mar 3!)", () => {
// 2024 is a leap year
const from = toUnix({ year: 2024, month: 1, day: 31 });
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { month, day } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(29); // Feb 29 (leap year)
});
test("Jan 31 + 1 month = Feb 28 (non-leap year)", () => {
const from = toUnix({ year: 2023, month: 1, day: 31 });
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { month, day } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(28); // Feb 28
});
});
describe("End-of-month: Feb 28 anchor", () => {
test("Feb 28 + 1 month = Mar 28 (non-leap year)", () => {
const from = toUnix({ year: 2023, month: 2, day: 28 }); // Feb 28, 2023 (non-leap)
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { month, day } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(28); // Mar 28
});
test("Feb 28 + 1 month = Mar 28 (leap year)", () => {
const from = toUnix({ year: 2024, month: 2, day: 28 }); // Feb 28, 2024 (leap year)
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { month, day } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(28); // Mar 28
});
test("Feb 29 (leap year) + 1 month = Mar 29", () => {
const from = toUnix({ year: 2024, month: 2, day: 29 }); // Feb 29, 2024
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { month, day } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(29); // Mar 29
});
test("Feb 29 + 1 year = Feb 28 (next year is non-leap)", () => {
const from = toUnix({ year: 2024, month: 2, day: 29 }); // Feb 29, 2024
const result = addInterval({
from,
interval: BillingInterval.Year,
intervalCount: 1,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(2);
expect(day).toBe(28); // Feb 28, 2025 (no Feb 29)
});
});
describe("End-of-month: Mar 31 anchor", () => {
test("Mar 31 + 1 month = Apr 30 (April only has 30 days)", () => {
const from = toUnix({ year: 2024, month: 3, day: 31 }); // Mar 31
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(30); // Apr 30 (not May 1!)
});
test("Mar 31 + 2 months = May 31", () => {
const from = toUnix({ year: 2024, month: 3, day: 31 });
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 2,
});
const { month, day } = fromUnix(result);
expect(month).toBe(5);
expect(day).toBe(31); // May 31
});
test("Mar 31 + 1 quarter = Jun 30", () => {
const from = toUnix({ year: 2024, month: 3, day: 31 });
const result = addInterval({
from,
interval: BillingInterval.Quarter,
intervalCount: 1,
});
const { month, day } = fromUnix(result);
expect(month).toBe(6);
expect(day).toBe(30); // Jun 30
});
});
describe("Time preservation", () => {
test("preserves hour, minute, second", () => {
const from = toUnix({
year: 2024,
month: 1,
day: 15,
hour: 14,
minute: 30,
second: 45,
}); // 2:30:45 PM
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { hour, minute, second } = fromUnix(result);
expect(hour).toBe(14);
expect(minute).toBe(30);
expect(second).toBe(45);
});
test("preserves time even on end-of-month edge case", () => {
const from = toUnix({
year: 2024,
month: 1,
day: 31,
hour: 23,
minute: 59,
second: 59,
}); // Jan 31 at 11:59:59 PM
const result = addInterval({
from,
interval: BillingInterval.Month,
intervalCount: 1,
});
const { month, day, hour, minute, second } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(29); // Feb 29, 2024
expect(hour).toBe(23);
expect(minute).toBe(59);
expect(second).toBe(59);
});
});
describe("Default intervalCount", () => {
test("defaults to 1 if not provided", () => {
const from = toUnix({ year: 2024, month: 1, day: 15 });
const result = addInterval({
from,
interval: BillingInterval.Month,
});
const { month } = fromUnix(result);
expect(month).toBe(2);
});
});
});

View File

@@ -0,0 +1,159 @@
import { describe, expect, test } from "bun:test";
import { BillingInterval, getCycleEnd } from "@autumn/shared";
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
describe("get-cycle-end: annual intervals", () => {
describe("anchor in the past", () => {
// Basic
test("anchor: Jan 15 2024, now: Jun 20 2024 -> Jan 15 2025", () => {
const anchor = toUnix({ year: 2024, month: 1, day: 15 });
const now = toUnix({ year: 2024, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(15);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: Jan 15 2022, now: Jun 20 2024 -> Jan 15 2026", () => {
const anchor = toUnix({ year: 2022, month: 1, day: 15 });
const now = toUnix({ year: 2024, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 2,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2026);
expect(month).toBe(1);
expect(day).toBe(15);
});
// Edge cases
test("leap year Feb 29: anchor: Feb 29 2024, now: Jun 20 2024 -> Feb 28 2025", () => {
const anchor = toUnix({ year: 2024, month: 2, day: 29 });
const now = toUnix({ year: 2024, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(2);
expect(day).toBe(28); // 2025 is not a leap year
});
test("leap year to leap year: anchor: Feb 29 2024, now: Jun 20 2027 -> Feb 29 2028", () => {
const anchor = toUnix({ year: 2024, month: 2, day: 29 });
const now = toUnix({ year: 2027, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2028);
expect(month).toBe(2);
expect(day).toBe(29); // 2028 is a leap year
});
test("end of month Jan 31: anchor: Jan 31 2024, now: Jun 20 2024 -> Jan 31 2025", () => {
const anchor = toUnix({ year: 2024, month: 1, day: 31 });
const now = toUnix({ year: 2024, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(31); // Jan always has 31 days
});
test("end of month Apr 30: anchor: Apr 30 2024, now: Jun 20 2024 -> Apr 30 2025", () => {
const anchor = toUnix({ year: 2024, month: 4, day: 30 });
const now = toUnix({ year: 2024, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(4);
expect(day).toBe(30); // Apr always has 30 days
});
});
describe("anchor in the future", () => {
// Basic
test("anchor: Dec 15 2025, now: Jun 20 2025 -> Dec 15 2025", () => {
const anchor = toUnix({ year: 2025, month: 12, day: 15 });
const now = toUnix({ year: 2025, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(12);
expect(day).toBe(15);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: Dec 15 2026, now: Jun 20 2025 -> Dec 15 2026", () => {
const anchor = toUnix({ year: 2026, month: 12, day: 15 });
const now = toUnix({ year: 2025, month: 6, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 2,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2026);
expect(month).toBe(12);
expect(day).toBe(15);
});
// Edge cases
test("just before anchor: anchor: Jul 1 2025, now: Jun 30 2025 -> Jul 1 2025", () => {
const anchor = toUnix({ year: 2025, month: 7, day: 1 });
const now = toUnix({ year: 2025, month: 6, day: 30 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Year,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(7);
expect(day).toBe(1);
});
});
});

View File

@@ -0,0 +1,441 @@
import { describe, expect, test } from "bun:test";
import { EntInterval, getCycleEnd } from "@autumn/shared";
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
describe("get-cycle-end-hourly: hourly intervals", () => {
describe("anchor in the past", () => {
// Basic
test("anchor: 10:00, now: 11:30 -> 12:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 11,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
test("anchor: 10:00, now: 10:30 -> 11:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(11);
expect(minute).toBe(0);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: 10:00, now: 11:30 -> 12:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 11,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 2,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
test("(intervalCount = 3) anchor: 10:00, now: 12:30 -> 13:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 12,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 3,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(13);
expect(minute).toBe(0);
});
test("(intervalCount = 4) anchor: 10:00, now: 15:30 -> 18:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 15,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 4,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(18);
expect(minute).toBe(0);
});
// Edge cases
test("anchor: 10:00:00, now: 10:59:30 -> 11:00:00 (just before cycle end)", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
second: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 59,
second: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(11);
expect(minute).toBe(0);
});
test("anchor: 10:00:00, now: 11:00:30 -> 12:00:00 (just after cycle end)", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
second: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 11,
minute: 0,
second: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
test("anchor: 23:00, now: 00:30 (next day) -> 01:00 (crossing midnight)", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 23,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 16,
hour: 0,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(16);
expect(hour).toBe(1);
expect(minute).toBe(0);
});
test("anchor: 22:00, now: 01:30 (next day) -> 02:00 (multiple hours past midnight)", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 22,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 16,
hour: 1,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(16);
expect(hour).toBe(2);
expect(minute).toBe(0);
});
});
describe("anchor in the future", () => {
// Basic
test("anchor: 15:00, now: 10:00 -> 11:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 15,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(11);
expect(minute).toBe(0);
});
test("anchor: 20:00, now: 18:00 -> 19:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 20,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 18,
minute: 0,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(19);
expect(minute).toBe(0);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: 15:00, now: 10:00 -> 11:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 15,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 2,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(11);
expect(minute).toBe(0);
});
test("(intervalCount = 3) anchor: 18:00, now: 10:00 -> 12:00", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 18,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 10,
minute: 0,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 3,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
// Edge cases
test("anchor: 15:00:00, now: 14:59:30 -> 15:00:00 (just before anchor)", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 15,
minute: 0,
second: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 14,
minute: 59,
second: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(15);
expect(hour).toBe(15);
expect(minute).toBe(0);
});
test("anchor: 00:00 (tomorrow), now: 23:30 (today) -> 00:00 (tomorrow)", () => {
const anchor = toUnix({
year: 2025,
month: 1,
day: 16,
hour: 0,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 15,
hour: 23,
minute: 30,
});
const result = getCycleEnd({
anchor,
interval: EntInterval.Hour,
intervalCount: 1,
now,
});
const { day, hour, minute } = fromUnix(result);
expect(day).toBe(16);
expect(hour).toBe(0);
expect(minute).toBe(0);
});
});
});

View File

@@ -0,0 +1,392 @@
import { describe, expect, test } from "bun:test";
import { BillingInterval, getCycleEnd } from "@autumn/shared";
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
/**
* Test suite 1: Monthly anchor in the past
*
* 1. Anchor is 2 Jan, now is 15 Feb, result should be 2 Mar
* 2. Anchor is 18 Jan, now is 15 Feb, result should be 18 Mar
* 3. (intervalCount = 3) Anchor is 2 Jan, now is 15 Feb, result should be 2 Apr
* 4. (intervalCount = 3) Anchor is 18 Jan, now is 15 Feb, result should be 18 Apr
*
* Test suite 2: Monthly anchor in the past, edge cases
*
* 1. Anchor is 28 Feb, now is 15 Mar, result should be 28 Mar
* 2. Anchor is 31 Mar, now is 15 Apr, result should be 30 Apr
* 3. Anchor is 31 Mar, now is 2 May, result should be 31 May
* 4. Anchor is 31 Mar, interval count is 2, now is 15 Apr, result should be 31 May
* 5. Anchor is 31 Mar 12:00, now is 30 Apr 11:59, result should be 30 Apr 12:00
* 6. Anchor is 31 Mar 12:00, now is 30 Apr 12:01, result should be 31 May 12:00
*
* Test suite 3: Monthly anchors, anchor in the future
*
* 1. Anchor is 28 Feb, now is 15 Jan, result should be 28 Feb
* 2. Anchor is 30 Feb, now is 15 Jan, result should be 28 Mar
*/
describe("get-cycle-end-monthly: monthly intervals, anchor in the past", () => {
describe("Monthly anchor in the past", () => {
test("anchor: 2 Jan, now: 15 Feb -> end of cycle should be 2 Mar", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 2 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(2);
expect(hour).toBe(12);
});
test("anchor: 18 Jan, now: 15 Feb -> end of cycle should be 18 Feb", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 18 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(18);
expect(hour).toBe(12);
});
test("(intervalCount = 3) anchor: 2 Jan, now: 15 Feb -> end of cycle should be 2 Apr", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 2 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 3,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(2);
});
test("(intervalCount = 3) anchor: 18 Jan, now: 15 Feb -> end of cycle should be 18 Apr", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 18 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 3,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(18);
});
});
describe("Monthly anchor in the past, edge cases", () => {
test("anchor: 28 Feb, now: 15 Mar -> end of cycle should be 28 Mar", () => {
const anchor = toUnix({ year: 2025, month: 2, day: 28 });
const now = toUnix({ year: 2025, month: 3, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("anchor: 31 Mar, now: 15 Apr -> end of cycle should be 30 Apr", () => {
const anchor = toUnix({ year: 2025, month: 3, day: 31 });
const now = toUnix({ year: 2025, month: 4, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(30);
expect(hour).toBe(12);
});
test("anchor: 31 Mar, now: 2 May -> end of cycle should be 31 May", () => {
const anchor = toUnix({ year: 2025, month: 3, day: 31 });
const now = toUnix({ year: 2025, month: 5, day: 2 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(5);
expect(day).toBe(31);
expect(hour).toBe(12);
});
test("(intervalCount = 2) anchor: 31 Mar, now: 15 Apr -> end of cycle should be 31 May", () => {
const anchor = toUnix({ year: 2025, month: 3, day: 31 });
const now = toUnix({ year: 2025, month: 4, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 2,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(5);
expect(day).toBe(31);
expect(hour).toBe(12);
});
test("anchor: 31 Mar 12:00, now: 30 Apr 11:59 -> end of cycle should be 30 Apr 12:00", () => {
const anchor = toUnix({
year: 2025,
month: 3,
day: 31,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 4,
day: 30,
hour: 11,
minute: 59,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour, minute } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(30);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
test("anchor: 31 Mar 12:00, now: 30 Apr 12:01 -> end of cycle should be 31 May 12:00", () => {
const anchor = toUnix({
year: 2025,
month: 3,
day: 31,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 4,
day: 30,
hour: 12,
minute: 1,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour, minute } = fromUnix(result);
expect(month).toBe(5);
expect(day).toBe(31);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
});
describe("Monthly anchors, anchor in the future", () => {
test("anchor: 28 Apr, now: 15 Jan -> end of cycle should be 28 Jan", () => {
// Anchor: 28 Apr, now: 15 Jan
// Cycles: ...28 Dec, 28 Jan, 28 Feb, 28 Mar, 28 Apr...
// 15 Jan is between 28 Dec and 28 Jan, so end should be 28 Jan
const anchor = toUnix({ year: 2025, month: 4, day: 28 });
const now = toUnix({ year: 2025, month: 1, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("(intervalCount = 3) anchor: 28 Apr, now: 15 Jan -> end of cycle should be 28 Jan", () => {
// Anchor: 28 Apr, now: 15 Jan
// Cycles (every 3 months): ...28 Oct, 28 Jan, 28 Apr...
// 15 Jan is between 28 Oct and 28 Jan, so end should be 28 Jan
const anchor = toUnix({ year: 2025, month: 4, day: 28 });
const now = toUnix({ year: 2025, month: 1, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 3,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("(intervalCount = 2) anchor: 31 May, now: 15 Feb -> end of cycle should be 31 Mar", () => {
// Anchor: 31 May, now: 15 Feb
// Cycles (every 2 months): ...31 Jan, 31 Mar, 31 May...
// 15 Feb is between 31 Jan and 31 Mar, so end should be 31 Mar
const anchor = toUnix({ year: 2025, month: 5, day: 31 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 2,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(3);
expect(day).toBe(31);
});
});
describe("Monthly anchors, anchor in the future, edge cases", () => {
test("anchor: 31 May, now: 15 Mar -> end should be 30 Apr (end-of-month capping)", () => {
// Anchor: 31 May, now: 15 Mar
// Cycles: ...28 Feb (capped), 31 Mar, 30 Apr (capped), 31 May...
// 15 Mar is between 28 Feb and 31 Mar, so end should be 31 Mar
const anchor = toUnix({ year: 2025, month: 5, day: 31 });
const now = toUnix({ year: 2025, month: 3, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(31);
});
test("anchor: 30 Apr, now: 15 Feb -> end should be 28 Feb (capped)", () => {
// Anchor: 30 Apr, now: 15 Feb
// Cycles: ...30 Jan, 28 Feb (capped), 30 Mar, 30 Apr...
// 15 Feb is between 30 Jan and 28 Feb, so end should be 28 Feb
const anchor = toUnix({ year: 2025, month: 4, day: 30 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(28);
});
test("anchor: 28 Apr 12:00, now: 28 Jan 11:59 -> end should be 28 Jan 12:00", () => {
// now is just before the Jan 28 cycle boundary
const anchor = toUnix({
year: 2025,
month: 4,
day: 28,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 28,
hour: 11,
minute: 59,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("anchor: 28 Apr 12:00, now: 28 Jan 12:01 -> end should be 28 Feb 12:00", () => {
// now is just after the Jan 28 cycle boundary
const anchor = toUnix({
year: 2025,
month: 4,
day: 28,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 28,
hour: 12,
minute: 1,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(2);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("(intervalCount = 2) anchor: 31 Jul, now: 15 Feb -> end should be 31 Mar", () => {
// Anchor: 31 Jul, now: 15 Feb
// Cycles (every 2 months): ...30 Nov, 31 Jan, 31 Mar, 31 May, 31 Jul...
// 15 Feb is between 31 Jan and 31 Mar, so end should be 31 Mar
const anchor = toUnix({ year: 2025, month: 7, day: 31 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Month,
intervalCount: 2,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(31);
});
});
});

View File

@@ -0,0 +1,122 @@
import { describe, expect, test } from "bun:test";
import { BillingInterval, getCycleEnd } from "@autumn/shared";
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
describe("get-cycle-end: quarterly intervals", () => {
describe("anchor in the past", () => {
// Basic
test("anchor: Jan 15, now: Feb 20 -> Apr 15", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 15 });
const now = toUnix({ year: 2025, month: 2, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Quarter,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(15);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: Jan 15, now: May 20 -> Jul 15", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 15 });
const now = toUnix({ year: 2025, month: 5, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Quarter,
intervalCount: 2,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(7);
expect(day).toBe(15);
});
// Edge cases
test("end of month anchor (31st): anchor: Jan 31, now: Mar 15 -> Apr 30", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 31 });
const now = toUnix({ year: 2025, month: 3, day: 15 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Quarter,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(30); // Apr has 30 days
});
test("crossing year boundary: anchor: Nov 15, now: Dec 20 -> Feb 15", () => {
const anchor = toUnix({ year: 2024, month: 11, day: 15 });
const now = toUnix({ year: 2024, month: 12, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Quarter,
intervalCount: 1,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(2);
expect(day).toBe(15);
});
});
describe("anchor in the future", () => {
// Basic
test("anchor: Apr 15, now: Feb 20 -> Apr 15", () => {
const anchor = toUnix({ year: 2025, month: 4, day: 15 });
const now = toUnix({ year: 2025, month: 2, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Quarter,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(15);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: Jul 15, now: Feb 20 -> Jan 15", () => {
const anchor = toUnix({ year: 2025, month: 7, day: 15 });
const now = toUnix({ year: 2025, month: 2, day: 20 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Quarter,
intervalCount: 2,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(7);
expect(day).toBe(15);
});
// Edge cases
test("just before anchor: anchor: Apr 15, now: Apr 14 -> Apr 15", () => {
const anchor = toUnix({ year: 2025, month: 4, day: 15 });
const now = toUnix({ year: 2025, month: 4, day: 14 });
const result = getCycleEnd({
anchor,
interval: BillingInterval.Quarter,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(15);
});
});
});

View File

@@ -0,0 +1,207 @@
import { describe, expect, test } from "bun:test";
import { BillingInterval, getCycleEnd } from "@autumn/shared";
import { DayOfWeek, fromUnix, toUnixWeekly } from "@tests/utils/testIntervalUtils/testUnixUtils";
/**
* January 2025 calendar (Week 1 = first full Mon-Sun week):
* Mon Tue Wed Thu Fri Sat Sun
* 1 2 3 4 5 <- (partial)
* 6 7 8 9 10 11 12 <- Week 1
* 13 14 15 16 17 18 19 <- Week 2
* 20 21 22 23 24 25 26 <- Week 3
* 27 28 29 30 31 <- Week 4
*/
describe("get-cycle-end: weekly intervals", () => {
describe("anchor in the past", () => {
// Basic
test("anchor: Jan W1 Wed (8th), now: Jan W1 Sun (12th) -> Jan W2 Wed (15th)", () => {
const anchor = toUnixWeekly({
year: 2025,
month: 1,
week: 1,
day: DayOfWeek.Wed,
});
const now = toUnixWeekly({
year: 2025,
month: 1,
week: 1,
day: DayOfWeek.Sun,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Week,
intervalCount: 1,
now,
});
const { month, day, dayOfWeek } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(15);
expect(dayOfWeek).toBe(DayOfWeek.Wed);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: Jan W1 Wed (8th), now: Jan W2 Fri (17th) -> Jan W3 Wed (22nd)", () => {
const anchor = toUnixWeekly({
year: 2025,
month: 1,
week: 1,
day: DayOfWeek.Wed,
});
const now = toUnixWeekly({
year: 2025,
month: 1,
week: 2,
day: DayOfWeek.Fri,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Week,
intervalCount: 2,
now,
});
const { month, day, dayOfWeek } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(22);
expect(dayOfWeek).toBe(DayOfWeek.Wed);
});
// Edge cases
test("crossing month: anchor: Jan W4 Tue (28th), now: Feb W1 Sun (9th) -> Feb W2 Tue (11th)", () => {
const anchor = toUnixWeekly({
year: 2025,
month: 1,
week: 4,
day: DayOfWeek.Tue,
});
const now = toUnixWeekly({
year: 2025,
month: 2,
week: 1,
day: DayOfWeek.Sun,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Week,
intervalCount: 1,
now,
});
const { month, day, dayOfWeek } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(11);
expect(dayOfWeek).toBe(DayOfWeek.Tue);
});
test("crossing year: anchor: Dec W1 Sun (7th), now: Jan W1 Fri (10th) -> Jan W1 Sun (12th)", () => {
const anchor = toUnixWeekly({
year: 2024,
month: 12,
week: 1,
day: DayOfWeek.Sun,
});
const now = toUnixWeekly({
year: 2025,
month: 1,
week: 1,
day: DayOfWeek.Fri,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Week,
intervalCount: 1,
now,
});
const { year, month, day, dayOfWeek } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(12);
expect(dayOfWeek).toBe(DayOfWeek.Sun);
});
});
describe("anchor in the future", () => {
// Basic
test("anchor: Jan W3 Wed (22nd), now: Jan W1 Sun (12th) -> Jan W2 Wed (15th)", () => {
const anchor = toUnixWeekly({
year: 2025,
month: 1,
week: 3,
day: DayOfWeek.Wed,
});
const now = toUnixWeekly({
year: 2025,
month: 1,
week: 1,
day: DayOfWeek.Sun,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Week,
intervalCount: 1,
now,
});
const { month, day, dayOfWeek } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(15);
expect(dayOfWeek).toBe(DayOfWeek.Wed);
});
// intervalCount > 1
test("(intervalCount = 2) anchor: Jan W3 Mon (20th), now: Jan W1 Sun (12th) -> Jan W3 Mon (20th)", () => {
const anchor = toUnixWeekly({
year: 2025,
month: 1,
week: 3,
day: DayOfWeek.Mon,
});
const now = toUnixWeekly({
year: 2025,
month: 1,
week: 1,
day: DayOfWeek.Sun,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Week,
intervalCount: 2,
now,
});
const { month, day, dayOfWeek } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(20);
expect(dayOfWeek).toBe(DayOfWeek.Mon);
});
// Edge cases
test("just before anchor: anchor: Jan W3 Wed (22nd), now: Jan W3 Tue (21st) -> Jan W3 Wed (22nd)", () => {
const anchor = toUnixWeekly({
year: 2025,
month: 1,
week: 3,
day: DayOfWeek.Wed,
});
const now = toUnixWeekly({
year: 2025,
month: 1,
week: 3,
day: DayOfWeek.Tue,
});
const result = getCycleEnd({
anchor,
interval: BillingInterval.Week,
intervalCount: 1,
now,
});
const { month, day, dayOfWeek } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(22);
expect(dayOfWeek).toBe(DayOfWeek.Wed);
});
});
});

View File

@@ -0,0 +1,369 @@
import { describe, expect, test } from "bun:test";
import { BillingInterval, getCycleStart } from "@autumn/shared";
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
/**
* Test suite 1: Monthly anchor in the past
*
* 1. Anchor is 2 Jan, now is 15 Feb, start should be 2 Feb
* 2. Anchor is 18 Jan, now is 15 Feb, start should be 18 Jan
* 3. (intervalCount = 3) Anchor is 2 Jan, now is 15 Feb, start should be 2 Jan
* 4. (intervalCount = 3) Anchor is 18 Jan, now is 15 May, start should be 18 Apr
*
* Test suite 2: Monthly anchor in the past, edge cases
*
* 1. Anchor is 28 Feb, now is 15 Mar, start should be 28 Feb
* 2. Anchor is 31 Mar, now is 15 Apr, start should be 31 Mar
* 3. Anchor is 31 Mar, now is 2 May, start should be 30 Apr
* 4. Anchor is 31 Mar 12:00, now is 30 Apr 11:59, start should be 31 Mar 12:00
* 5. Anchor is 31 Mar 12:00, now is 30 Apr 12:01, start should be 30 Apr 12:00
*
* Test suite 3: Monthly anchors, anchor in the future
*
* 1. Anchor is 28 Apr, now is 15 Jan, start should be 28 Dec (previous year)
* 2. (intervalCount = 3) Anchor is 28 Apr, now is 15 Jan, start should be 28 Oct (previous year)
*/
describe("get-cycle-start-monthly: monthly intervals", () => {
describe("Monthly anchor in the past", () => {
test("anchor: 2 Jan, now: 15 Feb -> start of cycle should be 2 Feb", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 2 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(2);
expect(hour).toBe(12);
});
test("anchor: 18 Jan, now: 15 Feb -> start of cycle should be 18 Jan", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 18 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(18);
expect(hour).toBe(12);
});
test("(intervalCount = 3) anchor: 2 Jan, now: 15 Feb -> start of cycle should be 2 Jan", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 2 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 3,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(2);
});
test("(intervalCount = 3) anchor: 18 Jan, now: 15 May -> start of cycle should be 18 Apr", () => {
const anchor = toUnix({ year: 2025, month: 1, day: 18 });
const now = toUnix({ year: 2025, month: 5, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 3,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(18);
});
});
describe("Monthly anchor in the past, edge cases", () => {
test("anchor: 28 Feb, now: 15 Mar -> start of cycle should be 28 Feb", () => {
const anchor = toUnix({ year: 2025, month: 2, day: 28 });
const now = toUnix({ year: 2025, month: 3, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("anchor: 31 Mar, now: 15 Apr -> start of cycle should be 31 Mar", () => {
const anchor = toUnix({ year: 2025, month: 3, day: 31 });
const now = toUnix({ year: 2025, month: 4, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(31);
expect(hour).toBe(12);
});
test("anchor: 31 Mar, now: 2 May -> start of cycle should be 30 Apr", () => {
const anchor = toUnix({ year: 2025, month: 3, day: 31 });
const now = toUnix({ year: 2025, month: 5, day: 2 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(30);
expect(hour).toBe(12);
});
test("anchor: 31 Mar 12:00, now: 30 Apr 11:59 -> start of cycle should be 31 Mar 12:00", () => {
const anchor = toUnix({
year: 2025,
month: 3,
day: 31,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 4,
day: 30,
hour: 11,
minute: 59,
});
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour, minute } = fromUnix(result);
expect(month).toBe(3);
expect(day).toBe(31);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
test("anchor: 31 Mar 12:00, now: 30 Apr 12:01 -> start of cycle should be 30 Apr 12:00", () => {
const anchor = toUnix({
year: 2025,
month: 3,
day: 31,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 4,
day: 30,
hour: 12,
minute: 1,
});
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day, hour, minute } = fromUnix(result);
expect(month).toBe(4);
expect(day).toBe(30);
expect(hour).toBe(12);
expect(minute).toBe(0);
});
});
describe("Monthly anchors, anchor in the future", () => {
test("anchor: 28 Apr, now: 15 Jan -> start of cycle should be 28 Dec (previous year)", () => {
const anchor = toUnix({ year: 2025, month: 4, day: 28 });
const now = toUnix({ year: 2025, month: 1, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2024);
expect(month).toBe(12);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("(intervalCount = 3) anchor: 28 Apr, now: 15 Jan -> start of cycle should be 28 Oct (previous year)", () => {
const anchor = toUnix({ year: 2025, month: 4, day: 28 });
const now = toUnix({ year: 2025, month: 1, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 3,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2024);
expect(month).toBe(10);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("(intervalCount = 2) anchor: 31 May, now: 15 Feb -> start of cycle should be 31 Jan", () => {
// Anchor: 31 May 2025, now: 15 Feb 2025
// Cycles (every 2 months): ...31 Jan, 31 Mar, 31 May...
// 15 Feb is between 31 Jan and 31 Mar, so start should be 31 Jan
const anchor = toUnix({ year: 2025, month: 5, day: 31 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 2,
now,
});
const { year, month, day } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(31);
});
});
describe("Monthly anchors, anchor in the future, edge cases", () => {
test("anchor: 31 May, now: 15 Mar -> start should be 28 Feb (end-of-month capping)", () => {
// Anchor: 31 May, now: 15 Mar
// Cycles: ...28 Feb (capped from 31), 31 Mar, 30 Apr (capped), 31 May...
// 15 Mar is between 28 Feb and 31 Mar, so start should be 28 Feb
const anchor = toUnix({ year: 2025, month: 5, day: 31 });
const now = toUnix({ year: 2025, month: 3, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(2);
expect(day).toBe(28);
});
test("anchor: 30 Apr, now: 15 Feb -> start should be 30 Jan", () => {
// Anchor: 30 Apr, now: 15 Feb
// Cycles: ...30 Jan, 28 Feb (capped), 30 Mar, 30 Apr...
// 15 Feb is between 30 Jan and 28 Feb, so start should be 30 Jan
const anchor = toUnix({ year: 2025, month: 4, day: 30 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(30);
});
test("anchor: 28 Apr 12:00, now: 28 Jan 11:59 -> start should be 28 Dec (previous year)", () => {
// now is just before the Jan 28 cycle boundary
const anchor = toUnix({
year: 2025,
month: 4,
day: 28,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 28,
hour: 11,
minute: 59,
});
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2024);
expect(month).toBe(12);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("anchor: 28 Apr 12:00, now: 28 Jan 12:01 -> start should be 28 Jan", () => {
// now is just after the Jan 28 cycle boundary
const anchor = toUnix({
year: 2025,
month: 4,
day: 28,
hour: 12,
minute: 0,
});
const now = toUnix({
year: 2025,
month: 1,
day: 28,
hour: 12,
minute: 1,
});
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 1,
now,
});
const { year, month, day, hour } = fromUnix(result);
expect(year).toBe(2025);
expect(month).toBe(1);
expect(day).toBe(28);
expect(hour).toBe(12);
});
test("(intervalCount = 2) anchor: 31 Jul, now: 15 Feb -> start should be 31 Jan", () => {
// Anchor: 31 Jul, now: 15 Feb
// Cycles (every 2 months): ...30 Nov, 31 Jan, 31 Mar, 31 May, 31 Jul...
// 15 Feb is between 31 Jan and 31 Mar, so start should be 31 Jan
const anchor = toUnix({ year: 2025, month: 7, day: 31 });
const now = toUnix({ year: 2025, month: 2, day: 15 });
const result = getCycleStart({
anchor,
interval: BillingInterval.Month,
intervalCount: 2,
now,
});
const { month, day } = fromUnix(result);
expect(month).toBe(1);
expect(day).toBe(31);
});
});
});

View File

@@ -1,6 +1,10 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
type AppEnv,
CusProductStatus,
type FullCusProduct,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { AppEnv, CusProductStatus, FullCusProduct } from "@autumn/shared";
export const getMainCusProduct = async ({
db,
@@ -15,7 +19,7 @@ export const getMainCusProduct = async ({
env: AppEnv;
productGroup?: string;
}) => {
let customer = await CusService.getFull({
const customer = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId,
@@ -24,9 +28,9 @@ export const getMainCusProduct = async ({
inStatuses: [CusProductStatus.Active],
});
let cusProducts = customer.customer_products;
const cusProducts = customer.customer_products;
let mainCusProduct = cusProducts.find(
const mainCusProduct = cusProducts.find(
(cusProduct: FullCusProduct) =>
!cusProduct.product.is_add_on &&
(productGroup ? cusProduct.product.group === productGroup : true),

View File

@@ -0,0 +1,97 @@
import { UTCDate } from "@date-fns/utc";
export enum DayOfWeek {
Mon = 1,
Tue = 2,
Wed = 3,
Thu = 4,
Fri = 5,
Sat = 6,
Sun = 0,
}
/**
* Helper to create a UTC timestamp from week-based date.
* Week 1 = first full Mon-Sun week of the month.
*
* January 2025 example:
* Mon Tue Wed Thu Fri Sat Sun
* 1 2 3 4 5 <- (partial, before Week 1)
* 6 7 8 9 10 11 12 <- Week 1
* 13 14 15 16 17 18 19 <- Week 2
*
* { year: 2025, month: 1, week: 1, day: DayOfWeek.Mon } = Jan 6
* { year: 2025, month: 1, week: 2, day: DayOfWeek.Tue } = Jan 14
*/
export const toUnixWeekly = ({
year,
month,
week,
day,
hour = 12,
}: {
year: number;
month: number; // 1-indexed
week: number; // 1-indexed (Week 1 = first full Mon-Sun week)
day: DayOfWeek;
hour?: number;
}): number => {
const firstOfMonth = new UTCDate(year, month - 1, 1, hour, 0, 0);
const firstDayOfMonth = firstOfMonth.getDay(); // 0=Sun, 1=Mon, ...
// Find the first Monday of the month
const daysToFirstMonday = (1 - firstDayOfMonth + 7) % 7;
const firstMonday = 1 + daysToFirstMonday;
// Week N starts on firstMonday + (N-1)*7
const weekStart = firstMonday + (week - 1) * 7;
// Day offset from Monday (Mon=0, Tue=1, ..., Sun=6)
const dayOffset = (day - 1 + 7) % 7;
return new UTCDate(
year,
month - 1,
weekStart + dayOffset,
hour,
0,
0,
).getTime();
};
/**
* Helper to create a UTC timestamp from date components (1-indexed month)
*/
export const toUnix = ({
year,
month,
day,
hour = 12,
minute = 0,
second = 0,
}: {
year: number;
month: number;
day: number;
hour?: number;
minute?: number;
second?: number;
}): number => {
return new UTCDate(year, month - 1, day, hour, minute, second).getTime();
};
/**
* Helper to extract date components from a unix timestamp
*/
export const fromUnix = (unix: number) => {
const date = new UTCDate(unix);
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
dayOfWeek: date.getDay() as DayOfWeek,
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
};
};

View File

@@ -0,0 +1,85 @@
import type { z } from "zod/v4";
import { ApiVersion } from "../../../versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "../../../versionUtils/versionChangeUtils/VersionChange.js";
import { AttachBodyV1Schema } from "../attachBodyV1.js";
import { AttachBodyV0Schema } from "../prevVersions/attachBodyV0.js";
/**
* V2_1_AttachBodyChange: Transforms attach request body from V2.0 to V2.1 format
*
* Applied when: sourceVersion <= V2.0
*
* Breaking changes introduced in V2.1:
*
* 1. Removed field: `customer_id`
*
* Input: AttachBodyV2 (V2.0 format)
* Output: AttachBodyV2.1 (V2.1 format)
*/
export const V2_1_AttachBodyChange = defineVersionChange({
name: "V2.1 Attach Body Change",
newVersion: ApiVersion.V2_1,
oldVersion: ApiVersion.V2_0,
description: ["Transforms attach body from V2.0 to V2.1 format"],
affectedResources: [AffectedResource.Attach],
newSchema: AttachBodyV1Schema,
oldSchema: AttachBodyV0Schema,
affectsRequest: true,
affectsResponse: false,
// Request: V0 (AttachBodyV0) → V1 (AttachBodyV1)
transformRequest: ({
input,
}: {
input: z.infer<typeof AttachBodyV0Schema>;
}): z.infer<typeof AttachBodyV1Schema> => {
// Get plan_id from product_id or first product_ids entry
const planId = input.product_id ?? input.product_ids?.[0];
if (!planId) {
throw new Error("product_id or product_ids is required");
}
// Transform options to feature_quantities
const featureQuantities = input.options?.map((opt) => ({
feature_id: opt.feature_id,
quantity: opt.quantity,
}));
// Build invoice_settings from legacy fields
const invoiceSettings = {
enable_immediately: input.enable_product_immediately ?? false,
finalize_immediately: input.finalize_invoice ?? false,
};
// const items = input.items?.map((item) => ({
// product_id: item.product_id,
// quantity: item.quantity,
// }));
return {
customer_id: input.customer_id,
plan_id: planId,
version: input.version,
entity_id: input.entity_id ?? undefined,
customer_data: input.customer_data ?? undefined,
entity_data: input.entity_data,
feature_quantities: featureQuantities,
success_url: input.success_url,
checkout_session_params: input.checkout_session_params,
reward: input.reward,
invoice: input.invoice,
invoice_settings: invoiceSettings,
setup_payment: input.setup_payment,
};
},
});

View File

@@ -4,6 +4,7 @@
* Internally we use SemVer for comparison (e.g., "1.1.0")
*/
export enum ApiVersion {
V2_1 = "2.1.0",
V2_0 = "2.0.0",
V1_Beta = "beta",
V1_2 = "1.2.0",
@@ -16,4 +17,4 @@ export type ApiVersionString = `${ApiVersion}`;
export const API_VERSIONS = Object.values(ApiVersion);
export const LATEST_VERSION = ApiVersion.V2_0;
export const LATEST_VERSION = ApiVersion.V2_1;

View File

@@ -9,6 +9,7 @@ import { V1_2_TrialsUsedChange } from "@api/customers/components/apiTrialsUsed/c
// Import customer product changes
import { V2_1_AttachBodyChange } from "@api/billing/attach/changes/V2.1_AttachBodyChange.js";
import { V1_2_CustomerChange } from "@api/customers/changes/V1.2_CustomerChange.js";
import { V1_2_CustomerQueryChange } from "@api/customers/requestChanges/V1.2_CustomerQueryChange.js";
// Import entity changes
@@ -31,6 +32,10 @@ import { ApiVersion } from "../ApiVersion.js";
import type { VersionChangeConstructor } from "./VersionChange.js";
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
export const V2_1_CHANGES: VersionChangeConstructor[] = [
V2_1_AttachBodyChange, // Transforms Attach Body TO V2.1 format from V2.0 format
];
export const V2_CHANGES: VersionChangeConstructor[] = [
V1_2_CustomerChange, // Transforms Customer TO V1.2 format from V2 format
V1_2_CustomerQueryChange, // Transforms Customer Query TO V2.0 format (adds expand options)
@@ -69,10 +74,16 @@ export const V0_2_CHANGES: VersionChangeConstructor[] = [
export const V0_1_CHANGES: VersionChangeConstructor[] = [];
export function registerAllVersionChanges() {
VersionChangeRegistryClass.register({
version: ApiVersion.V2_1,
changes: V2_1_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V2_0,
changes: V2_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V1_Beta,
changes: V1_4_CHANGES,

View File

@@ -14,6 +14,12 @@ export interface VersionMetadata {
* SemVer ↔ CalVer mappings and metadata
*/
export const VERSION_REGISTRY: Record<ApiVersion, VersionMetadata> = {
[ApiVersion.V2_1]: {
semver: ApiVersion.V2_1,
calver: "2026-03-31",
releasedAt: new Date("2026-03-31").getTime(),
description: "Plan features with max_purchase",
},
[ApiVersion.V2_0]: {
semver: ApiVersion.V2_0,
calver: "2026-03-31",

View File

@@ -23,11 +23,11 @@ export * from "./enums/WebhookEventType.js";
// ANALYTICS MODELS
export * from "./models/analyticsModels/actionEnums.js";
export * from "./models/analyticsModels/actionTable.js";
// Attach Models
export * from "./models/attachModels/attachContext.js";
export * from "./models/attachModels/attachEnums/AttachBranch.js";
export * from "./models/attachModels/attachEnums/AttachConfig.js";
export * from "./models/attachModels/attachEnums/AttachFunction.js";
// Attach Models
export * from "./models/attachModels/attachPreviewModels.js";
export * from "./models/authModels/membership.js";
export * from "./models/chatResultModels/chatResultFeature.js";
@@ -79,12 +79,8 @@ export * from "./models/featureModels/featureModels.js";
// export * from "./models/featureModels/featureResModels.js";
export * from "./api/products/planFeature/apiPlanFeature.js";
export type {
CreatePlanParams,
UpdatePlanParams,
} from "./api/products/planOpModels.js";
// Plan Models
export * from "./api/products/planOpModels.js";
// 2. Feature Models
export * from "./models/featureModels/featureTable.js";
// Gen Models
@@ -95,6 +91,10 @@ export * from "./models/genModels/processorSchemas.js";
// Attach Function Response
export * from "./models/attachModels/attachFunctionResponse.js";
export * from "./models/billingModels/cusProductActions.js";
export * from "./models/billingModels/insertFullCusProductContext.js";
// Billing Models
export * from "./models/billingModels/newProductAction.js";
export * from "./models/migrationModels/migrationErrorTable.js";
export * from "./models/migrationModels/migrationJobTable.js";
export * from "./models/migrationModels/migrationModels.js";
@@ -163,6 +163,8 @@ export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js"
export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
export * from "./models/subModels/subModels.js";
export * from "./models/subModels/subTable.js";
// Billing utils
export * from "./utils/billingUtils/index";
// Utils
export * from "./utils/displayUtils.js";
export * from "./utils/index.js";
@@ -187,4 +189,4 @@ export * from "./utils/productV2Utils/productItemUtils/convertItemUtils.js";
export * from "./utils/productV2Utils/productItemUtils/getProductItemRes.js";
export * from "./utils/productV2Utils/productItemUtils/itemIntervalUtils.js";
export * from "./utils/productV3Utils/productItemUtils/productV3ItemUtils.js";
export * from "./utils/rewardUtils/rewardMigrationUtils.js";
export * from "./utils/rewardUtils/rewardMigrationUtils";

View File

@@ -1,22 +1,21 @@
import type Stripe from "stripe";
import type { FullCustomer } from "../cusModels/fullCusModel.js";
import type { FeatureOptions } from "../cusProductModels/cusProductModels.js";
import type { EntitlementWithFeature } from "../productModels/entModels/entModels.js";
import type { CreateFreeTrial } from "../productModels/freeTrialModels/freeTrialModels.js";
import type { Price } from "../productModels/priceModels/priceModels.js";
import type { FreeTrial } from "../productModels/freeTrialModels/freeTrialModels.js";
import type { FullProduct } from "../productModels/productModels.js";
export type AttachContext = {
// Core
fullCus: FullCustomer;
products: FullProduct[];
prices: Price[];
entitlements: EntitlementWithFeature[];
freeTrial: CreateFreeTrial;
freeTrial?: FreeTrial;
featureQuantities: FeatureOptions[];
stripeCus: Stripe.Customer;
paymentMethod?: Stripe.PaymentMethod;
now?: number;
// Stripe
sub?: Stripe.Subscription;
schedule?: Stripe.SubscriptionSchedule;
testClockFrozenTime?: number; // in milliseconds since epoch
};
// stripeCli: Stripe;

View File

@@ -0,0 +1,21 @@
import z from "zod/v4";
import { FullCusProductSchema } from "../cusProductModels/cusProductModels";
// What happens to the CURRENT active cus product
export const OngoingCusProductActionSchema = z.object({
action: z.literal(["expire", "cancel", "uncancel"]),
cusProduct: FullCusProductSchema,
});
// What happens to any SCHEDULED cus product
export const ScheduledCusProductActionSchema = z.object({
action: z.literal("delete"),
cusProduct: FullCusProductSchema,
});
export type OngoingCusProductAction = z.infer<
typeof OngoingCusProductActionSchema
>;
export type ScheduledCusProductAction = z.infer<
typeof ScheduledCusProductActionSchema
>;

View File

@@ -0,0 +1,30 @@
import z from "zod/v4";
import {
type OngoingCusProductAction,
OngoingCusProductActionSchema,
type ScheduledCusProductAction,
ScheduledCusProductActionSchema,
} from "../attachModels/cusProductActions";
import {
EnrichedNewProductActionSchema,
type NewProductAction,
NewProductActionSchema,
} from "./newProductAction";
export interface CusProductActions {
ongoingCusProductAction?: OngoingCusProductAction;
scheduledCusProductAction?: ScheduledCusProductAction;
newProductActions: NewProductAction[];
}
export const CusProductActionsSchema = z.object({
ongoingCusProductAction: OngoingCusProductActionSchema,
scheduledCusProductAction: ScheduledCusProductActionSchema,
newProductActions: z.array(NewProductActionSchema),
});
export const EnrichedCusProductActionsSchema = CusProductActionsSchema.extend({
ongoingCusProductAction: OngoingCusProductActionSchema,
scheduledCusProductAction: ScheduledCusProductActionSchema,
newProductActions: z.array(EnrichedNewProductActionSchema),
});

View File

@@ -0,0 +1,30 @@
import type { ApiVersion } from "../../api/versionUtils/ApiVersion";
import type { FullCustomer } from "../cusModels/fullCusModel";
import type { AttachReplaceable } from "../cusProductModels/cusEntModels/replaceableSchema";
import type {
CollectionMethod,
CusProductStatus,
} from "../cusProductModels/cusProductEnums";
import type { FeatureOptions } from "../cusProductModels/cusProductModels";
import type { FullProduct } from "../productModels/productModels";
export interface InsertFullCusProductContext {
fullCus: FullCustomer;
product: FullProduct;
featureQuantities: FeatureOptions[];
replaceables: AttachReplaceable[];
}
export interface InsertCusProductOptions {
subscriptionId?: string;
subscriptionScheduleId?: string;
isCustom?: boolean;
resetCycleAnchor?: number; // Unix timestamp of the next
canceledAt?: number;
status?: CusProductStatus; // Used for scheduling product
startsAt?: number; // Used for scheduling product
// Optional + random
apiSemver?: ApiVersion;
collectionMethod?: CollectionMethod;
}

View File

@@ -0,0 +1,16 @@
import z from "zod/v4";
import { FullProductSchema } from "../productModels/productModels";
export const NewProductActionSchema = z.object({
timing: z.literal(["scheduled", "immediate"]),
product: FullProductSchema,
});
export const EnrichedNewProductActionSchema = NewProductActionSchema.extend({
startsAt: z.number().default(Date.now()),
});
export type NewProductAction = z.infer<typeof NewProductActionSchema>;
export type EnrichedNewProductAction = z.infer<
typeof EnrichedNewProductActionSchema
>;

View File

@@ -1,7 +1,6 @@
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import { z } from "zod/v4";
import { CustomerSchema } from "../cusModels/cusModels.js";
import { ProcessorType } from "../genModels/genEnums.js";
import { FreeTrialSchema } from "../productModels/freeTrialModels/freeTrialModels.js";
import { ProductSchema } from "../productModels/productModels.js";
import { FullCustomerEntitlementSchema } from "./cusEntModels/cusEntModels.js";
@@ -51,17 +50,19 @@ export const CusProductSchema = z.object({
// Fixed-cycle configuration
subscription_ids: z.array(z.string()).nullish(),
scheduled_ids: z.array(z.string()).nullish(),
processor: z
.object({
type: z.enum(ProcessorType),
subscription_id: z.string().optional().nullable(),
subscription_schedule_id: z.string().optional().nullable(),
last_invoice_id: z.string().optional().nullable(),
})
.optional(),
// processor: z
// .object({
// type: z.enum(ProcessorType),
// subscription_id: z.string().optional().nullable(),
// subscription_schedule_id: z.string().optional().nullable(),
// last_invoice_id: z.string().optional().nullable(),
// })
// .optional(),
quantity: z.number().default(1),
api_semver: z.enum(ApiVersion).nullable(),
is_custom: z.boolean().default(false),
});
export const FullCusProductSchema = CusProductSchema.extend({
@@ -71,7 +72,6 @@ export const FullCusProductSchema = CusProductSchema.extend({
customer: CustomerSchema.optional(),
product: ProductSchema,
free_trial: FreeTrialSchema.nullish(),
is_custom: z.boolean().default(false),
});
export type CusProduct = z.infer<typeof CusProductSchema>;

View File

@@ -23,6 +23,7 @@
},
"dependencies": {
"date-fns": "^4.1.0",
"@date-fns/utc": "catalog:",
"decimal.js": "^10.5.0",
"dotenv": "^16.5.0",
"drizzle-kit": "catalog:",

View File

@@ -0,0 +1,16 @@
# cycleUtils
## getCycleEnd
Returns the next cycle end after `now`, aligned to the anchor.
**Key behavior:** Cycles extend infinitely in both directions from the anchor. Even if anchor is in the future, we return the next aligned cycle end.
```
anchor: 15:00, now: 10:00, interval: 1 hour
→ cycles: ..., 11:00, 12:00, 13:00, 14:00, 15:00, ...
→ returns: 11:00 (next cycle end after 10:00)
```
This treats anchor as an **alignment point**, not a start time.

View File

@@ -0,0 +1,82 @@
import { UTCDate } from "@date-fns/utc";
import type { BillingInterval } from "@models/productModels/intervals/billingInterval";
import type { EntInterval } from "@models/productModels/intervals/entitlementInterval";
import { formatMs } from "../../common/timeUtils";
import { getCycleIntervalFunctions } from "./getCycleIntervalFunctions";
/**
* Get the next cycle end after `now`, aligned to the anchor.
*
* Uses mathematical calculation instead of iteration:
* 1. Calculate how many full cycles have passed since anchor
* 2. The next cycle end is (cyclesPassed + 1) cycles from anchor
*
* @param anchor - The billing cycle anchor (unix ms)
* @param interval - BillingInterval or EntInterval
* @param intervalCount - Number of intervals per cycle (default: 1)
* @param now - Current time (defaults to Date.now())
* @returns Unix timestamp of the next cycle end
*/
export const getCycleEnd = ({
anchor,
interval,
intervalCount = 1,
now,
}: {
anchor: number;
interval: BillingInterval | EntInterval;
intervalCount?: number;
now?: number;
}): number => {
// EDGE CASE: anchor might be slightly before now due to network latency.
now = now ?? Date.now();
const anchorDate = new UTCDate(anchor);
const nowDate = new UTCDate(now);
// For now, only handle monthly intervals
// TODO: Add support for other intervals
const intervalFunctions = getCycleIntervalFunctions({ interval });
if (!intervalFunctions) {
throw new Error(
`[internal] failed to get interval functions to calculate cycle end for interval: ${interval}`,
);
}
const { add, difference } = intervalFunctions;
const intervalsPassed = difference(nowDate, anchorDate);
// How many complete cycles have passed?
// e.g., if intervalCount=2 and 5 months passed, that's 2 complete cycles
const cyclesPassed = Math.floor(intervalsPassed / intervalCount);
// Next cycle end is (cyclesPassed + 1) * intervalCount months from anchor
const nextCycleEnd = add(anchorDate, (cyclesPassed + 1) * intervalCount);
/**
* Handling edge case with date-fns anchor in the future
* Example: anchorDate: 28 Feb, nowDate: 15 Jan -> Next cycle end will be 28 Feb
* This is because of how differenceInMonths rounds down
* (28 Feb will see cycles passes as -1, so next cycle will be anchorDate + (-1 + 1) months)
*/
// const TOLERANCE_MS = 30 * 1000; // 30 seconds buffer for network latency
/* TO CHECK: To we need a tolerance buffer? If so how much (seconds, milliseconds, etc.?) */
const candidate = add(anchorDate, cyclesPassed * intervalCount);
if (candidate.getTime() > now) return candidate.getTime();
const printLogs = false;
if (printLogs) {
console.log(`anchor: ${formatMs(anchor)}`);
console.log(`now: ${formatMs(now)}`);
console.log(`cycles passed: ${cyclesPassed}`);
console.log(`next cycle end: ${formatMs(nextCycleEnd.getTime())}`);
}
return nextCycleEnd.getTime();
};

View File

@@ -0,0 +1,82 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { EntInterval } from "@models/productModels/intervals/entitlementInterval";
import {
addDays,
addHours,
addMinutes,
addMonths,
addWeeks,
addYears,
differenceInDays,
differenceInHours,
differenceInMinutes,
differenceInMonths,
differenceInWeeks,
differenceInYears,
} from "date-fns";
export type IntervalFns = {
add: (date: Date, amount: number) => Date;
difference: (later: Date, earlier: Date) => number;
};
/**
* Maps an interval type to its corresponding date-fns add/difference functions.
* Returns null for non-recurring intervals (OneOff, Lifetime).
*/
export const getCycleIntervalFunctions = ({
interval,
}: {
interval: BillingInterval | EntInterval;
}): IntervalFns | null => {
switch (interval) {
// Non-recurring intervals
case BillingInterval.OneOff:
case EntInterval.Lifetime:
return null;
// Fine-grained intervals (EntInterval only) [minute is deprecated]
case EntInterval.Minute:
return { add: addMinutes, difference: differenceInMinutes };
case EntInterval.Hour:
return { add: addHours, difference: differenceInHours };
case EntInterval.Day:
return { add: addDays, difference: differenceInDays };
// Shared intervals
case BillingInterval.Week:
case EntInterval.Week:
return { add: addWeeks, difference: differenceInWeeks };
case BillingInterval.Month:
case EntInterval.Month:
return { add: addMonths, difference: differenceInMonths };
case BillingInterval.Quarter:
case EntInterval.Quarter:
return {
add: (date, amount) => addMonths(date, amount * 3),
difference: (later, earlier) =>
Math.floor(differenceInMonths(later, earlier) / 3),
};
case BillingInterval.SemiAnnual:
case EntInterval.SemiAnnual:
return {
add: (date, amount) => addMonths(date, amount * 6),
difference: (later, earlier) =>
Math.floor(differenceInMonths(later, earlier) / 6),
};
case BillingInterval.Year:
case EntInterval.Year:
return { add: addYears, difference: differenceInYears };
default: {
const exhaustiveCheck: never = interval;
throw new Error(`Unknown interval: ${exhaustiveCheck}`);
}
}
};

View File

@@ -0,0 +1,64 @@
import { UTCDate } from "@date-fns/utc";
import type { BillingInterval } from "@models/productModels/intervals/billingInterval";
import type { EntInterval } from "@models/productModels/intervals/entitlementInterval";
import { getCycleIntervalFunctions } from "./getCycleIntervalFunctions";
/**
* Get the start of the current cycle that contains `now`, aligned to the anchor.
*
* Uses mathematical calculation instead of iteration:
* 1. Calculate how many full cycles have passed since anchor
* 2. The cycle start is cyclesPassed cycles from anchor
*
* @param anchor - The billing cycle anchor (unix ms)
* @param interval - BillingInterval or EntInterval
* @param intervalCount - Number of intervals per cycle (default: 1)
* @param now - Current time (defaults to Date.now())
* @returns Unix timestamp of the current cycle start
*/
export const getCycleStart = ({
anchor,
interval,
intervalCount = 1,
now,
}: {
anchor: number;
interval: BillingInterval | EntInterval;
intervalCount?: number;
now?: number;
}): number => {
now = now ?? Date.now();
const anchorDate = new UTCDate(anchor);
const nowDate = new UTCDate(now);
const intervalFunctions = getCycleIntervalFunctions({ interval });
if (!intervalFunctions) {
throw new Error(
`[internal] failed to get interval functions to calculate cycle start for interval: ${interval}`,
);
}
const { add, difference } = intervalFunctions;
const intervalsPassed = difference(nowDate, anchorDate);
// How many complete cycles have passed?
const cyclesPassed = Math.floor(intervalsPassed / intervalCount);
// Cycle start is cyclesPassed * intervalCount from anchor
const cycleStart = add(anchorDate, cyclesPassed * intervalCount);
/**
* Handling edge case with date-fns anchor in the future
* Example: anchorDate: 28 Apr, nowDate: 15 Jan -> differenceInMonths gives -3
* cyclesPassed = floor(-3/3) = -1, so cycleStart = Apr 28 - 3 = Jan 28
* But Jan 28 > Jan 15, so we overshot - need to go back one more cycle to Oct 28
*/
if (cycleStart.getTime() > now) {
return add(anchorDate, (cyclesPassed - 1) * intervalCount).getTime();
}
return cycleStart.getTime();
};

View File

@@ -0,0 +1,3 @@
export * from "./cycleUtils/getCycleEnd.js";
export * from "./cycleUtils/getCycleStart.js";
export * from "./intervalUtils/intervalArithmetic.js";

View File

@@ -0,0 +1,101 @@
import { UTCDate } from "@date-fns/utc";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { EntInterval } from "@models/productModels/intervals/entitlementInterval";
import {
addDays,
addHours,
addMinutes,
addMonths,
addWeeks,
addYears,
getDate,
} from "date-fns";
// Union type for all intervals
type Interval = BillingInterval | EntInterval;
// Intervals that support end-of-month preservation
const MONTH_BASED_INTERVALS = [
BillingInterval.Month,
BillingInterval.Quarter,
BillingInterval.SemiAnnual,
BillingInterval.Year,
EntInterval.Month,
EntInterval.Quarter,
EntInterval.SemiAnnual,
EntInterval.Year,
] as const;
/**
* Adds an interval to a timestamp.
* For month-based intervals, preserves the anchor day (Stripe-compatible end-of-month behavior).
*
* @param from - Unix timestamp in milliseconds
* @param interval - BillingInterval or EntInterval
* @param intervalCount - Number of intervals to add (default: 1)
* @returns Unix timestamp in milliseconds
*/
export const addInterval = ({
from,
interval,
intervalCount = 1,
}: {
from: number;
interval: Interval;
intervalCount?: number;
}): number => {
const fromDate = new UTCDate(from);
const anchorDay = getDate(fromDate);
const isMonthBased = (MONTH_BASED_INTERVALS as readonly string[]).includes(
interval,
);
let result: UTCDate;
switch (interval) {
// Fine-grained intervals (EntInterval only)
case EntInterval.Minute:
return addMinutes(fromDate, intervalCount).getTime();
case EntInterval.Hour:
return addHours(fromDate, intervalCount).getTime();
case EntInterval.Day:
return addDays(fromDate, intervalCount).getTime();
// Week (no end-of-month handling needed)
case BillingInterval.Week:
case EntInterval.Week:
return addWeeks(fromDate, intervalCount).getTime();
// Month-based intervals (with end-of-month preservation)
case BillingInterval.Month:
case EntInterval.Month:
result = new UTCDate(addMonths(fromDate, intervalCount).getTime());
break;
case BillingInterval.Quarter:
case EntInterval.Quarter:
result = new UTCDate(addMonths(fromDate, 3 * intervalCount).getTime());
break;
case BillingInterval.SemiAnnual:
case EntInterval.SemiAnnual:
result = new UTCDate(addMonths(fromDate, 6 * intervalCount).getTime());
break;
case BillingInterval.Year:
case EntInterval.Year:
result = new UTCDate(addYears(fromDate, intervalCount).getTime());
break;
// Non-recurring intervals
case BillingInterval.OneOff:
case EntInterval.Lifetime:
return from; // No change for non-recurring
default:
throw new Error(`Invalid interval: ${interval}`);
}
return result.getTime();
};

View File

@@ -0,0 +1,63 @@
import type { CusProductActions } from "../../../models/billingModels/cusProductActions";
import type { FullCustomer } from "../../../models/cusModels/fullCusModel";
import type { FullProduct } from "../../../models/productModels/productModels";
import { isCusProductCanceled } from "../../cusProductUtils/classifyCusProduct";
import {
getOngoingCusProductById,
getScheduledMainCusProductByGroup,
} from "../../cusProductUtils/getCusProductFromCustomer";
/**
* Gets the actions for uncanceling a cus product.
* @param fullCus - The full customer object.
* @param product - The product object.
* @returns The actions for uncanceling a cus product.
*/
export const getUncancelAttachActions = ({
fullCus,
product,
}: {
fullCus: FullCustomer;
product: FullProduct;
}): CusProductActions | undefined => {
// 1. Get active cus product by ID:
const ongoingSameCusProduct = getOngoingCusProductById({
fullCus,
productId: product.id,
});
if (
!ongoingSameCusProduct ||
!isCusProductCanceled({ cusProduct: ongoingSameCusProduct })
) {
return undefined;
}
// 1. Active cus product actions:
const ongoingCusProductAction = {
action: "uncancel" as const,
cusProduct: ongoingSameCusProduct,
};
// 2. Scheduled cus product actions:
const uncancellingMain = !product.is_add_on;
const scheduledCusProduct = uncancellingMain
? getScheduledMainCusProductByGroup({
fullCus,
productGroup: product.group,
})
: undefined;
const scheduledCusProductAction = scheduledCusProduct
? {
action: "delete" as const,
cusProduct: scheduledCusProduct,
}
: undefined;
return {
ongoingCusProductAction,
scheduledCusProductAction,
newProductActions: [],
};
};

View File

@@ -0,0 +1,59 @@
import type { CusProductActions } from "../../../models/billingModels/cusProductActions";
import type { NewProductAction } from "../../../models/billingModels/newProductAction";
import type { FullCustomer } from "../../../models/cusModels/fullCusModel";
import type { FullProduct } from "../../../models/productModels/productModels";
import { getUncancelAttachActions } from "./getUncancelAttachActions";
import { resolveNewProductTiming } from "./resolveNewProductTiming";
import { resolveOngoingCusProductAction } from "./resolveOngoingCusProductAction";
import { resolveScheduledCusProductAction } from "./resolveScheduledCusProductAction";
export const resolveAttachActions = ({
fullCus,
products,
}: {
fullCus: FullCustomer;
products: FullProduct[];
}): CusProductActions => {
// SHORT CIRCUIT 1: Multiple products:
const product = products[0];
// SHORT CIRCUIT 2: Uncancelling ongoing cus product:
const uncancelAttachActions = getUncancelAttachActions({
fullCus,
product,
});
if (uncancelAttachActions) return uncancelAttachActions;
// 1. Resolve new product timing:
const newProductTiming = resolveNewProductTiming({
fullCus,
product,
});
// 2. Resolve ongoing cus product action:
const ongoingCusProductAction = resolveOngoingCusProductAction({
fullCus,
product,
newProductTiming,
});
// 3. Resolve scheduled cus product action:
const scheduledCusProductAction = resolveScheduledCusProductAction({
fullCus,
product,
newProductTiming,
});
// 4. Resolve new products action:
const newProductAction: NewProductAction = {
timing: newProductTiming,
product,
};
return {
ongoingCusProductAction: ongoingCusProductAction,
scheduledCusProductAction,
newProductActions: [newProductAction],
};
};

View File

@@ -0,0 +1,40 @@
import type { FullCustomer } from "../../../models/cusModels/fullCusModel";
import type { FullProduct } from "../../../models/productModels/productModels";
import { cusProductToPrices } from "../../cusProductUtils/convertCusProduct";
import { getOngoingMainCusProductByGroup } from "../../cusProductUtils/getCusProductFromCustomer";
import { isProductUpgrade } from "../../productUtils/isProductUpgrade";
export const resolveNewProductTiming = ({
fullCus,
product,
}: {
fullCus: FullCustomer;
product: FullProduct;
}): "immediate" | "scheduled" => {
// 1. If product is an add on, return immediate
if (product.is_add_on) return "immediate";
// 2. Get current main cus product
const ongoingCusProduct = getOngoingMainCusProductByGroup({
fullCus,
productGroup: product.group,
});
// 3. If no current main cus product, return immediate
if (!ongoingCusProduct) return "immediate";
// 4. If current main cus product is same as new product return immediate:
const isSameProduct = ongoingCusProduct.product.id === product.id;
if (isSameProduct) return "immediate";
// 4. If current cus product is different from new product:
const curPrices = cusProductToPrices({ cusProduct: ongoingCusProduct });
const isUpgrade = isProductUpgrade({
prices1: curPrices,
prices2: product.prices,
});
if (isUpgrade) return "immediate";
return "scheduled";
};

View File

@@ -0,0 +1,36 @@
import type { FullCustomer } from "../../../models/cusModels/fullCusModel";
import type { FullProduct } from "../../../models/productModels/productModels";
import { getOngoingMainCusProductByGroup } from "../../cusProductUtils/getCusProductFromCustomer";
export const resolveOngoingCusProductAction = ({
fullCus,
product,
newProductTiming,
}: {
fullCus: FullCustomer;
product: FullProduct;
newProductTiming: "immediate" | "scheduled";
}) => {
// 1. If it's an add on, return null
if (product.is_add_on) return;
// 2. Get current main cus product
const ongoingMainCusProduct = getOngoingMainCusProductByGroup({
fullCus,
productGroup: product.group,
});
if (!ongoingMainCusProduct) return;
if (newProductTiming === "immediate") {
return {
action: "expire" as const,
cusProduct: ongoingMainCusProduct,
};
}
return {
action: "cancel" as const,
cusProduct: ongoingMainCusProduct,
};
};

View File

@@ -0,0 +1,35 @@
import type { FullCustomer } from "../../../models/cusModels/fullCusModel";
import type { FullProduct } from "../../../models/productModels/productModels";
import { getScheduledMainCusProductByGroup } from "../../cusProductUtils/getCusProductFromCustomer";
// COMPUTES THE ACTIONS FOR THE SCHEDULED CUS PRODUCT, can be overridden by attach override
export const resolveScheduledCusProductAction = ({
fullCus,
product,
newProductTiming,
}: {
fullCus: FullCustomer;
product: FullProduct;
newProductTiming: "immediate" | "scheduled";
}) => {
// 1. If it's an add on, return null
if (product.is_add_on) return;
// 2. Get scheduled main cus product
const scheduledMainCusProduct = getScheduledMainCusProductByGroup({
fullCus,
productGroup: product.group,
});
if (!scheduledMainCusProduct) return;
// Get new product timing
if (newProductTiming === "immediate") {
return;
}
return {
action: "delete" as const,
cusProduct: scheduledMainCusProduct,
};
};

View File

@@ -0,0 +1,31 @@
import { format } from "date-fns/format";
export const formatMs = (
unixDate?: number | null,
options?: { withTimezone?: boolean },
) => {
if (!unixDate) {
return "undefined unix date";
}
return format(
new Date(unixDate),
options?.withTimezone ? "dd MMM yyyy HH:mm:ss z" : "dd MMM yyyy HH:mm:ss",
);
};
/**
* Formats a unix timestamp in SECONDS to a date and time string.
* If unixSeconds is falsy, returns "undefined unix date".
*/
export const formatSeconds = (
unixSeconds?: number | null,
options?: { withTimezone?: boolean },
): string => {
if (!unixSeconds && unixSeconds !== 0) {
return "undefined unix date";
}
return format(
new Date(unixSeconds * 1000),
options?.withTimezone ? "dd MMM yyyy HH:mm:ss z" : "dd MMM yyyy HH:mm:ss",
);
};

View File

@@ -0,0 +1,43 @@
/**
* Validates that a timestamp is in milliseconds (not seconds).
* Returns true if valid, false otherwise.
*/
export const isValidMsTimestamp = (unixTimestamp: number): boolean => {
// Millisecond timestamps from ~2001 onwards are > 10^12
// Second timestamps won't reach 10^12 until year ~33658
const MIN_MS_TIMESTAMP = 1_000_000_000_000; // ~Sept 2001 in ms
const MAX_MS_TIMESTAMP = 10_000_000_000_000; // ~Nov 2286 in ms
if (unixTimestamp < MIN_MS_TIMESTAMP) {
return false; // Likely in seconds, not milliseconds
}
if (unixTimestamp > MAX_MS_TIMESTAMP) {
return false; // Too large to be valid
}
return true;
};
/**
* Validates that a timestamp is in seconds, then converts to milliseconds.
* Returns undefined if input is undefined or not a valid seconds timestamp.
*/
export const secondsToMs = (
seconds: number | undefined,
): number | undefined => {
if (seconds === undefined) {
return undefined;
}
// Seconds timestamps are currently ~10 digits (1.7 billion)
// They won't reach 10^12 until year ~33658
const MIN_SEC_TIMESTAMP = 0;
const MAX_SEC_TIMESTAMP = 10_000_000_000; // ~Nov 2286 in seconds
if (seconds < MIN_SEC_TIMESTAMP || seconds > MAX_SEC_TIMESTAMP) {
return undefined; // Not a valid seconds timestamp
}
return seconds * 1000;
};

View File

@@ -1,17 +1,90 @@
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels.js";
import {
isFreeProduct,
isOneOffProduct,
} from "../productUtils/classifyProductUtils";
import { notNullish, nullish } from "../utils";
import { cusProductToPrices } from "./convertCusProduct";
import { ACTIVE_STATUSES } from "./cusProductConstants";
export const isCusProductOneOff = ({
cusProduct,
}: {
cusProduct?: FullCusProduct;
}) => {
if (!cusProduct) return false;
const prices = cusProductToPrices({ cusProduct });
return isOneOffProduct({ prices });
};
export const isCusProductCanceled = ({
cusProduct,
}: {
cusProduct?: FullCusProduct;
}) => {
if (!cusProduct) return false;
export const isCanceled = ({ cusProduct }: { cusProduct: FullCusProduct }) => {
return cusProduct.canceled;
};
export const isTrialing = ({
export const isCusProductTrialing = ({
cusProduct,
now,
}: {
cusProduct: FullCusProduct;
cusProduct?: FullCusProduct;
now?: number;
}) => {
if (!cusProduct) return false;
return (
cusProduct.trial_ends_at && cusProduct.trial_ends_at > (now || Date.now())
);
};
// ATTACH PRIMITIVES
export const isCusProductOnEntity = ({
cusProduct,
internalEntityId,
}: {
cusProduct: FullCusProduct;
internalEntityId?: string;
}) => {
return internalEntityId
? cusProduct.internal_entity_id === internalEntityId
: nullish(cusProduct.internal_entity_id);
};
/**
* An "ongoing" customer product is one that:
* - Is NOT a "one off" (recurring or repeating, not a purchase-once type)
* - Has an "active" status (see ACTIVE_STATUSES)
*/
export const isCusProductOngoing = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const isActive = ACTIVE_STATUSES.includes(cusProduct.status);
const prices = cusProductToPrices({ cusProduct });
const isNotOneOff = !isOneOffProduct({ prices });
return isActive && isNotOneOff;
};
// Note, this does not CONFIRM that the subscription is active (might be canceled in Stripe...)
export const cusProductHasSubscription = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const prices = cusProductToPrices({ cusProduct });
if (isFreeProduct({ prices }) || isOneOffProduct({ prices })) return false;
const subId = cusProduct.subscription_ids?.[0];
return notNullish(subId);
};

View File

@@ -1,12 +0,0 @@
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
export const logCusProducts = ({
cusProducts,
}: {
cusProducts: FullCusProduct[];
}) => {
console.log(`CUS PRODUCTS:`);
for (const cusProduct of cusProducts) {
console.log(`${cusProduct.product.id} - ${cusProduct.status}`);
}
};

View File

@@ -1,24 +0,0 @@
import type { FullCustomer } from "../../models/cusModels/fullCusModel";
export const getCurrentCusProduct = ({
fullCus,
productId,
productGroup,
}: {
fullCus: FullCustomer;
productId: string;
productGroup: string;
}) => {
const cusProducts = fullCus.customer_products;
const entity = fullCus.entity;
// 1. If entity, filter out cusProducts for that entity...?
if (entity) {
const filteredCusProducts = cusProducts.filter(
(cp) => cp.internal_entity_id === entity.internal_id,
);
}
// return fullCus.customer_products.find((cp) => cp.product_id === productId);
};

View File

@@ -0,0 +1,181 @@
import type { FullCustomer } from "../../models/cusModels/fullCusModel";
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums";
import {
cusProductHasSubscription,
isCusProductOnEntity,
isCusProductOngoing,
} from "./classifyCusProduct";
/**
* Finds the active main (not add-on, not one-off) customer product in a given group for a customer.
* Filters by product group, entity, active status, non-add-on, and non-one-off products.
*/
export const getOngoingMainCusProductByGroup = ({
fullCus,
productGroup,
}: {
fullCus: FullCustomer;
productGroup: string;
}) => {
const internalEntityId = fullCus.entity?.internal_id;
const cusProducts = fullCus.customer_products;
const activeMainCusProduct = cusProducts.find((cp) => {
// 1. Product group matches
const productGroupMatches = cp.product.group === productGroup;
// 2. Entity matches
const entityMatches = isCusProductOnEntity({
cusProduct: cp,
internalEntityId,
});
// 3. Status is active
const isOngoing = isCusProductOngoing({ cusProduct: cp });
// 4. Is main product
const isMainProduct = !cp.product.is_add_on;
return productGroupMatches && entityMatches && isOngoing && isMainProduct;
});
return activeMainCusProduct;
};
/**
* Finds the active customer product by id for a customer.
* Filters by product id, entity, active status.
*/
export const getOngoingCusProductById = ({
fullCus,
productId,
}: {
fullCus: FullCustomer;
productId: string;
}) => {
const internalEntityId = fullCus.entity?.internal_id;
const cusProducts = fullCus.customer_products;
const activeCusProduct = cusProducts.find((cp) => {
// 1. Product matches
const productMatches = cp.product.id === productId;
// 2. Entity matches
const entityMatches = isCusProductOnEntity({
cusProduct: cp,
internalEntityId,
});
// 3. Status is active
const isOngoing = isCusProductOngoing({ cusProduct: cp });
return productMatches && entityMatches && isOngoing;
});
return activeCusProduct;
};
/**
* Finds the scheduled customer product in a given group for a customer.
* Filters by product group, scheduled status, and entity.
*/
export const getScheduledMainCusProductByGroup = ({
fullCus,
productGroup,
}: {
fullCus: FullCustomer;
productGroup: string;
}) => {
return fullCus.customer_products.find((cp) => {
const productGroupMatches = cp.product.group === productGroup;
const statusMatches = cp.status === CusProductStatus.Scheduled;
const entityMatches = isCusProductOnEntity({
cusProduct: cp,
internalEntityId: fullCus.entity?.internal_id,
});
const isMainProduct = !cp.product.is_add_on;
return (
productGroupMatches && statusMatches && entityMatches && isMainProduct
);
});
};
/**
* Finds the best cus product to merge subscriptions with for an incoming product.
* Priority: 1. Entity match, 2. Product ID match, 3. Product group match
*/
export const getTargetSubscriptionCusProduct = ({
fullCus,
productId,
productGroup,
cusProductId,
}: {
fullCus: FullCustomer;
productId: string;
productGroup: string;
cusProductId?: string;
}) => {
const internalEntityId = fullCus.entity?.internal_id;
const cusProducts = fullCus.customer_products.filter((cp) => {
const isOngoing = isCusProductOngoing({ cusProduct: cp });
const hasSub = cusProductHasSubscription({ cusProduct: cp });
return isOngoing && hasSub;
});
// Sort by merge order:
// 1. Entity match (highest priority)
// 2. Main product (add-ons lowest priority)
// 3. Product ID match
// 4. Product group match
cusProducts.sort((a, b) => {
// 0. Cus product ID match
const aCusProductIdMatch = a.id === cusProductId;
const bCusProductIdMatch = b.id === cusProductId;
if (aCusProductIdMatch && !bCusProductIdMatch) return -1;
if (!aCusProductIdMatch && bCusProductIdMatch) return 1;
// 1. Entity match (highest priority)
const aEntityMatch = isCusProductOnEntity({
cusProduct: a,
internalEntityId,
});
const bEntityMatch = isCusProductOnEntity({
cusProduct: b,
internalEntityId,
});
if (aEntityMatch && !bEntityMatch) return -1;
if (!aEntityMatch && bEntityMatch) return 1;
// 2. Main product (add-ons lowest priority)
const aIsMain = !a.product.is_add_on;
const bIsMain = !b.product.is_add_on;
if (aIsMain && !bIsMain) return -1;
if (!aIsMain && bIsMain) return 1;
// 3. Product ID match
const aProductIdMatch = a.product.id === productId;
const bProductIdMatch = b.product.id === productId;
if (aProductIdMatch && !bProductIdMatch) return -1;
if (!aProductIdMatch && bProductIdMatch) return 1;
// 4. Product group match
const aGroupMatch = a.product.group === productGroup;
const bGroupMatch = b.product.group === productGroup;
if (aGroupMatch && !bGroupMatch) return -1;
if (!aGroupMatch && bGroupMatch) return 1;
return 0;
});
return cusProducts[0];
};

View File

@@ -1,13 +1,24 @@
// Cus ent utils
// Billing utils
export * from "./cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.js";
export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js";
export * from "./cusEntUtils/balanceUtils.js";
export * from "./cusEntUtils/classifyCusEntUtils.js";
export * from "./cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.js";
export * from "./cusEntUtils/convertCusEntUtils.js";
export * from "./cusEntUtils/cusEntUtils.js";
export * from "./cusEntUtils/filterCusEntUtils.js";
// Billing utils
export * from "./billingUtils/resolveAttachUtils/getUncancelAttachActions.js";
export * from "./billingUtils/resolveAttachUtils/resolveAttachActions.js";
export * from "./billingUtils/resolveAttachUtils/resolveNewProductTiming.js";
export * from "./billingUtils/resolveAttachUtils/resolveOngoingCusProductAction.js";
export * from "./billingUtils/resolveAttachUtils/resolveScheduledCusProductAction.js";
export * from "./common/timeUtils.js";
export * from "./common/unixUtils.js";
export * from "./common/unixUtils.js";
// Cus ent utils
export * from "./cusEntUtils/balanceUtils";
export * from "./cusEntUtils/balanceUtils/cusEntToPrepaidQuantity";
export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance";
export * from "./cusEntUtils/classifyCusEntUtils";
export * from "./cusEntUtils/convertCusEntUtils";
export * from "./cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase";
export * from "./cusEntUtils/cusEntUtils";
export * from "./cusEntUtils/filterCusEntUtils";
// Cus ent utils
export * from "./cusEntUtils/getRolloverFields.js";
export * from "./cusEntUtils/getStartingBalance.js";
@@ -19,7 +30,7 @@ export * from "./cusProductUtils/cusProductConstants.js";
export * from "./cusProductUtils/cusProductUtils.js";
export * from "./cusProductUtils/filterCusProductUtils.js";
export * from "./cusProductUtils/filterCusProductUtils.js";
export * from "./cusProductUtils/formatCusProductUtils.js";
export * from "./cusProductUtils/getCusProductFromCustomer.js";
export * from "./cusProductUtils/productIdToCusProduct.js";
// Cus utils
export * from "./cusUtils/cusPlanUtils/cusPlanUtils.js";
@@ -28,11 +39,18 @@ export * from "./featureUtils/apiFeatureToDbFeature.js";
export * from "./featureUtils/convertFeatureUtils.js";
// Feature utils
export * from "./featureUtils.js";
// INTERVAL UTILS
export * from "./intervalUtils/addBillingInterval.js";
export * from "./intervalUtils/priceIntervalUtils.js";
// Org utils
export * from "./orgUtils/convertOrgUtils.js";
export * from "./productUtils/classifyProductUtils.js";
// Product utils
export * from "./productUtils/convertUtils.js";
export * from "./productUtils/entUtils/classifyEntUtils.js";
export * from "./productUtils/entUtils/entUtils.js";
export * from "./productUtils/entUtils/formatEntUtils.js";
export * from "./productUtils/priceUtils/classifyPriceUtils.js";
export * from "./productUtils/priceUtils/convertAmountUtils.js";
export * from "./productUtils/priceUtils/formatPriceUtils.js";
export * from "./productUtils/priceUtils.js";

View File

@@ -20,29 +20,7 @@ export const intervalToValue = (
export type IntervalConfig = {
interval: BillingInterval;
intervalCount?: number | null;
};
export const intervalsDifferent = ({
intervalA,
intervalB,
}: {
intervalA: IntervalConfig;
intervalB: IntervalConfig;
}) => {
const valA = intervalToValue(intervalA.interval, intervalA.intervalCount);
const valB = intervalToValue(intervalB.interval, intervalB.intervalCount);
return valA !== valB;
};
export const intervalsSame = ({
intervalA,
intervalB,
}: {
intervalA: IntervalConfig;
intervalB: IntervalConfig;
}) => {
return !intervalsDifferent({ intervalA, intervalB });
intervalCount?: number;
};
type EntIntervalConfig = {

View File

@@ -0,0 +1,32 @@
import { UTCDate } from "@date-fns/utc";
import { addMonths, addWeeks, addYears } from "date-fns";
import { BillingInterval } from "../../models/productModels/intervals/billingInterval";
import type { IntervalConfig } from "../intervalUtils";
// Validate unix timestamp
export const addBillingInterval = ({
fromUnix,
intervalConfig,
}: {
fromUnix: number;
intervalConfig: IntervalConfig;
}) => {
const { interval, intervalCount = 1 } = intervalConfig;
const fromDate = new UTCDate(fromUnix);
switch (interval) {
case BillingInterval.Week:
return addWeeks(fromDate, 1 * intervalCount).getTime();
case BillingInterval.Month:
return addMonths(fromDate, intervalCount).getTime();
case BillingInterval.Quarter:
return addMonths(fromDate, 3 * intervalCount).getTime();
case BillingInterval.SemiAnnual:
return addMonths(fromDate, 6 * intervalCount).getTime();
case BillingInterval.Year:
return addYears(fromDate, 1 * intervalCount).getTime();
default:
throw new Error(`Invalid billing interval: ${interval}`);
}
};

View File

@@ -0,0 +1,165 @@
import {
BillingInterval,
EntInterval,
type Entitlement,
type Price,
} from "@autumn/shared";
import { nullish } from "../utils";
type IntervalConfig = {
interval: BillingInterval;
intervalCount?: number | null;
};
const entToBillingInterval = (entInterval: EntInterval | null | undefined) => {
if (entInterval === EntInterval.Lifetime || !entInterval) {
return BillingInterval.OneOff;
} else return entInterval as unknown as BillingInterval;
};
const intervalToValue = (
interval: BillingInterval,
intervalCount?: number | null,
) => {
const intervalToBaseVal: Record<BillingInterval, number> = {
[BillingInterval.OneOff]: 0,
[BillingInterval.Week]: 0.25,
[BillingInterval.Month]: 1,
[BillingInterval.Quarter]: 3,
[BillingInterval.SemiAnnual]: 6,
[BillingInterval.Year]: 12,
};
return intervalToBaseVal[interval] * (intervalCount ?? 1);
};
export const compareBillingIntervals = ({
configA,
configB,
}: {
configA: IntervalConfig;
configB: IntervalConfig;
}): number => {
const a = intervalToValue(configA.interval, configA.intervalCount);
const b = intervalToValue(configB.interval, configB.intervalCount);
return b - a;
};
export const getLargestInterval = ({
prices,
excludeOneOff = false,
}: {
prices: Price[];
excludeOneOff?: boolean;
}) => {
let sortedPrices = structuredClone(prices);
sortPricesByInterval(sortedPrices);
if (excludeOneOff) {
sortedPrices = sortedPrices.filter(
(price) => price.config.interval !== BillingInterval.OneOff,
);
}
if (sortedPrices.length === 0) {
return null;
}
return {
interval: sortedPrices[0].config.interval,
intervalCount: sortedPrices[0].config.interval_count ?? 1,
};
};
export const getSmallestInterval = ({
prices,
ents,
excludeOneOff = false,
}: {
prices: Price[];
ents?: Entitlement[];
excludeOneOff?: boolean;
}) => {
// let sortedPrices = structuredClone(prices);
// sortPricesByInterval(sortedPrices);
let allPriceIntervals = prices.map((p) => {
return {
interval: p.config.interval,
intervalCount: p.config.interval_count ?? 1,
};
});
if (excludeOneOff) {
allPriceIntervals = allPriceIntervals.filter(
(p) => p.interval !== BillingInterval.OneOff,
);
}
const allEntIntervals = ents?.map((e) => {
return {
interval: entToBillingInterval(e.interval),
intervalCount: e.interval_count ?? 1,
};
});
const allIntervals = [...allPriceIntervals, ...(allEntIntervals || [])];
if (allIntervals.length === 0) {
return null;
}
allIntervals.sort((a, b) => {
return compareBillingIntervals({ configA: a, configB: b });
});
const smallestInterval = allIntervals?.[allIntervals.length - 1];
return {
interval: smallestInterval.interval,
intervalCount: smallestInterval.intervalCount,
};
};
export const sortPricesByInterval = (prices: Price[]) => {
return prices.sort((a, b) => {
return compareBillingIntervals({ configA: a.config, configB: b.config });
});
};
export const intervalsDifferent = ({
intervalA,
intervalB,
}: {
intervalA: IntervalConfig | null;
intervalB: IntervalConfig | null;
}) => {
// return compareBillingIntervals({ configA: intervalA, configB: intervalB }) !== 0;
if (nullish(intervalA) && nullish(intervalB)) {
return false;
}
if (nullish(intervalA) || nullish(intervalB)) {
return true;
}
const intervalCountA = intervalToValue(
intervalA.interval,
intervalA.intervalCount,
);
const intervalCountB = intervalToValue(
intervalB.interval,
intervalB.intervalCount,
);
return intervalCountA !== intervalCountB;
};
export const intervalsSame = ({
intervalA,
intervalB,
}: {
intervalA: IntervalConfig;
intervalB: IntervalConfig;
}) => {
return !intervalsDifferent({ intervalA, intervalB });
};

View File

@@ -1,10 +1,11 @@
import { Decimal } from "decimal.js";
import type {
CreateFreeTrial,
FreeTrial,
Price,
ProductItem,
ProductV2,
import {
type CreateFreeTrial,
type FreeTrial,
isFreeProduct,
type Price,
type ProductItem,
type ProductV2,
} from "../index.js";
import {
isFeaturePriceItem,
@@ -113,24 +114,6 @@ export const sortProductsV2 = ({ products }: { products: ProductV2[] }) => {
});
};
export const isFreeProduct = ({ prices }: { prices: Price[] }) => {
if (prices.length === 0) {
return true;
}
let totalPrice = 0;
for (const price of prices) {
if ("usage_tiers" in price.config) {
const tiers = price.config.usage_tiers;
if (nullish(tiers) || tiers.length === 0) continue;
totalPrice += tiers.reduce((acc, tier) => acc + tier.amount, 0);
} else {
totalPrice += price.config.amount;
}
}
return totalPrice === 0;
};
export const isDefaultTrial = ({
freeTrial,
isDefault,

View File

@@ -0,0 +1,49 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { type FullProduct, nullish } from "../../index.js";
import type { FixedPriceConfig } from "../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js";
import type { UsagePriceConfig } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import { PriceType } from "../../models/productModels/priceModels/priceEnums.js";
import type { Price } from "../../models/productModels/priceModels/priceModels.js";
// TODO: Write unit tests for these functions (?)
export const isOneOffProduct = ({ prices }: { prices: Price[] }) => {
return (
prices.every((p) => p.config?.interval === BillingInterval.OneOff) &&
prices.some((p) => {
if (p.config?.type === PriceType.Usage) {
const config = p.config as UsagePriceConfig;
return config.usage_tiers.some((t) => t.amount > 0);
} else {
const config = p.config as FixedPriceConfig;
return config.amount > 0;
}
})
);
};
export const isFreeProduct = ({ prices }: { prices: Price[] }) => {
if (prices.length === 0) {
return true;
}
let totalPrice = 0;
for (const price of prices) {
if ("usage_tiers" in price.config) {
const tiers = price.config.usage_tiers;
if (nullish(tiers) || tiers.length === 0) continue;
totalPrice += tiers.reduce((acc, tier) => acc + tier.amount, 0);
} else {
totalPrice += price.config.amount;
}
}
return totalPrice === 0;
};
export const isOneOffOrAddOnProduct = ({
product,
}: {
product: FullProduct;
}) => {
return isOneOffProduct({ prices: product.prices }) || product.is_add_on;
};

View File

@@ -0,0 +1,39 @@
import { FeatureType } from "../../../models/featureModels/featureEnums";
import {
AllowanceType,
type EntitlementWithFeature,
} from "../../../models/productModels/entModels/entModels";
import { EntInterval } from "../../../models/productModels/intervals/entitlementInterval";
import { notNullish } from "../../utils";
export const isBooleanEntitlement = ({
entitlement,
}: {
entitlement: EntitlementWithFeature;
}) => {
return entitlement.feature.type === FeatureType.Boolean;
};
export const isUnlimitedEntitlement = ({
entitlement,
}: {
entitlement: EntitlementWithFeature;
}) => {
return entitlement.allowance_type === AllowanceType.Unlimited;
};
export const isEntityScopedEntitlement = ({
entitlement,
}: {
entitlement: EntitlementWithFeature;
}) => {
return notNullish(entitlement.entity_feature_id);
};
export const isLifetimeEntitlement = ({
entitlement,
}: {
entitlement: EntitlementWithFeature;
}) => {
return entitlement.interval === EntInterval.Lifetime;
};

View File

@@ -0,0 +1,12 @@
import type { Entity } from "../../../models/cusModels/entityModels/entityModels";
import type { EntitlementWithFeature } from "../../../models/productModels/entModels/entModels";
export const entitlementFeatureMatchesEntityFeature = ({
entitlement,
entity,
}: {
entitlement: EntitlementWithFeature;
entity: Entity;
}) => {
return entitlement.feature.internal_id === entity.internal_feature_id;
};

View File

@@ -0,0 +1,73 @@
import { Decimal } from "decimal.js";
import { intervalsSame, isConsumablePayPerUsePrice, nullish } from "../..";
import type { Price } from "../../models/productModels/priceModels/priceModels";
import {
compareBillingIntervals,
getLargestInterval,
} from "../intervalUtils/priceIntervalUtils";
import { isFreeProduct } from "./classifyProductUtils";
export const isProductUpgrade = ({
prices1,
prices2,
usageAlwaysUpgrade = true,
}: {
prices1: Price[];
prices2: Price[];
usageAlwaysUpgrade?: boolean;
}) => {
const prod1IsFree = isFreeProduct({ prices: prices1 });
const prod2IsFree = isFreeProduct({ prices: prices2 });
// 1. If one product is free and the other is not, then free -> paid is an upgrade
if (prod1IsFree && prod2IsFree) return true;
if (prod1IsFree && !prod2IsFree) return true;
if (!prod1IsFree && prod2IsFree) return false;
if (
prices1.every((p) => isConsumablePayPerUsePrice({ price: p })) &&
prices2.every((p) => isConsumablePayPerUsePrice({ price: p })) &&
usageAlwaysUpgrade
) {
return true;
}
const billingInterval1 = getLargestInterval({ prices: prices1 }); // pro quarter
const billingInterval2 = getLargestInterval({ prices: prices2 }); // premium
// Billing is nullish if there's a free product. Should not happen!
if (nullish(billingInterval1) || nullish(billingInterval2)) return false;
// 2. Get total price for each product
const getTotalPrice = (prices: Price[]) => {
let totalPrice = new Decimal(0);
for (const price of prices) {
if ("usage_tiers" in price.config) {
const tiers = price.config.usage_tiers;
if (nullish(tiers) || tiers.length === 0) continue;
totalPrice = totalPrice.plus(tiers[0].amount);
} else {
totalPrice = totalPrice.plus(price.config.amount);
}
}
return totalPrice.toNumber();
};
// 3. Compare prices
if (
intervalsSame({
intervalA: billingInterval1,
intervalB: billingInterval2,
})
) {
return getTotalPrice(prices1) < getTotalPrice(prices2);
} else {
return (
compareBillingIntervals({
configA: billingInterval1,
configB: billingInterval2,
}) > 0
);
}
};

View File

@@ -52,40 +52,11 @@ export const isOneOffPrice = ({ price }: { price: Price }) => {
return price.config.interval === BillingInterval.OneOff;
};
export const isUsagePrice = ({
price,
featureId,
}: {
price: Price;
featureId?: string;
}) => {
const billingType = getBillingType(price.config);
const isUsage =
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated ||
billingType === BillingType.UsageInAdvance;
if (featureId) {
return isUsage && price.config.feature_id === featureId;
}
return isUsage;
};
export const isPrepaidPrice = ({ price }: { price: Price }) => {
const billingType = getBillingType(price.config);
return billingType === BillingType.UsageInAdvance;
};
export const isPayPerUse = ({ price }: { price: Price }) => {
const billingType = getBillingType(price.config);
return (
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated
);
};
export const isFixedPrice = ({ price }: { price: Price }) => {
const billingType = getBillingType(price.config);

View File

@@ -0,0 +1,38 @@
import { BillingType } from "../../../models/productModels/priceModels/priceEnums";
import type { Price } from "../../../models/productModels/priceModels/priceModels";
import { getBillingType } from "../priceUtils";
export const isUsagePrice = ({
price,
featureId,
}: {
price: Price;
featureId?: string;
}) => {
const billingType = getBillingType(price.config);
const isUsage =
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated ||
billingType === BillingType.UsageInAdvance;
if (featureId) {
return isUsage && price.config.feature_id === featureId;
}
return isUsage;
};
export const isPayPerUsePrice = ({ price }: { price: Price }) => {
const billingType = getBillingType(price.config);
return (
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated
);
};
export const isConsumablePayPerUsePrice = ({ price }: { price?: Price }) => {
if (!price) return false;
const billingType = getBillingType(price.config);
return billingType === BillingType.UsageInArrear;
};

View File

@@ -11,7 +11,8 @@ import type {
ProductItemConfig,
RolloverConfig,
} from "../../../models/productV2Models/productItemModels/productItemModels.js";
import { entIntervalsSame, intervalsSame } from "../../intervalUtils.js";
import { intervalsSame } from "../../intervalUtils/priceIntervalUtils.js";
import { entIntervalsSame } from "../../intervalUtils.js";
import { itemToFeature } from "../productItemUtils/convertItemUtils.js";
import {
isFeatureItem,

View File

@@ -1,16 +1,13 @@
import type {
FixedPriceConfig,
Price,
Reward,
RewardType,
UsagePriceConfig,
UsageTier,
} from "../../index.js";
import {
getBillingType,
isFixedPrice,
type FixedPriceConfig,
isUsagePrice,
} from "../productUtils/priceUtils.js";
type Price,
type Reward,
type RewardType,
type UsagePriceConfig,
type UsageTier,
} from "../../index.js";
import { getBillingType, isFixedPrice } from "../productUtils/priceUtils.js";
// Helper function to check if tier structures match
const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => {

View File

@@ -1,5 +1,5 @@
import axios, { AxiosInstance } from "axios";
import { Customer } from "@autumn/shared";
import axios, { type AxiosInstance } from "axios";
export class CusService {
static async createCustomer(axios: AxiosInstance, data: any) {
@@ -11,7 +11,7 @@ export class CusService {
}
static async attach(axios: AxiosInstance, data: any) {
return await axios.post(`/v1/attach`, {
return await axios.post(`/v1/attach_v2`, {
...data,
});
}

View File

@@ -2,7 +2,7 @@ import {
CusProductStatus,
type Entity,
featureToOptions,
isTrialing,
isCusProductTrialing,
type ProductItem,
UsageModel,
} from "@autumn/shared";
@@ -301,7 +301,10 @@ export function SubscriptionDetailSheet() {
status={cusProduct.status}
canceled={cusProduct.canceled}
trialing={
isTrialing({ cusProduct, now: Date.now() }) || false
isCusProductTrialing({
cusProduct,
now: Date.now(),
}) || false
}
trial_ends_at={cusProduct.trial_ends_at ?? undefined}
/>

Some files were not shown because too many files have changed in this diff Show More