adding tests

This commit is contained in:
John Yeo
2025-08-08 19:40:49 +01:00
parent 9235b0239b
commit 0500b84e8b
30 changed files with 841 additions and 99 deletions

View File

@@ -4,5 +4,6 @@ import Stripe from "stripe";
export interface ScheduleObj {
schedule: Stripe.SubscriptionSchedule;
interval: BillingInterval;
intervalCount: number;
prices: Stripe.Price[];
}

View File

@@ -1,4 +1,9 @@
import { AppEnv, FullCusProduct, Organization } from "@autumn/shared";
import {
AppEnv,
FullCusProduct,
intervalsSame,
Organization,
} from "@autumn/shared";
import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { getStripeSchedules } from "@/external/stripe/stripeSubUtils.js";
import { getScheduleIdsFromCusProducts } from "../scheduleUtils.js";
@@ -23,7 +28,7 @@ export const getOtherCusProductsOnSub = async ({
if (
cusProduct.id === curMainProduct.id ||
!curMainSubIds?.some((subId) =>
cusProduct?.subscription_ids?.includes(subId),
cusProduct?.subscription_ids?.includes(subId)
)
) {
continue;
@@ -72,10 +77,13 @@ export const addCurMainProductToSchedule = async ({
});
for (const scheduleObj of schedules) {
const { schedule, interval } = scheduleObj;
const { schedule, interval, intervalCount } = scheduleObj;
let oldItemSet = oldItemSets.find(
(itemSet) => itemSet.interval === interval,
let oldItemSet = oldItemSets.find((itemSet) =>
intervalsSame({
intervalA: { interval, intervalCount },
intervalB: itemSet,
})
);
await updateScheduledSubWithNewItems({
@@ -99,7 +107,7 @@ export const addCurMainProductToSchedule = async ({
});
logger.info(
`✅ Added old items for product ${curMainProduct.product.name} to schedule: ${schedule.id}`,
`✅ Added old items for product ${curMainProduct.product.name} to schedule: ${schedule.id}`
);
}
};

View File

@@ -10,7 +10,7 @@ export const getFilteredScheduleItems = ({
scheduleObj: ScheduleObj;
cusProducts: (FullCusProduct | undefined)[];
}) => {
const { schedule, interval, prices } = scheduleObj;
const { schedule, prices } = scheduleObj;
let scheduleItems = schedule.phases[0].items;
let curPrices: any[] = [];
@@ -31,10 +31,10 @@ export const getFilteredScheduleItems = ({
curPrices.some(
(price) =>
price.config?.stripe_price_id === scheduleItem.price ||
price.config?.stripe_product_id === stripePrice?.product,
price.config?.stripe_product_id === stripePrice?.product
) ||
products.some(
(product) => product.processor?.id === stripePrice?.product,
(product) => product.processor?.id === stripePrice?.product
);
return !inCurProduct;

View File

@@ -5,6 +5,8 @@ import {
CusProductStatus,
Customer,
EntInterval,
entIntervalsDifferent,
entIntervalToValue,
Entitlement,
EntitlementWithFeature,
Entity,
@@ -46,14 +48,14 @@ export const getCusEntMasterBalance = ({
(acc, curr) => {
return acc + curr.balance;
},
0,
0
);
let totalAdjustment = Object.values(cusEnt.entities || {}).reduce(
(acc, curr) => {
return acc + curr.adjustment;
},
0,
0
);
return {
@@ -69,7 +71,7 @@ export const getCusEntMasterBalance = ({
entities &&
entities.filter(
(entity) =>
entity.internal_feature_id == feature.internal_id && entity.deleted,
entity.internal_feature_id == feature.internal_id && entity.deleted
).length;
return {
@@ -120,7 +122,7 @@ export const sortCusEntsForDeduction = (
cusEnts: (FullCustomerEntitlement & {
customer_product?: FullCusProduct;
})[],
reverseOrder: boolean = false,
reverseOrder: boolean = false
) => {
let intervalOrder: Record<EntInterval, number> = {
[EntInterval.Minute]: 0, // 1 minute
@@ -206,11 +208,15 @@ export const sortCusEntsForDeduction = (
}
// 3. Sort by interval
if (aEnt.interval && bEnt.interval && aEnt.interval != bEnt.interval) {
let aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count);
let bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count);
if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) {
if (reverseOrder) {
return intervalOrder[bEnt.interval] - intervalOrder[aEnt.interval];
return bVal.sub(aVal).toNumber();
// return intervalOrder[bEnt.interval] - intervalOrder[aEnt.interval];
} else {
return intervalOrder[aEnt.interval] - intervalOrder[bEnt.interval];
return aVal.sub(bVal).toNumber();
// return intervalOrder[aEnt.interval] - intervalOrder[bEnt.interval];
}
}
@@ -241,7 +247,7 @@ export const sortCusEntsForDeduction = (
// Get related cusPrice
export const getRelatedCusPrice = (
cusEnt: FullCustomerEntitlement,
cusPrices: FullCustomerPrice[],
cusPrices: FullCustomerPrice[]
) => {
return cusPrices.find((cusPrice) => {
let productMatch =
@@ -328,7 +334,7 @@ export const getResetBalance = ({
return (entitlement.allowance || 0) + quantity! * billingUnits!;
} catch (error) {
console.log(
"WARNING: Failed to return quantity * billing units, returning allowance...",
"WARNING: Failed to return quantity * billing units, returning allowance..."
);
return entitlement.allowance || 0;
}
@@ -347,14 +353,14 @@ export const getUnlimitedAndUsageAllowed = ({
(cusEnt) =>
cusEnt.internal_feature_id === internalFeatureId &&
(cusEnt.entitlement.allowance_type === AllowanceType.Unlimited ||
cusEnt.unlimited),
cusEnt.unlimited)
);
const usageAllowed = cusEnts.some(
(ent) =>
ent.internal_feature_id === internalFeatureId &&
ent.usage_allowed &&
nullish(ent.entitlement.usage_limit),
nullish(ent.entitlement.usage_limit)
);
return { unlimited, usageAllowed };
@@ -426,7 +432,7 @@ export const cusEntsContainFeature = ({
feature: Feature;
}) => {
return cusEnts.some(
(cusEnt) => cusEnt.internal_feature_id === feature.internal_id!,
(cusEnt) => cusEnt.internal_feature_id === feature.internal_id!
);
};
@@ -516,7 +522,7 @@ export const getExistingUsageFromCusProducts = ({
!cp.product.is_add_on &&
(internalEntityId
? cp.internal_entity_id === internalEntityId
: nullish(cp.internal_entity_id)),
: nullish(cp.internal_entity_id))
)
.flatMap((cp) => cp.customer_entitlements)
.find((ce) => ce.internal_feature_id === entitlement.internal_feature_id);
@@ -537,15 +543,15 @@ export const getExistingUsageFromCusProducts = ({
// Get options
let cusProduct = cusProducts?.find(
(cp) => cp.id === existingCusEnt.customer_product_id,
(cp) => cp.id === existingCusEnt.customer_product_id
);
let options = getEntOptions(
cusProduct?.options || [],
existingCusEnt.entitlement,
existingCusEnt.entitlement
);
let price = getRelatedCusPrice(
existingCusEnt,
cusProduct?.customer_prices || [],
cusProduct?.customer_prices || []
);
let existingAllowance = getResetBalance({
entitlement: existingCusEnt.entitlement,

View File

@@ -37,7 +37,7 @@ export const getExistingCusEntAndUsage = async ({
// 1. If there is only one cus ent, return it and usage
let similarCusEnts = curCusProduct.customer_entitlements.filter(
(ce) => ce.internal_feature_id === entitlement.internal_feature_id,
(ce) => ce.internal_feature_id === entitlement.internal_feature_id
// &&
// ce.entitlement.interval === entitlement.interval
);
@@ -47,8 +47,8 @@ export const getExistingCusEntAndUsage = async ({
"Similar entitlements:",
similarCusEnts.map(
(ce) =>
`${ce.entitlement.feature_id} (${ce.entitlement.interval}) (${ce.balance})`,
),
`${ce.entitlement.feature_id} (${ce.entitlement.interval}) (${ce.balance})`
)
);
if (similarCusEnts.length === 1) {
@@ -79,7 +79,7 @@ export const getExistingUsages = ({
// Get entityUsage
for (const entity of entities) {
let feature = features.find(
(f) => f.internal_id === entity.internal_feature_id,
(f) => f.internal_id === entity.internal_feature_id
);
let key = `${feature?.id}-${EntInterval.Lifetime}`;
@@ -96,7 +96,7 @@ export const getExistingUsages = ({
for (const cusEnt of curCusProduct?.customer_entitlements || []) {
let ent = cusEnt.entitlement;
let key = `${ent.feature_id}-${ent.interval}`;
let key = `${ent.feature_id}-${ent.interval}-${ent.interval_count || 1}`;
let feature = ent.feature;
if (feature.type == FeatureType.Boolean) {
continue;
@@ -199,8 +199,8 @@ export const addExistingUsagesToCusEnts = ({
"Sorted cusEnts:",
fullCusEnts.map(
(ce) =>
`${ce.entitlement.feature_id} (${ce.entitlement.interval}), balance: ${ce.balance}`,
),
`${ce.entitlement.feature_id} (${ce.entitlement.interval}), balance: ${ce.balance}`
)
);
}

View File

@@ -62,6 +62,7 @@ export const initNextResetAt = ({
nextResetAtCalculated = getNextEntitlementReset(
nextResetAtCalculated || new UTCDate(now),
resetInterval,
entitlement.interval_count || 1
).getTime();
// If anchorToUnix, align next reset at to anchorToUnix...

View File

@@ -147,9 +147,14 @@ export const handleUpdateBalances = async (req: any, res: any) => {
continue;
}
let intervalCount = cusEnt.entitlement.interval_count || 1;
let intervalCountMatch =
intervalCount > 1 ? balance.interval_count === intervalCount : true;
if (
notNullish(balance.interval) &&
balance.interval !== cusEnt.entitlement.interval
balance.interval !== cusEnt.entitlement.interval &&
intervalCountMatch
) {
continue;
}
@@ -173,6 +178,7 @@ export const handleUpdateBalances = async (req: any, res: any) => {
toDeduct,
properties,
interval: balance.interval,
intervalCount: balance.interval_count,
});
}
@@ -186,12 +192,18 @@ export const handleUpdateBalances = async (req: any, res: any) => {
// Handle unlimited
if (featureDeduction.unlimited) {
// Get one active cusEnt and set unlimited to true
const cusEnt = notNullish(interval)
? cusEnts.find(
(cusEnt) =>
? cusEnts.find((cusEnt) => {
let cusEntIntCount = cusEnt.entitlement.interval_count || 1;
let deductionIntCount = featureDeduction.intervalCount || 1;
return (
cusEnt.internal_feature_id === feature!.internal_id! &&
cusEnt.entitlement.interval === interval
)
cusEnt.entitlement.interval === interval &&
cusEntIntCount === deductionIntCount
);
})
: cusEnts.find(
(cusEnt) => cusEnt.internal_feature_id === feature!.internal_id!
);

View File

@@ -24,6 +24,7 @@ import {
} from "../customers/cusProducts/cusPrices/cusPriceUtils.js";
import { getFeatureQuantity } from "../customers/cusProducts/cusProductUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { getIntervalString } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js";
const getSingularAndPlural = (feature: Feature) => {
const singular = getFeatureName({
@@ -72,6 +73,7 @@ export const formatFixedPrice = ({
const config = price.config as FixedPriceConfig;
const amount = formatAmount({ org, amount: config.amount });
// const intervalStr = getIntervalString({});
if (config.interval == BillingInterval.OneOff) {
return `${amount}`;
} else {

View File

@@ -63,6 +63,7 @@ export const getCurContUseItems = async ({
const finalProration = getProration({
now,
interval: price.config.interval!,
intervalCount: price.config.interval_count || 1,
anchorToUnix: sub.current_period_end * 1000,
})!;

View File

@@ -86,6 +86,7 @@ export const getItemsForCurProduct = async ({
const finalProration = getProration({
now,
interval: price.config.interval!,
intervalCount: price.config.interval_count || 1,
anchorToUnix: sub.current_period_end * 1000,
})!;

View File

@@ -23,22 +23,28 @@ import { addDays } from "date-fns";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { features } from "process";
export const entIntervalToTrialDuration = (interval: EntInterval) => {
export const entIntervalToTrialDuration = ({
interval,
intervalCount,
}: {
interval: EntInterval;
intervalCount: number;
}) => {
switch (interval) {
case EntInterval.Day:
return 1;
return intervalCount;
case EntInterval.Week:
return 7;
return intervalCount * 7;
case EntInterval.Month:
return 30;
return intervalCount * 30;
case EntInterval.Quarter:
return 90;
return intervalCount * 90;
case EntInterval.SemiAnnual:
return 180;
return intervalCount * 180;
case EntInterval.Year:
return 365;
return intervalCount * 365;
case EntInterval.Lifetime:
return 1000;
return intervalCount * 1000;
}
};
@@ -54,7 +60,10 @@ export const applyTrialToEntitlement = (
if (entitlement.allowance_type === AllowanceType.Unlimited) return false;
const trialDays = freeTrial.length;
const entDays = entIntervalToTrialDuration(entitlement.interval!);
const entDays = entIntervalToTrialDuration({
interval: entitlement.interval!,
intervalCount: entitlement.interval_count || 1,
});
if (entDays && entDays > trialDays) {
return true;

View File

@@ -20,6 +20,7 @@ import {
import RecaseError from "@/utils/errorUtils.js";
import { StatusCodes } from "http-status-codes";
import { Decimal } from "decimal.js";
import { compareBillingIntervals } from "./priceUtils/priceIntervalUtils.js";
const BillingIntervalOrder = [
BillingInterval.Year,
@@ -100,17 +101,30 @@ export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => {
export const getBillingInterval = (prices: Price[]) => {
if (prices.length === 0) {
return BillingInterval.OneOff;
return {
interval: BillingInterval.OneOff,
intervalCount: 1,
};
}
const pricesCopy = structuredClone(prices);
try {
pricesCopy.sort((a, b) => {
return (
BillingIntervalOrder.indexOf(b.config!.interval!) -
BillingIntervalOrder.indexOf(a.config!.interval!)
);
return compareBillingIntervals({
configA: {
interval: a.config!.interval as BillingInterval,
intervalCount: a.config!.interval_count || 1,
},
configB: {
interval: b.config!.interval as BillingInterval,
intervalCount: b.config!.interval_count || 1,
},
});
// return (
// BillingIntervalOrder.indexOf(b.config!.interval!) -
// BillingIntervalOrder.indexOf(a.config!.interval!)
// );
});
} catch (error) {
console.log("Error sorting prices:", error);
@@ -125,7 +139,13 @@ export const getBillingInterval = (prices: Price[]) => {
});
}
return pricesCopy[pricesCopy.length - 1].config!.interval as BillingInterval;
return {
interval: pricesCopy[pricesCopy.length - 1].config!
.interval as BillingInterval,
intervalCount:
pricesCopy[pricesCopy.length - 1].config!.interval_count || 1,
};
// return pricesCopy[pricesCopy.length - 1].config!.interval as BillingInterval;
};
export const pricesOnlyOneOff = (prices: Price[]) => {
@@ -154,36 +174,16 @@ export const pricesContainRecurring = (prices: Price[]) => {
});
};
export const haveDifferentRecurringIntervals = (prices: Price[]) => {
let interval = null;
for (const price of prices) {
const newInterval = price.config?.interval;
if (newInterval == BillingInterval.OneOff) {
continue;
}
if (interval !== null && newInterval !== null && newInterval !== interval) {
return true;
}
interval = newInterval;
}
return false;
};
// Get price options
export const getEntOptions = (
optionsList: FeatureOptions[],
entitlement: Entitlement | EntitlementWithFeature,
entitlement: Entitlement | EntitlementWithFeature
) => {
if (!entitlement) {
return null;
}
const options = optionsList.find(
(options) =>
options.internal_feature_id === entitlement.internal_feature_id,
(options) => options.internal_feature_id === entitlement.internal_feature_id
);
return options;
};
@@ -191,7 +191,7 @@ export const getEntOptions = (
export const getPriceEntitlement = (
price: Price,
entitlements: EntitlementWithFeature[],
allowFeatureMatch = false,
allowFeatureMatch = false
) => {
let config = price.config as UsagePriceConfig;
@@ -217,12 +217,12 @@ export const getPriceEntitlement = (
export const getPriceOptions = (
price: Price,
optionsList: FeatureOptions[],
optionsList: FeatureOptions[]
) => {
let config = price.config as UsagePriceConfig;
const options = optionsList.find(
(options) => options.internal_feature_id === config.internal_feature_id,
(options) => options.internal_feature_id === config.internal_feature_id
);
return options;
@@ -307,7 +307,7 @@ export const getPriceForOverage = (price: Price, overage?: number) => {
let amount = 0;
let billingUnits = usageConfig.billing_units || 1;
let remainingUsage = new Decimal(
Math.ceil(new Decimal(overage!).div(billingUnits).toNumber()),
Math.ceil(new Decimal(overage!).div(billingUnits).toNumber())
)
.mul(billingUnits)
.toNumber();
@@ -353,7 +353,7 @@ export const roundPriceAmounts = (price: Price) => {
const config = price.config as UsagePriceConfig;
for (let i = 0; i < config.usage_tiers.length; i++) {
config.usage_tiers[i].amount = Number(
config.usage_tiers[i].amount.toFixed(10),
config.usage_tiers[i].amount.toFixed(10)
);
}
@@ -363,7 +363,7 @@ export const roundPriceAmounts = (price: Price) => {
export const priceIsOneOffAndTiered = (
price: Price,
relatedEnt: EntitlementWithFeature,
relatedEnt: EntitlementWithFeature
) => {
let config = price.config as UsagePriceConfig;
if (config.type == PriceType.Fixed) {
@@ -371,17 +371,13 @@ export const priceIsOneOffAndTiered = (
}
return (
// (config.interval == BillingInterval.OneOff &&
// config.usage_tiers.length > 0 &&
// relatedEnt.allowance &&
// relatedEnt.allowance > 0) ||
config.interval == BillingInterval.OneOff && config.usage_tiers.length > 1
);
};
export const getProductForPrice = (price: Price, products: FullProduct[]) => {
return products.find(
(product) => product.internal_id === price.internal_product_id,
(product) => product.internal_id === price.internal_product_id
);
};

View File

@@ -124,13 +124,16 @@ export const constructFeatureItem = ({
export const constructPriceItem = ({
price,
interval,
intervalCount,
}: {
price: number;
interval: BillingInterval | null;
intervalCount?: number;
}) => {
let item: ProductItem = {
price: price,
interval: interval as any,
interval_count: intervalCount || 1,
};
return item;

View File

@@ -202,6 +202,7 @@ export const validateProductItems = ({
for (let index = 0; index < newItems.length; index++) {
let item = newItems[index];
let entInterval = itemToEntInterval(item);
const intervalCount = item.interval_count || 1;
if (isFeaturePriceItem(item) && entInterval == EntInterval.Lifetime) {
let otherItem = newItems.find((i: any, index2: any) => {
@@ -241,6 +242,7 @@ export const validateProductItems = ({
i.feature_id == item.feature_id &&
index2 != index &&
itemToEntInterval(i) == entInterval &&
(i.interval_count || 1) == intervalCount &&
i.entity_feature_id == item.entity_feature_id
);
});

View File

@@ -8,6 +8,7 @@ import {
ErrCode,
Feature,
FixedPriceConfig,
intervalsSame,
Organization,
Price,
PriceSchema,
@@ -141,10 +142,20 @@ export const isProductUpgrade = ({
};
// 3. Compare prices
if (billingInterval1 == billingInterval2) {
if (
intervalsSame({
intervalA: billingInterval1,
intervalB: billingInterval2,
})
) {
return getTotalPrice(prices1) < getTotalPrice(prices2);
} else {
return compareBillingIntervals(billingInterval1, billingInterval2) > 0;
return (
compareBillingIntervals({
configA: billingInterval1,
configB: billingInterval2,
}) > 0
);
}
};

View File

@@ -18,9 +18,7 @@ To determine if a product is an interval variant, please follow these guidelines
1. Look at the name of the product. If it contains a word like "annual", "yearly", etc. and the name resembles another product, it's a variant.
- Example of this: "Pro (Annual)" is a variant of "Pro".
2.
2. If the product has a similar name to another product, but interval is different, it's probably a variant.
4. If the current product is not a variant of any existing product, return null.
`;
@@ -62,9 +60,9 @@ export const detectBaseVariant = async ({
!p.is_add_on &&
p.prices.length > 0 &&
p.prices.every(
(price) => price.config.interval == BillingInterval.Month,
(price) => price.config.interval == BillingInterval.Month
) &&
p.group == curProduct.group,
p.group == curProduct.group
);
if (filteredExistingProducts.length == 0) return null;
@@ -85,7 +83,7 @@ export const detectBaseVariant = async ({
id: p.id,
name: p.name,
prices: p.prices,
}),
})
)
.join("\n")}
</existing_products>
@@ -100,7 +98,7 @@ export const detectBaseVariant = async ({
let baseVariantId = object.base_variant_id;
logger.info(
`llm response for base variant of ${curProduct.id}: ${baseVariantId}`,
`llm response for base variant of ${curProduct.id}: ${baseVariantId}`
);
if (baseVariantId) {

View File

@@ -18,7 +18,7 @@ import {
isPriceItem,
} from "../../product-items/productItemUtils/getItemType.js";
const getIntervalString = ({
export const getIntervalString = ({
interval,
intervalCount,
}: {

View File

@@ -61,6 +61,7 @@ export const constructPrepaidItem = ({
},
rolloverConfig,
usageLimit,
intervalCount = 2,
}: {
featureId: string;
price?: number;
@@ -70,6 +71,7 @@ export const constructPrepaidItem = ({
config?: ProductItemConfig;
rolloverConfig?: RolloverConfig;
usageLimit?: number;
intervalCount?: number;
}) => {
let item: ProductItem = {
feature_id: featureId,
@@ -77,8 +79,8 @@ export const constructPrepaidItem = ({
price: price,
billing_units: billingUnits || 100,
interval: isOneOff ? null : ProductItemInterval.Month,
interval_count: intervalCount,
included_usage: includedUsage,
config: {
@@ -102,6 +104,7 @@ export const constructArrearItem = ({
},
entityFeatureId,
usageLimit,
intervalCount = 1,
}: {
featureId: string;
includedUsage?: number;
@@ -110,6 +113,7 @@ export const constructArrearItem = ({
config?: ProductItemConfig;
entityFeatureId?: string;
usageLimit?: number;
intervalCount?: number;
}) => {
let item: ProductItem = {
feature_id: featureId,
@@ -118,6 +122,7 @@ export const constructArrearItem = ({
price: price,
billing_units: billingUnits,
interval: ProductItemInterval.Month,
interval_count: intervalCount,
reset_usage_when_enabled: true,
config,
entity_feature_id: entityFeatureId,

View File

@@ -74,13 +74,16 @@ export const constructRawProduct = ({
is_default: false,
version: 1,
group: "",
created_at: Date.now(),
};
};
export const constructProduct = ({
id,
items,
type,
interval,
intervalCount,
isAnnual = false,
trial = false,
excludeBase = false,
@@ -92,6 +95,7 @@ export const constructProduct = ({
items: ProductItem[];
type: "free" | "pro" | "premium" | "growth" | "one_off";
interval?: BillingInterval;
intervalCount?: number;
isAnnual?: boolean;
trial?: boolean;
excludeBase?: boolean;
@@ -117,6 +121,7 @@ export const constructProduct = ({
: interval
? interval
: BillingInterval.Month,
intervalCount: intervalCount || 1,
})
);
}

View File

@@ -17,8 +17,10 @@ export const logSubItems = ({
console.log(`Usage price`);
} else {
let price = item.price.unit_amount! / 100;
let interval = subItemToAutumnInterval(item);
console.log(`${price} ${item.price.currency} / ${interval}`);
let subInterval = subItemToAutumnInterval(item);
console.log(
`${price} ${item.price.currency} / ${subInterval?.interval} (${subInterval?.intervalCount})`
);
}
}
};

View File

@@ -0,0 +1,120 @@
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 } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addHours, addMonths } from "date-fns";
import { expect } from "chai";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
const testCase = "customInterval2";
export let pro = constructRawProduct({
id: "pro",
items: [
constructArrearItem({
includedUsage: 0,
featureId: TestFeature.Words,
intervalCount: 2,
}),
],
});
describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear prorated price`)}`, () => {
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,
});
});
let usage = 100012;
it("should upgrade to premium product and have correct invoice next cycle", async function () {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Words,
value: usage,
});
const curUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(
addMonths(new Date(), 2),
hoursToFinalizeInvoice
).getTime(),
waitForSeconds: 30,
});
const invoiceAmount = await getExpectedInvoiceTotal({
customerId,
productId: pro.id,
usage: [{ featureId: TestFeature.Words, value: usage }],
stripeCli,
db,
org,
env,
});
const customer = await autumn.customers.get(customerId);
expect(customer.invoices.length).to.equal(2);
expect(invoiceAmount).to.equal(customer.invoices[0].total);
});
});

View File

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

@@ -0,0 +1,157 @@
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 { 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";
import { expect } from "chai";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
const testCase = "customInterval1";
export let pro = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
intervalCount: 200,
}),
// constructArrearItem({ featureId: TestFeature.Words }),
// constructArrearProratedItem({
// featureId: TestFeature.Users,
// pricePerUnit: 20,
// }),
],
intervalCount: 2,
type: "pro",
});
export let premium = constructProduct({
items: [
constructFeatureItem({
featureId: TestFeature.Words,
intervalCount: 500,
}),
// constructArrearItem({ featureId: TestFeature.Words }),
// constructArrearProratedItem({
// featureId: TestFeature.Users,
// pricePerUnit: 30,
// }),
],
intervalCount: 2,
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 have correct invoice next cycle", async function () {
const curUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addMonths(new Date(), 1).getTime(),
waitForSeconds: 15,
});
await runAttachTest({
autumn,
customerId,
product: premium,
stripeCli,
db,
org,
env,
});
const customer = await autumn.customers.get(customerId);
expect(customer.invoices.length).to.equal(2);
const nextUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(
addMonths(new Date(curUnix), 1),
hoursToFinalizeInvoice
).getTime(),
waitForSeconds: 30,
});
const customer2 = await autumn.customers.get(customerId);
const invoices = customer2.invoices;
expect(invoices.length).to.equal(3);
expect(invoices[0].product_ids).to.include(premium.id);
expect(invoices[0].total).to.equal(getBasePrice({ product: premium }));
});
});

View File

@@ -0,0 +1,173 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
BillingInterval,
LimitedItem,
Organization,
Product,
} 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,
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import {
constructProduct,
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import { addPrefixToProducts, runAttachTest } from "tests/attach/utils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addDays, addHours, addMonths } from "date-fns";
import { expect } from "chai";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
const testCase = "customInterval3";
export let pro = constructProduct({
type: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Words,
intervalCount: 2,
}),
],
intervalCount: 2,
});
const prepaidWordsItem = constructPrepaidItem({
featureId: TestFeature.Words,
price: 10,
billingUnits: 1,
includedUsage: 0,
});
export const addOn = constructRawProduct({
id: "addOn",
items: [prepaidWordsItem],
isAddOn: true,
});
describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear prorated price`)}`, () => {
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, addOn],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro, addOn],
db,
orgId: org.id,
env,
});
testClockId = testClockId1!;
});
it("should attach pro product", async function () {
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
});
it("should upgrade to premium product and have correct invoice next cycle", async function () {
const curUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addDays(new Date(), 20).getTime(),
waitForSeconds: 15,
});
const wordBillingSets = 2;
const wordsBillingUnits = prepaidWordsItem.billing_units! * wordBillingSets;
await autumn.attach({
customer_id: customerId,
product_id: addOn.id,
options: [
{
feature_id: TestFeature.Words,
quantity: wordsBillingUnits,
},
],
});
const customer = await autumn.customers.get(customerId);
const proProduct = customer.products.find((p) => p.id === pro.id);
const invoices = customer.invoices;
expectProductAttached({
customer,
product: pro,
});
expectProductAttached({
customer,
product: addOn,
});
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 expectedAddonEnd = addMonths(curUnix, 1);
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(
expectedAddonEnd.getTime(),
approximate
);
});
});

View File

@@ -7,6 +7,7 @@ export const FeatureItemSchema = ProductItemSchema.pick({
feature_type: true,
included_usage: true,
interval: true,
interval_count: true,
entity_feature_id: true,
reset_usage_when_enabled: true,
config: true,

View File

@@ -6,6 +6,7 @@ export const FeaturePriceItemSchema = ProductItemSchema.pick({
feature_type: true,
included_usage: true,
interval: true,
interval_count: true,
usage_model: true,
price: true,

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
export const PriceItemSchema = ProductItemSchema.pick({
price: true,
interval: true,
interval_count: true,
}).extend({
price: z.number().nonnegative(),
});

View File

@@ -1,3 +1,5 @@
import { Decimal } from "decimal.js";
import { EntInterval } from "../models/productModels/entModels/entEnums.js";
import { BillingInterval } from "../models/productModels/priceModels/priceEnums.js";
export const intervalToValue = (
@@ -41,3 +43,54 @@ export const intervalsSame = ({
}) => {
return !intervalsDifferent({ intervalA, intervalB });
};
type EntIntervalConfig = {
interval: EntInterval;
intervalCount?: number | null;
};
export const entIntervalToValue = (
interval?: EntInterval | null,
intervalCount?: number | null
) => {
if (!interval) {
return new Decimal(10000000);
}
const intervalToBaseVal: Record<EntInterval, number> = {
[EntInterval.Minute]: 1,
[EntInterval.Hour]: 60,
[EntInterval.Day]: 1 * 60 * 24,
[EntInterval.Week]: 1 * 60 * 24 * 7,
[EntInterval.Month]: 1 * 60 * 24 * 30,
[EntInterval.Quarter]: 1 * 60 * 24 * 90,
[EntInterval.SemiAnnual]: 1 * 60 * 24 * 180,
[EntInterval.Year]: 1 * 60 * 24 * 365,
[EntInterval.Lifetime]: 1000000000,
};
const baseValue = intervalToBaseVal[interval];
return new Decimal(baseValue).mul(intervalCount ?? 1);
};
export const entIntervalsSame = ({
intervalA,
intervalB,
}: {
intervalA: EntIntervalConfig;
intervalB: EntIntervalConfig;
}) => {
const valA = entIntervalToValue(intervalA.interval, intervalA.intervalCount);
const valB = entIntervalToValue(intervalB.interval, intervalB.intervalCount);
return valA.eq(valB);
};
export const entIntervalsDifferent = ({
intervalA,
intervalB,
}: {
intervalA: EntIntervalConfig;
intervalB: EntIntervalConfig;
}) => {
return !entIntervalsSame({ intervalA, intervalB });
};

View File

@@ -109,7 +109,13 @@ export const getPriceItemDisplay = ({
currency,
amount: item.price as number,
});
let secondaryText = item.interval ? `per ${item.interval}` : undefined;
const intervalStr = getIntervalString({
interval: item.interval!,
intervalCount: item.interval_count,
});
let secondaryText = intervalStr || undefined;
return {
primary_text: primaryText,

View File

@@ -6,6 +6,7 @@ export const defaultFeatureItem: ProductItem = {
included_usage: null,
interval: ProductItemInterval.Month,
interval_count: 1,
// Price config
price: null,
@@ -21,6 +22,7 @@ export const defaultPaidFeatureItem: ProductItem = {
feature_id: null,
included_usage: null,
interval: ProductItemInterval.Month,
interval_count: 1,
// Price config
price: null,
@@ -42,6 +44,7 @@ export const defaultPriceItem: ProductItem = {
included_usage: null,
interval: ProductItemInterval.Month,
interval_count: 1,
// Price config
price: 0,