tests: custom intervals

This commit is contained in:
John Yeo
2025-08-10 07:15:16 +01:00
parent 0500b84e8b
commit d8874d9dad
26 changed files with 600 additions and 645 deletions

View File

@@ -12,7 +12,8 @@ $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
'tests/advanced/coupons/*.ts' \
'tests/attach/updateQuantity/*.ts' \
'tests/advanced/referrals/*.ts' \
'tests/advanced/rollovers/*.ts'
'tests/advanced/rollovers/*.ts' \
'tests/advanced/customInterval/*.ts'
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
# 'tests/advanced/usageLimit/*.ts'

View File

@@ -12,6 +12,8 @@ import {
} from "@autumn/shared";
import {
CancelParams,
CheckoutParams,
CheckoutResult,
CheckParams,
CheckResult,
Customer,
@@ -168,6 +170,16 @@ export class AutumnInt {
return data;
}
async checkout(params: CheckoutParams) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
// options: toSnakeCase(options),
// });
const data = await this.post(`/checkout`, params);
return data as CheckoutResult;
}
async sendEvent({
customerId,

View File

@@ -32,6 +32,11 @@ export const billingIntervalToStripe = ({
}) => {
const finalCount = intervalCount ?? 1;
switch (interval) {
case BillingInterval.Week:
return {
interval: "week",
interval_count: finalCount,
};
case BillingInterval.Month:
return {
interval: "month",

View File

@@ -273,6 +273,7 @@ const getChangeProductBranch = async ({
// }
let isUpgrade = isProductUpgrade({ prices1: curPrices, prices2: newPrices });
if (isUpgrade) {
if (isTrialing(curMainProduct!)) {
return AttachBranch.MainIsTrial;

View File

@@ -1,410 +0,0 @@
import { Stripe } from "stripe";
import { AttachParams } from "../cusProducts/AttachParams.js";
import { FullCusProduct, InvoiceItem } from "@autumn/shared";
import { BillingInterval, BillingType, UsagePriceConfig } from "@autumn/shared";
import {
getBillingType,
getPriceEntitlement,
getPriceForOverage,
} from "@/internal/products/prices/priceUtils.js";
import {
createStripeCli,
subToAutumnInterval,
} from "@/external/stripe/utils.js";
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import { getResetBalancesUpdate } from "../cusProducts/cusEnts/groupByUtils.js";
import {
getCusPriceUsage,
getRelatedCusEnt,
} from "../cusProducts/cusPrices/cusPriceUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import {
cusProductsToCusPrices,
cusProductToEnts,
} from "../cusProducts/cusProductUtils/convertCusProduct.js";
import { getUsageBasedSub } from "@/external/stripe/stripeSubUtils.js";
// Add usage to end of cycle
// const addUsageToNextInvoice = async ({
// db,
// intervalToInvoiceItems,
// intervalToSub,
// customer,
// org,
// logger,
// attachParams,
// }: {
// db: DrizzleCli;
// intervalToInvoiceItems: any;
// intervalToSub: any;
// customer: any;
// org: any;
// logger: any;
// attachParams: AttachParams;
// }) => {
// for (const interval in intervalToInvoiceItems) {
// const itemsToInvoice = intervalToInvoiceItems[interval];
// if (itemsToInvoice.length === 0) {
// continue;
// }
// // Add items to invoice
// const stripeCli = createStripeCli({
// org: org,
// env: customer.env,
// });
// for (const item of itemsToInvoice) {
// const { amount, description } = item;
// logger.info(
// ` feature: ${item.feature.id}, overage: ${item.overage}, amount: ${amount}`,
// );
// let relatedSub = intervalToSub[interval];
// if (!relatedSub) {
// continue;
// }
// // Create invoice item
// let invoiceItem = {
// customer: customer.processor.id,
// currency: org.default_currency,
// description,
// price_data: {
// product: (item.price.config! as UsagePriceConfig).stripe_product_id!,
// unit_amount: Math.round(amount * 100),
// currency: org.default_currency,
// },
// subscription: relatedSub.id,
// period: {
// start: item.periodStart,
// end: item.periodEnd,
// },
// };
// await stripeCli.invoiceItems.create(invoiceItem);
// // Update cus ent to 0
// await CusEntService.update({
// db,
// id: item.relatedCusEnt!.id,
// updates: getResetBalancesUpdate({
// cusEnt: item.relatedCusEnt!,
// allowance: 0,
// }),
// });
// // Update existing cusEnt in attachParams
// let cusProducts = attachParams.cusProducts;
// for (const cusProduct of cusProducts!) {
// for (let i = 0; i < cusProduct.customer_entitlements.length; i++) {
// let cusEnt = cusProduct.customer_entitlements[i];
// if (cusEnt.id === item.relatedCusEnt!.id) {
// let balancesUpdate = getResetBalancesUpdate({
// cusEnt,
// allowance: 0,
// });
// cusProduct.customer_entitlements[i] = {
// ...cusEnt,
// ...balancesUpdate,
// };
// }
// }
// }
// }
// }
// };
// const invoiceForUsageImmediately = async ({
// db,
// intervalToInvoiceItems,
// customer,
// org,
// logger,
// curCusProduct,
// attachParams,
// newSubs,
// }: {
// db: DrizzleCli;
// intervalToInvoiceItems: any;
// customer: any;
// org: any;
// logger: any;
// curCusProduct: FullCusProduct;
// attachParams: AttachParams;
// newSubs: Stripe.Subscription[];
// }) => {
// // 1. Create invoice
// const stripeCli = createStripeCli({
// org: org,
// env: customer.env,
// });
// const product = curCusProduct.product;
// let invoiceItems = Object.values(intervalToInvoiceItems).flat() as any[];
// if (invoiceItems.length === 0) {
// return;
// }
// let invoice: Stripe.Invoice;
// let newInvoice = false;
// if (attachParams.invoiceOnly && newSubs.length > 0) {
// invoice = await stripeCli.invoices.retrieve(
// newSubs[0].latest_invoice as string,
// );
// if (invoice.status !== "draft") {
// newInvoice = true;
// invoice = await stripeCli.invoices.create({
// customer: customer.processor.id,
// auto_advance: true,
// });
// }
// } else {
// newInvoice = true;
// invoice = await stripeCli.invoices.create({
// customer: customer.processor.id,
// auto_advance: true,
// });
// }
// let autumnInvoiceItems: InvoiceItem[] = [];
// for (const item of invoiceItems) {
// // const amount = getPriceForOverage(item.price, item.overage);
// const { amount, description } = item;
// let config = item.price.config! as UsagePriceConfig;
// // let stripePrice = await stripeCli.prices.retrieve(config.stripe_price_id!);
// let stripeProdId = config.stripe_product_id;
// if (!stripeProdId) {
// try {
// let stripePrice = await stripeCli.prices.retrieve(
// config.stripe_price_id!,
// );
// stripeProdId = stripePrice.product as string;
// } catch (error) {}
// }
// if (!stripeProdId) {
// stripeProdId = product.processor?.id;
// }
// logger.info(
// `🌟🌟🌟 (Bill remaining) created invoice item: ${description} -- ${amount}`,
// );
// let invoiceItem = {
// customer: customer.processor.id,
// invoice: invoice.id,
// currency: org.default_currency,
// description,
// price_data: {
// product: stripeProdId!,
// unit_amount: Math.round(amount * 100),
// currency: org.default_currency,
// },
// period: {
// start: item.periodStart,
// end: item.periodEnd,
// },
// };
// let stripeInvoiceItem = await stripeCli.invoiceItems.create(invoiceItem);
// autumnInvoiceItems.push({
// price_id: item.price.id!,
// internal_feature_id: item.feature.internal_id || null,
// description: description,
// period_start: item.periodStart * 1000,
// period_end: item.periodEnd * 1000,
// stripe_id: stripeInvoiceItem.id,
// });
// await CusEntService.update({
// db,
// id: item.relatedCusEnt!.id,
// updates: {
// balance: 0,
// },
// });
// let index = curCusProduct.customer_entitlements.findIndex(
// (ce) => ce.id === item.relatedCusEnt!.id,
// );
// curCusProduct.customer_entitlements[index] = {
// ...curCusProduct.customer_entitlements[index],
// balance: 0,
// };
// }
// if (newInvoice) {
// await stripeCli.invoices.finalizeInvoice(invoice.id);
// const { paid, error } = await payForInvoice({
// stripeCli,
// paymentMethod: null,
// invoiceId: invoice.id,
// logger,
// });
// if (!paid) {
// logger.warn("Failed to pay invoice for remaining usages", {
// stripeInvoice: newInvoice,
// paymentError: error,
// });
// }
// }
// await insertInvoiceFromAttach({
// db,
// attachParams,
// invoiceId: invoice.id,
// logger,
// });
// };
// const getRemainingUsagesPreview = async ({
// intervalToInvoiceItems,
// curCusProduct,
// }: {
// intervalToInvoiceItems: any;
// curCusProduct: FullCusProduct;
// }) => {
// let invoiceItems = Object.values(intervalToInvoiceItems).flat() as any[];
// if (invoiceItems.length === 0) {
// return;
// }
// let items = [];
// for (const item of invoiceItems) {
// const amount = getPriceForOverage(item.price, item.overage);
// const description = `${curCusProduct.product.name} - ${
// item.feature.name
// } x ${Math.round(item.usage)}`;
// items.push({
// amount,
// description,
// });
// }
// return items;
// };
// export const billForRemainingUsages = async ({
// db,
// logger,
// attachParams,
// curCusProduct,
// newSubs,
// shouldPreview = false,
// billImmediately = false,
// }: {
// db: DrizzleCli;
// logger: any;
// attachParams: AttachParams;
// curCusProduct: FullCusProduct;
// newSubs: Stripe.Subscription[];
// shouldPreview?: boolean;
// billImmediately?: boolean;
// }) => {
// const { customer_prices, customer_entitlements } = curCusProduct;
// const { customer, org } = attachParams;
// const intervalToSub: any = {};
// for (const sub of newSubs) {
// const interval = subToAutumnInterval(sub);
// if (interval) {
// intervalToSub[interval] = sub;
// }
// }
// const intervalToInvoiceItems: any = {};
// const stripeCli = createStripeCli({
// org: org,
// env: customer.env,
// });
// for (const cp of customer_prices) {
// const config = cp.price.config! as UsagePriceConfig;
// const relatedCusEnt = getRelatedCusEnt({
// cusPrice: cp,
// cusEnts: customer_entitlements,
// });
// const billingType = getBillingType(config);
// if (billingType !== BillingType.UsageInArrear) continue;
// const { usage, overage, description, amount } = getCusPriceUsage({
// cusPrice: cp,
// cusProduct: curCusProduct,
// logger,
// });
// if (overage <= 0) continue; // no overage, no need to bill...
// let interval = config.interval as BillingInterval;
// if (!intervalToInvoiceItems[interval]) {
// intervalToInvoiceItems[interval] = [];
// }
// let sub = intervalToSub[interval];
// const stripeNow = await getStripeNow({
// stripeCli,
// stripeSub: sub,
// });
// intervalToInvoiceItems[interval].push({
// overage,
// usage,
// description,
// amount,
// feature: relatedCusEnt?.entitlement.feature,
// price: cp.price,
// relatedCusEnt,
// periodStart: sub?.current_period_start,
// periodEnd: stripeNow,
// });
// }
// if (shouldPreview) {
// return getRemainingUsagesPreview({
// intervalToInvoiceItems,
// curCusProduct,
// });
// }
// if (billImmediately) {
// await invoiceForUsageImmediately({
// db,
// intervalToInvoiceItems,
// customer,
// org,
// logger,
// curCusProduct,
// attachParams,
// newSubs,
// });
// } else {
// await addUsageToNextInvoice({
// db,
// intervalToInvoiceItems,
// intervalToSub,
// customer,
// org,
// logger,
// attachParams,
// });
// }
// };

View File

@@ -41,7 +41,7 @@ export const createNewCustomer = async ({
const { db, org, env, logger } = req;
logger.info(
`Creating customer: ${customer.email || customer.id}, org: ${org.slug}`,
`Creating customer: ${customer.email || customer.id}, org: ${org.slug}`
);
const defaultProds = await ProductService.listDefault({
@@ -149,7 +149,10 @@ export const createNewCustomer = async ({
}),
nextResetAt,
anchorToUnix: org.config.anchor_start_of_month
? getNextStartOfMonthUnix(BillingInterval.Month)
? getNextStartOfMonthUnix({
interval: BillingInterval.Month,
intervalCount: 1,
})
: undefined,
scenario: AttachScenario.New,
logger,

View File

@@ -87,11 +87,14 @@ export const featuresToObject = ({
next_reset_at: getEarliestNextResetAt(relatedEnts),
interval: relatedEnts.length == 1 ? relatedEnts[0].interval : "multiple",
interval_count:
relatedEnts.length == 1 ? relatedEnts[0].interval_count : null,
overage_allowed: relatedEnts.some((e) => e.overage_allowed),
breakdown:
!unlimited && relatedEnts.length > 1
? relatedEnts.map((e) => ({
interval: e.interval!,
interval_count: e.interval_count,
balance: e.balance,
usage: e.usage,
included_usage: e.included_usage,

View File

@@ -257,6 +257,7 @@ export const getCusBalances = async ({
isBoolean || unlimited ? undefined : cusEnt.next_reset_at;
data[key].allowance = isBoolean || unlimited ? undefined : 0;
data[key].usage_limit = isBoolean || unlimited ? undefined : 0;
data[key].interval_count = ent.interval_count || 1;
}
}
}

View File

@@ -143,18 +143,21 @@ export const handleUpdateBalances = async (req: any, res: any) => {
delete properties.balance;
for (const cusEnt of cusEnts) {
if (cusEnt.internal_feature_id !== feature!.internal_id!) {
continue;
}
let cusEntIntCount = cusEnt.entitlement.interval_count || 1;
let deductionIntCount = balance.interval_count || 1;
let intervalCount = cusEnt.entitlement.interval_count || 1;
let intervalCountMatch =
intervalCount > 1 ? balance.interval_count === intervalCount : true;
let intCountMatch = notNullish(balance.interval_count)
? cusEntIntCount === deductionIntCount
: true;
let intMatch = notNullish(balance.interval)
? balance.interval === cusEnt.entitlement.interval
: true;
if (
notNullish(balance.interval) &&
balance.interval !== cusEnt.entitlement.interval &&
intervalCountMatch
cusEnt.internal_feature_id !== feature!.internal_id! ||
!intMatch ||
!intCountMatch
) {
continue;
}
@@ -230,9 +233,22 @@ export const handleUpdateBalances = async (req: any, res: any) => {
}
for (const cusEnt of cusEnts) {
let cusEntIntCount = cusEnt.entitlement.interval_count || 1;
let deductionIntCount = featureDeduction.intervalCount || 1;
let intCountMatch = notNullish(featureDeduction.intervalCount)
? cusEntIntCount === deductionIntCount
: true;
let intMatch = notNullish(featureDeduction.interval)
? featureDeduction.interval === cusEnt.entitlement.interval
: true;
if (
cusEnt.internal_feature_id !==
featureDeduction.feature!.internal_id!
featureDeduction.feature!.internal_id! ||
!intMatch ||
!intCountMatch
) {
continue;
}

View File

@@ -3,6 +3,7 @@ import {
addMinutes,
addMonths,
addSeconds,
addWeeks,
addYears,
differenceInSeconds,
getDate,
@@ -16,6 +17,7 @@ import {
setSeconds,
startOfMonth,
subMonths,
subWeeks,
subYears,
} from "date-fns";
import { UTCDate } from "@date-fns/utc";
@@ -33,6 +35,9 @@ export const subtractBillingIntervalUnix = ({
const date = new UTCDate(unixTimestamp);
let subtractedDate = date;
switch (interval) {
case BillingInterval.Week:
subtractedDate = subWeeks(date, 1 * intervalCount);
break;
case BillingInterval.Month:
subtractedDate = subMonths(date, 1 * intervalCount);
break;
@@ -63,6 +68,9 @@ export const addBillingIntervalUnix = ({
const date = new UTCDate(unixTimestamp);
let addedDate = date;
switch (interval) {
case BillingInterval.Week:
addedDate = addWeeks(date, 1 * intervalCount);
break;
case BillingInterval.Month:
addedDate = addMonths(date, intervalCount);
break;

View File

@@ -131,6 +131,14 @@ export const getBillingInterval = (prices: Price[]) => {
throw error;
}
// console.log(
// "pricesCopy",
// pricesCopy.map((p) => ({
// interval: p.config!.interval,
// intervalCount: p.config!.interval_count,
// }))
// );
if (pricesCopy.length == 0) {
throw new RecaseError({
message: "No prices found, can't get billing interval",
@@ -140,10 +148,8 @@ export const getBillingInterval = (prices: Price[]) => {
}
return {
interval: pricesCopy[pricesCopy.length - 1].config!
.interval as BillingInterval,
intervalCount:
pricesCopy[pricesCopy.length - 1].config!.interval_count || 1,
interval: pricesCopy[0].config!.interval as BillingInterval,
intervalCount: pricesCopy[0].config!.interval_count || 1,
};
// return pricesCopy[pricesCopy.length - 1].config!.interval as BillingInterval;
};

View File

@@ -33,8 +33,12 @@ export const toIntervalKey = ({
} else if (interval == BillingInterval.SemiAnnual) {
let finalCount = (intervalCount ?? 1) * 6;
return `${BillingInterval.Month}-${finalCount}`;
}
if (interval == BillingInterval.Week) {
return `${BillingInterval.Week}-${intervalCount}`;
} else if (interval == BillingInterval.Year) {
return BillingInterval.Year;
return `${BillingInterval.Year}-${intervalCount}`;
}
return `${interval}-${intervalCount}`;
};

View File

@@ -36,6 +36,7 @@ const intervalToValue = (
) => {
const intervalToBaseVal: Record<BillingInterval, number> = {
[BillingInterval.OneOff]: 0,
[BillingInterval.Week]: 0.25,
[BillingInterval.Month]: 1,
[BillingInterval.Quarter]: 3,
[BillingInterval.SemiAnnual]: 6,

View File

@@ -125,8 +125,8 @@ export const isProductUpgrade = ({
return true;
}
let billingInterval1 = getBillingInterval(prices1);
let billingInterval2 = getBillingInterval(prices2);
let billingInterval1 = getBillingInterval(prices1); // pro quarter
let billingInterval2 = getBillingInterval(prices2); // premium
// 2. Get total price for each product
const getTotalPrice = (prices: Price[]) => {
@@ -142,6 +142,7 @@ export const isProductUpgrade = ({
};
// 3. Compare prices
if (
intervalsSame({
intervalA: billingInterval1,

View File

@@ -51,9 +51,7 @@ export const addProductFromSubs = async ({
(cp) =>
!cp.product.is_add_on &&
cp.product_id == autumnProduct.id &&
(notNullish(entity)
? cp.internal_entity_id == entity!.internal_id
: true),
(notNullish(entity) ? cp.internal_entity_id == entity!.internal_id : true)
);
if (mainCusProduct && !force) {
@@ -66,7 +64,7 @@ export const addProductFromSubs = async ({
autumnCus.id || autumnCus.email
} already has non-free free product: ${
mainCusProduct.product.name
}, skipping...`,
}, skipping...`
);
return mainCusProduct;
}
@@ -112,7 +110,7 @@ export const addProductFromSubs = async ({
anchorToUnix: anchorToUnix || stripeSubs[0].current_period_end * 1000,
subscriptionStatus: stripeToAutumnSubStatus(
stripeSubs[0].status,
stripeSubs[0].status
) as CusProductStatus,
canceledAt: stripeSubs[0].canceled_at
@@ -124,7 +122,7 @@ export const addProductFromSubs = async ({
});
logger.info(
`Added product ${autumnProduct.name} to customer ${autumnCus.name}`,
`Added product ${autumnProduct.name} to customer ${autumnCus.name}`
);
// Create sub
@@ -146,7 +144,7 @@ export const addProductFromSubs = async ({
sub: constructSub({
stripeId: sub.id,
usageFeatures:
subInterval == BillingInterval.Month ? usageFeatures : [],
subInterval.interval == BillingInterval.Month ? usageFeatures : [],
orgId: org.id,
env,
currentPeriodStart: sub.current_period_start,

View File

@@ -61,7 +61,7 @@ export const constructPrepaidItem = ({
},
rolloverConfig,
usageLimit,
intervalCount = 2,
intervalCount = 1,
}: {
featureId: string;
price?: number;

View File

@@ -1,164 +0,0 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, Organization } from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js";
import {
constructArrearItem,
constructArrearProratedItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { timeout } from "@/utils/genUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { expectSubItemsCorrect } from "tests/utils/expectUtils/expectSubUtils.js";
import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js";
const testCase = "upgrade6";
export let pro = constructProduct({
items: [
constructArrearItem({ featureId: TestFeature.Words }),
constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 20,
}),
],
type: "pro",
});
export let premium = constructProduct({
items: [
constructArrearItem({ featureId: TestFeature.Words }),
constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 30,
}),
],
type: "premium",
});
describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [pro, premium],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro, premium],
db,
orgId: org.id,
env,
});
testClockId = testClockId1!;
});
it("should attach pro product", async function () {
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
});
let usage = 100012;
it("should upgrade to premium product and fail", async function () {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Words,
value: usage,
});
await timeout(4000);
let cus = await CusService.get({
db,
orgId: org.id,
idOrInternalId: customerId,
env,
});
await attachFailedPaymentMethod({ stripeCli, customer: cus! });
await timeout(2000);
await expectAutumnError({
func: async () => {
await runAttachTest({
autumn,
customerId,
product: premium,
stripeCli,
db,
org,
env,
});
},
errMessage: "Failed to update subscription. Your card was declined.",
});
await timeout(4000);
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
expectFeaturesCorrect({
customer,
product: pro,
usage: [
{
featureId: TestFeature.Words,
value: usage,
},
],
});
await expectSubItemsCorrect({
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
});
});

View File

@@ -1,27 +1,16 @@
import chalk from "chalk";
import Stripe from "stripe";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, Organization } from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import {
constructArrearItem,
constructArrearProratedItem,
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { timeout } from "@/utils/genUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { expectSubItemsCorrect } from "tests/utils/expectUtils/expectSubUtils.js";
import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js";
import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addHours, addMonths } from "date-fns";
@@ -35,13 +24,9 @@ export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
intervalCount: 200,
intervalCount: 2,
includedUsage: 500,
}),
// constructArrearItem({ featureId: TestFeature.Words }),
// constructArrearProratedItem({
// featureId: TestFeature.Users,
// pricePerUnit: 20,
// }),
],
intervalCount: 2,
type: "pro",
@@ -51,7 +36,7 @@ export let premium = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
intervalCount: 500,
intervalCount: 2,
}),
// constructArrearItem({ featureId: TestFeature.Words }),
// constructArrearProratedItem({
@@ -63,7 +48,7 @@ export let premium = constructProduct({
type: "premium",
});
describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () => {
describe(`${chalk.yellowBright(`${testCase}: Testing custom interval and interval count`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
@@ -153,5 +138,9 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () =>
expect(invoices.length).to.equal(3);
expect(invoices[0].product_ids).to.include(premium.id);
expect(invoices[0].total).to.equal(getBasePrice({ product: premium }));
const wordsFeature = customer2.features[TestFeature.Words];
// @ts-ignore
expect(wordsFeature.interval_count).to.equal(2);
});
});

View File

@@ -54,6 +54,7 @@ const prepaidWordsItem = constructPrepaidItem({
price: 10,
billingUnits: 1,
includedUsage: 0,
intervalCount: 2,
});
export const addOn = constructRawProduct({
@@ -62,7 +63,7 @@ export const addOn = constructRawProduct({
isAddOn: true,
});
describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear prorated price`)}`, () => {
describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on add on merged product`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
@@ -115,7 +116,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear p
});
});
it("should upgrade to premium product and have correct invoice next cycle", async function () {
it("should upgrade to attached add on and have correct invoice next cycle", async function () {
const curUnix = await advanceTestClock({
stripeCli,
testClockId,
@@ -150,22 +151,21 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear p
});
let expectedPrice = wordsBillingUnits * prepaidWordsItem.price!;
expect(invoices[0].product_ids).to.include(addOn.id);
expect(invoices[0].total).to.approximately(
calculateProrationAmount({
amount: expectedPrice,
periodStart: curUnix!,
periodEnd: addMonths(curUnix!, 1).getTime(),
now: curUnix!,
}),
0.1
);
const proratedPrice = calculateProrationAmount({
amount: expectedPrice,
periodStart: new Date().getTime(),
periodEnd: addMonths(new Date(), 2).getTime(),
now: curUnix!,
});
const expectedAddonEnd = addMonths(curUnix, 1);
expect(invoices[0].product_ids).to.include(addOn.id);
expect(invoices[0].total).to.approximately(proratedPrice, 0.1);
const expectedAddonEnd = addMonths(new Date(), 2);
const approximate = 1000 * 60 * 60 * 24; // +- 1 day
const addOnProduct = customer.products.find((p) => p.id === addOn.id);
expect(addOnProduct?.current_period_end).to.be.closeTo(
expect(addOnProduct?.current_period_end).to.be.approximately(
expectedAddonEnd.getTime(),
approximate
);

View File

@@ -0,0 +1,150 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, Organization } from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js";
import { addMonths } from "date-fns";
import { expect } from "chai";
import {
expectDowngradeCorrect,
expectNextCycleCorrect,
} from "tests/utils/expectUtils/expectScheduleUtils.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
const testCase = "customInterval4";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 500,
}),
],
intervalCount: 2,
type: "pro",
});
export let premium = constructProduct({
id: "premium",
items: [
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 500,
}),
],
intervalCount: 2,
type: "premium",
});
describe(`${chalk.yellowBright(`${testCase}: Testing downgrades for custom intervals`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [pro, premium],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro, premium],
db,
orgId: org.id,
env,
});
testClockId = testClockId1!;
});
it("should attach premium product", async function () {
await runAttachTest({
autumn,
customerId,
product: premium,
stripeCli,
db,
org,
env,
});
});
it("should have correct next cycle at on checkout", async function () {
const checkout = await autumn.checkout({
customer_id: customerId,
product_id: pro.id,
});
let expectedNextCycle = addMonths(new Date(), 2);
expect(checkout.next_cycle?.starts_at).to.be.approximately(
expectedNextCycle.getTime(),
1000 * 60 * 60 * 24
);
expect(checkout.total).to.equal(0);
});
let preview: any;
it("should downgrade to pro", async function () {
const { preview: preview_ } = await expectDowngradeCorrect({
autumn,
customerId,
curProduct: premium,
newProduct: pro,
stripeCli,
db,
org,
env,
});
preview = preview_;
});
it("should have pro attached on next cycle", async function () {
await expectNextCycleCorrect({
preview: preview!,
autumn,
stripeCli,
customerId,
testClockId,
product: pro,
db,
org,
env,
});
const customer = await autumn.customers.get(customerId);
const invoices = customer.invoices;
expect(invoices.length).to.equal(2);
expect(invoices[0].total).to.equal(getBasePrice({ product: pro }));
});
});

View File

@@ -0,0 +1,163 @@
import chalk from "chalk";
import Stripe from "stripe";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, FullCustomer, Organization } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js";
import { expect } from "chai";
import { Customer } from "autumn-js";
import { timeout } from "@/utils/genUtils.js";
const testCase = "customInterval5";
const includedUsage = 500;
const monthlyWords = constructFeatureItem({
featureId: TestFeature.Words,
includedUsage,
});
const biMonthlyWords = constructFeatureItem({
featureId: TestFeature.Words,
intervalCount: 2,
includedUsage,
});
export let pro = constructProduct({
items: [monthlyWords, biMonthlyWords],
intervalCount: 2,
type: "pro",
});
const getBreakdown = ({
customer,
intervalCount,
}: {
customer: Customer;
intervalCount: number;
}) => {
const wordsFeature = customer.features[TestFeature.Words];
// @ts-ignore
return wordsFeature.breakdown?.find(
(b: any) => b.interval_count == intervalCount
);
};
describe(`${chalk.yellowBright(`${testCase}: Testing multi interval features with custom intervals`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
db,
orgId: org.id,
env,
});
testClockId = testClockId1!;
});
it("should attach pro product", async function () {
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
const customer = await autumn.customers.get(customerId);
const wordsFeature = customer.features[TestFeature.Words];
// @ts-ignore
expect(wordsFeature.interval_count).to.equal(null);
expect(wordsFeature.breakdown?.length).to.equal(2);
expect(
wordsFeature.breakdown?.some(
(b: any) => b.interval_count == 1 && b.interval == "month"
)
).to.equal(true);
expect(
wordsFeature.breakdown?.some(
(b: any) => b.interval_count == 2 && b.interval == "month"
)
).to.equal(true);
});
const trackVal = 300;
it("should have correct breakdown after usage", async function () {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Words,
value: trackVal,
});
await timeout(3000);
const customer = await autumn.customers.get(customerId);
// Should deduct
const monthlyBreakdown = getBreakdown({ customer, intervalCount: 1 });
const biMonthlyBreakdown = getBreakdown({ customer, intervalCount: 2 });
expect(monthlyBreakdown?.balance).to.equal(includedUsage - trackVal);
expect(biMonthlyBreakdown?.balance).to.equal(includedUsage);
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Words,
value: trackVal,
});
await timeout(3000);
const customer2 = await autumn.customers.get(customerId);
const monthlyBreakdown2 = getBreakdown({
customer: customer2,
intervalCount: 1,
});
const biMonthlyBreakdown2 = getBreakdown({
customer: customer2,
intervalCount: 2,
});
expect(monthlyBreakdown2?.balance).to.equal(0);
expect(biMonthlyBreakdown2?.balance).to.equal(includedUsage - 100);
});
});

View File

@@ -0,0 +1,165 @@
import chalk from "chalk";
import Stripe from "stripe";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { APIVersion, AppEnv, FullCustomer, Organization } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js";
import { expect } from "chai";
import { Customer } from "autumn-js";
import { timeout } from "@/utils/genUtils.js";
const testCase = "customInterval6";
// Update balances!
// const includedUsage = 500;
// const monthlyWords = constructFeatureItem({
// featureId: TestFeature.Words,
// includedUsage,
// });
// const biMonthlyWords = constructFeatureItem({
// featureId: TestFeature.Words,
// intervalCount: 2,
// includedUsage,
// });
// export let pro = constructProduct({
// items: [monthlyWords, biMonthlyWords],
// intervalCount: 2,
// type: "pro",
// });
// const getBreakdown = ({
// customer,
// intervalCount,
// }: {
// customer: Customer;
// intervalCount: number;
// }) => {
// const wordsFeature = customer.features[TestFeature.Words];
// // @ts-ignore
// return wordsFeature.breakdown?.find(
// (b: any) => b.interval_count == intervalCount
// );
// };
// describe(`${chalk.yellowBright(`${testCase}: Testing multi interval features with custom intervals`)}`, () => {
// let customerId = testCase;
// let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
// let testClockId: string;
// let db: DrizzleCli, org: Organization, env: AppEnv;
// let stripeCli: Stripe;
// before(async function () {
// await setupBefore(this);
// const { autumnJs } = this;
// db = this.db;
// org = this.org;
// env = this.env;
// stripeCli = this.stripeCli;
// const { testClockId: testClockId1 } = await initCustomer({
// autumn: autumnJs,
// customerId,
// db,
// org,
// env,
// attachPm: "success",
// });
// addPrefixToProducts({
// products: [pro],
// prefix: testCase,
// });
// await createProducts({
// autumn,
// products: [pro],
// db,
// orgId: org.id,
// env,
// });
// testClockId = testClockId1!;
// });
// it("should attach pro product", async function () {
// await runAttachTest({
// autumn,
// customerId,
// product: pro,
// stripeCli,
// db,
// org,
// env,
// });
// const customer = await autumn.customers.get(customerId);
// const wordsFeature = customer.features[TestFeature.Words];
// // @ts-ignore
// expect(wordsFeature.interval_count).to.equal(null);
// expect(wordsFeature.breakdown?.length).to.equal(2);
// expect(
// wordsFeature.breakdown?.some(
// (b: any) => b.interval_count == 1 && b.interval == "month"
// )
// ).to.equal(true);
// expect(
// wordsFeature.breakdown?.some(
// (b: any) => b.interval_count == 2 && b.interval == "month"
// )
// ).to.equal(true);
// });
// const trackVal = 300;
// it("should have correct breakdown after usage", async function () {
// await autumn.track({
// customer_id: customerId,
// feature_id: TestFeature.Words,
// value: trackVal,
// });
// await timeout(3000);
// const customer = await autumn.customers.get(customerId);
// // Should deduct
// const monthlyBreakdown = getBreakdown({ customer, intervalCount: 1 });
// const biMonthlyBreakdown = getBreakdown({ customer, intervalCount: 2 });
// expect(monthlyBreakdown?.balance).to.equal(includedUsage - trackVal);
// expect(biMonthlyBreakdown?.balance).to.equal(includedUsage);
// await autumn.track({
// customer_id: customerId,
// feature_id: TestFeature.Words,
// value: trackVal,
// });
// await timeout(3000);
// const customer2 = await autumn.customers.get(customerId);
// const monthlyBreakdown2 = getBreakdown({
// customer: customer2,
// intervalCount: 1,
// });
// const biMonthlyBreakdown2 = getBreakdown({
// customer: customer2,
// intervalCount: 2,
// });
// expect(monthlyBreakdown2?.balance).to.equal(0);
// expect(biMonthlyBreakdown2?.balance).to.equal(includedUsage - 100);
// });
// });

View File

@@ -36,7 +36,7 @@ export const createProduct = async ({
internalId: prod.internal_id,
orgId,
env,
}),
})
);
}
@@ -82,7 +82,7 @@ export const createProducts = async ({
const batchCreate = [];
for (const product of products) {
batchCreate.push(
createProduct({ db, orgId, env, autumn, product, prefix }),
createProduct({ db, orgId, env, autumn, product, prefix })
);
}
@@ -113,12 +113,12 @@ export const createReward = async ({
idOrInternalId: productId!,
});
let usagePrices = fullProduct.prices.filter((price) =>
isUsagePrice({ price }),
let usagePrices = fullProduct?.prices.filter((price) =>
isUsagePrice({ price })
);
if (onlyUsage) {
reward.discount_config!.price_ids = usagePrices.map((price) => price.id);
reward.discount_config!.price_ids = usagePrices?.map((price) => price.id);
}
try {

View File

@@ -1,5 +1,6 @@
export enum BillingInterval {
OneOff = "one_off",
Week = "week",
Month = "month",
Quarter = "quarter",
SemiAnnual = "semi_annual",

View File

@@ -8,6 +8,7 @@ export const intervalToValue = (
) => {
const intervalToBaseVal: Record<BillingInterval, number> = {
[BillingInterval.OneOff]: 0,
[BillingInterval.Week]: 0.25,
[BillingInterval.Month]: 1,
[BillingInterval.Quarter]: 3,
[BillingInterval.SemiAnnual]: 6,