diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index c2d7c0b53..8dbfd596f 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -7,7 +7,7 @@ import { CusExpand, ErrCode, } from "@autumn/shared"; -import { TrackParams } from "autumn-js"; +import { TrackParams, UsageParams } from "autumn-js"; import { AttachBody } from "@/internal/customers/attach/models/AttachBody.js"; export default class AutumnError extends Error { @@ -393,6 +393,11 @@ export class AutumnInt { return data; }; + usage = async (params: UsageParams) => { + const data = await this.post(`/usage`, params); + return data; + }; + attachPreview = async (params: AttachBody) => { const data = await this.post(`/attach/preview`, params); return data; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleContUsePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleContUsePrices.ts new file mode 100644 index 000000000..5e217a107 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleContUsePrices.ts @@ -0,0 +1,108 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { findLinkedCusEnts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; +import { removeReplaceablesFromCusEnt } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils.js"; +import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; +import { FullCustomerEntitlement, FullCustomerPrice } from "@autumn/shared"; +import Stripe from "stripe"; +import { findStripeItemForPrice } from "../../stripeSubUtils/stripeSubItemUtils.js"; +import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js"; + +export const handleContUsePrices = async ({ + db, + cusEnts, + cusPrice, + stripeCli, + // customer, + // org, + // env, + invoice, + usageSub, + logger, +}: { + db: DrizzleCli; + cusEnts: FullCustomerEntitlement[]; + cusPrice: FullCustomerPrice; + stripeCli: Stripe; + // customer: Customer; + // org: Organization; + // env: AppEnv; + invoice: Stripe.Invoice; + usageSub: Stripe.Subscription; + logger: any; +}) => { + const cusEnt = getRelatedCusEnt({ + cusPrice, + cusEnts, + }); + + if (!cusEnt) { + console.log("No related cus ent found"); + return; + } + + // If invoice is not for new period (eg. upgrades, etc, skip) + const isNewPeriod = invoice.period_start !== usageSub.current_period_start; + if (!isNewPeriod) { + return; + } + + let feature = cusEnt.entitlement.feature; + logger.info( + `Handling invoice.created for in arrear prorated, feature: ${feature.id}`, + ); + + let replaceables = cusEnt.replaceables.filter((r) => r.delete_next_cycle); + + if (replaceables.length == 0) { + return; + } + + logger.info(`🚀 Deleting replaceables for ${feature.id}`); + + let linkedCusEnts = findLinkedCusEnts({ + cusEnts, + feature, + }); + + for (const linkedCusEnt of linkedCusEnts) { + let { newEntities } = removeReplaceablesFromCusEnt({ + cusEnt: linkedCusEnt, + replaceableIds: replaceables.map((r) => r.id), + }); + + await CusEntService.update({ + db, + id: linkedCusEnt.id, + updates: { + entities: newEntities, + }, + }); + } + + let subItem = findStripeItemForPrice({ + stripeItems: usageSub.items.data, + price: cusPrice.price, + }); + + // if (subItem) { + // let newQuantity = (subItem.quantity || 0) - replaceables.length; + // newQuantity = Math.max(0, newQuantity); + // await stripeCli.subscriptionItems.update(subItem.id, { + // quantity: newQuantity, + // proration_behavior: "always_invoice", + // }); + // logger.info(`Update sub item quantity to ${newQuantity}`); + // } + + await CusEntService.increment({ + db, + id: cusEnt.id, + amount: replaceables.length, + }); + + await RepService.deleteInIds({ + db, + ids: replaceables.map((r) => r.id), + }); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts index bd445cba0..fe6c13939 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts @@ -28,6 +28,7 @@ import { getFeatureName } from "@/internal/features/utils/displayUtils.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { getFullStripeInvoice } from "../../stripeInvoiceUtils.js"; import { handleUsagePrices } from "./handleUsagePrices.js"; +import { handleContUsePrices } from "./handleContUsePrices.js"; const handleInArrearProrated = async ({ db, @@ -247,17 +248,29 @@ export const sendUsageAndReset = async ({ } if (billingType == BillingType.InArrearProrated) { - await handleInArrearProrated({ + await handleContUsePrices({ db, + stripeCli, cusEnts, cusPrice, - customer, - org, - env, + // customer, + // org, + // env, invoice, usageSub: usageBasedSub, logger, }); + // await handleInArrearProrated({ + // db, + // cusEnts, + // cusPrice, + // customer, + // org, + // env, + // invoice, + // usageSub: usageBasedSub, + // logger, + // }); } } }; diff --git a/server/src/internal/api/entities/handlers/handleDeleteEntity.ts b/server/src/internal/api/entities/handlers/handleDeleteEntity.ts index 8a782497e..fb78600c4 100644 --- a/server/src/internal/api/entities/handlers/handleDeleteEntity.ts +++ b/server/src/internal/api/entities/handlers/handleDeleteEntity.ts @@ -95,10 +95,6 @@ export const handleDeleteEntity = async (req: any, res: any) => { originalBalance: mainCusEnt.balance!, newBalance: mainCusEnt.balance! + 1, logger, - - // deduction: 1, - // product, - // fromEntities: true, }); let linkedCusEnts = findLinkedCusEnts({ diff --git a/server/src/internal/api/events/eventRouter.ts b/server/src/internal/api/events/eventRouter.ts index ec3479721..db713779e 100644 --- a/server/src/internal/api/events/eventRouter.ts +++ b/server/src/internal/api/events/eventRouter.ts @@ -1,4 +1,5 @@ import { Router } from "express"; + import { APIVersion, AppEnv, @@ -12,13 +13,11 @@ import { FullCustomer, Organization, } from "@autumn/shared"; + import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { generateId } from "@/utils/genUtils.js"; - import { EventService } from "./EventService.js"; - import { OrgService } from "@/internal/orgs/OrgService.js"; - import { handleUsageEvent } from "./usageRouter.js"; import { StatusCodes } from "http-status-codes"; import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; diff --git a/server/src/internal/api/events/usageRouter.ts b/server/src/internal/api/events/usageRouter.ts index 634158c16..0958e22d5 100644 --- a/server/src/internal/api/events/usageRouter.ts +++ b/server/src/internal/api/events/usageRouter.ts @@ -6,6 +6,7 @@ import { Event, EventInsert, FeatureType, + FeatureUsageType, } from "@autumn/shared"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { generateId, nullish } from "@/utils/genUtils.js"; @@ -23,6 +24,8 @@ import { addTaskToQueue } from "@/queue/queueUtils.js"; import { getOrgAndFeatures } from "@/internal/orgs/orgUtils.js"; import { getEventTimestamp } from "./eventUtils.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; +import { runUpdateBalanceTask } from "@/trigger/updateBalanceTask.js"; +import { runUpdateUsageTask } from "@/trigger/updateUsageTask.js"; export const eventsRouter = Router(); export const usageRouter = Router(); @@ -169,8 +172,6 @@ export const handleUsageEvent = async ({ const features = [feature, ...creditSystems]; - const queue = await QueueManager.getQueue({ useBackup: false }); - if (nullish(value) || isNaN(parseFloat(value))) { value = 1; } else { @@ -189,10 +190,20 @@ export const handleUsageEvent = async ({ entityId: entity_id, }; - await addTaskToQueue({ - jobName: JobName.UpdateUsage, - payload, - }); + const featureUsageType = feature.config?.usage_type; + // console.log(`Feature Usage Type: ${featureUsageType}`); + if (featureUsageType === FeatureUsageType.Continuous) { + await runUpdateUsageTask({ + payload, + logger: console, + db: req.db, + }); + } else { + await addTaskToQueue({ + jobName: JobName.UpdateUsage, + payload, + }); + } return { event: newEvent, affectedFeatures: features, org }; }; diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts index afbf62e5d..5c33127f3 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/createContUseInvoiceItems.ts @@ -100,13 +100,18 @@ export const createAndFilterContUseItems = async ({ }); const items = [...oldItems, ...newItems]; + const curPrices = cusProductToPrices({ + cusProduct: curMainProduct, + }); for (const item of items) { if (!item.amount || item.amount === 0) { continue; } - let price = product.prices.find((p) => p.id === item.price_id); + let price = + product.prices.find((p) => p.id === item.price_id) || + curPrices.find((p) => p.id === item.price_id); if (interval && price?.config.interval !== interval) { continue; diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseDowngradeItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseDowngradeItems.ts index f5ab08a5b..eeae6ad8f 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseDowngradeItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseDowngradeItems.ts @@ -41,7 +41,7 @@ export const getContUseDowngradeItems = async ({ const product = attachParamsToProduct({ attachParams }); const feature = prevCusEnt.entitlement.feature; - let { usage: prevUsage } = getUsageFromBalance({ + let { usage: prevUsage, overage: prevOverage } = getUsageFromBalance({ ent: prevCusEnt.entitlement, price, balance: prevBalance, @@ -53,6 +53,42 @@ export const getContUseDowngradeItems = async ({ balance: prevBalance, }); + if (prevOverage == 0) { + let { usage: newUsage } = getUsageFromBalance({ + ent, + price, + balance: ent.allowance! - curUsage, + }); + + let newItem = priceToInvoiceItem({ + price, + ent, + org: attachParams.org, + usage: newUsage, + prodName: product.name, + proration, + now, + allowNegative: false, + }); + + return { + oldItem: prevInvoiceItem, + newItem, + newUsageItem: null, + replaceables: [], + }; + } + + const newItem = priceToInvoiceItem({ + price, + ent, + org: attachParams.org, + usage: newUsage, + prodName: product.name, + proration, + now, + }); + let numReplaceables = newUsage - prevUsage; let replaceables = Array.from({ length: numReplaceables }, (_, i) => @@ -64,16 +100,6 @@ export const getContUseDowngradeItems = async ({ }), ); - const newItem = priceToInvoiceItem({ - price, - ent, - org: attachParams.org, - usage: newUsage, - prodName: product.name, - proration, - now, - }); - const featureName = usageToFeatureName({ usage: numReplaceables, feature, diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts index 171fd2463..797486008 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts @@ -128,13 +128,19 @@ export const getContUseInvoiceItems = async ({ feature: ent.feature, }); + let prevCusPrice = prevCusEnt + ? getRelatedCusPrice(prevCusEnt, cusPrices)! + : undefined; + if (!intervalsSame || !prevCusEnt || !stripeSubs) { const newItem = await getContUseNewItems({ price, ent, attachParams, }); - const prevItem = curItems.find((item) => item.price_id === price.id); + const prevItem = curItems.find( + (item) => item.price_id === prevCusPrice?.price.id, + ); newItems.push(newItem); @@ -145,7 +151,6 @@ export const getContUseInvoiceItems = async ({ continue; } - let prevCusPrice = getRelatedCusPrice(prevCusEnt, cusPrices)!; const curItem = curItems.find( (item) => item.price_id === prevCusPrice?.price.id, ); diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts index bb030b550..a9dcf3786 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/priceToContUseItem.ts @@ -15,6 +15,7 @@ import { shouldProrate } from "@/internal/products/prices/priceUtils/prorationCo import { getContUseUpgradeItems } from "./getContUseUpgradeItems.js"; import { priceToInvoiceItem } from "@/internal/products/prices/priceUtils/priceToInvoiceItem.js"; import { Decimal } from "decimal.js"; +import { notNullish } from "@/utils/genUtils.js"; export const priceToContUseItem = async ({ price, @@ -94,6 +95,8 @@ export const priceToContUseItem = async ({ org: attachParams.org, usage: curUsage, prodName: product.name, + proration, + now, }); res = { @@ -111,15 +114,17 @@ export const priceToContUseItem = async ({ if (new Decimal(oldAmount).add(newAmount).eq(0)) { return { oldItem: null, - newItems: [res.newUsageItem].filter((item) => item !== undefined), + newItems: [res.newUsageItem].filter((item) => + notNullish(item), + ) as PreviewLineItem[], replaceables: res.replaceables, }; } else { return { oldItem: res.oldItem, - newItems: [res.newItem, res.newUsageItem].filter( - (item) => item !== undefined, - ), + newItems: [res.newItem, res.newUsageItem].filter((item) => + notNullish(item), + ) as PreviewLineItem[], replaceables: res.replaceables, }; } diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index db878a8f4..38b4788e8 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -69,6 +69,7 @@ const getFullCusProdRelations = () => { feature: true as const, }, }, + replaceables: true, }, }, customer_prices: { @@ -130,6 +131,7 @@ export class CusProductService { feature: true, }, }, + replaceables: true, }, }, customer_prices: { @@ -192,6 +194,7 @@ export class CusProductService { feature: true, }, }, + replaceables: true, }, }, customer_prices: { @@ -279,6 +282,7 @@ export class CusProductService { feature: true, }, }, + replaceables: true, }, }, customer_prices: { @@ -332,6 +336,7 @@ export class CusProductService { feature: true, }, }, + replaceables: true, }, }, customer_prices: { @@ -371,6 +376,7 @@ export class CusProductService { feature: true as const, }, }, + replaceables: true, }, }, customer_prices: { @@ -422,10 +428,6 @@ export class CusProductService { stripeSubId: string; updates: Partial; }) { - // eq( - // sql`${customerProducts.processor}->>'subscription_id'`, - // stripeSubId, - // ), let updated = await db .update(customerProducts) .set(updates as any) diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils.ts index 2f7d9ae2b..aac7267d1 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/linkedCusEntUtils.ts @@ -33,3 +33,18 @@ export const deleteEntityFromCusEnt = ({ return { newEntities }; }; + +export const removeReplaceablesFromCusEnt = ({ + cusEnt, + replaceableIds, +}: { + cusEnt: FullCustomerEntitlement; + replaceableIds: string[]; +}) => { + let newEntities = structuredClone(cusEnt.entities) || {}; + for (const replaceableId of replaceableIds) { + delete newEntities[replaceableId]; + } + + return { newEntities }; +}; diff --git a/server/src/internal/customers/handlers/handleUpdateBalances.ts b/server/src/internal/customers/handlers/handleUpdateBalances.ts index a86656770..5fb45d538 100644 --- a/server/src/internal/customers/handlers/handleUpdateBalances.ts +++ b/server/src/internal/customers/handlers/handleUpdateBalances.ts @@ -74,8 +74,6 @@ export const handleUpdateBalances = async (req: any, res: any) => { }); } - // Can't update feature -> credit system here... - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ customer, internalFeatureIds: featuresToUpdate.map((f) => f.internal_id!), diff --git a/server/src/internal/customers/handlers/handleUpdateEntitlement.ts b/server/src/internal/customers/handlers/handleUpdateEntitlement.ts index bde2295eb..55b8ce169 100644 --- a/server/src/internal/customers/handlers/handleUpdateEntitlement.ts +++ b/server/src/internal/customers/handlers/handleUpdateEntitlement.ts @@ -17,6 +17,7 @@ import { getCusEntBalance } from "@/internal/customers/cusProducts/cusEnts/cusEn import { performDeductionOnCusEnt } from "@/trigger/updateBalanceTask.js"; import { ExtendedRequest } from "@/utils/models/Request.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusProductService } from "../cusProducts/CusProductService.js"; const getCusOrgAndCusPrice = async ({ db, @@ -44,7 +45,7 @@ const getCusOrgAndCusPrice = async ({ export const handleUpdateEntitlement = async (req: any, res: any) => { try { - const { db } = req; + const { db, logtail: logger } = req; const { customer_entitlement_id } = req.params; const { balance, next_reset_at, entity_id } = req.body; @@ -92,10 +93,6 @@ export const handleUpdateEntitlement = async (req: any, res: any) => { }); } - // let { balance: masterBalance } = getCusEntMasterBalance({ - // cusEnt, - // entities: cusEnt.customer_product.entities, - // }); let { balance: masterBalance } = getCusEntBalance({ cusEnt, entityId: entity_id, @@ -113,16 +110,12 @@ export const handleUpdateEntitlement = async (req: any, res: any) => { entityId: entity_id, }); - await CusEntService.update({ - db, - id: customer_entitlement_id, - updates: { - balance: newBalance, - next_reset_at, - entities: newEntities, - adjustment: newAdjustment, - }, - }); + let updates = { + balance: newBalance, + next_reset_at, + entities: newEntities, + adjustment: newAdjustment, + }; const { cusPrice, customer, org } = await getCusOrgAndCusPrice({ db, @@ -130,23 +123,43 @@ export const handleUpdateEntitlement = async (req: any, res: any) => { cusEnt, }); - if (!cusPrice || !customer) { - res.status(200).json({ success: true }); - return; + if (cusPrice && customer) { + let fullCusProduct = await CusProductService.get({ + db, + id: cusEnt.customer_product_id, + orgId: req.orgId, + env: req.env, + }); + + const { newReplaceables, deletedReplaceables } = await adjustAllowance({ + db, + env: req.env, + org: org, + affectedFeature: cusEnt.entitlement.feature, + cusEnt: { + ...cusEnt, + customer_product: fullCusProduct!, + }, + cusPrices: [cusPrice], + customer: customer, + originalBalance: originalBalance!, + newBalance: balance, + logger: req.logtail, + }); + + if (newReplaceables && newReplaceables.length > 0) { + updates.balance = newBalance! - newReplaceables.length; + } + + if (deletedReplaceables && deletedReplaceables.length > 0) { + updates.balance = newBalance! + deletedReplaceables.length; + } } - await adjustAllowance({ + await CusEntService.update({ db, - - env: req.env, - org: org, - affectedFeature: cusEnt.entitlement.feature, - cusEnt: cusEnt as FullCusEntWithProduct, - cusPrices: [cusPrice], - customer: customer, - originalBalance: originalBalance!, - newBalance: balance, - deduction: deducted, + id: customer_entitlement_id, + updates, }); res.status(200).json({ success: true }); diff --git a/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.ts b/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.ts index 8e9fb36b7..d00bd5a85 100644 --- a/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.ts +++ b/server/src/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.ts @@ -1,6 +1,5 @@ import { Entitlement, Price, UsagePriceConfig } from "@autumn/shared"; import { Decimal } from "decimal.js"; -import { priceToInvoiceAmount } from "../priceToInvoiceAmount.js"; export const getUsageFromBalance = ({ ent, @@ -34,68 +33,3 @@ export const getUsageFromBalance = ({ return { usage, roundedUsage, overage, roundedOverage }; }; - -export const getPrevAndNewPriceForUpgrade = ({ - ent, - numReplaceables, - price, - newBalance, - prevBalance, -}: { - ent: Entitlement; - numReplaceables: number; - price: Price; - newBalance: number; - prevBalance: number; -}) => { - const { - usage: newUsage, - roundedUsage: newRoundedUsage, - roundedOverage: newRoundedOverage, - } = getUsageFromBalance({ - ent, - price, - balance: newBalance, - }); - - const { - usage: prevUsage, - overage: prevOverage, - roundedOverage: prevRoundedOverage, - } = getUsageFromBalance({ - ent, - price, - balance: prevBalance, - }); - - const { roundedOverage: overageWithReplaceables } = getUsageFromBalance({ - ent, - price, - balance: prevBalance - numReplaceables, - }); - - // Get price for usage... - let prevPrice = priceToInvoiceAmount({ - price, - overage: overageWithReplaceables, - }); - - let newPrice = priceToInvoiceAmount({ - price, - overage: newRoundedOverage, - }); - - return { - prevPrice, - newPrice, - - prevUsage, - newUsage, - - overageWithReplaceables, - newRoundedOverage, - // newRoundedUsage, - // prevRoundedOverage, - // prevOverage, - }; -}; diff --git a/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts b/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts index 52eb7b81f..36d4bb3dd 100644 --- a/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts +++ b/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts @@ -18,6 +18,7 @@ export const priceToInvoiceItem = ({ org, proration, now, + allowNegative, }: { price: Price; ent: FullEntitlement; @@ -26,6 +27,7 @@ export const priceToInvoiceItem = ({ org: Organization; proration?: Proration; now?: number; + allowNegative?: boolean; }) => { const config = price.config as UsagePriceConfig; const billingUnits = config.billing_units || 1; @@ -45,13 +47,17 @@ export const priceToInvoiceItem = ({ // Get overage const overage = usage - ent.allowance!; - const invoiceAmount = priceToInvoiceAmount({ + let invoiceAmount = priceToInvoiceAmount({ price, overage, proration, now, }); + if (!allowNegative && invoiceAmount < 0) { + invoiceAmount = 0; + } + let newPreviewItem = constructPreviewItem({ price, org, diff --git a/server/src/internal/products/prices/priceUtils/prorationConfigUtils.ts b/server/src/internal/products/prices/priceUtils/prorationConfigUtils.ts index 78d4cd10a..03a03ba7e 100644 --- a/server/src/internal/products/prices/priceUtils/prorationConfigUtils.ts +++ b/server/src/internal/products/prices/priceUtils/prorationConfigUtils.ts @@ -8,10 +8,11 @@ export const shouldCreateInvoiceItem = (onIncrease: OnIncrease) => { ); }; -export const shouldBillNow = (onIncrease: OnIncrease) => { +export const shouldBillNow = (onIncrease: OnIncrease | OnDecrease) => { return ( onIncrease === OnIncrease.BillImmediately || - onIncrease === OnIncrease.ProrateImmediately + onIncrease === OnIncrease.ProrateImmediately || + onIncrease === OnDecrease.ProrateImmediately ); }; @@ -23,6 +24,7 @@ export const shouldProrate = (onIncrease?: OnIncrease | OnDecrease) => { return ( onIncrease === OnIncrease.ProrateNextCycle || onIncrease === OnIncrease.ProrateImmediately || - onIncrease === OnDecrease.Prorate + onIncrease === OnDecrease.ProrateImmediately || + onIncrease === OnDecrease.ProrateNextCycle ); }; diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts index c49581a1f..4a26d69e0 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils.ts @@ -82,8 +82,10 @@ export const onDecreaseToStripeProration = ({ onDecrease: OnDecrease; }) => { let behavior = "none"; - if (onDecrease === OnDecrease.Prorate) { + if (onDecrease === OnDecrease.ProrateImmediately) { behavior = "always_invoice"; + } else if (onDecrease === OnDecrease.ProrateNextCycle) { + behavior = "create_prorations"; } return behavior as Stripe.SubscriptionItemUpdateParams.ProrationBehavior; diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index aaecc1f4b..44ddf0d82 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -207,7 +207,7 @@ export const toFeatureAndPrice = ({ if (itemCanBeProrated({ item, features })) { prorationConfig = { on_increase: item.config?.on_increase || OnIncrease.ProrateImmediately, - on_decrease: item.config?.on_decrease || OnDecrease.Prorate, + on_decrease: item.config?.on_decrease || OnDecrease.ProrateImmediately, }; } diff --git a/server/src/trigger/adjustAllowance.ts b/server/src/trigger/adjustAllowance.ts index b020d9836..837f4bc75 100644 --- a/server/src/trigger/adjustAllowance.ts +++ b/server/src/trigger/adjustAllowance.ts @@ -100,6 +100,7 @@ export const adjustAllowance = async ({ return { newReplaceables: [], invoice: null, deletedReplaceables: null }; } + logger.info(`--------------------------------`); logger.info(`Updating arrear prorated usage: ${affectedFeature.name}`); logger.info(`Customer: ${customer.name}, Org: ${org.slug}`); diff --git a/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts b/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts index cd41c5e13..f994d750d 100644 --- a/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts +++ b/server/src/trigger/arrearProratedUsage/createUpgradeProrationInvoice.ts @@ -75,8 +75,8 @@ export const getUpgradeProrationInvoiceItem = ({ description: invoiceDescription, stripeSubId: stripeSub.id, stripeCustomerId: stripeSub.customer as string, - periodStart: stripeSub.current_period_start, - periodEnd: Math.floor(now / 1000), + periodStart: Math.floor(now / 1000), + periodEnd: Math.floor(stripeSub.current_period_end * 1000), }); return invoiceItem; diff --git a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts index 58dd6ed7d..bf7298f44 100644 --- a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts +++ b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts @@ -15,19 +15,21 @@ import { } from "@autumn/shared"; import { Decimal } from "decimal.js"; import { getFeatureInvoiceDescription } from "@autumn/shared"; -import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js"; import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js"; import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; import { formatUnixToDate } from "@/utils/genUtils.js"; import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js"; import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js"; -import { handleCreateReplaceables } from "./handleCreateReplaceables.js"; import { DrizzleCli } from "@/db/initDrizzle.js"; import { getUsageFromBalance } from "../adjustAllowance.js"; import { roundUsage } from "@/internal/products/prices/priceUtils/usagePriceUtils.js"; import { getReplaceables } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.js"; import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js"; +import { + shouldBillNow, + shouldProrate, +} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js"; export const createDowngradeProrationInvoice = async ({ org, @@ -87,25 +89,31 @@ export const createDowngradeProrationInvoice = async ({ `🚀 Creating invoice item: ${invoiceDescription} - ${invoiceAmount.toFixed(2)}`, ); - const { invoice } = await createAndFinalizeInvoice({ - stripeCli, - stripeCusId: sub.customer as string, + const invoiceItem = constructStripeInvoiceItem({ + product, + amount: invoiceAmount, + org, + price: cusPrice.price, + description: invoiceDescription, stripeSubId: sub.id, - invoiceItems: [ - constructStripeInvoiceItem({ - product, - amount: invoiceAmount, - org, - price: cusPrice.price, - description: invoiceDescription, - stripeSubId: sub.id, - stripeCustomerId: sub.customer as string, - periodStart: sub.current_period_start, - periodEnd: Math.floor(now / 1000), - }), - ], + stripeCustomerId: sub.customer as string, + periodStart: Math.floor(now / 1000), + periodEnd: Math.floor(sub.current_period_end * 1000), }); + await stripeCli.invoiceItems.create(invoiceItem); + let invoice = null; + + if (shouldBillNow(onDecrease)) { + const { invoice: finalInvoice } = await createAndFinalizeInvoice({ + stripeCli, + stripeCusId: sub.customer as string, + stripeSubId: sub.id, + }); + + invoice = finalInvoice; + } + return invoice; }; @@ -134,7 +142,7 @@ export const handleProratedDowngrade = async ({ }) => { logger.info(`Handling quantity decrease`); - const { overage: prevOverage } = getUsageFromBalance({ + const { overage: prevOverage, usage: prevUsage } = getUsageFromBalance({ ent: cusEnt.entitlement, price: cusPrice.price, balance: prevBalance, @@ -147,14 +155,18 @@ export const handleProratedDowngrade = async ({ }); let onDecrease = - cusPrice.price.proration_config?.on_decrease || OnDecrease.Prorate; + cusPrice.price.proration_config?.on_decrease || + OnDecrease.ProrateImmediately; const feature = cusEnt.entitlement.feature; const product = cusEnt.customer_product.product; let invoice = null; let newReplaceables: InsertReplaceable[] = []; - if (onDecrease === OnDecrease.Prorate) { + console.log("On decrease:", onDecrease); + console.log("Should bill now:", shouldBillNow(onDecrease)); + + if (shouldProrate(onDecrease)) { let prevPrice = priceToInvoiceAmount({ price: cusPrice.price, overage: roundUsage({ @@ -179,7 +191,7 @@ export const handleProratedDowngrade = async ({ newPrice, prevPrice, newRoundedUsage: roundUsage({ - usage: newOverage, + usage: newUsage, price: cusPrice.price, }), feature, @@ -188,38 +200,33 @@ export const handleProratedDowngrade = async ({ logger, }); } else { - // newReplaceables = await handleCreateReplaceables({ - // db, - // prevOverage, - // newOverage, - // cusEnt, - // logger, - // }); - newReplaceables = getReplaceables({ - cusEnt, - prevOverage, - newOverage, - }); + if (prevOverage > 0) { + newReplaceables = getReplaceables({ + cusEnt, + prevOverage: prevUsage, + newOverage: newUsage, + }); - await RepService.insert({ - db, - data: newReplaceables, - }); - - logger.info("New replaceables", { - newReplaceables, - }); + await RepService.insert({ + db, + data: newReplaceables, + }); + } } - let quantity = newUsage + (newReplaceables?.length || 0); + let numDeletedReplaceables = cusEnt.replaceables.filter( + (r) => r.delete_next_cycle, + ).length; + let newQuantity = newUsage - numDeletedReplaceables; await stripeCli.subscriptionItems.update(subItem.id, { quantity: roundUsage({ - usage: quantity, + usage: newQuantity, price: cusPrice.price, }), proration_behavior: "none", }); + logger.info(`Updated sub item quantity to ${newUsage}`); return { invoice, newReplaceables, deletedReplaceables: null }; }; diff --git a/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts b/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts index b871d3728..7d0e295fb 100644 --- a/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts +++ b/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts @@ -53,22 +53,29 @@ export const getPrevAndNewPriceForUpgrade = ({ balance: newBalance, }); - // Get price for usage... let prevPrice = priceToInvoiceAmount({ price, - overage: prevOverage, + overage: roundUsage({ + // usage: prevUsage, + usage: prevOverage, + price, + }), }); let newPrice = priceToInvoiceAmount({ price, - overage: newOverage, + overage: roundUsage({ + // usage: newUsage, + usage: newOverage, + price, + }), }); return { - prevOverage, - newOverage, + // prevOverage, + // newOverage, newUsage, - prevUsage, + // prevUsage, prevPrice, newPrice, }; @@ -100,23 +107,18 @@ export const handleProratedUpgrade = async ({ logger.info(`Handling quantity increase`); // 1. Get num reps to use - - // let reps = cusEnt.replaceables.slice(0, usageDiff); - // newBalance = newBalance + reps.length; // Increase new balance by number of reps - - let { prevPrice, newPrice, newUsage, prevUsage, prevOverage, newOverage } = - getPrevAndNewPriceForUpgrade({ - ent: cusEnt.entitlement, - price: cusPrice.price, - newBalance, - prevBalance, - logger, - }); - - let overageDiff = newOverage - prevOverage; - let reps = cusEnt.replaceables.slice(0, overageDiff); + let usageDiff = prevBalance - newBalance; + let reps = cusEnt.replaceables.slice(0, usageDiff); newBalance = newBalance + reps.length; // Increase new balance by number of reps + let { prevPrice, newPrice, newUsage } = getPrevAndNewPriceForUpgrade({ + ent: cusEnt.entitlement, + price: cusPrice.price, + newBalance, + prevBalance, + logger, + }); + const config = cusPrice.price.config as UsagePriceConfig; const product = cusEnt.customer_product.product; const feature = cusEnt.entitlement.feature; diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index 476eaf628..8ec3d4a6a 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -340,13 +340,7 @@ export const deductAllowanceFromCusEnt = async ({ updates.adjustment = 0; } - await CusEntService.update({ - db, - id: cusEnt.id, - updates, - }); - - await adjustAllowance({ + const { newReplaceables, deletedReplaceables } = await adjustAllowance({ db, env, org, @@ -357,7 +351,21 @@ export const deductAllowanceFromCusEnt = async ({ originalBalance: originalGrpBalance, newBalance: newGrpBalance, logger: console, - // deduction: deducted, + }); + + console.log("New balance:", newBalance); + console.log("New replaceables:", newReplaceables); + + if (newReplaceables && newReplaceables.length > 0) { + updates.balance = newBalance! - newReplaceables.length; + } else if (deletedReplaceables && deletedReplaceables.length > 0) { + updates.balance = newBalance! + deletedReplaceables.length; + } + + await CusEntService.update({ + db, + id: cusEnt.id, + updates, }); // Deduct credit amounts too @@ -449,7 +457,7 @@ export const deductFromUsageBasedCusEnt = async ({ updates.adjustment = 0; } - await adjustAllowance({ + const { newReplaceables, deletedReplaceables } = await adjustAllowance({ db, env, affectedFeature: feature, @@ -462,6 +470,12 @@ export const deductFromUsageBasedCusEnt = async ({ logger: console, }); + if (newReplaceables && newReplaceables.length > 0) { + updates.balance = newBalance! - newReplaceables.length; + } else if (deletedReplaceables && deletedReplaceables.length > 0) { + updates.balance = newBalance! + deletedReplaceables.length; + } + await CusEntService.update({ db, id: usageBasedEnt!.id, diff --git a/server/tests/attach/entities/expectEntity.ts b/server/tests/attach/entities/expectEntity.ts index 0bb303eb5..00d44c934 100644 --- a/server/tests/attach/entities/expectEntity.ts +++ b/server/tests/attach/entities/expectEntity.ts @@ -1,13 +1,10 @@ import { DrizzleCli } from "@/db/initDrizzle.js"; import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; -import { - cusProductsToCusEnts, - cusProductToPrices, -} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; import { CusService } from "@/internal/customers/CusService.js"; import { findContUsePrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; -import { AppEnv, FullCusProduct, Organization } from "@autumn/shared"; +import { AppEnv, Organization } from "@autumn/shared"; import { expect } from "chai"; import Stripe from "stripe"; import { TestFeature } from "tests/setup/v2Features.js"; @@ -16,6 +13,7 @@ export const expectSubQuantityCorrect = async ({ stripeCli, productId, usage, + itemQuantity, db, org, env, @@ -25,6 +23,7 @@ export const expectSubQuantityCorrect = async ({ stripeCli: Stripe; productId: string; usage: number; + itemQuantity: number; db: DrizzleCli; org: Organization; env: AppEnv; @@ -58,7 +57,7 @@ export const expectSubQuantityCorrect = async ({ }); expect(subItem).to.exist; - expect(subItem!.quantity).to.equal(usage); + expect(subItem!.quantity).to.equal(itemQuantity); // Check num replaceables correct let cusEnts = cusProduct?.customer_entitlements; @@ -69,4 +68,9 @@ export const expectSubQuantityCorrect = async ({ let expectedBalance = cusEnt!.entitlement.allowance! - usage; expect(cusEnt!.balance).to.equal(expectedBalance); + + return { + stripeSubs, + cusProduct, + }; }; diff --git a/server/tests/attach/entities/entity1.ts b/server/tests/contUse/entities/entity1.ts similarity index 89% rename from server/tests/attach/entities/entity1.ts rename to server/tests/contUse/entities/entity1.ts index 8356660a1..627be6a67 100644 --- a/server/tests/attach/entities/entity1.ts +++ b/server/tests/contUse/entities/entity1.ts @@ -12,12 +12,12 @@ 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 "../utils.js"; +import { addPrefixToProducts, runAttachTest } from "../../attach/utils.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { expect } from "chai"; -import { expectSubQuantityCorrect } from "./expectEntity.js"; +import { expectSubQuantityCorrect } from "../../attach/entities/expectEntity.js"; import { addWeeks } from "date-fns"; import { timeout } from "@/utils/genUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; @@ -146,6 +146,7 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing create / de env, customerId, usage, + itemQuantity: usage, }); let customer = await autumn.customers.get(customerId); @@ -170,6 +171,7 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing create / de customerId, usage, numReplaceables: 1, + itemQuantity: usage - 1, }); }); @@ -205,13 +207,7 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing create / de env, customerId, usage, + itemQuantity: usage, }); }); }); - -// Product is 1 free, $10 per seat -// 1. Attach product -// 2. Create three entities -> should have -2 balance, 3 qty sub item -// 3. Delete two entities -> should have -2 balance, 2 replaceables, 3 qty sub item -// 4. Create two entities -> should have -2 balance, 0 replaceables, 3 qty sub item, No invoice -// 4. Delete two entities, advance clock to end of cycle -> should have 0 balance, 0 replaceables, 1 qty sub item, 1 invoice (correct amount) diff --git a/server/tests/contUse/entities/entity3.ts b/server/tests/contUse/entities/entity3.ts new file mode 100644 index 000000000..bf68aeee8 --- /dev/null +++ b/server/tests/contUse/entities/entity3.ts @@ -0,0 +1,186 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + 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 "../../attach/utils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { expect } from "chai"; +import { expectSubQuantityCorrect } from "../../attach/entities/expectEntity.js"; +import { addHours, addMonths, addWeeks } from "date-fns"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; +import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "entity3"; + +describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing create / delete entities`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + let usage = 0; + let firstEntities = [ + { + id: "1", + name: "test", + featureId: TestFeature.Users, + }, + { + id: "2", + name: "test", + featureId: TestFeature.Users, + }, + { + id: "3", + name: "test", + featureId: TestFeature.Users, + }, + ]; + + it("should create three entities, then attach pro", async function () { + await autumn.entities.create(customerId, firstEntities); + usage += firstEntities.length; + + await runAttachTest({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + usage: [ + { + featureId: TestFeature.Users, + value: usage, + }, + ], + }); + }); + + it("should delete 2 entities and have no new invoice", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(new Date(), 2).getTime(), + waitForSeconds: 10, + }); + + await autumn.entities.delete(customerId, firstEntities[0].id); + await autumn.entities.delete(customerId, firstEntities[1].id); + + const numReplaceables = 2; + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + numReplaceables, + itemQuantity: usage - numReplaceables, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); + + it("should advance clock to next cycle and have correct invoice", async function () { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(new Date(), 1), + hoursToFinalizeInvoice, + ).getTime(), + }); + + usage -= 2; // 2 entities deleted + + const customer = await autumn.customers.get(customerId); + const invoices = customer.invoices; + + let basePrice = getBasePrice({ product: pro }); + expect(invoices.length).to.equal(2); + expect(invoices[0].total).to.equal(basePrice); // 0 entities + + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + itemQuantity: usage, + numReplaceables: 0, + }); + }); +}); diff --git a/server/tests/contUse/entities/entity4.ts b/server/tests/contUse/entities/entity4.ts new file mode 100644 index 000000000..c32899b41 --- /dev/null +++ b/server/tests/contUse/entities/entity4.ts @@ -0,0 +1 @@ +// Handling per entity features! diff --git a/server/tests/contUse/entities/entity5.ts b/server/tests/contUse/entities/entity5.ts new file mode 100644 index 000000000..6f69c64b6 --- /dev/null +++ b/server/tests/contUse/entities/entity5.ts @@ -0,0 +1,3 @@ +// test payment failures + +// test update product / delete entity mix -- should have correct sub item quantity... diff --git a/server/tests/attach/entities/entity2.ts b/server/tests/contUse/track/track1.ts similarity index 75% rename from server/tests/attach/entities/entity2.ts rename to server/tests/contUse/track/track1.ts index d588154da..746738b7c 100644 --- a/server/tests/attach/entities/entity2.ts +++ b/server/tests/contUse/track/track1.ts @@ -3,6 +3,7 @@ import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; import { APIVersion, AppEnv, + entities, OnDecrease, OnIncrease, Organization, @@ -12,15 +13,16 @@ 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 "../utils.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; import { TestFeature } from "tests/setup/v2Features.js"; import { expect } from "chai"; -import { expectSubQuantityCorrect } from "./expectEntity.js"; import { addWeeks } from "date-fns"; import { timeout } from "@/utils/genUtils.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; let userItem = constructArrearProratedItem({ featureId: TestFeature.Users, @@ -37,11 +39,9 @@ export let pro = constructProduct({ type: "pro", }); -const testCase = "entity2"; +const testCase = "track1"; -// Pro is $20 / month, Seat is $50 / user - -describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing track usage for cont use`)}`, () => { +describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for cont use`)}`, () => { let customerId = testCase; let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 }); let testClockId: string; @@ -84,8 +84,9 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing track usage testClockId = testClockId1!; }); + let usage = 0; it("should attach pro", async function () { - await runAttachTest({ + await attachAndExpectCorrect({ autumn, customerId, product: pro, @@ -96,13 +97,12 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing track usage }); }); - let usage = 0; it("should create track +3 usage and have correct invoice", async function () { curUnix = await advanceTestClock({ stripeCli, testClockId, advanceTo: addWeeks(new Date(), 2).getTime(), - waitForSeconds: 10, + waitForSeconds: 5, }); await autumn.track({ @@ -111,6 +111,8 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing track usage value: 3, }); + await timeout(15000); + usage += 3; await expectSubQuantityCorrect({ @@ -126,13 +128,17 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing track usage let customer = await autumn.customers.get(customerId); let invoices = customer.invoices; expect(invoices.length).to.equal(2); - expect(invoices[0].total).to.equal(userItem.price! * 3); + expect(invoices[0].total).to.equal(userItem.price! * 2); }); - return; + it("should track -3 and have no new invoice", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: -3, + }); - it("should delete 1 entity and have no new invoice", async function () { - await autumn.entities.delete(customerId, entities[0].id); + await timeout(5000); let customer = await autumn.customers.get(customerId); let invoices = customer.invoices; @@ -146,33 +152,22 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing track usage env, customerId, usage, - numReplaceables: 1, + numReplaceables: 3, }); }); - const newEntities = [ - { - id: "4", - name: "test3", - featureId: TestFeature.Users, - }, - { - id: "5", - name: "test4", - featureId: TestFeature.Users, - }, - ]; + it("should track +3 and have no new invoice", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 3, + }); - it("should create 2 entities and have correct invoice (only pay for 1)", async function () { - await autumn.entities.create(customerId, newEntities); - await timeout(3000); - usage += 1; + await timeout(5000); let customer = await autumn.customers.get(customerId); let invoices = customer.invoices; - - expect(invoices.length).to.equal(3); - expect(invoices[0].total).to.equal(userItem.price!); + expect(invoices.length).to.equal(2); await expectSubQuantityCorrect({ stripeCli, diff --git a/server/tests/contUse/track/track2.ts b/server/tests/contUse/track/track2.ts new file mode 100644 index 000000000..593a0ba37 --- /dev/null +++ b/server/tests/contUse/track/track2.ts @@ -0,0 +1,143 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + entities, + OnDecrease, + OnIncrease, + 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { expect } from "chai"; +import { addWeeks } from "date-fns"; +import { timeout } from "@/utils/genUtils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "track2"; + +describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for cont use (without overage)`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + let usage = 0; + it("should attach pro", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + it("should track +1 and have no new invoice", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 1, + }); + + usage += 1; + + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); + + it("should track -1 and have no new invoice", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: -1, + }); + + usage -= 1; + + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + }); + }); +}); diff --git a/server/tests/contUse/track/track3.ts b/server/tests/contUse/track/track3.ts new file mode 100644 index 000000000..42ab5facb --- /dev/null +++ b/server/tests/contUse/track/track3.ts @@ -0,0 +1,218 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { expect } from "chai"; +import { addWeeks } from "date-fns"; +import { timeout } from "@/utils/genUtils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; +import { expectUpcomingItemsCorrect } from "tests/utils/expectUtils/expectContUseUtils.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.ProrateNextCycle, + on_decrease: OnDecrease.ProrateNextCycle, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "track3"; + +describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for cont use, prorate next cycle`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + let usage = 0; + it("should attach pro", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + it("should create track +3 usage and have correct invoice", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(new Date(), 2).getTime(), + waitForSeconds: 5, + }); + + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 3, + }); + + await timeout(15000); + + usage += 3; + + let { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + }); + + await expectUpcomingItemsCorrect({ + stripeCli, + fullCus, + stripeSubs, + curUnix, + expectedNumItems: 1, + unitPrice: userItem.price!, + quantity: 2, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); + + it("should track -1 and have no new invoice", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(curUnix, 1).getTime(), + waitForSeconds: 5, + }); + + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: -1, + }); + + usage -= 1; + + let { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + }); + + await expectUpcomingItemsCorrect({ + stripeCli, + fullCus, + stripeSubs, + unitPrice: userItem.price!, + curUnix, + expectedNumItems: 2, + quantity: -1, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); + + it("should track -1 and have no new invoice", async function () { + let quantity = 2; + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: quantity, + }); + + usage += quantity; + + let { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + }); + + await expectUpcomingItemsCorrect({ + stripeCli, + fullCus, + stripeSubs, + unitPrice: userItem.price!, + curUnix, + expectedNumItems: 3, + quantity, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); +}); diff --git a/server/tests/contUse/track/track4.ts b/server/tests/contUse/track/track4.ts new file mode 100644 index 000000000..eb8c7511d --- /dev/null +++ b/server/tests/contUse/track/track4.ts @@ -0,0 +1,220 @@ +import chalk from "chalk"; +import Stripe from "stripe"; + +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + Organization, +} from "@autumn/shared"; + +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { setupBefore } from "tests/before.js"; +import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { expect } from "chai"; +import { addWeeks } from "date-fns"; +import { timeout } from "@/utils/genUtils.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; +import { expectUpcomingItemsCorrect } from "tests/utils/expectUtils/expectContUseUtils.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.ProrateNextCycle, + on_decrease: OnDecrease.ProrateNextCycle, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "track4"; + +describe(`${chalk.yellowBright(`contUse/${testCase}: Testing set usage for cont use, prorate next cycle`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + let usage = 0; + it("should attach pro", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + it("should create set usage to 3 and have correct invoice", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(curUnix, 2).getTime(), + waitForSeconds: 5, + }); + + await autumn.usage({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 3, + }); + + await timeout(15000); + + usage += 3; + + let { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + }); + + await expectUpcomingItemsCorrect({ + stripeCli, + fullCus, + stripeSubs, + curUnix, + expectedNumItems: 1, + unitPrice: userItem.price!, + quantity: 2, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); + + it("should set usage to 2 and have no new invoice", async function () { + let newUsage = 2; + + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(curUnix, 1).getTime(), + waitForSeconds: 5, + }); + + await autumn.usage({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: newUsage, + }); + + let { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage: newUsage, + }); + + await expectUpcomingItemsCorrect({ + stripeCli, + fullCus, + stripeSubs, + unitPrice: userItem.price!, + curUnix, + expectedNumItems: 2, + quantity: -1, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); + + it("should set usage to 4 and have no new invoice", async function () { + let newUsage = 4; + await autumn.usage({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: newUsage, + }); + + usage = newUsage; + + let { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + }); + + await expectUpcomingItemsCorrect({ + stripeCli, + fullCus, + stripeSubs, + unitPrice: userItem.price!, + curUnix, + expectedNumItems: 3, + quantity: 2, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(1); + }); +}); diff --git a/server/tests/contUse/update/updateContUse1.ts b/server/tests/contUse/update/updateContUse1.ts new file mode 100644 index 000000000..8dfc42d40 --- /dev/null +++ b/server/tests/contUse/update/updateContUse1.ts @@ -0,0 +1,207 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { expect } from "chai"; +import { addWeeks } from "date-fns"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addPrefixToProducts, replaceItems } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "updateContUse1"; + +describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing update contUse, add included usage`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + let usage = 0; + let firstEntities = [ + { + id: "1", + name: "test", + featureId: TestFeature.Users, + }, + { + id: "2", + name: "test2", + featureId: TestFeature.Users, + }, + { + id: "3", + name: "test3", + featureId: TestFeature.Users, + }, + ]; + + it("should create entity, then attach pro", async function () { + await autumn.entities.create(customerId, firstEntities); + usage += 3; + + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + usage: [ + { + featureId: TestFeature.Users, + value: usage, + }, + ], + }); + }); + + let extraUsage = 2; + let newItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: (userItem.included_usage as number) + extraUsage, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }); + + it("should update product with extra included usage", async function () { + let customItems = replaceItems({ + featureId: TestFeature.Users, + items: pro.items, + newItem, + }); + + usage += extraUsage; + + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + is_custom: true, + items: customItems, + }); + + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + numReplaceables: extraUsage, + }); + + // Will have 1 invoice because price is replaced... + }); + + const entities = [ + { + id: "4", + name: "test4", + featureId: TestFeature.Users, + }, + { + id: "5", + name: "test5", + featureId: TestFeature.Users, + }, + ]; + + it("should create 2 entities and have no invoice", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(new Date(), 2).getTime(), + waitForSeconds: 10, + }); + + await autumn.entities.create(customerId, entities); + + // Usage won't change since using replaceables... + // usage += entities.length; + + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + numReplaceables: 0, + }); + + let customer = await autumn.customers.get(customerId); + let invoices = customer.invoices; + expect(invoices.length).to.equal(2); + }); +}); diff --git a/server/tests/contUse/update/updateContUse2.ts b/server/tests/contUse/update/updateContUse2.ts new file mode 100644 index 000000000..b6fb8c7d6 --- /dev/null +++ b/server/tests/contUse/update/updateContUse2.ts @@ -0,0 +1,182 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { expect } from "chai"; +import { addWeeks } from "date-fns"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addPrefixToProducts, replaceItems } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "updateContUse2"; + +describe(`${chalk.yellowBright(`contUse/update/${testCase}: Testing update cont use, remove included usage`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + let usage = 0; + let firstEntities = [ + { + id: "1", + name: "test", + featureId: TestFeature.Users, + }, + { + id: "2", + name: "test2", + featureId: TestFeature.Users, + }, + { + id: "3", + name: "test3", + featureId: TestFeature.Users, + }, + ]; + + it("should create entity, then attach pro", async function () { + await autumn.entities.create(customerId, firstEntities); + usage += 3; + + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + usage: [ + { + featureId: TestFeature.Users, + value: usage, + }, + ], + }); + }); + + let reduceUsageBy = 1; + let newItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: (userItem.included_usage as number) - reduceUsageBy, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }); + + it("should update product with reduced included usage", async function () { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(new Date(), 1).getTime(), + waitForSeconds: 5, + }); + + const customItems = replaceItems({ + featureId: TestFeature.Users, + items: pro.items, + newItem, + }); + + const preview = await autumn.attachPreview({ + customer_id: customerId, + product_id: pro.id, + is_custom: true, + items: customItems, + }); + + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + is_custom: true, + items: customItems, + }); + + const customer = await autumn.customers.get(customerId); + const invoices = customer.invoices; + expect(invoices.length).to.equal(2); + expect(invoices[0].total).to.equal(preview.due_today.total); + + // Usage stays the same... + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + numReplaceables: 0, + }); + }); + return; +}); diff --git a/server/tests/contUse/update/updateContUse3.ts b/server/tests/contUse/update/updateContUse3.ts new file mode 100644 index 000000000..e20a91328 --- /dev/null +++ b/server/tests/contUse/update/updateContUse3.ts @@ -0,0 +1,138 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts, replaceItems } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; +import { attachNewContUseAndExpectCorrect } from "tests/utils/expectUtils/expectContUse/expectUpdateContUse.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "updateContUse3"; + +describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse included usage when no entities created`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + it("should attach pro", async function () { + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + }); + }); + + let extraUsage = 2; + let newItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: (userItem.included_usage as number) + extraUsage, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }); + + it("should update product with extra included usage", async function () { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 1, + }); + + let customItems = replaceItems({ + featureId: TestFeature.Users, + items: pro.items, + newItem, + }); + + await attachNewContUseAndExpectCorrect({ + autumn, + customerId, + product: pro, + customItems, + numInvoices: 2, + }); + + await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage: 1, + numReplaceables: 0, + }); + }); +}); diff --git a/server/tests/contUse/update/updateContUse4.ts b/server/tests/contUse/update/updateContUse4.ts new file mode 100644 index 000000000..50904d4b0 --- /dev/null +++ b/server/tests/contUse/update/updateContUse4.ts @@ -0,0 +1,246 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts, replaceItems } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js"; +import { attachNewContUseAndExpectCorrect } from "tests/utils/expectUtils/expectContUse/expectUpdateContUse.js"; +import { expect } from "chai"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addWeeks } from "date-fns"; +import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "updateContUse4"; + +describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse included usage, prorate now`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const firstEntities = [ + { + id: "1", + name: "entity1", + featureId: TestFeature.Users, + }, + { + id: "2", + name: "entity2", + featureId: TestFeature.Users, + }, + ]; + + let usage = 0; + it("should attach pro", async function () { + await autumn.entities.create(customerId, firstEntities); + usage += firstEntities.length; + + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + usage: [ + { + featureId: TestFeature.Users, + value: usage, + }, + ], + }); + }); + + let extraUsage = 2; + let newItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: (userItem.included_usage as number) + extraUsage, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + + it("should update product with extra included usage", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(curUnix, 2).getTime(), + waitForSeconds: 5, + }); + + let customItems = replaceItems({ + featureId: TestFeature.Users, + items: pro.items, + newItem, + }); + + const { invoices } = await attachNewContUseAndExpectCorrect({ + autumn, + customerId, + product: pro, + customItems, + numInvoices: 2, + }); + + const { stripeSubs } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + numReplaceables: 0, + }); + + // Do own calculation too.. + let sub = stripeSubs[0]; + let amount = -userItem.price!; + let proratedAmount = calculateProrationAmount({ + amount, + periodStart: sub.current_period_start * 1000, + periodEnd: sub.current_period_end * 1000, + now: curUnix, + allowNegative: true, + }); + proratedAmount = Number(proratedAmount.toFixed(2)); + + expect(invoices[0].total).to.equal( + proratedAmount, + "invoice is equal to calculated prorated amount", + ); + }); + + const reducedUsage = 3; + const newItem2 = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: (newItem.included_usage as number) - reducedUsage, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + + it("should update product with reduced included usage", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(curUnix, 1).getTime(), + waitForSeconds: 5, + }); + + let customItems = replaceItems({ + featureId: TestFeature.Users, + items: pro.items, + newItem: newItem2, + }); + + const { invoices } = await attachNewContUseAndExpectCorrect({ + autumn, + customerId, + product: pro, + customItems, + numInvoices: 3, + }); + + const { stripeSubs } = await expectSubQuantityCorrect({ + stripeCli, + productId: pro.id, + db, + org, + env, + customerId, + usage, + numReplaceables: 0, + }); + + // Do own calculation too.. + let sub = stripeSubs[0]; + let amount = Math.min(reducedUsage, usage) * userItem.price!; + + let proratedAmount = calculateProrationAmount({ + amount, + periodStart: sub.current_period_start * 1000, + periodEnd: sub.current_period_end * 1000, + now: curUnix, + allowNegative: true, + }); + proratedAmount = Number(proratedAmount.toFixed(2)); + + expect(invoices[0].total).to.equal( + proratedAmount, + "invoice is equal to calculated prorated amount", + ); + }); +}); diff --git a/server/tests/contUse/update/updateContUse5.ts b/server/tests/contUse/update/updateContUse5.ts new file mode 100644 index 000000000..fd644e72b --- /dev/null +++ b/server/tests/contUse/update/updateContUse5.ts @@ -0,0 +1,162 @@ +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; +import { + APIVersion, + AppEnv, + OnDecrease, + OnIncrease, + 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { addPrefixToProducts } from "tests/attach/utils.js"; +import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js"; +import { advanceTestClock } from "tests/utils/stripeUtils.js"; +import { addWeeks } from "date-fns"; + +let userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +export let pro = constructProduct({ + items: [userItem], + type: "pro", +}); +export let proAnnual = constructProduct({ + items: [ + constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 2, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, + }), + ], + type: "pro", + isAnnual: true, +}); + +const testCase = "updateContUse5"; + +describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse included usage, prorate now`)}`, () => { + 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; + let curUnix = new Date().getTime(); + + before(async function () { + await setupBefore(this); + const { autumnJs } = this; + db = this.db; + org = this.org; + env = this.env; + + stripeCli = this.stripeCli; + + addPrefixToProducts({ + products: [pro, proAnnual], + prefix: testCase, + }); + + await createProducts({ + autumn, + products: [pro, proAnnual], + customerId, + db, + orgId: org.id, + env, + }); + + const { testClockId: testClockId1 } = await initCustomer({ + autumn: autumnJs, + customerId, + db, + org, + env, + attachPm: "success", + }); + + testClockId = testClockId1!; + }); + + const firstEntities = [ + { + id: "1", + name: "entity1", + featureId: TestFeature.Users, + }, + { + id: "2", + name: "entity2", + featureId: TestFeature.Users, + }, + { + id: "3", + name: "entity3", + featureId: TestFeature.Users, + }, + ]; + + let usage = 0; + it("should attach pro", async function () { + await autumn.entities.create(customerId, firstEntities); + usage += firstEntities.length; + + await attachAndExpectCorrect({ + autumn, + customerId, + product: pro, + stripeCli, + db, + org, + env, + usage: [ + { + featureId: TestFeature.Users, + value: usage, + }, + ], + }); + }); + + it("should upgrade to pro annual", async function () { + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addWeeks(curUnix, 2).getTime(), + waitForSeconds: 5, + }); + + await attachAndExpectCorrect({ + autumn, + customerId, + product: proAnnual, + stripeCli, + db, + org, + env, + usage: [ + { + featureId: TestFeature.Users, + value: usage, + }, + ], + }); + }); +}); diff --git a/server/tests/utils/expectUtils/expectAttach.ts b/server/tests/utils/expectUtils/expectAttach.ts new file mode 100644 index 000000000..475e5fad6 --- /dev/null +++ b/server/tests/utils/expectUtils/expectAttach.ts @@ -0,0 +1,133 @@ +import Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + AppEnv, + AttachBranch, + FeatureOptions, + Organization, + ProductV2, +} from "@autumn/shared"; + +import { getAttachTotal } from "tests/utils/testAttachUtils/testAttachUtils.js"; +import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js"; +import { expectInvoicesCorrect } from "tests/utils/expectUtils/expectProductAttached.js"; +import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js"; +import { notNullish, timeout, toSnakeCase } from "@/utils/genUtils.js"; +import { expectSubItemsCorrect } from "tests/utils/expectUtils/expectSubUtils.js"; +import { DrizzleCli } from "@/db/initDrizzle.js"; + +import { expect } from "chai"; + +export const attachAndExpectCorrect = async ({ + autumn, + customerId, + product, + options, + stripeCli, + db, + org, + env, + usage, + waitForInvoice = 0, + isCanceled = false, + skipFeatureCheck = false, +}: { + autumn: AutumnInt; + customerId: string; + product: ProductV2; + options?: FeatureOptions[]; + stripeCli: Stripe; + db: DrizzleCli; + org: Organization; + env: AppEnv; + usage?: { + featureId: string; + value: number; + }[]; + waitForInvoice?: number; + isCanceled?: boolean; + skipFeatureCheck?: boolean; +}) => { + const preview = await autumn.attachPreview({ + customer_id: customerId, + product_id: product.id, + }); + + const total = getAttachTotal({ + preview, + options, + }); + + await autumn.attach({ + customer_id: customerId, + product_id: product.id, + options: toSnakeCase(options), + }); + + if (waitForInvoice) { + await timeout(waitForInvoice); + } + + const customer = await autumn.customers.get(customerId); + const productCount = customer.products.reduce((acc: number, p: any) => { + if (product.group == p.group) { + return acc + 1; + } else return acc; + }, 0); + + expect( + productCount, + `customer should only have 1 product (from this group: ${product.group})`, + ).to.equal(1); + + expectProductAttached({ + customer, + product, + }); + + let intervals = Array.from( + new Set(product.items.map((item) => item.interval)), + ).filter(notNullish); + const multiInterval = intervals.length > 1; + + expectInvoicesCorrect({ + customer, + first: multiInterval ? undefined : { productId: product.id, total }, + second: multiInterval ? { productId: product.id, total } : undefined, + }); + + if (!skipFeatureCheck) { + expectFeaturesCorrect({ + customer, + product, + usage, + options, + }); + } + + const branch = preview.branch; + if (branch == AttachBranch.OneOff) { + return; + } + await expectSubItemsCorrect({ + stripeCli, + customerId, + product, + db, + org, + env, + isCanceled, + }); + + const stripeSubs = await stripeCli.subscriptions.list({ + customer: customer.stripe_id, + }); + if (multiInterval) { + expect(stripeSubs.data.length).to.equal(2, "should have 2 subscriptions"); + } else { + expect(stripeSubs.data.length).to.equal( + 1, + "should only have 1 subscription", + ); + } +}; diff --git a/server/tests/utils/expectUtils/expectContUse/expectUpdateContUse.ts b/server/tests/utils/expectUtils/expectContUse/expectUpdateContUse.ts new file mode 100644 index 000000000..8a134e516 --- /dev/null +++ b/server/tests/utils/expectUtils/expectContUse/expectUpdateContUse.ts @@ -0,0 +1,41 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { ProductV2, Organization, ProductItem } from "@autumn/shared"; +import { AppEnv } from "autumn-js"; +import { expect } from "chai"; +import Stripe from "stripe"; + +export const attachNewContUseAndExpectCorrect = async ({ + autumn, + customerId, + product, + customItems, + numInvoices, +}: { + autumn: AutumnInt; + customerId: string; + product: ProductV2; + customItems: ProductItem[]; + + numInvoices: number; +}) => { + const preview = await autumn.attachPreview({ + customer_id: customerId, + product_id: product.id, + is_custom: true, + items: customItems, + }); + + await autumn.attach({ + customer_id: customerId, + product_id: product.id, + is_custom: true, + items: customItems, + }); + + const customer = await autumn.customers.get(customerId); + const invoices = customer.invoices; + expect(invoices.length).to.equal(numInvoices); + expect(invoices[0].total).to.equal(preview.due_today.total); + return { customer, invoices }; +}; diff --git a/server/tests/utils/expectUtils/expectContUseUtils.ts b/server/tests/utils/expectUtils/expectContUseUtils.ts new file mode 100644 index 000000000..89f7f2da9 --- /dev/null +++ b/server/tests/utils/expectUtils/expectContUseUtils.ts @@ -0,0 +1,114 @@ +import Stripe from "stripe"; +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js"; +import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; +import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { findContUsePrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; +import { AppEnv, FullCustomer, Organization } from "@autumn/shared"; +import { expect } from "chai"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; + +export const expectSubQuantityCorrect = async ({ + stripeCli, + productId, + usage, + db, + org, + env, + customerId, + numReplaceables = 0, +}: { + stripeCli: Stripe; + productId: string; + usage: number; + db: DrizzleCli; + org: Organization; + env: AppEnv; + customerId: string; + numReplaceables?: number; +}) => { + const fullCus = await CusService.getFull({ + db, + orgId: org.id, + env, + idOrInternalId: customerId, + }); + + let cusProduct = fullCus.customer_products.find( + (cp) => cp.product_id === productId, + ); + + let stripeSubs = await getStripeSubs({ + stripeCli, + subIds: cusProduct?.subscription_ids, + }); + + let subItems = stripeSubs.flatMap((sub) => sub.items.data); + let prices = cusProductToPrices({ cusProduct: cusProduct! }); + + let contPrice = findContUsePrice({ prices }); + + let subItem = findStripeItemForPrice({ + price: contPrice!, + stripeItems: subItems, + }); + + expect(subItem).to.exist; + expect(subItem!.quantity).to.equal(usage); + + // Check num replaceables correct + let cusEnts = cusProduct?.customer_entitlements; + let cusEnt = cusEnts?.find((ent) => ent.feature_id === TestFeature.Users); + + expect(cusEnt).to.exist; + expect(cusEnt?.replaceables.length).to.equal(numReplaceables); + + let expectedBalance = cusEnt!.entitlement.allowance! - usage; + expect(cusEnt!.balance).to.equal(expectedBalance); +}; + +export const expectUpcomingItemsCorrect = async ({ + stripeCli, + fullCus, + stripeSubs, + curUnix, + unitPrice, + expectedNumItems = 1, + quantity, +}: { + stripeCli: Stripe; + fullCus: FullCustomer; + stripeSubs: Stripe.Subscription[]; + curUnix: number; + unitPrice: number; + expectedNumItems: number; + quantity: number; +}) => { + let sub = stripeSubs[0]; + let upcomingLines = await stripeCli.invoices.listUpcomingLines({ + subscription: sub.id, + }); + + let lines = upcomingLines.data.filter((line) => line.type === "invoiceitem"); + + let amount = quantity * unitPrice!; + let proratedAmount = calculateProrationAmount({ + amount, + periodStart: sub.current_period_start * 1000, + periodEnd: sub.current_period_end * 1000, + now: curUnix, + allowNegative: true, + }); + + console.group(); + console.group("Upcoming lines"); + for (const line of lines) { + console.log(line.description, line.amount / 100); + } + console.groupEnd(); + console.groupEnd(); + + expect(lines[0].amount).to.equal(Math.round(proratedAmount * 100)); +}; diff --git a/server/tests/utils/genUtils.ts b/server/tests/utils/genUtils.ts index e0aac5ae1..9457586dc 100644 --- a/server/tests/utils/genUtils.ts +++ b/server/tests/utils/genUtils.ts @@ -25,7 +25,7 @@ export const batchSendCountEvents = async ({ AutumnCli.sendEvent({ customerId: customerId, eventName: featureId, - }) + }), ); } @@ -48,7 +48,7 @@ export const searchCusProducts = ({ return cusProducts.find( (cusProduct: FullCusProduct) => cusProduct.product.id === productId && - (status ? cusProduct.status === status : true) + (status ? cusProduct.status === status : true), ); }; diff --git a/server/tests/utils/testProductUtils/testProductUtils.ts b/server/tests/utils/testProductUtils/testProductUtils.ts new file mode 100644 index 000000000..ab3355f5b --- /dev/null +++ b/server/tests/utils/testProductUtils/testProductUtils.ts @@ -0,0 +1,56 @@ +import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js"; +import { nullish } from "@/utils/genUtils.js"; +import { BillingInterval, ProductItem, ProductV2 } from "@autumn/shared"; + +export const addPrefixToProducts = ({ + products, + prefix, +}: { + products: ProductV2[]; + prefix: string; +}) => { + for (const product of products) { + product.id = `${prefix}_${product.id}`; + product.name = `${prefix} ${product.name}`; + product.group = prefix; + } + + return products; +}; + +export const replaceItems = ({ + featureId, + interval, + newItem, + items, +}: { + featureId?: string; + interval?: BillingInterval; + newItem: ProductItem; + items: ProductItem[]; +}) => { + let newItems = structuredClone(items); + + let index; + if (featureId) { + index = newItems.findIndex((item) => item.feature_id == featureId); + } + + if (interval) { + index = newItems.findIndex( + (item) => item.interval == (interval as any) && nullish(item.feature_id), + ); + } + + if (index == -1) { + throw new Error("Item not found"); + } + + newItems[index!] = newItem; + + return newItems; +}; + +export const getBasePrice = ({ product }: { product: ProductV2 }) => { + return product.items.find((item) => isPriceItem(item))?.price || 0; +}; diff --git a/shared/db/schema.ts b/shared/db/schema.ts index 996c6b65b..7bc4f3130 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -53,6 +53,7 @@ import { customerProductsRelations } from "../models/cusProductModels/cusProduct import { customerPricesRelations } from "../models/cusProductModels/cusPriceModels/cusPriceRelations.js"; import { customerEntitlementsRelations } from "../models/cusProductModels/cusEntModels/cusEntRelations.js"; import { apiKeyRelations } from "../models/devModels/apiKeyRelations.js"; +import { replaceableRelations } from "../models/cusProductModels/cusEntModels/replaceableRelations.js"; // Reward Relations import { rewardProgramRelations } from "../models/rewardModels/rewardProgramModels/rewardProgramRelations.js"; @@ -112,4 +113,5 @@ export { referralCodeRelations, rewardRedemptionRelations, migrationErrorRelations, + replaceableRelations, }; diff --git a/shared/models/cusProductModels/cusEntModels/replaceableRelations.ts b/shared/models/cusProductModels/cusEntModels/replaceableRelations.ts new file mode 100644 index 000000000..6dc27e14a --- /dev/null +++ b/shared/models/cusProductModels/cusEntModels/replaceableRelations.ts @@ -0,0 +1,10 @@ +import { relations } from "drizzle-orm"; +import { replaceables } from "./replaceableTable.js"; +import { customerEntitlements } from "./cusEntTable.js"; + +export const replaceableRelations = relations(replaceables, ({ one }) => ({ + customer_entitlement: one(customerEntitlements, { + fields: [replaceables.cus_ent_id], + references: [customerEntitlements.id], + }), +})); diff --git a/shared/models/productModels/priceModels/priceModels.ts b/shared/models/productModels/priceModels/priceModels.ts index d0e082b1e..46702ba9f 100644 --- a/shared/models/productModels/priceModels/priceModels.ts +++ b/shared/models/productModels/priceModels/priceModels.ts @@ -7,7 +7,7 @@ import { OnIncrease } from "../../productV2Models/productItemModels/productItemE const ProrationConfigSchema = z.object({ on_increase: z.nativeEnum(OnIncrease).default(OnIncrease.ProrateImmediately), - on_decrease: z.nativeEnum(OnDecrease).default(OnDecrease.Prorate), + on_decrease: z.nativeEnum(OnDecrease).default(OnDecrease.ProrateImmediately), }); export const PriceSchema = z.object({ diff --git a/shared/models/productV2Models/productItemModels/productItemEnums.ts b/shared/models/productV2Models/productItemModels/productItemEnums.ts index 3a3ecbad3..ca48cb4b7 100644 --- a/shared/models/productV2Models/productItemModels/productItemEnums.ts +++ b/shared/models/productV2Models/productItemModels/productItemEnums.ts @@ -6,6 +6,7 @@ export enum OnIncrease { } export enum OnDecrease { - Prorate = "prorate", + ProrateImmediately = "prorate_immediately", + ProrateNextCycle = "prorate_next_cycle", None = "none", } diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index 702e1064c..b252d24e4 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -48,7 +48,10 @@ const ProductItemConfigSchema = z.object({ .nativeEnum(OnIncrease) .optional() .default(OnIncrease.BillImmediately), - on_decrease: z.nativeEnum(OnDecrease).optional().default(OnDecrease.None), + on_decrease: z + .nativeEnum(OnDecrease) + .optional() + .default(OnDecrease.ProrateImmediately), }); export const ProductItemSchema = z.object({ diff --git a/vite/src/views/products/product/product-item/components/prorationConfig/OnDecreaseSelect.tsx b/vite/src/views/products/product/product-item/components/prorationConfig/OnDecreaseSelect.tsx index a3707b0dc..807b66898 100644 --- a/vite/src/views/products/product/product-item/components/prorationConfig/OnDecreaseSelect.tsx +++ b/vite/src/views/products/product/product-item/components/prorationConfig/OnDecreaseSelect.tsx @@ -12,8 +12,10 @@ import { ProrationSelect } from "./ProrationSelect"; const optionToText = (option: OnDecrease) => { switch (option) { - case OnDecrease.Prorate: - return "Refund prorated amount"; + case OnDecrease.ProrateImmediately: + return "Refund prorated amount immediately"; + case OnDecrease.ProrateNextCycle: + return "Add prorated amount to next cycle"; case OnDecrease.None: return "No proration (usage will be kept till next cycle)"; } @@ -28,7 +30,10 @@ export const OnDecreaseSelect = () => { if (!item.config?.on_decrease) { setItem({ ...item, - config: { ...item.config, on_decrease: OnDecrease.Prorate }, + config: { + ...item.config, + on_decrease: OnDecrease.ProrateImmediately, + }, }); } }, [item]);