diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 34408b995..7917eebf6 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -696,8 +696,19 @@ export class AutumnInt { }; subscriptions = { - update: async (params: UpdateSubscriptionV0Params) => { + update: async ( + params: UpdateSubscriptionV0Params, + { timeout }: { timeout?: number } = {}, + ) => { const data = await this.post(`/subscriptions/update`, params); + if (timeout) { + await new Promise((resolve) => setTimeout(resolve, timeout)); + } + return data; + }, + + previewUpdate: async (params: UpdateSubscriptionV0Params) => { + const data = await this.post(`/subscriptions/preview_update`, params); return data; }, }; diff --git a/server/src/internal/billing/v2/billingContext.ts b/server/src/internal/billing/v2/billingContext.ts index dd2277213..4ec4a5bac 100644 --- a/server/src/internal/billing/v2/billingContext.ts +++ b/server/src/internal/billing/v2/billingContext.ts @@ -1,4 +1,8 @@ -import type { FullCusProduct, FullProduct } from "@autumn/shared"; +import type { + FeatureOptions, + FullCusProduct, + FullProduct, +} from "@autumn/shared"; import type { FullCustomer } from "@shared/models/cusModels/fullCusModel"; import type Stripe from "stripe"; import { z } from "zod/v4"; @@ -21,10 +25,13 @@ export interface BillingContext { // Timestamps... currentEpochMs: number; - billingCycleAnchorMs?: number; + billingCycleAnchorMs: number | "now"; // Invoice mode invoiceMode?: InvoiceMode; + + // Feature quantities + featureQuantities: FeatureOptions[]; } export interface UpdateSubscriptionBillingContext extends BillingContext { diff --git a/server/src/internal/billing/v2/compute/computeAttachPlan.ts b/server/src/internal/billing/v2/compute/computeAttachPlan.ts deleted file mode 100644 index f310c6503..000000000 --- a/server/src/internal/billing/v2/compute/computeAttachPlan.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { secondsToMs } from "@autumn/shared"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { AttachContext } from "../typesOld"; -import { buildAutumnLineItems } from "./computeAutumnUtils/buildAutumnLineItems"; -import { buildNewCusProducts } from "./computeAutumnUtils/buildNewCusProducts"; - -/** - * Shared logic by attach, cancel and - */ -export const computeAttachPlan = async ({ - ctx, - attachContext, -}: { - ctx: AutumnContext; - attachContext: AttachContext; -}) => { - const { - fullCus, - products, - ongoingCusProductAction, - scheduledCusProductAction, - } = attachContext; - - // 1. Build new cus products - const newCusProducts = buildNewCusProducts({ - ctx, - attachContext, - }); - - const billingCycleAnchor = secondsToMs( - attachContext.stripeSub?.billing_cycle_anchor, - ); - const testClockFrozenTime = attachContext.testClockFrozenTime; - - // When to build checkout action? - - // 2. Build autumn line items - const lineItems = buildAutumnLineItems({ - ctx, - newCusProducts, - ongoingCustomerProduct: ongoingCusProductAction?.cusProduct, - billingCycleAnchor, - testClockFrozenTime, - }); - - // 3. Build updateOneOff action - // const updateOneOffAction = buildUpdateOneOffAction({ - // ctx, - // attachContext, - // newCusProducts, - // }); - - // 5. Build stripe sub action - const stripeSubAction = undefined; - - // 6. Build stripe invoice action - // const stripeInvoiceAction = buildStripeInvoiceAction({ - // attachContext, - // lineItems, - // stripeSubAction, - // newCusProducts, - // }); - - return { - lineItems, - - ongoingCusProductAction, - scheduledCusProductAction, - newCusProducts, - - stripeSubAction, - }; -}; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 3cee62d5e..a74404616 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -18,16 +18,15 @@ export const buildAutumnLineItems = ({ billingContext: BillingContext; }) => { // billingCycleAnchor = billingCycleAnchor ?? now; - const billingCycleAnchor = billingContext.billingCycleAnchorMs; - const now = billingContext.currentEpochMs; + const { billingCycleAnchorMs, currentEpochMs } = billingContext; - const { org } = ctx; + const { org, logger } = ctx; const arrearLineItems = deletedCustomerProduct ? cusProductToArrearLineItems({ cusProduct: deletedCustomerProduct, - billingCycleAnchor: billingCycleAnchor!, - now, + billingCycleAnchorMs, + nowMs: currentEpochMs, org, }) : []; @@ -36,20 +35,22 @@ export const buildAutumnLineItems = ({ const deletedLineItems = deletedCustomerProduct ? cusProductToLineItems({ cusProduct: deletedCustomerProduct, - now, - billingCycleAnchor: billingCycleAnchor!, + nowMs: currentEpochMs, + billingCycleAnchorMs, direction: "refund", org, + logger, }) : []; const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) => cusProductToLineItems({ cusProduct: newCustomerProduct, - now, - billingCycleAnchor: billingCycleAnchor!, + nowMs: currentEpochMs, + billingCycleAnchorMs, direction: "charge", org, + logger, }), ); diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index 3de4a5ce9..9edc4f1cf 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -23,15 +23,16 @@ export const executeAutumnBillingPlan = async ({ customFreeTrial, } = autumnBillingPlan; + await EntitlementService.insert({ + db, + data: customEntitlements, + }); + await PriceService.insert({ db, data: customPrices, }); - await EntitlementService.insert({ - db, - data: customEntitlements, - }); if (customFreeTrial) { await FreeTrialService.insert({ db, diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index a172aac84..3d2889a14 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -8,9 +8,12 @@ import { buildStripeSubscriptionAction } from "../../../providers/stripe/actionB import type { AutumnBillingPlan, StripeBillingPlan, + StripeInvoiceAction, + StripeInvoiceItemsAction, } from "../../../types/billingPlan"; +import { initStripeResourcesForBillingPlan } from "../utils/common/initStripeResourcesForProducts"; -export const evaluateStripeBillingPlan = ({ +export const evaluateStripeBillingPlan = async ({ ctx, billingContext, autumnBillingPlan, @@ -18,7 +21,13 @@ export const evaluateStripeBillingPlan = ({ ctx: AutumnContext; billingContext: BillingContext; autumnBillingPlan: AutumnBillingPlan; -}): StripeBillingPlan => { +}): Promise => { + await initStripeResourcesForBillingPlan({ + ctx, + autumnBillingPlan, + billingContext, + }); + const finalFullCustomer = autumnBillingPlanToFinalFullCustomer({ billingContext, autumnBillingPlan, @@ -32,14 +41,21 @@ export const evaluateStripeBillingPlan = ({ const { lineItems } = autumnBillingPlan; - const stripeInvoiceAction = buildStripeInvoiceAction({ - lineItems, - }); + const subscriptionActionIsCreate = + stripeSubscriptionAction?.type === "create"; - const stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({ - lineItems, - billingContext, - }); + let stripeInvoiceAction: StripeInvoiceAction | undefined; + let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined; + if (!subscriptionActionIsCreate) { + stripeInvoiceAction = buildStripeInvoiceAction({ + lineItems, + }); + + stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({ + lineItems, + billingContext, + }); + } // Build stripe subscription schedule action const stripeSubscriptionScheduleAction = diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index c8dae623a..8490429e1 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -11,6 +11,7 @@ import type { StripeSubscriptionAction, } from "@/internal/billing/v2/types/billingPlan"; import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult"; +import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling"; import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan"; @@ -107,6 +108,15 @@ export const executeStripeSubscriptionAction = async ({ const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired; + if (latestStripeInvoice) { + await upsertInvoiceFromBilling({ + ctx, + stripeInvoice: latestStripeInvoice, + fullProducts: billingContext.fullProducts, + fullCustomer: billingContext.fullCustomer, + }); + } + if (deferBillingPlan) { await insertMetadataFromBillingPlan({ ctx, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/createStripeResourcesForProduct.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts similarity index 60% rename from server/src/internal/billing/v2/providers/stripe/utils/common/createStripeResourcesForProduct.ts rename to server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts index cada798c1..aeec69fd7 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/createStripeResourcesForProduct.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/initStripeResourcesForProducts.ts @@ -1,22 +1,32 @@ -import type { FullCustomer, FullProduct } from "@autumn/shared"; +import { cusProductToProduct } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; import { checkStripeProductExists } from "@/internal/products/productUtils"; -export const createStripeResourcesForProducts = async ({ +export const initStripeResourcesForBillingPlan = async ({ ctx, - fullProducts, - fullCustomer, + autumnBillingPlan, + billingContext, }: { ctx: AutumnContext; - fullProducts: FullProduct[]; - fullCustomer: FullCustomer; + autumnBillingPlan: AutumnBillingPlan; + billingContext: BillingContext; }) => { const { db, org, env, logger } = ctx; + // For each insert customer product + const { fullCustomer } = billingContext; + const { insertCustomerProducts } = autumnBillingPlan; + + const newProducts = insertCustomerProducts.flatMap((cp) => + cusProductToProduct({ cusProduct: cp }), + ); + const batchProductUpdates = []; - for (const product of fullProducts) { + for (const product of newProducts) { batchProductUpdates.push( checkStripeProductExists({ db, @@ -38,7 +48,7 @@ export const createStripeResourcesForProducts = async ({ const internalEntityId = fullCustomer.entity?.internal_id; - for (const product of fullProducts) { + for (const product of newProducts) { for (const price of product.prices) { batchPriceUpdates.push( createStripePriceIFNotExist({ diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts index 6df625fb4..6b935f411 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts @@ -5,6 +5,8 @@ import { entToOptions, type FeatureOptions, type FullCusProduct, + formatPrice, + InternalError, isAllocatedCusEnt, isOneOffPrice, notNullish, @@ -84,15 +86,21 @@ export const customerProductToStripeItemSpecs = ({ const { lineItem } = stripeItem; + if (!lineItem.price) { + throw new InternalError({ + message: `Autumn price ${formatPrice({ price })} has no stripe price id`, + }); + } + if (isOneOffPrice(price)) { oneOffItems.push({ - stripePriceId: lineItem?.price ?? "", + stripePriceId: lineItem.price, quantity: lineItem?.quantity, autumnPrice: price, }); } else { recurringItems.push({ - stripePriceId: lineItem?.price ?? "", + stripePriceId: lineItem.price, quantity: lineItem?.quantity, autumnPrice: price, }); diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts index d402cbd03..962ac2f39 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -1,15 +1,14 @@ import { CusProductStatus, cusProductToProduct, - secondsToMs, type UpdateSubscriptionV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@server/internal/billing/v2/billingContext"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; +import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; import { computeCustomPlanFreeTrial } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFreeTrial"; import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; -import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct"; export const computeCustomPlan = async ({ @@ -21,7 +20,7 @@ export const computeCustomPlan = async ({ updateSubscriptionContext: UpdateSubscriptionBillingContext; params: UpdateSubscriptionV0Params; }) => { - const { customerProduct, stripeSubscription } = updateSubscriptionContext; + const { customerProduct } = updateSubscriptionContext; const currentFullProduct = cusProductToProduct({ cusProduct: customerProduct, @@ -46,15 +45,14 @@ export const computeCustomPlan = async ({ fullProduct: customFullProduct, }); - updateSubscriptionContext.billingCycleAnchorMs = - freeTrialPlan.trialEndsAt ?? - secondsToMs(stripeSubscription?.billing_cycle_anchor); + if (freeTrialPlan.trialEndsAt) { + updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt; + } // 3. Compute the new customer product const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({ ctx, updateSubscriptionContext, - params, fullProduct: customFullProduct, freeTrialPlan, }); diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFeatureQuantities.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFeatureQuantities.ts deleted file mode 100644 index b5296e3b6..000000000 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFeatureQuantities.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - cusProductToConvertedFeatureOptions, - type FeatureOptions, - type FullCusProduct, - type FullProduct, - InternalError, - isPrepaidPrice, - priceToFeature, - type UpdateSubscriptionV0Params, -} from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions"; - -/** - * Compute the feature quantities for a subscription update - */ -export const computeCustomPlanFeatureQuantities = ({ - ctx, - fullProduct, - currentCustomerProduct, - params, -}: { - ctx: AutumnContext; - fullProduct: FullProduct; - currentCustomerProduct: FullCusProduct; - params: UpdateSubscriptionV0Params; -}) => { - const newFeatureQuantities: FeatureOptions[] = []; - for (const price of fullProduct.prices) { - if (!isPrepaidPrice(price)) continue; - - const feature = priceToFeature({ - price, - features: ctx.features, - }); - - if (!feature) - throw new InternalError({ - message: `computing feature quantities for price ${price.id} but no feature found`, - }); - - const newFeatureQuantity = paramsToFeatureOptions({ - params, - price, - feature, - }); - - // Convert current quantity from old price's billing units to new price's billing units - const currentFeatureQuantity = cusProductToConvertedFeatureOptions({ - cusProduct: currentCustomerProduct, - feature, - newPrice: price, - }); - - const featureQuantity = newFeatureQuantity ?? currentFeatureQuantity; - - if (featureQuantity) newFeatureQuantities.push(featureQuantity); - } - - return newFeatureQuantities; -}; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index 59acce386..cd7290569 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -1,7 +1,6 @@ -import type { FullProduct, UpdateSubscriptionV0Params } from "@autumn/shared"; +import type { FullProduct } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext"; -import { computeCustomPlanFeatureQuantities } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFeatureQuantities"; import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan"; import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers"; import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages"; @@ -9,13 +8,11 @@ import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCus export const computeCustomPlanNewCustomerProduct = ({ ctx, - params, updateSubscriptionContext, fullProduct, freeTrialPlan, }: { ctx: AutumnContext; - params: UpdateSubscriptionV0Params; updateSubscriptionContext: UpdateSubscriptionBillingContext; fullProduct: FullProduct; freeTrialPlan: FreeTrialPlan; @@ -27,9 +24,9 @@ export const computeCustomPlanNewCustomerProduct = ({ stripeSubscriptionSchedule, billingCycleAnchorMs, currentEpochMs, + featureQuantities, } = updateSubscriptionContext; - // 1. Get feature quantities const existingUsages = cusProductToExistingUsages({ cusProduct: customerProduct, entityId: fullCustomer.entity?.id, @@ -39,14 +36,7 @@ export const computeCustomPlanNewCustomerProduct = ({ cusProduct: customerProduct, }); - const featureQuantities = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: customerProduct, - params, - }); - - // 1. Compute the new full customer product + // Compute the new full customer product const newFullCustomerProduct = initFullCustomerProduct({ ctx, diff --git a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts index 86301b555..84a043af8 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityDetails.ts @@ -92,9 +92,9 @@ export const computeUpdateQuantityDetails = ({ }); const billingPeriod = getLineItemBillingPeriod({ - anchor: billingCycleAnchorMs, + anchorMs: billingCycleAnchorMs, price: customerPrice.price, - now: currentEpochMs, + nowMs: currentEpochMs, }); if (!billingPeriod) { diff --git a/server/src/internal/billing/v2/updateSubscription/fetch/fetchUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/updateSubscription/fetch/fetchUpdateSubscriptionBillingContext.ts index 1385b9f89..ee7445350 100644 --- a/server/src/internal/billing/v2/updateSubscription/fetch/fetchUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/updateSubscription/fetch/fetchUpdateSubscriptionBillingContext.ts @@ -26,7 +26,7 @@ export const fetchUpdateSubscriptionBillingContext = async ({ ctx: AutumnContext; params: UpdateSubscriptionV0Params; }): Promise => { - const { db, org, env, features } = ctx; + const { db, org, env } = ctx; const { customer_id: customerId, product_id: productId } = params; const fullCustomer = await CusService.getFull({ @@ -82,16 +82,15 @@ export const fetchUpdateSubscriptionBillingContext = async ({ fullCus: fullCustomer, }); - if (params.options) { - params.options = parseFeatureQuantitiesParams({ - optionsInput: params.options, - features, - prices: targetCustomerProduct.customer_prices.map((cp) => cp.price), - currentCustomerProduct: targetCustomerProduct, - }); - } + const featureQuantities = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: targetCustomerProduct, + }); const currentEpochMs = testClockFrozenTime ?? Date.now(); + const billingCycleAnchorMs = secondsToMs( stripeSubscription?.billing_cycle_anchor, ); @@ -115,7 +114,8 @@ export const fetchUpdateSubscriptionBillingContext = async ({ paymentMethod, currentEpochMs, - billingCycleAnchorMs, + billingCycleAnchorMs: billingCycleAnchorMs ?? "now", invoiceMode, + featureQuantities, }; }; diff --git a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts index 16312dc18..f2bbb4e86 100644 --- a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts @@ -1,5 +1,6 @@ import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared"; import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; +import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { computeUpdateSubscriptionPlan } from "./compute/computeUpdateSubscriptionPlan"; import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext"; @@ -22,20 +23,25 @@ export const handlePreviewUpdateSubscription = createRoute({ params: body, }); - const stripeBillingPlan = evaluateStripeBillingPlan({ + const stripeBillingPlan = await evaluateStripeBillingPlan({ ctx, billingContext: updateSubscriptionBillingContext, autumnBillingPlan, }); - // Convert to preview response - - return c.json( - { + const previewResponse = billingPlanToPreviewResponse({ + ctx, + billingContext: updateSubscriptionBillingContext, + billingPlan: { autumn: autumnBillingPlan, stripe: stripeBillingPlan, }, - 200, - ); + }); + + return c.json({ + ...previewResponse, + autumn: autumnBillingPlan, + stripe: stripeBillingPlan, + }); }, }); diff --git a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts index d7f1c419c..aa61ad29e 100644 --- a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts @@ -22,7 +22,7 @@ export const handleUpdateSubscription = createRoute({ params: body, }); - const stripeBillingPlan = evaluateStripeBillingPlan({ + const stripeBillingPlan = await evaluateStripeBillingPlan({ ctx, billingContext, autumnBillingPlan, diff --git a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts index dc0629b30..f651359fd 100644 --- a/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts +++ b/server/src/internal/billing/v2/utils/billingPlanToPreviewResponse.ts @@ -1,25 +1,37 @@ -import type { BillingPreviewResponse } from "@autumn/shared"; +import { + type BillingPreviewResponse, + orgToCurrency, + sumValues, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan"; export const billingPlanToPreviewResponse = ({ + ctx, + billingContext, billingPlan, }: { + ctx: AutumnContext; + billingContext: BillingContext; billingPlan: BillingPlan; }): BillingPreviewResponse => { - // 1. Get lines + const { fullCustomer } = billingContext; + const autumnBillingPlan = billingPlan.autumn; + const previewLineItems = autumnBillingPlan.lineItems.map((line) => ({ + description: line.description, + amount: line.finalAmount, + })); - // const previewLineItems = autumnBillingPlan.lineItems.map((line) => ({ - // description: line.description, - // amount: line.amount, - // })); + const total = sumValues(previewLineItems.map((line) => line.amount)); - // const total = autumnBillingPlanLines.reduce( - // (acc, line) => acc + line.amount, - // 0, - // ); + const currency = orgToCurrency({ org: ctx.org }); - // return { - // customer_id: billingPlan.customer_id, - // }; + return { + customer_id: fullCustomer.id || "", + line_items: previewLineItems, + total, + currency, + } satisfies BillingPreviewResponse; }; diff --git a/server/src/internal/billing/v2/utils/parseFeatureQuantitiesParams.ts b/server/src/internal/billing/v2/utils/parseFeatureQuantitiesParams.ts index 396039cbd..b7a3ab2a6 100644 --- a/server/src/internal/billing/v2/utils/parseFeatureQuantitiesParams.ts +++ b/server/src/internal/billing/v2/utils/parseFeatureQuantitiesParams.ts @@ -1,145 +1,64 @@ import { - ErrCode, - type Feature, + cusProductToConvertedFeatureOptions, type FeatureOptions, type FullCusProduct, - nullish, - type Price, - type UsagePriceConfig, + type FullProduct, + isPrepaidPrice, + priceToFeature, + type UpdateSubscriptionV0Params, } from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; -import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions"; /** - * Parses and normalizes feature quantity options for billing. - * - * Converts raw quantity input to billing units and merges with existing options - * from the current customer product (for recurring products only). - * - * @param optionsInput - Raw feature options with quantities - * @param features - Available features to validate against - * @param prices - Product prices to find prepaid price config - * @param currentCustomerProduct - Existing customer product for merging options - * @returns Normalized feature options with internal_feature_id and adjusted quantities + * Parses feature quantities from params, iterating over all prepaid prices. + * For each prepaid price, uses new quantity from params or falls back to existing subscription. */ export const parseFeatureQuantitiesParams = ({ - optionsInput, - features, - prices, + ctx, + featureQuantitiesParams, + fullProduct, currentCustomerProduct, }: { - optionsInput?: FeatureOptions[]; - features: Feature[]; - prices: Price[]; + ctx: AutumnContext; + featureQuantitiesParams: UpdateSubscriptionV0Params; + fullProduct: FullProduct; currentCustomerProduct?: FullCusProduct; }): FeatureOptions[] => { - const parsedOptions = parseAndNormalizeOptions({ - optionsInput, - features, - prices, - }); + const options: FeatureOptions[] = []; - if (isOneOff(prices) || isFreeProduct(prices)) { - return parsedOptions; - } + for (const price of fullProduct.prices) { + if (!isPrepaidPrice(price)) continue; - return mergeWithExistingOptions({ - parsedOptions, - currentCustomerProduct, - prices, - }); -}; - -const parseAndNormalizeOptions = ({ - optionsInput, - features, - prices, -}: { - optionsInput?: FeatureOptions[]; - features: Feature[]; - prices: Price[]; -}): FeatureOptions[] => { - const result: FeatureOptions[] = []; - - for (const options of optionsInput || []) { - const feature = features.find( - (feature) => feature.id === options.feature_id, - ); - - if (!feature) { - throw new RecaseError({ - message: `Feature ${options.feature_id} passed into options but not found`, - code: ErrCode.FeatureNotFound, - }); - } - - const prepaidPrice = findPrepaidPrice({ - prices, - internalFeatureId: feature.internal_id, + const feature = priceToFeature({ + price, + features: ctx.features, + errorOnNotFound: true, }); - if (!prepaidPrice) { - throw new RecaseError({ - message: `No prepaid price found for feature ${feature.id}`, - code: ErrCode.PriceNotFound, - }); - } - - const config = prepaidPrice.config as UsagePriceConfig; - const billingUnits = config.billing_units || 1; - - if (nullish(options.quantity)) { - throw new RecaseError({ - message: `Quantity is required for feature ${feature.id}`, - code: ErrCode.InvalidOptions, - }); - } - - const normalizedQuantity = new Decimal(options.quantity) - .div(billingUnits) - .ceil() - .toNumber(); - - result.push({ - ...options, - internal_feature_id: feature.internal_id, - quantity: normalizedQuantity, - }); - } - - return result; -}; - -const mergeWithExistingOptions = ({ - parsedOptions, - currentCustomerProduct, - prices, -}: { - parsedOptions: FeatureOptions[]; - currentCustomerProduct?: FullCusProduct; - prices: Price[]; -}): FeatureOptions[] => { - const existingOptions = currentCustomerProduct?.options || []; - const mergedOptions = [...parsedOptions]; - - for (const existingOption of existingOptions) { - const alreadyIncluded = parsedOptions.some( - (option) => option.feature_id === existingOption.feature_id, - ); - - if (alreadyIncluded) continue; - - const hasPrepaidPrice = findPrepaidPrice({ - prices, - internalFeatureId: existingOption.internal_feature_id!, + // Get new feature quantity from params + const newFeatureQuantity = paramsToFeatureOptions({ + params: featureQuantitiesParams, + price, + feature, }); - if (hasPrepaidPrice) { - mergedOptions.push(existingOption); + // Get current feature quantity from existing subscription + const currentFeatureQuantity = currentCustomerProduct + ? cusProductToConvertedFeatureOptions({ + cusProduct: currentCustomerProduct, + feature, + newPrice: price, + }) + : undefined; + + // Prefer new quantity, fall back to current + const featureQuantity = newFeatureQuantity ?? currentFeatureQuantity; + + if (featureQuantity) { + options.push(featureQuantity); } } - return mergedOptions; + return options; }; diff --git a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts index 4f6f9252b..53454e49a 100644 --- a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts +++ b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts @@ -7,13 +7,12 @@ import { type UsagePriceConfig, } from "@autumn/shared"; import { Decimal } from "decimal.js"; -import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams.js"; import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; /** - * @deprecated Can now use {@link parseFeatureQuantitiesParams} instead + * @deprecated Use parseFeatureQuantitiesParams from billing/v2/utils instead */ export const mapOptionsList = ({ optionsInput, diff --git a/server/tests/billing/update-subscription/custom-plan/update-free-plan.test.ts b/server/tests/billing/update-subscription/custom-plan/update-free-plan.test.ts index f297ae7e8..8f1da8080 100644 --- a/server/tests/billing/update-subscription/custom-plan/update-free-plan.test.ts +++ b/server/tests/billing/update-subscription/custom-plan/update-free-plan.test.ts @@ -1,4 +1,8 @@ -import { test } from "bun:test"; +import { expect, test } from "bun:test"; +import { + expectCustomerFeatureCorrect, + expectCustomerFeatureExists, +} from "@tests/billing/utils/expectCustomerFeatureCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; @@ -32,43 +36,41 @@ test.concurrent(`${chalk.yellowBright("custom-plan: update free plan")}`, async { timeout: 2000 }, ); - await autumnV1.subscriptions.update({ + // 1. Test update free plan preview + const preview = await autumnV1.subscriptions.previewUpdate({ customer_id: customerId, product_id: free.id, items: [messagesItem, dashboardItem, wordsItem], }); -}); -test.concurrent(`${chalk.yellowBright("custom-plan: something else")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 500 }); - const dashboardItem = items.dashboard(); - const wordsItem = items.monthlyWords({ includedUsage: 100 }); - - const free = products.base({ items: [messagesItem] }); - - const { customerId, autumnV1 } = await initTestScenario({ - customerId: "custom-plan-something-else", - products: [free], - attachProducts: [free.id], - customerOptions: { - withTestClock: true, - attachPm: "success", - }, - }); - - const messagesUsage = 100; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); + expect(preview.total).toEqual(0); await autumnV1.subscriptions.update({ customer_id: customerId, product_id: free.id, items: [messagesItem, dashboardItem, wordsItem], }); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + expectCustomerFeatureExists({ + customer, + featureId: TestFeature.Dashboard, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: wordsItem.included_usage, + balance: wordsItem.included_usage, + usage: 0, + }); }); diff --git a/server/tests/billing/update-subscription/custom-plan/update-free-plan2.test.ts b/server/tests/billing/update-subscription/custom-plan/update-free-plan2.test.ts deleted file mode 100644 index 0c6451cad..000000000 --- a/server/tests/billing/update-subscription/custom-plan/update-free-plan2.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { ApiVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -describe(`${chalk.yellowBright("custom-plan: update free plan")}`, () => { - const testCase = "custom-plan-update-free-plan"; - - const messagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 500, - }); - - const dashboardItem = constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }); - - const wordsItem = constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 100, - }); - - const free = constructProduct({ - type: "free", - items: [messagesItem], - }); - - const customerId = testCase; - const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - console.log("FILE 2 - describe 1 started at:", new Date().toISOString()); - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [free], - prefix: testCase, - }); - - await autumnV1.attach({ - customer_id: customerId, - product_id: free.id, - }); - }); - - const messagesUsage = 100; - test("should add boolean and metered feature to free plan", async () => { - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: free.id, - items: [messagesItem, dashboardItem, wordsItem], - }); - }); -}); - -describe(`${chalk.yellowBright("custom-plan: something else")}`, () => { - const testCase = "custom-plan-update-free-plan"; - - const messagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 500, - }); - - const dashboardItem = constructFeatureItem({ - featureId: TestFeature.Dashboard, - isBoolean: true, - }); - - const wordsItem = constructFeatureItem({ - featureId: TestFeature.Words, - includedUsage: 100, - }); - - const free = constructProduct({ - type: "free", - items: [messagesItem], - }); - - const customerId = testCase; - const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [free], - prefix: testCase, - }); - - await autumnV1.attach({ - customer_id: customerId, - product_id: free.id, - }); - }); - - const messagesUsage = 100; - test("should add boolean and metered feature to free plan", async () => { - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: free.id, - items: [messagesItem, dashboardItem, wordsItem], - }); - }); -}); diff --git a/server/tests/billing/update-subscription/custom-plan/update-free-to-paid.test.ts b/server/tests/billing/update-subscription/custom-plan/update-free-to-paid.test.ts new file mode 100644 index 000000000..ff63d9811 --- /dev/null +++ b/server/tests/billing/update-subscription/custom-plan/update-free-to-paid.test.ts @@ -0,0 +1,308 @@ +import { expect, test } from "bun:test"; +import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js"; +import chalk from "chalk"; + +// 1. Adding a monthly base price to free product +test.concurrent(`${chalk.yellowBright("free-to-paid: add monthly base price")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 300 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1 } = await initTestScenario({ + customerId: "free-to-paid-monthly-base", + products: [free], + attachProducts: [free.id], + customerOptions: { + withTestClock: true, + attachPm: "success", + }, + }); + + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }, + { timeout: 2000 }, + ); + + const priceItem = items.monthlyPrice(); + + // Preview should show $20 charge + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: free.id, + items: [messagesItem, priceItem], + }); + + expect(preview.total).toEqual(20); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + items: [messagesItem, priceItem], + }); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: messagesItem.included_usage, + balance: messagesItem.included_usage - 100, + usage: 100, + }); + + expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); +}); + +// 2. Adding monthly base price + consumable to free product +test.concurrent(`${chalk.yellowBright("free-to-paid: add monthly base + consumable")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1 } = await initTestScenario({ + customerId: "free-to-paid-monthly-consumable", + products: [free], + attachProducts: [free.id], + customerOptions: { + withTestClock: true, + attachPm: "success", + }, + }); + + // Track some usage before update + const messagesUsage = 30; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + const priceItem = items.monthlyPrice(); + const consumableItem = items.consumableMessages({ includedUsage: 50 }); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: free.id, + items: [consumableItem, priceItem], + }); + + expect(preview.total).toEqual(20); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + items: [consumableItem, priceItem], + }); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: consumableItem.included_usage, + balance: consumableItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); +}); + +// 3. Adding annual base price + monthly consumable to free product +test.concurrent(`${chalk.yellowBright("free-to-paid: add annual base + monthly consumable")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1, ctx } = await initTestScenario({ + customerId: "free-to-paid-annual-consumable", + products: [free], + attachProducts: [free.id], + customerOptions: { + withTestClock: true, + attachPm: "success", + }, + }); + + const priceItem = items.annualPrice(); + const consumableItem = items.consumableMessages({ includedUsage: 50 }); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: free.id, + items: [consumableItem, priceItem], + }); + + expect(preview.total).toEqual(200); + + await autumnV1.subscriptions.update( + { + customer_id: customerId, + product_id: free.id, + items: [consumableItem, priceItem], + }, + { timeout: 2000 }, + ); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: consumableItem.included_usage, + balance: consumableItem.included_usage, + usage: 0, + }); + + expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 200, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// 4. Updating free feature item to consumable +test.concurrent(`${chalk.yellowBright("free-to-paid: update free item to consumable")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1 } = await initTestScenario({ + customerId: "free-to-paid-to-consumable", + products: [free], + attachProducts: [free.id], + customerOptions: { + withTestClock: true, + attachPm: "success", + }, + }); + + // Track some usage first + const messagesUsage = 50; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Update to consumable (pay-per-use after included usage) + const consumableItem = items.consumableMessages({ includedUsage: 100 }); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: free.id, + items: [consumableItem], + }); + + // No immediate charge - consumable bills in arrears + expect(preview.total).toEqual(0); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + items: [consumableItem], + }); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: consumableItem.included_usage, + balance: consumableItem.included_usage - messagesUsage, + usage: messagesUsage, + }); + + // No invoice - consumable bills in arrears, no immediate charge + expectCustomerInvoiceCorrect({ + customer, + count: 0, + }); +}); + +// 5. Updating free feature item to prepaid +test.concurrent(`${chalk.yellowBright("free-to-paid: update free item to prepaid")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ items: [messagesItem] }); + + const { customerId, autumnV1 } = await initTestScenario({ + customerId: "free-to-paid-to-prepaid", + products: [free], + attachProducts: [free.id], + customerOptions: { + withTestClock: true, + attachPm: "success", + }, + }); + + // Track some usage first + const messagesUsage = 30; + await autumnV1.track( + { + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesUsage, + }, + { timeout: 2000 }, + ); + + // Update to prepaid (purchase units upfront) + const prepaidItem = items.prepaidMessages(); + + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: free.id, + items: [prepaidItem], + }); + + // No immediate charge for switching to prepaid model + expect(preview.total).toEqual(0); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: free.id, + items: [prepaidItem], + options: [ + { + feature_id: TestFeature.Messages, + quantity: 100, + }, + ], + }); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100 - messagesUsage, + usage: messagesUsage, + }); +}); diff --git a/server/tests/billing/utils/expectCustomerFeatureCorrect.ts b/server/tests/billing/utils/expectCustomerFeatureCorrect.ts index 13f6493b3..f485dad19 100644 --- a/server/tests/billing/utils/expectCustomerFeatureCorrect.ts +++ b/server/tests/billing/utils/expectCustomerFeatureCorrect.ts @@ -5,6 +5,26 @@ import { AutumnInt } from "@/external/autumn/autumnCli"; const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); +export const expectCustomerFeatureExists = async ({ + customerId, + customer: providedCustomer, + featureId, +}: { + customerId?: string; + customer?: Customer; + featureId: string; +}) => { + const customer = providedCustomer + ? providedCustomer + : await defaultAutumn.customers.get(customerId!); + + const feature = customer.features[featureId]; + + expect(feature).toBeDefined(); +}; + +const ONE_HOUR_MS = 60 * 60 * 1000; + export const expectCustomerFeatureCorrect = async ({ customerId, customer: providedCustomer, @@ -12,13 +32,15 @@ export const expectCustomerFeatureCorrect = async ({ includedUsage, balance, usage, + resetsAt, }: { customerId?: string; customer?: Customer; featureId: string; - includedUsage: number; - balance: number; - usage: number; + includedUsage?: number; + balance?: number; + usage?: number; + resetsAt?: number; }) => { const customer = providedCustomer ? providedCustomer @@ -30,4 +52,12 @@ export const expectCustomerFeatureCorrect = async ({ balance, usage, }); + + if (resetsAt !== undefined) { + const actualResetsAt = feature.next_reset_at ?? 0; + expect(actualResetsAt).toBeDefined(); + expect(Math.abs(actualResetsAt - resetsAt)).toBeLessThanOrEqual( + ONE_HOUR_MS, + ); + } }; diff --git a/server/tests/billing/utils/expectCustomerInvoiceCorrect.ts b/server/tests/billing/utils/expectCustomerInvoiceCorrect.ts new file mode 100644 index 000000000..40169e56a --- /dev/null +++ b/server/tests/billing/utils/expectCustomerInvoiceCorrect.ts @@ -0,0 +1,43 @@ +import { expect } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import type { Customer } from "autumn-js"; +import { AutumnInt } from "@/external/autumn/autumnCli"; + +const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); + +/** + * Check customer invoice count and optionally the latest invoice details + */ +export const expectCustomerInvoiceCorrect = async ({ + customerId, + customer: providedCustomer, + count, + latestTotal, + latestStatus, +}: { + customerId?: string; + customer?: Customer; + count: number; + latestTotal?: number; + latestStatus?: "paid" | "draft" | "open" | "void"; +}) => { + const customer = providedCustomer + ? providedCustomer + : await defaultAutumn.customers.get(customerId!); + + const invoices = customer.invoices; + + if (invoices === undefined) { + throw new Error("No invoices found"); + } + + expect(invoices.length).toBe(count); + + if (latestTotal !== undefined && invoices.length > 0) { + expect(invoices[0].total).toBe(latestTotal); + } + + if (latestStatus !== undefined && invoices.length > 0) { + expect(invoices[0].status).toBe(latestStatus); + } +}; diff --git a/server/tests/billing/utils/expectFeatureCorrect.ts b/server/tests/billing/utils/expectFeatureCorrect.ts deleted file mode 100644 index 2064d77eb..000000000 --- a/server/tests/billing/utils/expectFeatureCorrect.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect } from "bun:test"; -import type { AutumnInt } from "@/external/autumn/autumnCli"; - -export const expectCustomerFeatureCorrect = async ({ - autumn, - customerId, - featureId, - includedUsage, - balance, - usage, -}: { - autumn: AutumnInt; - customerId: string; - featureId: string; - includedUsage: number; - balance: number; - usage: number; -}) => { - const customer = await autumn.customers.get(customerId); - const feature = customer.features[featureId]; - - expect(feature).toMatchObject({ - included_usage: includedUsage, - balance, - usage, - }); -}; diff --git a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts index f07f2f3ff..cbb9f8418 100644 --- a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts +++ b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts @@ -10,736 +10,737 @@ import { } from "@tests/utils/mockUtils/priceMocks"; import { createMockFullProduct } from "@tests/utils/mockUtils/productMocks"; import chalk from "chalk"; -import { computeCustomPlanFeatureQuantities } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFeatureQuantities"; +import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams"; // ============ TESTS ============ -describe(chalk.yellowBright("computeCustomPlanFeatureQuantities"), () => { - describe("basic quantity inheritance", () => { - test("1. current has quantity, new params has none → uses current", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 100, - }, - ], - customerPrices: [createMockCustomerPrice({ price })], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - // No options provided - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - expect(result[0].feature_id).toBe("credits"); - expect(result[0].quantity).toBe(100); +describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { + describe("basic quantity inheritance", () => { + test("1. current has quantity, new params has none → uses current", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", }); - test("2. current has no quantity, new params has quantity → uses new", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ options: [] }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "credits", quantity: 50 }], - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - expect(result[0].feature_id).toBe("credits"); - expect(result[0].quantity).toBe(50); + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", }); - test("3. both have quantity → uses new (new takes precedence)", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 100, - }, - ], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "credits", quantity: 200 }], - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - expect(result[0].feature_id).toBe("credits"); - expect(result[0].quantity).toBe(200); + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 100, + }, + ], + customerPrices: [createMockCustomerPrice({ price })], }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + // No options provided + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + expect(result[0].quantity).toBe(100); }); - describe("multiple features", () => { - test("4. previous has all quantities, new has just one → updates only the specified one", () => { - const creditsFeature = createMockFeature({ - id: "credits", - name: "Credits", - }); - const seatsFeature = createMockFeature({ - id: "seats", - name: "Seats", - }); - const storageFeature = createMockFeature({ - id: "storage", - name: "Storage", - }); - - const creditsPrice = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - const seatsPrice = createMockPrepaidPrice({ - id: "price_seats", - featureId: "seats", - }); - const storagePrice = createMockPrepaidPrice({ - id: "price_storage", - featureId: "storage", - }); - - const fullProduct = createMockFullProduct({ - prices: [creditsPrice, seatsPrice, storagePrice], - }); - - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 100, - }, - { - feature_id: "seats", - internal_feature_id: "internal_seats", - quantity: 5, - }, - { - feature_id: "storage", - internal_feature_id: "internal_storage", - quantity: 50, - }, - ], - customerPrices: [ - createMockCustomerPrice({ price: creditsPrice }), - createMockCustomerPrice({ price: seatsPrice }), - createMockCustomerPrice({ price: storagePrice }), - ], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats - }; - - const ctx = createMockCtx({ - features: [creditsFeature, seatsFeature, storageFeature], - }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(3); - - const credits = result.find((r) => r.feature_id === "credits"); - const seats = result.find((r) => r.feature_id === "seats"); - const storage = result.find((r) => r.feature_id === "storage"); - - expect(credits?.quantity).toBe(100); // Unchanged - expect(seats?.quantity).toBe(10); // Updated - expect(storage?.quantity).toBe(50); // Unchanged + test("2. current has no quantity, new params has quantity → uses new", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", }); + + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ options: [] }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 50 }], + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + expect(result[0].quantity).toBe(50); }); - describe("billing units handling", () => { - test("5. new params quantity is rounded to billing units", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - billingUnits: 100, // Billing in units of 100 - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ options: [] }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200 - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - expect(result[0].feature_id).toBe("credits"); - // 150 rounded up to 200, then divided by billingUnits (100) = 2 - expect(result[0].quantity).toBe(2); + test("3. both have quantity → uses new (new takes precedence)", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", }); - test("exact billing unit multiple is not changed", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - billingUnits: 50, - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ options: [] }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - // 200 / 50 = 4 - expect(result[0].quantity).toBe(4); + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", }); - test("small quantity rounds up to 1 billing unit", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - billingUnits: 1000, - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ options: [] }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000 - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - // 1 rounded to 1000, then divided by billingUnits (1000) = 1 - expect(result[0].quantity).toBe(1); + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 100, + }, + ], }); - test("converts current quantity from old billing units to new billing units", () => { - // Scenario: Customer has 5 packs of 100 credits (500 actual credits) - // New price uses packs of 250 - // Expected: 500 credits → rounds to 500 (nearest 250) → 2 packs - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 200 }], + }; - // Old price: billing units of 100 - const oldPrice = createMockPrepaidPrice({ - id: "price_credits_old", - featureId: "credits", - billingUnits: 100, - }); + const ctx = createMockCtx({ features: [feature] }); - // New price: billing units of 250 - const newPrice = createMockPrepaidPrice({ - id: "price_credits_new", - featureId: "credits", - billingUnits: 250, - }); - - const fullProduct = createMockFullProduct({ prices: [newPrice] }); - - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 5, // 5 packs of 100 = 500 actual credits - }, - ], - customerPrices: [createMockCustomerPrice({ price: oldPrice })], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - // No options - should inherit from current - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - expect(result[0].feature_id).toBe("credits"); - // Old: 5 packs * 100 = 500 credits - // Round 500 to nearest 250 = 500 - // New: 500 / 250 = 2 packs - expect(result[0].quantity).toBe(2); + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, }); - test("converts and rounds up when not exact multiple of new billing units", () => { - // Scenario: Customer has 3 packs of 100 credits (300 actual credits) - // New price uses packs of 250 - // Expected: 300 credits → rounds to 500 (nearest 250) → 2 packs - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + expect(result[0].quantity).toBe(200); + }); + }); - const oldPrice = createMockPrepaidPrice({ - id: "price_credits_old", - featureId: "credits", - billingUnits: 100, - }); - - const newPrice = createMockPrepaidPrice({ - id: "price_credits_new", - featureId: "credits", - billingUnits: 250, - }); - - const fullProduct = createMockFullProduct({ prices: [newPrice] }); - - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 3, // 3 packs of 100 = 300 actual credits - }, - ], - customerPrices: [createMockCustomerPrice({ price: oldPrice })], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - // Old: 3 packs * 100 = 300 credits - // Round 300 to nearest 250 (ceiling) = 500 - // New: 500 / 250 = 2 packs - expect(result[0].quantity).toBe(2); + describe("multiple features", () => { + test("4. previous has all quantities, new has just one → updates only the specified one", () => { + const creditsFeature = createMockFeature({ + id: "credits", + name: "Credits", + }); + const seatsFeature = createMockFeature({ + id: "seats", + name: "Seats", + }); + const storageFeature = createMockFeature({ + id: "storage", + name: "Storage", }); - test("no old customer price returns undefined (no quantity inherited)", () => { - // Scenario: No old price found - can't interpret the stored quantity - // Should return empty result (no feature quantities) - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const newPrice = createMockPrepaidPrice({ - id: "price_credits_new", - featureId: "credits", - billingUnits: 250, - }); - - const fullProduct = createMockFullProduct({ prices: [newPrice] }); - - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 500, - }, - ], - // No customerPrices - can't interpret stored quantity - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - // No old price means we can't interpret the stored quantity - // So no feature quantity is inherited - expect(result).toHaveLength(0); + const creditsPrice = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", }); + const seatsPrice = createMockPrepaidPrice({ + id: "price_seats", + featureId: "seats", + }); + const storagePrice = createMockPrepaidPrice({ + id: "price_storage", + featureId: "storage", + }); + + const fullProduct = createMockFullProduct({ + prices: [creditsPrice, seatsPrice, storagePrice], + }); + + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 100, + }, + { + feature_id: "seats", + internal_feature_id: "internal_seats", + quantity: 5, + }, + { + feature_id: "storage", + internal_feature_id: "internal_storage", + quantity: 50, + }, + ], + customerPrices: [ + createMockCustomerPrice({ price: creditsPrice }), + createMockCustomerPrice({ price: seatsPrice }), + createMockCustomerPrice({ price: storagePrice }), + ], + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats + }; + + const ctx = createMockCtx({ + features: [creditsFeature, seatsFeature, storageFeature], + }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(3); + + const credits = result.find((r) => r.feature_id === "credits"); + const seats = result.find((r) => r.feature_id === "seats"); + const storage = result.find((r) => r.feature_id === "storage"); + + expect(credits?.quantity).toBe(100); // Unchanged + expect(seats?.quantity).toBe(10); // Updated + expect(storage?.quantity).toBe(50); // Unchanged + }); + }); + + describe("billing units handling", () => { + test("5. new params quantity is rounded to billing units", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + billingUnits: 100, // Billing in units of 100 + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ options: [] }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200 + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + // 150 rounded up to 200, then divided by billingUnits (100) = 2 + expect(result[0].quantity).toBe(2); }); - describe("price type filtering", () => { - test("skips non-prepaid prices (fixed prices)", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const fixedPrice = createMockFixedPrice({ id: "price_fixed" }); - const prepaidPrice = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - - const fullProduct = createMockFullProduct({ - prices: [fixedPrice, prepaidPrice], - }); - - const cusProduct = createMockCustomerProduct({ options: [] }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "credits", quantity: 50 }], - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - // Only the prepaid price should be processed - expect(result).toHaveLength(1); - expect(result[0].feature_id).toBe("credits"); + test("exact billing unit multiple is not changed", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", }); + + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + billingUnits: 50, + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ options: [] }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + // 200 / 50 = 4 + expect(result[0].quantity).toBe(4); }); - describe("edge cases", () => { - test("empty prices array returns empty array", () => { - const fullProduct = createMockFullProduct({ prices: [] }); - const cusProduct = createMockCustomerProduct({ options: [] }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - }; - - const ctx = createMockCtx({ features: [] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(0); + test("small quantity rounds up to 1 billing unit", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", }); - test("throws InternalError when feature not found for price", () => { - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ options: [] }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - }; - - // Empty features array - feature won't be found - const ctx = createMockCtx({ features: [] }); - - expect(() => - computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }), - ).toThrow("computing feature quantities for price"); + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + billingUnits: 1000, }); - test("neither current nor new has quantity → feature not included", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ options: [] }); - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000 + }; - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ options: [] }); // No current options + const ctx = createMockCtx({ features: [feature] }); - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - // No options in params either - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - // Since neither current nor new has quantity, nothing should be included - expect(result).toHaveLength(0); + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, }); - test("params.options explicitly set to empty array → uses current quantities", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 100, - }, - ], - customerPrices: [createMockCustomerPrice({ price })], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [], // Explicitly empty - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - expect(result[0].feature_id).toBe("credits"); - expect(result[0].quantity).toBe(100); // Falls back to current - }); - - test("new quantity of 0 is valid and replaces current", () => { - const feature = createMockFeature({ - id: "credits", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "credits", - internal_feature_id: "internal_credits", - quantity: 100, - }, - ], - customerPrices: [createMockCustomerPrice({ price })], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - options: [{ feature_id: "credits", quantity: 0 }], - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - // quantity: 0 is falsy, so paramsToFeatureOptions returns undefined - // Falls back to current quantity - expect(result).toHaveLength(1); - expect(result[0].quantity).toBe(100); - }); - - test("handles feature matched by internal_feature_id in current options", () => { - const feature = createMockFeature({ - id: "credits", - internalId: "internal_credits_v2", - name: "Credits", - }); - - const price = createMockPrepaidPrice({ - id: "price_credits", - featureId: "credits", - internalFeatureId: "internal_credits_v2", - }); - - const fullProduct = createMockFullProduct({ prices: [price] }); - const cusProduct = createMockCustomerProduct({ - options: [ - { - feature_id: "old_credits_id", // Different feature_id - internal_feature_id: "internal_credits_v2", // Matches by internal_id - quantity: 75, - }, - ], - customerPrices: [createMockCustomerPrice({ price })], - }); - - const params: UpdateSubscriptionV0Params = { - customer_id: "cus_test", - product_id: "prod_test", - }; - - const ctx = createMockCtx({ features: [feature] }); - - const result = computeCustomPlanFeatureQuantities({ - ctx, - fullProduct, - currentCustomerProduct: cusProduct, - params, - }); - - expect(result).toHaveLength(1); - expect(result[0].quantity).toBe(75); - }); + expect(result).toHaveLength(1); + // 1 rounded to 1000, then divided by billingUnits (1000) = 1 + expect(result[0].quantity).toBe(1); }); + + test("converts current quantity from old billing units to new billing units", () => { + // Scenario: Customer has 5 packs of 100 credits (500 actual credits) + // New price uses packs of 250 + // Expected: 500 credits → rounds to 500 (nearest 250) → 2 packs + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + // Old price: billing units of 100 + const oldPrice = createMockPrepaidPrice({ + id: "price_credits_old", + featureId: "credits", + billingUnits: 100, + }); + + // New price: billing units of 250 + const newPrice = createMockPrepaidPrice({ + id: "price_credits_new", + featureId: "credits", + billingUnits: 250, + }); + + const fullProduct = createMockFullProduct({ prices: [newPrice] }); + + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 5, // 5 packs of 100 = 500 actual credits + }, + ], + customerPrices: [createMockCustomerPrice({ price: oldPrice })], + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + // No options - should inherit from current + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + // Old: 5 packs * 100 = 500 credits + // Round 500 to nearest 250 = 500 + // New: 500 / 250 = 2 packs + expect(result[0].quantity).toBe(2); + }); + + test("converts and rounds up when not exact multiple of new billing units", () => { + // Scenario: Customer has 3 packs of 100 credits (300 actual credits) + // New price uses packs of 250 + // Expected: 300 credits → rounds to 500 (nearest 250) → 2 packs + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const oldPrice = createMockPrepaidPrice({ + id: "price_credits_old", + featureId: "credits", + billingUnits: 100, + }); + + const newPrice = createMockPrepaidPrice({ + id: "price_credits_new", + featureId: "credits", + billingUnits: 250, + }); + + const fullProduct = createMockFullProduct({ prices: [newPrice] }); + + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 3, // 3 packs of 100 = 300 actual credits + }, + ], + customerPrices: [createMockCustomerPrice({ price: oldPrice })], + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + // Old: 3 packs * 100 = 300 credits + // Round 300 to nearest 250 (ceiling) = 500 + // New: 500 / 250 = 2 packs + expect(result[0].quantity).toBe(2); + }); + + test("no old customer price returns undefined (no quantity inherited)", () => { + // Scenario: No old price found - can't interpret the stored quantity + // Should return empty result (no feature quantities) + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const newPrice = createMockPrepaidPrice({ + id: "price_credits_new", + featureId: "credits", + billingUnits: 250, + }); + + const fullProduct = createMockFullProduct({ prices: [newPrice] }); + + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 500, + }, + ], + // No customerPrices - can't interpret stored quantity + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + // No old price means we can't interpret the stored quantity + // So no feature quantity is inherited + expect(result).toHaveLength(0); + }); + }); + + describe("price type filtering", () => { + test("skips non-prepaid prices (fixed prices)", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const fixedPrice = createMockFixedPrice({ id: "price_fixed" }); + const prepaidPrice = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + }); + + const fullProduct = createMockFullProduct({ + prices: [fixedPrice, prepaidPrice], + }); + + const cusProduct = createMockCustomerProduct({ options: [] }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 50 }], + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + // Only the prepaid price should be processed + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + }); + }); + + describe("edge cases", () => { + test("empty prices array returns empty array", () => { + const fullProduct = createMockFullProduct({ prices: [] }); + const cusProduct = createMockCustomerProduct({ options: [] }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(0); + }); + + test("skips price when feature not found (no error thrown)", () => { + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ options: [] }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + // Empty features array - feature won't be found + const ctx = createMockCtx({ features: [] }); + + // Should not throw, just skip the price + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(0); + }); + + test("neither current nor new has quantity → feature not included", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ options: [] }); // No current options + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + // No options in params either + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + // Since neither current nor new has quantity, nothing should be included + expect(result).toHaveLength(0); + }); + + test("params.options explicitly set to empty array → uses current quantities", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 100, + }, + ], + customerPrices: [createMockCustomerPrice({ price })], + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [], // Explicitly empty + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + expect(result[0].quantity).toBe(100); // Falls back to current + }); + + test("new quantity of 0 is valid and replaces current", () => { + const feature = createMockFeature({ + id: "credits", + name: "Credits", + }); + + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "credits", + internal_feature_id: "internal_credits", + quantity: 100, + }, + ], + customerPrices: [createMockCustomerPrice({ price })], + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 0 }], + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + // quantity: 0 is falsy, so paramsToFeatureOptions returns undefined + // Falls back to current quantity + expect(result).toHaveLength(1); + expect(result[0].quantity).toBe(100); + }); + + test("handles feature matched by internal_feature_id in current options", () => { + const feature = createMockFeature({ + id: "credits", + internalId: "internal_credits_v2", + name: "Credits", + }); + + const price = createMockPrepaidPrice({ + id: "price_credits", + featureId: "credits", + internalFeatureId: "internal_credits_v2", + }); + + const fullProduct = createMockFullProduct({ prices: [price] }); + const cusProduct = createMockCustomerProduct({ + options: [ + { + feature_id: "old_credits_id", // Different feature_id + internal_feature_id: "internal_credits_v2", // Matches by internal_id + quantity: 75, + }, + ], + customerPrices: [createMockCustomerPrice({ price })], + }); + + const params: UpdateSubscriptionV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }); + + expect(result).toHaveLength(1); + expect(result[0].quantity).toBe(75); + }); + }); }); diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index e8621b585..6a1517a55 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -1,4 +1,6 @@ +import { BillingInterval, type LimitedItem } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; +import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; import { constructArrearItem, constructArrearProratedItem, @@ -32,8 +34,11 @@ const monthlyMessages = ({ includedUsage = 100, }: { includedUsage?: number; -} = {}) => - constructFeatureItem({ featureId: TestFeature.Messages, includedUsage }); +} = {}): LimitedItem => + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage, + }) as LimitedItem; /** * Monthly words - resets each billing cycle @@ -43,8 +48,11 @@ const monthlyWords = ({ includedUsage = 100, }: { includedUsage?: number; -} = {}) => - constructFeatureItem({ featureId: TestFeature.Words, includedUsage }); +} = {}): LimitedItem => + constructFeatureItem({ + featureId: TestFeature.Words, + includedUsage, + }) as LimitedItem; /** * Monthly credits - resets each billing cycle @@ -54,8 +62,11 @@ const monthlyCredits = ({ includedUsage = 100, }: { includedUsage?: number; -} = {}) => - constructFeatureItem({ featureId: TestFeature.Credits, includedUsage }); +} = {}): LimitedItem => + constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage, + }) as LimitedItem; /** * Unlimited messages - no usage cap @@ -75,12 +86,12 @@ const lifetimeMessages = ({ includedUsage = 100, }: { includedUsage?: number; -} = {}) => +} = {}): LimitedItem => constructFeatureItem({ featureId: TestFeature.Messages, includedUsage, interval: null, - }); + }) as LimitedItem; // ═══════════════════════════════════════════════════════════════════ // PREPAID (purchase units upfront) @@ -88,19 +99,21 @@ const lifetimeMessages = ({ /** * Prepaid messages - purchase units upfront ($10/unit) - * @param includedUsage - Free units before purchase required (default: 0) + * @param includedUsage - Free units before purchase required (default: 0), billing units are 100 */ const prepaidMessages = ({ includedUsage = 0, + billingUnits = 100, }: { includedUsage?: number; -} = {}) => + billingUnits?: number; +} = {}): LimitedItem => constructPrepaidItem({ featureId: TestFeature.Messages, price: 10, - billingUnits: 1, + billingUnits, includedUsage, - }); + }) as LimitedItem; // ═══════════════════════════════════════════════════════════════════ // CONSUMABLE / PAY-PER-USE (overage pricing) @@ -114,13 +127,13 @@ const consumableMessages = ({ includedUsage = 0, }: { includedUsage?: number; -} = {}) => +} = {}): LimitedItem => constructArrearItem({ featureId: TestFeature.Messages, includedUsage, price: 0.1, billingUnits: 1, - }); + }) as LimitedItem; // ═══════════════════════════════════════════════════════════════════ // ALLOCATED / SEATS (prorated billing) @@ -134,11 +147,35 @@ const allocatedUsers = ({ includedUsage = 0, }: { includedUsage?: number; -} = {}) => +} = {}): LimitedItem => constructArrearProratedItem({ featureId: TestFeature.Users, pricePerUnit: 10, includedUsage, + }) as LimitedItem; + +// ═══════════════════════════════════════════════════════════════════ +// BASE PRICES +// ═══════════════════════════════════════════════════════════════════ + +/** + * Monthly base price item + * @param price - Monthly price (default: 20) + */ +const monthlyPrice = ({ price = 20 }: { price?: number } = {}) => + constructPriceItem({ + price, + interval: BillingInterval.Month, + }); + +/** + * Annual base price item + * @param price - Annual price (default: 200) + */ +const annualPrice = ({ price = 200 }: { price?: number } = {}) => + constructPriceItem({ + price, + interval: BillingInterval.Year, }); // ═══════════════════════════════════════════════════════════════════ @@ -164,4 +201,8 @@ export const items = { // Allocated allocatedUsers, + + // Base prices + monthlyPrice, + annualPrice, } as const; diff --git a/server/tests/utils/fixtures/products.ts b/server/tests/utils/fixtures/products.ts index 44de42d0b..1f0e600f8 100644 --- a/server/tests/utils/fixtures/products.ts +++ b/server/tests/utils/fixtures/products.ts @@ -1,5 +1,8 @@ import type { ProductItem, ProductV2 } from "@autumn/shared"; -import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { + constructProduct, + constructRawProduct, +} from "@/utils/scriptUtils/createTestProducts.js"; /** * Base product - no base price, customizable defaults @@ -20,6 +23,47 @@ const base = ({ is_default: isDefault, }); +/** + * Pro product - $20/month base price + * @param items - Product items (features) + * @param id - Product ID (default: "pro") + */ +const pro = ({ + items, + id = "pro", +}: { + items: ProductItem[]; + id?: string; +}): ProductV2 => + constructProduct({ + id, + items: [...items], + type: "pro", + isDefault: false, + }); + +/** + * Pro annual product - $200/year base price + * @param items - Product items (features) + * @param id - Product ID (default: "pro-annual") + */ +const proAnnual = ({ + items, + id = "pro-annual", +}: { + items: ProductItem[]; + id?: string; +}): ProductV2 => + constructProduct({ + id, + items: [...items], + type: "pro", + isAnnual: true, + isDefault: false, + }); + export const products = { base, + pro, + proAnnual, } as const; diff --git a/shared/utils/billingUtils/cycleUtils/getCycleStart.ts b/shared/utils/billingUtils/cycleUtils/getCycleStart.ts index 4b1c0985c..361f0e137 100644 --- a/shared/utils/billingUtils/cycleUtils/getCycleStart.ts +++ b/shared/utils/billingUtils/cycleUtils/getCycleStart.ts @@ -24,13 +24,13 @@ export const getCycleStart = ({ now, floor, }: { - anchor: number; + anchor: number | "now"; interval: BillingInterval | EntInterval; intervalCount?: number; now: number; // milliseconds since epoch; floor?: number | undefined; }): number => { - const anchorDate = new UTCDate(anchor); + const anchorDate = anchor === "now" ? new UTCDate(now) : new UTCDate(anchor); const nowDate = new UTCDate(now); const intervalFunctions = getCycleIntervalFunctions({ interval }); diff --git a/shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts b/shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts index fdfee4b16..2d5ed5ba5 100644 --- a/shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts +++ b/shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod.ts @@ -5,30 +5,29 @@ import { getCycleEnd } from "./getCycleEnd"; import { getCycleStart } from "./getCycleStart"; export const getLineItemBillingPeriod = ({ - anchor, + anchorMs, price, - now, + nowMs, }: { - anchor: number; + anchorMs: number | "now"; price: Price; - now: number; + nowMs: number; }): BillingPeriod | undefined => { if (isOneOffPrice(price)) return undefined; const { interval, interval_count: intervalCount } = price.config; - return { - start: getCycleStart({ - anchor, - interval: price.config.interval, - intervalCount: price.config.interval_count, - now, - }), + const start = getCycleStart({ + anchor: anchorMs, + interval, + intervalCount, + now: nowMs, + }); + const end = getCycleEnd({ + anchor: anchorMs, + interval, + intervalCount, + now: nowMs, + }); - end: getCycleEnd({ - anchor, - interval, - intervalCount, - now, - }), - }; + return { start, end }; }; diff --git a/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts b/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts index 01ec3dea1..c64b2b41f 100644 --- a/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts +++ b/shared/utils/billingUtils/invoicingUtils/cusProductToArrearLineItems.ts @@ -10,13 +10,13 @@ import { usagePriceToLineItem } from "./lineItemBuilders/usagePriceToLineItem"; export const cusProductToArrearLineItems = ({ cusProduct, - billingCycleAnchor, - now, + billingCycleAnchorMs, + nowMs, org, }: { cusProduct: FullCusProduct; - billingCycleAnchor: number; - now: number; + billingCycleAnchorMs: number | "now"; + nowMs: number; org: Organization; }) => { let lineItems: LineItem[] = []; @@ -28,9 +28,9 @@ export const cusProductToArrearLineItems = ({ // Calculate billing period const billingPeriod = getLineItemBillingPeriod({ - anchor: billingCycleAnchor, + anchorMs: billingCycleAnchorMs, price, - now, + nowMs, }); const cusEnt = cusPriceToCusEntWithCusProduct({ @@ -53,7 +53,7 @@ export const cusProductToArrearLineItems = ({ billingPeriod, direction: "charge", billingTiming: "in_arrear", - now, + now: nowMs, currency: orgToCurrency({ org }), }; diff --git a/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts b/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts index 0107aeb83..1474281fb 100644 --- a/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts +++ b/shared/utils/billingUtils/invoicingUtils/cusProductToLineItems.ts @@ -1,3 +1,4 @@ +import { formatMs } from "@utils/common"; import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem"; import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext"; import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels"; @@ -29,29 +30,43 @@ export type LineItemDirection = "charge" | "refund"; */ export const cusProductToLineItems = ({ cusProduct, - now, - billingCycleAnchor, + nowMs, + billingCycleAnchorMs, direction, org, + logger, }: { cusProduct: FullCusProduct; - now: number; - billingCycleAnchor: number; + nowMs: number; + billingCycleAnchorMs: number | "now"; direction: "charge" | "refund"; org: Organization; + // biome-ignore lint/suspicious/noExplicitAny: Logger type defined in server package + logger: any; }): LineItem[] => { let lineItems: LineItem[] = []; + logger.debug( + `Building line items for customer product: ${cusProduct.product.id} (${direction})`, + ); + logger.debug( + `Billing cycle anchor: ${formatMs(billingCycleAnchorMs)}, now: ${formatMs(nowMs)}`, + ); + for (const cusPrice of cusProduct.customer_prices) { const price = cusPrice.price; // Calculate billing period const billingPeriod = getLineItemBillingPeriod({ - anchor: billingCycleAnchor, + anchorMs: billingCycleAnchorMs, price, - now, + nowMs, }); + logger.debug( + `Billing period: ${formatMs(billingPeriod?.start)} - ${formatMs(billingPeriod?.end)}`, + ); + // Build line item context const context: LineItemContext = { price, @@ -61,7 +76,7 @@ export const cusProductToLineItems = ({ billingPeriod, direction, billingTiming: "in_advance", - now, + now: nowMs, currency: orgToCurrency({ org }), }; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts index edb01e997..e212f71c2 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts @@ -40,12 +40,19 @@ export const buildLineItem = ({ } // 3. Return LineItem - return LineItemSchema.parse({ + const lineItemData = { amount, description, context, stripePriceId, stripeProductId, chargeImmediately, - } satisfies LineItemCreate); + } satisfies LineItemCreate; + + const result = LineItemSchema.safeParse(lineItemData); + if (!result.success) { + throw result.error; + } + + return result.data; }; diff --git a/shared/utils/common/formatUtils/formatUnix.ts b/shared/utils/common/formatUtils/formatUnix.ts index 24d5c6a6e..cdb0b19b4 100644 --- a/shared/utils/common/formatUtils/formatUnix.ts +++ b/shared/utils/common/formatUtils/formatUnix.ts @@ -5,7 +5,7 @@ export const formatMsToDate = ( options?: { withTimezone?: boolean }, ) => { if (!unixDate) { - return "undefined unix date"; + return "undefined"; } return format(new Date(unixDate), "dd MMM yyyy"); }; @@ -18,7 +18,7 @@ export const formatMs = ( return "now"; } if (!unixDate) { - return "undefined unix date"; + return "undefined"; } let formatString = options?.excludeSeconds diff --git a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts index db488831d..3d6c3566b 100644 --- a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts @@ -1,3 +1,4 @@ +import { InternalError } from "@api/errors/base/InternalError"; import type { Feature } from "@models/featureModels/featureModels"; import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; @@ -13,30 +14,59 @@ import { } from "@utils/billingUtils"; import { priceToEnt } from "@utils/productUtils/convertProductUtils"; -export const priceToFeature = ({ +// Overload: errorOnNotFound = true → guaranteed Feature +export function priceToFeature(params: { + price: Price; + ents?: EntitlementWithFeature[]; + features?: Feature[]; + errorOnNotFound: true; +}): Feature; + +// Overload: errorOnNotFound = false/undefined → Feature | undefined +export function priceToFeature(params: { + price: Price; + ents?: EntitlementWithFeature[]; + features?: Feature[]; + errorOnNotFound?: false; +}): Feature | undefined; + +// Implementation +export function priceToFeature({ price, ents, features, + errorOnNotFound, }: { price: Price; ents?: EntitlementWithFeature[]; features?: Feature[]; -}) => { + errorOnNotFound?: boolean; +}): Feature | undefined { if (!features && !ents) { throw new Error("priceToFeature requires either ents or features as arg"); } + let result: Feature | undefined; + if (features) { - return features.find( + result = features.find( (f) => f.internal_id === (price.config as UsagePriceConfig).internal_feature_id, ); + } else { + const ent = priceToEnt({ price, entitlements: ents ?? [] }); + result = ent?.feature; } - const ent = priceToEnt({ price, entitlements: ents ?? [] }); - return ent?.feature; -}; + if (errorOnNotFound && !result) { + throw new InternalError({ + message: `Feature not found for price ${price.id}`, + }); + } + + return result; +} export const priceToProrationConfig = ({ price, diff --git a/vite/src/components/forms/attach-product/update-confirmation-info.tsx b/vite/src/components/forms/attach-product/update-confirmation-info.tsx index 4a78083df..1b9a4e9b9 100644 --- a/vite/src/components/forms/attach-product/update-confirmation-info.tsx +++ b/vite/src/components/forms/attach-product/update-confirmation-info.tsx @@ -25,8 +25,6 @@ export const UpdateConfirmationInfo = ({ newProduct: previewData?.product, }); - console.log("previewData", previewData); - const hasPrepaidQuantityChanges = useHasPrepaidQuantityChanges(product, form); const renderInfoBoxes = (): ReactNode[] => {