From 0500b84e8b89c617701d3279bf5dc357e55a1039 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 8 Aug 2025 19:40:49 +0100 Subject: [PATCH] adding tests --- .../scheduleUtils/ScheduleObj.ts | 1 + .../cancelScheduledFreeProduct.ts | 20 +- .../scheduleUtils/getFilteredScheduleItems.ts | 6 +- .../cusProducts/cusEnts/cusEntUtils.ts | 38 ++-- .../cusEnts/cusEntUtils/getExistingUsage.ts | 14 +- .../initCusEnt/initNextResetAt.ts | 1 + .../handlers/handleUpdateBalances.ts | 22 ++- .../internal/invoices/invoiceFormatUtils.ts | 2 + .../previewItemUtils/getCurContUseItems.ts | 1 + .../previewItemUtils/getItemsForCurProduct.ts | 1 + .../products/entitlements/entitlementUtils.ts | 27 ++- .../internal/products/prices/priceUtils.ts | 74 ++++---- .../product-items/productItemUtils.ts | 3 + .../product-items/validateProductItems.ts | 2 + server/src/internal/products/productUtils.ts | 15 +- .../productUtils/detectProductVariant.ts | 12 +- .../getProductItemDisplay.ts | 2 +- server/src/utils/scriptUtils/constructItem.ts | 7 +- .../utils/scriptUtils/createTestProducts.ts | 5 + .../utils/scriptUtils/logUtils/logSubItems.ts | 6 +- .../advanced/customInterval/customInteral2.ts | 120 ++++++++++++ .../customInterval/customInterval1 copy.ts | 164 +++++++++++++++++ .../customInterval/customInterval1.ts | 157 ++++++++++++++++ .../customInterval/customInterval3.ts | 173 ++++++++++++++++++ .../productItemModels/featureItem.ts | 1 + .../productItemModels/featurePriceItem.ts | 1 + .../productItemModels/priceItem.ts | 1 + shared/utils/intervalUtils.ts | 53 ++++++ shared/utils/productDisplayUtils.ts | 8 +- .../create-product-item/defaultItemConfigs.ts | 3 + 30 files changed, 841 insertions(+), 99 deletions(-) create mode 100644 server/tests/advanced/customInterval/customInteral2.ts create mode 100644 server/tests/advanced/customInterval/customInterval1 copy.ts create mode 100644 server/tests/advanced/customInterval/customInterval1.ts create mode 100644 server/tests/advanced/customInterval/customInterval3.ts diff --git a/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts b/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts index c5901d6f7..0dc863237 100644 --- a/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts +++ b/server/src/internal/customers/change-product/scheduleUtils/ScheduleObj.ts @@ -4,5 +4,6 @@ import Stripe from "stripe"; export interface ScheduleObj { schedule: Stripe.SubscriptionSchedule; interval: BillingInterval; + intervalCount: number; prices: Stripe.Price[]; } diff --git a/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts b/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts index 8d18832d3..ebba6d3c4 100644 --- a/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts +++ b/server/src/internal/customers/change-product/scheduleUtils/cancelScheduledFreeProduct.ts @@ -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}` ); } }; diff --git a/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts b/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts index d8bfaa44a..3bf93aacd 100644 --- a/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts +++ b/server/src/internal/customers/change-product/scheduleUtils/getFilteredScheduleItems.ts @@ -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; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts index ef4659023..5497cbacb 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts @@ -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.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, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts index d9079eff5..70aa06475 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getExistingUsage.ts @@ -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}` + ) ); } diff --git a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts index 1744dd900..8687ad941 100644 --- a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts +++ b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts @@ -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... diff --git a/server/src/internal/customers/handlers/handleUpdateBalances.ts b/server/src/internal/customers/handlers/handleUpdateBalances.ts index 278194824..23ec8dfdc 100644 --- a/server/src/internal/customers/handlers/handleUpdateBalances.ts +++ b/server/src/internal/customers/handlers/handleUpdateBalances.ts @@ -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! ); diff --git a/server/src/internal/invoices/invoiceFormatUtils.ts b/server/src/internal/invoices/invoiceFormatUtils.ts index 646f06dcc..e190f7b71 100644 --- a/server/src/internal/invoices/invoiceFormatUtils.ts +++ b/server/src/internal/invoices/invoiceFormatUtils.ts @@ -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 { diff --git a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts index 64a5451ae..cc69007a6 100644 --- a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts +++ b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts @@ -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, })!; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts index 43d678914..565637e4e 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForCurProduct.ts @@ -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, })!; diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index 8c3ed276a..6d219532f 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -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; diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index cd6875cc0..38c932c12 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -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 ); }; diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index 00e83f41e..edd44f375 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -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; diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 13d9214e8..e84c376bc 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -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 ); }); diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 4e22567e8..e6d03166b 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -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 + ); } }; diff --git a/server/src/internal/products/productUtils/detectProductVariant.ts b/server/src/internal/products/productUtils/detectProductVariant.ts index 6ec596ee0..637bdb1e2 100644 --- a/server/src/internal/products/productUtils/detectProductVariant.ts +++ b/server/src/internal/products/productUtils/detectProductVariant.ts @@ -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")} @@ -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) { diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts index 3a4f8e212..ddb9717bf 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductItemDisplay.ts @@ -18,7 +18,7 @@ import { isPriceItem, } from "../../product-items/productItemUtils/getItemType.js"; -const getIntervalString = ({ +export const getIntervalString = ({ interval, intervalCount, }: { diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index f19c8a3b3..0a7373221 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -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, diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 268ab394c..fb1bd26cf 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -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, }) ); } diff --git a/server/src/utils/scriptUtils/logUtils/logSubItems.ts b/server/src/utils/scriptUtils/logUtils/logSubItems.ts index 679087de7..f5d443661 100644 --- a/server/src/utils/scriptUtils/logUtils/logSubItems.ts +++ b/server/src/utils/scriptUtils/logUtils/logSubItems.ts @@ -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})` + ); } } }; diff --git a/server/tests/advanced/customInterval/customInteral2.ts b/server/tests/advanced/customInterval/customInteral2.ts new file mode 100644 index 000000000..9b39c0d9f --- /dev/null +++ b/server/tests/advanced/customInterval/customInteral2.ts @@ -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); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval1 copy.ts b/server/tests/advanced/customInterval/customInterval1 copy.ts new file mode 100644 index 000000000..6d2a4e673 --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval1 copy.ts @@ -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, + }); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval1.ts b/server/tests/advanced/customInterval/customInterval1.ts new file mode 100644 index 000000000..360d9e161 --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval1.ts @@ -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 })); + }); +}); diff --git a/server/tests/advanced/customInterval/customInterval3.ts b/server/tests/advanced/customInterval/customInterval3.ts new file mode 100644 index 000000000..ea89242f8 --- /dev/null +++ b/server/tests/advanced/customInterval/customInterval3.ts @@ -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 + ); + }); +}); diff --git a/shared/models/productV2Models/productItemModels/featureItem.ts b/shared/models/productV2Models/productItemModels/featureItem.ts index 3ce838cba..1ca38afdd 100644 --- a/shared/models/productV2Models/productItemModels/featureItem.ts +++ b/shared/models/productV2Models/productItemModels/featureItem.ts @@ -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, diff --git a/shared/models/productV2Models/productItemModels/featurePriceItem.ts b/shared/models/productV2Models/productItemModels/featurePriceItem.ts index 63eb4a780..93391f973 100644 --- a/shared/models/productV2Models/productItemModels/featurePriceItem.ts +++ b/shared/models/productV2Models/productItemModels/featurePriceItem.ts @@ -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, diff --git a/shared/models/productV2Models/productItemModels/priceItem.ts b/shared/models/productV2Models/productItemModels/priceItem.ts index a5bf44199..db075b3b2 100644 --- a/shared/models/productV2Models/productItemModels/priceItem.ts +++ b/shared/models/productV2Models/productItemModels/priceItem.ts @@ -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(), }); diff --git a/shared/utils/intervalUtils.ts b/shared/utils/intervalUtils.ts index 15fb535e2..fb88ce05f 100644 --- a/shared/utils/intervalUtils.ts +++ b/shared/utils/intervalUtils.ts @@ -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.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 }); +}; diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 692cbda58..ef1d1f2f1 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -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, diff --git a/vite/src/views/products/product/product-item/create-product-item/defaultItemConfigs.ts b/vite/src/views/products/product/product-item/create-product-item/defaultItemConfigs.ts index f206ad400..2628d2a81 100644 --- a/vite/src/views/products/product/product-item/create-product-item/defaultItemConfigs.ts +++ b/vite/src/views/products/product/product-item/create-product-item/defaultItemConfigs.ts @@ -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,