wip, built cycle utils
This commit is contained in:
@@ -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();`
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
@@ -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,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
34
server/src/internal/billing/billingUtils/getAttachSub.ts
Normal file
34
server/src/internal/billing/billingUtils/getAttachSub.ts
Normal 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 };
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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();
|
||||
// }
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
// },
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { FullCusProduct } from "@autumn/shared";
|
||||
|
||||
export const isCanceled = ({ cusProduct }: { cusProduct: FullCusProduct }) => {
|
||||
return cusProduct.canceled;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
211
server/tests/unit-tests/billing/interval/add-interval1.test.ts
Normal file
211
server/tests/unit-tests/billing/interval/add-interval1.test.ts
Normal 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);
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
177
server/tests/unit-tests/billing/interval/add-interval2.test.ts
Normal file
177
server/tests/unit-tests/billing/interval/add-interval2.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
97
server/tests/utils/testIntervalUtils/testUnixUtils.ts
Normal file
97
server/tests/utils/testIntervalUtils/testUnixUtils.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
85
shared/api/billing/attach/changes/V2.1_AttachBodyChange.ts
Normal file
85
shared/api/billing/attach/changes/V2.1_AttachBodyChange.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
shared/models/attachModels/cusProductActions.ts
Normal file
21
shared/models/attachModels/cusProductActions.ts
Normal 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
|
||||
>;
|
||||
30
shared/models/billingModels/cusProductActions.ts
Normal file
30
shared/models/billingModels/cusProductActions.ts
Normal 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),
|
||||
});
|
||||
30
shared/models/billingModels/insertFullCusProductContext.ts
Normal file
30
shared/models/billingModels/insertFullCusProductContext.ts
Normal 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;
|
||||
}
|
||||
16
shared/models/billingModels/newProductAction.ts
Normal file
16
shared/models/billingModels/newProductAction.ts
Normal 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
|
||||
>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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:",
|
||||
|
||||
16
shared/utils/billingUtils/cycleUtils/README.md
Normal file
16
shared/utils/billingUtils/cycleUtils/README.md
Normal 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.
|
||||
|
||||
82
shared/utils/billingUtils/cycleUtils/getCycleEnd.ts
Normal file
82
shared/utils/billingUtils/cycleUtils/getCycleEnd.ts
Normal 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();
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
64
shared/utils/billingUtils/cycleUtils/getCycleStart.ts
Normal file
64
shared/utils/billingUtils/cycleUtils/getCycleStart.ts
Normal 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();
|
||||
};
|
||||
3
shared/utils/billingUtils/index.ts
Normal file
3
shared/utils/billingUtils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./cycleUtils/getCycleEnd.js";
|
||||
export * from "./cycleUtils/getCycleStart.js";
|
||||
export * from "./intervalUtils/intervalArithmetic.js";
|
||||
101
shared/utils/billingUtils/intervalUtils/intervalArithmetic.ts
Normal file
101
shared/utils/billingUtils/intervalUtils/intervalArithmetic.ts
Normal 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();
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
@@ -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],
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
31
shared/utils/common/timeUtils.ts
Normal file
31
shared/utils/common/timeUtils.ts
Normal 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",
|
||||
);
|
||||
};
|
||||
43
shared/utils/common/unixUtils.ts
Normal file
43
shared/utils/common/unixUtils.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
181
shared/utils/cusProductUtils/getCusProductFromCustomer.ts
Normal file
181
shared/utils/cusProductUtils/getCusProductFromCustomer.ts
Normal 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];
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
32
shared/utils/intervalUtils/addBillingInterval.ts
Normal file
32
shared/utils/intervalUtils/addBillingInterval.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
165
shared/utils/intervalUtils/priceIntervalUtils.ts
Normal file
165
shared/utils/intervalUtils/priceIntervalUtils.ts
Normal 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 });
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
49
shared/utils/productUtils/classifyProductUtils.ts
Normal file
49
shared/utils/productUtils/classifyProductUtils.ts
Normal 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;
|
||||
};
|
||||
39
shared/utils/productUtils/entUtils/classifyEntUtils.ts
Normal file
39
shared/utils/productUtils/entUtils/classifyEntUtils.ts
Normal 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;
|
||||
};
|
||||
12
shared/utils/productUtils/entUtils/entUtils.ts
Normal file
12
shared/utils/productUtils/entUtils/entUtils.ts
Normal 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;
|
||||
};
|
||||
73
shared/utils/productUtils/isProductUpgrade.ts
Normal file
73
shared/utils/productUtils/isProductUpgrade.ts
Normal 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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
38
shared/utils/productUtils/priceUtils/classifyPriceUtils.ts
Normal file
38
shared/utils/productUtils/priceUtils/classifyPriceUtils.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user