From 7ee92f4e8876c66c7f7856f226df29ce51975d3a Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 22 Dec 2025 10:45:49 +0000 Subject: [PATCH] wip --- AGENTS.md | 3 + CLAUDE.md | 3 + .../applyExistingRollovers.ts | 6 +- .../applyExistingUsages.ts | 31 +- ...itCusProduct.ts => initCustomerProduct.ts} | 23 +- .../initFullCusProduct/initFullCusProduct.ts | 93 --- .../initFullCustomerProduct.ts | 76 ++ .../checkout/utils/getCheckoutOptions.ts | 11 +- server/src/internal/billing/v2/billingPlan.ts | 56 ++ .../billing/v2/compute/computeAttachPlan.ts | 2 +- .../buildAutumnLineItems.ts | 14 +- .../computeAutumnUtils/buildNewCusProducts.ts | 10 +- .../buildUpdateOneOffAction.ts | 2 +- .../computeCustomFullProduct.ts | 51 ++ .../paramsToFeatureOptions.ts | 44 ++ .../buildStripeCheckoutAction.ts | 2 +- .../buildStripeInvoiceAction.ts | 2 +- .../buildStripeSubAction.ts | 2 +- .../computeShouldCreateStripeCheckout.ts | 2 +- .../v2/execute/executeAttachActions.ts | 2 +- .../executeCusProductActions.ts | 2 +- .../updateCustomerEntitlements.ts | 2 +- .../v2/execute/executeInvoiceAction.ts | 2 +- .../v2/execute/executeStripeCheckoutAction.ts | 2 +- .../v2/execute/executeStripeInvoiceAction.ts | 2 +- .../v2/execute/executeStripeSubAction.ts | 2 +- .../executeStripeSubscriptionUpdate.ts | 2 +- .../fetch/fetchAutumnUtils/overrideProduct.ts | 82 -- .../compute/computeInvoiceAction.ts | 2 +- .../compute/computeQuantityUpdateDetails.ts | 2 +- .../computeSubscriptionUpdateAllPlan.ts | 25 - .../computeSubscriptionUpdateCustomPlan.ts | 53 ++ ...bscriptionUpdateCustomPlanInvoiceAction.ts | 60 ++ ...puteSubscriptionUpdateFeatureQuantities.ts | 61 ++ ...riptionUpdateICustomPlanInvoiceRequired.ts | 28 + ...uteSubscriptionUpdateNewCustomerProduct.ts | 63 ++ ...mputeSubscriptionUpdateResetCycleAnchor.ts | 12 + .../computeSubscriptionUpdateTrialDetails.ts | 12 + .../compute/computeSubscriptionUpdatePlan.ts | 2 +- .../computeSubscriptionUpdatePlanIntentMap.ts | 2 +- .../computeSubscriptionUpdateQuantityPlan.ts | 2 +- .../execute/executeSubscriptionUpdate.ts | 2 +- .../fetchApiSubscriptionUpdateContext.ts | 11 +- .../fetch/updateSubscriptionContextSchema.ts | 6 +- .../billing/v2/{types.ts => typesOld.ts} | 0 .../getUpgradeProductPreview.ts | 4 +- .../customers/attach/mergeUtils/mergeUtils.ts | 10 +- .../getApiBalance/getApiBalance.ts | 2 +- .../previewItemUtils/getCurContUseItems.ts | 6 +- .../previewItemUtils/getItemsForNewProduct.ts | 10 +- .../prices/priceUtils/convertPrice.ts | 30 +- .../usagePriceUtils/classifyUsagePrice.ts | 5 - .../usagePriceUtils/convertUsagePrice.ts | 13 +- .../getProductResponse.ts | 5 +- .../utils/checkUtils/checkCustomerCorrect.ts | 17 +- ...cription-update-feature-quantities.test.ts | 748 ++++++++++++++++++ .../apply-existing-rollovers1.test.ts | 4 +- .../apply-existing-rollovers2.test.ts | 4 +- .../apply-existing-rollovers3.test.ts | 4 +- .../apply-existing-rollovers4.test.ts | 4 +- .../apply-existing-rollovers5.test.ts | 4 +- .../apply-existing-rollovers6.test.ts | 4 +- .../apply-existing-rollovers7.test.ts | 4 +- .../apply-existing-usages1.test.ts | 4 +- .../apply-existing-usages2.test.ts | 8 +- .../apply-existing-usages3.test.ts | 10 +- .../apply-existing-usages4.test.ts | 10 +- .../cus-product-to-existing-usages.test.ts | 8 +- server/tests/utils/mockUtils/contextMocks.ts | 12 + .../tests/utils/mockUtils/cusProductMocks.ts | 18 +- server/tests/utils/mockUtils/priceMocks.ts | 69 ++ server/tests/utils/mockUtils/productMocks.ts | 28 +- shared/api/models.ts | 1 + shared/index.ts | 4 +- ...t.ts => initFullCustomerProductContext.ts} | 12 +- shared/utils/billingUtils/index.ts | 1 + .../featureUsageToDescription.ts | 2 +- .../lineItemBuilders/usagePriceToLineItem.ts | 10 +- .../lineItemUtils/tiersToLineAmount.ts | 2 +- .../roundUsageToNearestBillingUnit.ts | 0 shared/utils/cusEntUtils/balanceUtils.ts | 2 - .../balanceUtils/cusEntToPrepaidQuantity.ts | 4 +- shared/utils/cusEntUtils/cusEntUtils.ts | 4 +- .../findPrepaidCusPriceByFeature.ts | 16 + .../cusProductToConvertedFeatureOptions.ts | 60 ++ .../cusProductToFeatureOptions.ts | 23 + shared/utils/index.ts | 3 + shared/utils/productUtils/priceUtils.ts | 5 - .../priceUtils/classifyPriceUtils.ts | 7 + .../priceUtils/convertPriceUtils.ts | 30 + 90 files changed, 1673 insertions(+), 432 deletions(-) rename server/src/internal/billing/billingUtils/initFullCusProduct/{initCusProduct.ts => initCustomerProduct.ts} (75%) delete mode 100644 server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts create mode 100644 server/src/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct.ts create mode 100644 server/src/internal/billing/v2/billingPlan.ts create mode 100644 server/src/internal/billing/v2/compute/computeAutumnUtils/computeCustomFullProduct.ts create mode 100644 server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts delete mode 100644 server/src/internal/billing/v2/fetch/fetchAutumnUtils/overrideProduct.ts delete mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlanInvoiceAction.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateICustomPlanInvoiceRequired.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateResetCycleAnchor.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateTrialDetails.ts rename server/src/internal/billing/v2/{types.ts => typesOld.ts} (100%) create mode 100644 server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts create mode 100644 server/tests/utils/mockUtils/contextMocks.ts create mode 100644 server/tests/utils/mockUtils/priceMocks.ts rename shared/models/billingModels/{initFullCusProductContext.ts => initFullCustomerProductContext.ts} (76%) rename shared/utils/billingUtils/{invoicingUtils/lineItemUtils => usageUtils}/roundUsageToNearestBillingUnit.ts (100%) create mode 100644 shared/utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature.ts create mode 100644 shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts create mode 100644 shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts diff --git a/AGENTS.md b/AGENTS.md index 416ead4f4..f130bede9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - When writing DB queries, for the `customers`, `products` and `features` tables (and others possibly not mentioned here), the primary key when updating is `internal_id`, not `id` - When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas. - Do NOT use "any" type. +- **Spell out variable names in full form** - avoid abbreviations in variable/function names. Use `customerProduct` not `cusProduct`, `customerEntitlements` not `cusEnts`, `organization` not `org` (in new code). Clarity over brevity. # Testing - When writing tests, ALWAYS read: @@ -21,6 +22,8 @@ - **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";` +- **ALWAYS use named import for Decimal.js**: `import { Decimal } from "decimal.js";` NOT `import Decimal from "decimal.js";` + - **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` ## Import Conventions diff --git a/CLAUDE.md b/CLAUDE.md index 86f897dcd..bb2701d1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ - DO NOT alter .gitignore - JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary - When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas. +- **Spell out variable names in full form** - avoid abbreviations in variable/function names. Use `customerProduct` not `cusProduct`, `customerEntitlements` not `cusEnts`, `organization` not `org` (in new code). Clarity over brevity. # Testing - When writing tests, ALWAYS read: @@ -19,6 +20,8 @@ - **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";` +- **ALWAYS use named import for Decimal.js**: `import { Decimal } from "decimal.js";` NOT `import Decimal from "decimal.js";` + - Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier. - When creating "hooks" folders, don't nest them under "components" diff --git a/server/src/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers.ts b/server/src/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers.ts index f7c8ae142..cb5d5ffba 100644 --- a/server/src/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers.ts +++ b/server/src/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers.ts @@ -1,10 +1,10 @@ import type { ExistingRollover, FullCusProduct } from "@shared/index"; export const applyExistingRollovers = ({ - newCusProduct, + customerProduct, existingRollovers, }: { - newCusProduct: FullCusProduct; + customerProduct: FullCusProduct; existingRollovers: ExistingRollover[]; }) => { const getApplicableRollovers = (): ExistingRollover[] => { @@ -16,7 +16,7 @@ export const applyExistingRollovers = ({ }; for (const existingRollover of getApplicableRollovers()) { - const targetCusEnt = newCusProduct.customer_entitlements.find( + const targetCusEnt = customerProduct.customer_entitlements.find( (cusEnt) => cusEnt.entitlement.internal_feature_id === existingRollover.internal_feature_id, diff --git a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts b/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts index 2d857f55d..19e77aee1 100644 --- a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts +++ b/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts @@ -8,18 +8,14 @@ import { deductFromCusEntsTypescript } from "../../../balances/track/deductUtils import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages"; export const applyExistingUsages = ({ - cusProduct, + customerProduct, existingUsages = {}, entities, }: { - cusProduct: FullCusProduct; + customerProduct: FullCusProduct; existingUsages?: ExistingUsages; entities: Entity[]; }) => { - // console.log( - // `applying existing usages to new cus product: ${cusProduct.product.name}`, - // ); - // 1. Merge entities with existing usages const mergedExistingUsages = mergeEntitiesWithExistingUsages({ entities, @@ -29,13 +25,8 @@ export const applyExistingUsages = ({ for (const [internalFeatureId, existingUsage] of Object.entries( mergedExistingUsages, )) { - // console.log( - // `Applying existing usage for feature: ${internalFeatureId}, usage: `, - // existingUsage, - // ); - const cusEnts = cusProductsToCusEnts({ - cusProducts: [cusProduct], + cusProducts: [customerProduct], internalFeatureId, }); @@ -57,7 +48,7 @@ export const applyExistingUsages = ({ }); for (const newCusEnt of cusEnts) { - const original = cusProduct.customer_entitlements.find( + const original = customerProduct.customer_entitlements.find( (ce) => ce.id === newCusEnt.id, ); if (original) { @@ -66,19 +57,5 @@ export const applyExistingUsages = ({ original.adjustment = newCusEnt.adjustment; } } - - // console.log( - // "New cus ents:", - // JSON.stringify( - // cusProduct.customer_entitlements.map((ce) => ({ - // feature_id: ce.feature_id, - // balance: ce.balance, - // entities: ce.entities, - // adjustment: ce.adjustment, - // })), - // null, - // 2, - // ), - // ); } }; diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusProduct.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initCustomerProduct.ts similarity index 75% rename from server/src/internal/billing/billingUtils/initFullCusProduct/initCusProduct.ts rename to server/src/internal/billing/billingUtils/initFullCusProduct/initCustomerProduct.ts index eedea55bd..c8ae1ec6e 100644 --- a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusProduct.ts +++ b/server/src/internal/billing/billingUtils/initFullCusProduct/initCustomerProduct.ts @@ -6,17 +6,18 @@ import { type InitFullCusProductOptions, notNullish, } from "@autumn/shared"; +import { generateId } from "../../../../utils/genUtils"; -export const initCusProduct = ({ +export const initCustomerProduct = ({ initContext, initOptions, - cusProductId, + customerProductId, }: { initContext: InitFullCusProductContext; initOptions?: InitFullCusProductOptions; - cusProductId: string; + customerProductId?: string; }): CusProduct => { - const { fullCus, product, featureQuantities } = initContext; + const { fullCustomer, fullProduct, featureQuantities } = initContext; const { subscriptionId, subscriptionScheduleId, @@ -25,8 +26,8 @@ export const initCusProduct = ({ apiSemver, } = initOptions ?? {}; - const internalEntityId = fullCus.entity?.internal_id; - const entityId = fullCus.entity?.id; + const internalEntityId = fullCustomer.entity?.internal_id; + const entityId = fullCustomer.entity?.id; const status = initOptions?.status ?? CusProductStatus.Active; const startsAt = initOptions?.startsAt ?? Date.now(); @@ -41,14 +42,14 @@ export const initCusProduct = ({ : undefined; return { - id: cusProductId, + id: customerProductId ?? generateId("cus_prod"), - internal_customer_id: fullCus.internal_id, - customer_id: fullCus.id, + internal_customer_id: fullCustomer.internal_id, + customer_id: fullCustomer.id, internal_entity_id: internalEntityId, entity_id: entityId, - internal_product_id: product.internal_id, - product_id: product.id, + internal_product_id: fullProduct.internal_id, + product_id: fullProduct.id, created_at: Date.now(), diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts deleted file mode 100644 index 9ebaf4e81..000000000 --- a/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { - FullCusProduct, - FullCustomer, - InitFullCusProductContext, - InitFullCusProductOptions, -} from "@autumn/shared"; -import type { AutumnContext } from "@server/honoUtils/HonoEnv"; -import { generateId } from "@/utils/genUtils"; -import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages"; -import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement"; -import { initCusPrice } from "./initCusPrice"; -import { initCusProduct } from "./initCusProduct"; - -export const initFullCusProduct = ({ - ctx, - fullCus, - initContext, - initOptions, -}: { - ctx: AutumnContext; - fullCus: FullCustomer; - initContext: InitFullCusProductContext; - initOptions?: InitFullCusProductOptions; -}): FullCusProduct => { - const { product } = initContext; - - const cusProductId = generateId("cus_prod"); - - const newFullCusEnts = product.entitlements.map((entitlement) => ({ - ...initCusEntitlement({ - initContext, - entitlement, - cusProductId, - }), - entitlement, - replaceables: [], - rollovers: [], - })); - - const newCusPrices = product.prices.map((price) => ({ - ...initCusPrice({ - fullCus, - price, - cusProductId, - }), - price, - })); - - const newCusProduct = initCusProduct({ - initContext, - cusProductId, - }); - - ctx.logger.info( - `[insertFullCusProduct] inserting new cus product ${product.id}`, - ); - - const { entitlements: _ents, prices: _prices, ...rawProduct } = product; - - const newFullCusProduct = { - ...newCusProduct, - product: rawProduct, - customer_entitlements: newFullCusEnts, - customer_prices: newCusPrices, - }; - - // Finally, apply existing usages to new cus product - applyExistingUsages({ - cusProduct: newFullCusProduct, - existingUsages: initContext.existingUsages, - entities: fullCus.entities, - }); - - // TODO: Add rollovers to customer entitlements - - return newFullCusProduct; - - // await CusProductService.insert({ - // db, - // data: newCusProduct, - // }); - - // await Promise.all([ - // CusEntService.insert({ - // db, - // data: newCusEnts, - // }), - // CusPriceService.insert({ - // db, - // data: newCusPrices, - // }), - // ]); -}; diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct.ts new file mode 100644 index 000000000..56979e2a9 --- /dev/null +++ b/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct.ts @@ -0,0 +1,76 @@ +import type { + FullCusProduct, + InitFullCustomerProductContext, + InitFullCustomerProductOptions, +} from "@autumn/shared"; +import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; +import { generateId } from "@/utils/genUtils"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; +import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages"; +import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement"; +import { initCusPrice } from "./initCusPrice"; +import { initCustomerProduct } from "./initCustomerProduct"; + +export const initFullCustomerProduct = ({ + // biome-ignore lint/correctness/noUnusedFunctionParameters: will need it at some point + ctx, + initContext, + initOptions, +}: { + ctx: AutumnContext; + initContext: InitFullCustomerProductContext; + initOptions?: InitFullCustomerProductOptions; +}): FullCusProduct => { + const { fullCustomer, fullProduct } = initContext; + + const cusProductId = generateId("cus_prod"); + + const newFullCusEnts = fullProduct.entitlements.map((entitlement) => ({ + ...initCusEntitlement({ + initContext, + entitlement, + cusProductId, + }), + entitlement, + replaceables: [], + rollovers: [], + })); + + const newCusPrices = fullProduct.prices.map((price) => ({ + ...initCusPrice({ + fullCus: fullCustomer, + price, + cusProductId, + }), + price, + })); + + const newCusProduct = initCustomerProduct({ + initContext, + customerProductId: cusProductId, + }); + + const { entitlements: _ents, prices: _prices, ...rawProduct } = fullProduct; + + const newFullCustomerProduct = { + ...newCusProduct, + product: rawProduct, + customer_entitlements: newFullCusEnts, + customer_prices: newCusPrices, + }; + + // Finally, apply existing usages to new cus product + applyExistingUsages({ + customerProduct: newFullCustomerProduct, + existingUsages: initContext.existingUsages, + entities: fullCustomer.entities, + }); + + // TODO: Add rollovers to customer entitlements + applyExistingRollovers({ + customerProduct: newFullCustomerProduct, + existingRollovers: initContext.existingRollovers ?? [], + }); + + return newFullCustomerProduct; +}; diff --git a/server/src/internal/billing/checkout/utils/getCheckoutOptions.ts b/server/src/internal/billing/checkout/utils/getCheckoutOptions.ts index f062f0919..051b430a1 100644 --- a/server/src/internal/billing/checkout/utils/getCheckoutOptions.ts +++ b/server/src/internal/billing/checkout/utils/getCheckoutOptions.ts @@ -1,9 +1,12 @@ -import { type FeatureOptions, isPrepaidPrice } from "@autumn/shared"; +import { + type FeatureOptions, + isPrepaidPrice, + priceToFeature, +} from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import { attachParamsToProduct } from "../../../customers/attach/attachUtils/convertAttachParams"; import type { AttachParams } from "../../../customers/cusProducts/AttachParams"; import { getPriceOptions } from "../../../products/prices/priceUtils"; -import { priceToFeature } from "../../../products/prices/priceUtils/convertPrice"; export const getCheckoutOptions = async ({ ctx, @@ -13,9 +16,7 @@ export const getCheckoutOptions = async ({ attachParams: AttachParams; }) => { const product = attachParamsToProduct({ attachParams }); - const prepaidPrices = product.prices.filter((p) => - isPrepaidPrice({ price: p }), - ); + const prepaidPrices = product.prices.filter((p) => isPrepaidPrice(p)); const newOptions: FeatureOptions[] = structuredClone( attachParams.optionsList, diff --git a/server/src/internal/billing/v2/billingPlan.ts b/server/src/internal/billing/v2/billingPlan.ts new file mode 100644 index 000000000..10802e8cb --- /dev/null +++ b/server/src/internal/billing/v2/billingPlan.ts @@ -0,0 +1,56 @@ +import { EntitlementSchema, PriceSchema } from "@autumn/shared"; +import { z } from "zod/v4"; +import { FullCusProductSchema } from "../../../../../shared/models/cusProductModels/cusProductModels"; + +// manualInvoice?: { +// items: Stripe.InvoiceItemCreateParams[]; +// finalize: boolean; +// chargeAutomatically: boolean; +// }; +// subscription?: { +// action: "create" | "update" | "cancel"; +// params: SubscriptionParams; +// }; +// subscriptionItemUpdates?: { itemId: string; quantity: number }[]; +// checkout?: Stripe.Checkout.SessionCreateParams; + +export const StripeBillingPlanSchema = z.object({ + subscription: z.object({ + action: z.enum(["create", "update", "cancel"]), + params: z.object({ + items: z.array(z.object({ id: z.string(), quantity: z.number() })), + }), + }), +}); + +export const AutumnBillingPlanSchema = z.object({ + insertCusProducts: z.array(FullCusProductSchema), + + updateCusProduct: z.object({ + cusProductId: z.string(), + action: z.enum(["expire"]), + }), + + insertCustomPrices: z.array(PriceSchema), + insertCustomEntitlements: z.array(EntitlementSchema), + + // expireCusProducts: z.array(z.string()), + + // updateCusProduct: z.object({ + // cusProductId: z.string(), + // options: z.array(FeatureOptionsSchema), + // }), + // entitlementChanges: z.array( + // z.object({ cusEntId: z.string(), delta: z.number() }), + // ), +}); + +export const BillingPlanSchema = z.object({ + intent: z.enum(["update_quantity", "update_plan"]), + featureQuantities: z.array( + z.object({ + featureId: z.string(), + quantity: z.number(), + }), + ), +}); diff --git a/server/src/internal/billing/v2/compute/computeAttachPlan.ts b/server/src/internal/billing/v2/compute/computeAttachPlan.ts index b8f7898f2..0cc48a89e 100644 --- a/server/src/internal/billing/v2/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/compute/computeAttachPlan.ts @@ -1,6 +1,6 @@ import { secondsToMs } from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { AttachContext } from "../types"; +import type { AttachContext } from "../typesOld"; import { buildAutumnLineItems } from "./computeAutumnUtils/buildAutumnLineItems"; import { buildNewCusProducts } from "./computeAutumnUtils/buildNewCusProducts"; import { buildStripeCheckoutAction } from "./computeStripeUtils/buildStripeCheckoutAction"; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts index 703ba6033..03b16c867 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts @@ -2,7 +2,6 @@ import { cusProductToArrearLineItems, cusProductToLineItems, type FullCusProduct, - type OngoingCusProductAction, } from "@autumn/shared"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; @@ -10,13 +9,13 @@ import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; export const buildAutumnLineItems = ({ ctx, newCusProducts, - ongoingCusProductAction, + ongoingCustomerProduct, billingCycleAnchor, testClockFrozenTime, }: { ctx: AutumnContext; newCusProducts: FullCusProduct[]; - ongoingCusProductAction?: OngoingCusProductAction; + ongoingCustomerProduct?: FullCusProduct; billingCycleAnchor?: number; testClockFrozenTime?: number; }) => { @@ -24,11 +23,10 @@ export const buildAutumnLineItems = ({ billingCycleAnchor = billingCycleAnchor ?? now; const { org } = ctx; - const ongoingCusProduct = ongoingCusProductAction?.cusProduct; - const arrearLineItems = ongoingCusProduct + const arrearLineItems = ongoingCustomerProduct ? cusProductToArrearLineItems({ - cusProduct: ongoingCusProduct, + cusProduct: ongoingCustomerProduct, billingCycleAnchor: billingCycleAnchor!, now, org, @@ -36,9 +34,9 @@ export const buildAutumnLineItems = ({ : []; // Get line items for ongoing cus product - const ongoingLineItems = ongoingCusProduct + const ongoingLineItems = ongoingCustomerProduct ? cusProductToLineItems({ - cusProduct: ongoingCusProduct, + cusProduct: ongoingCustomerProduct, now, billingCycleAnchor: billingCycleAnchor!, direction: "refund", diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts index 1d401805f..dae93d922 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildNewCusProducts.ts @@ -1,7 +1,7 @@ import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; import { cusProductToExistingUsages } from "../../../billingUtils/handleExistingUsages/cusProductToExistingUsages"; -import { initFullCusProduct } from "../../../billingUtils/initFullCusProduct/initFullCusProduct"; -import type { AttachContext } from "../../types"; +import { initFullCustomerProduct } from "../../../billingUtils/initFullCusProduct/initFullCustomerProduct"; +import type { AttachContext } from "../../typesOld"; export const buildNewCusProducts = ({ ctx, @@ -21,12 +21,12 @@ export const buildNewCusProducts = ({ }); // Initialize new cus product - const newCusProduct = initFullCusProduct({ + const newCusProduct = initFullCustomerProduct({ ctx, fullCus, initContext: { - fullCus, - product: products[0], + fullCustomer: fullCus, + fullProduct: products[0], featureQuantities: [], replaceables: [], existingUsages, diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildUpdateOneOffAction.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildUpdateOneOffAction.ts index 2c1ab3715..dbc65f628 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildUpdateOneOffAction.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildUpdateOneOffAction.ts @@ -1,6 +1,6 @@ import type { FullCusProduct } from "@autumn/shared"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import type { AttachContext } from "../../types"; +import type { AttachContext } from "../../typesOld"; export const buildUpdateOneOffAction = ({ ctx, diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/computeCustomFullProduct.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/computeCustomFullProduct.ts new file mode 100644 index 000000000..f43ed032b --- /dev/null +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/computeCustomFullProduct.ts @@ -0,0 +1,51 @@ +import type { FullProduct, ProductItem } from "@autumn/shared"; +import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; +import { getEntsWithFeature } from "../../../../products/entitlements/entitlementUtils"; +import { handleNewProductItems } from "../../../../products/product-items/productItemUtils/handleNewProductItems"; + +export const computeCustomFullProduct = async ({ + ctx, + customItems, + currentFullProduct, +}: { + ctx: AutumnContext; + customItems?: ProductItem[]; + currentFullProduct: FullProduct; +}) => { + if (!customItems) { + return { + fullProduct: currentFullProduct, + customPrices: [], + customEnts: [], + }; + } + + const { db, logger, features } = ctx; + + const { prices: currentPrices, entitlements: currentEntitlements } = + currentFullProduct; + + const { prices, entitlements, customPrices, customEnts } = + await handleNewProductItems({ + db, + curPrices: currentPrices, + curEnts: currentEntitlements, + newItems: customItems, + features, + product: currentFullProduct, + logger, + isCustom: true, + }); + + const newFullProduct = { + ...currentFullProduct, + prices, + entitlements: getEntsWithFeature({ ents: entitlements, features }), + }; + + return { + fullProduct: newFullProduct, + customPrices, + customEnts, + }; +}; diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts new file mode 100644 index 000000000..0485ee77b --- /dev/null +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts @@ -0,0 +1,44 @@ +import type { + Feature, + FeatureOptions, + Price, + SubscriptionUpdateV0Params, +} from "@autumn/shared"; +import { roundUsageToNearestBillingUnit } from "@autumn/shared"; +import { Decimal } from "decimal.js"; + +export const paramsToFeatureOptions = ({ + params, + price, + feature, +}: { + params: SubscriptionUpdateV0Params; + price: Price; + feature: Feature; +}): FeatureOptions | undefined => { + const options = params.options?.find( + (option) => option.feature_id === feature.id, + ); + + const billingUnits = price.config.billing_units ?? 1; + + if (options?.quantity) { + // 1. Round options quantity to nearest billing units: + const roundedQuantity = roundUsageToNearestBillingUnit({ + usage: options.quantity, + billingUnits, + }); + + const quantityDividedByBillingUnits = new Decimal(roundedQuantity) + .div(billingUnits) + .toNumber(); + + return { + internal_feature_id: feature.internal_id, + feature_id: feature.id, + quantity: quantityDividedByBillingUnits, + }; + } + + return undefined; +}; diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeCheckoutAction.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeCheckoutAction.ts index 1514dc008..162250509 100644 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeCheckoutAction.ts +++ b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeCheckoutAction.ts @@ -9,7 +9,7 @@ import type Stripe from "stripe"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; import { toSuccessUrl } from "../../../../orgs/orgUtils/convertOrgUtils"; import { cusProductToStripeItemSpecs } from "../../../billingUtils/stripeAdapter/cusProductToStripeItemSpecs"; -import type { AttachContext } from "../../types"; +import type { AttachContext } from "../../typesOld"; import { computeShouldCreateStripeCheckout } from "./computeShouldCreateStripeCheckout"; export const buildCheckoutSessionCreateSubscriptionData = ({ diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts index da40ad7f2..cbeef5c9b 100644 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeInvoiceAction.ts @@ -7,7 +7,7 @@ import { import { applyStripeDiscountsToLineItems } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems"; import { subToDiscounts } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts"; import { lineItemsToStripeLines } from "../../../billingUtils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines"; -import type { AttachContext, StripeSubAction } from "../../types"; +import type { AttachContext, StripeSubAction } from "../../typesOld"; export const buildStripeInvoiceAction = ({ attachContext, diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeSubAction.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeSubAction.ts index 205d7e7be..a02434d9e 100644 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeSubAction.ts +++ b/server/src/internal/billing/v2/compute/computeStripeUtils/buildStripeSubAction.ts @@ -6,7 +6,7 @@ import type { import type Stripe from "stripe"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; import { buildSubItemUpdate } from "../../../billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate"; -import type { StripeSubAction } from "../../types"; +import type { StripeSubAction } from "../../typesOld"; export const buildStripeSubAction = ({ ctx, diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/computeShouldCreateStripeCheckout.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/computeShouldCreateStripeCheckout.ts index a571b8a57..fb75455e9 100644 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/computeShouldCreateStripeCheckout.ts +++ b/server/src/internal/billing/v2/compute/computeStripeUtils/computeShouldCreateStripeCheckout.ts @@ -5,7 +5,7 @@ import { isFreeProduct, } from "@autumn/shared"; import { notNullish } from "../../../../../utils/genUtils"; -import type { AttachContext } from "../../types"; +import type { AttachContext } from "../../typesOld"; export const computeShouldCreateStripeCheckout = ({ attachContext, diff --git a/server/src/internal/billing/v2/execute/executeAttachActions.ts b/server/src/internal/billing/v2/execute/executeAttachActions.ts index 47b18d6d9..8108f4b42 100644 --- a/server/src/internal/billing/v2/execute/executeAttachActions.ts +++ b/server/src/internal/billing/v2/execute/executeAttachActions.ts @@ -1,5 +1,5 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { AttachContext, AttachPlan } from "../types"; +import type { AttachContext, AttachPlan } from "../typesOld"; import { applyCusProductActions } from "./executeAutumnActions/applyCusProductActions"; import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction"; import { executeStripeInvoiceAction } from "./executeStripeInvoiceAction"; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/executeCusProductActions.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/executeCusProductActions.ts index 050ddba07..816bc3224 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/executeCusProductActions.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/executeCusProductActions.ts @@ -4,7 +4,7 @@ import type { OngoingCusProductAction, } from "@autumn/shared"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import type { QuantityUpdateDetails } from "../../types"; +import type { QuantityUpdateDetails } from "../../typesOld"; import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction"; import { insertNewCusProducts } from "./insertNewCusProducts"; import { updateCustomerEntitlements } from "./updateCustomerEntitlements"; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts index 7d8a55a20..98c10f12e 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts @@ -1,6 +1,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -import type { QuantityUpdateDetails } from "../../types"; +import type { QuantityUpdateDetails } from "../../typesOld"; /** * Update customer entitlement balances based on quantity changes. diff --git a/server/src/internal/billing/v2/execute/executeInvoiceAction.ts b/server/src/internal/billing/v2/execute/executeInvoiceAction.ts index becffe53e..ccda4bee3 100644 --- a/server/src/internal/billing/v2/execute/executeInvoiceAction.ts +++ b/server/src/internal/billing/v2/execute/executeInvoiceAction.ts @@ -5,7 +5,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { InvoiceService } from "@/internal/invoices/InvoiceService"; import { getInvoiceItems } from "@/internal/invoices/invoiceUtils"; import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice"; -import type { SubscriptionUpdateInvoiceAction } from "../types"; +import type { SubscriptionUpdateInvoiceAction } from "../typesOld"; /** * Execute invoice creation and finalization for subscription updates. diff --git a/server/src/internal/billing/v2/execute/executeStripeCheckoutAction.ts b/server/src/internal/billing/v2/execute/executeStripeCheckoutAction.ts index a4dff0188..1e0caf919 100644 --- a/server/src/internal/billing/v2/execute/executeStripeCheckoutAction.ts +++ b/server/src/internal/billing/v2/execute/executeStripeCheckoutAction.ts @@ -2,7 +2,7 @@ import type Stripe from "stripe"; import { createStripeCli } from "../../../../external/connect/createStripeCli"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import { notNullish } from "../../../../utils/genUtils"; -import type { StripeCheckoutAction } from "../types"; +import type { StripeCheckoutAction } from "../typesOld"; export const executeStripeCheckoutAction = async ({ ctx, diff --git a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts index 6d3b88392..d8409e7a9 100644 --- a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts @@ -5,7 +5,7 @@ import type { AttachContext, StripeCheckoutAction, StripeInvoiceAction, -} from "../types"; +} from "../typesOld"; import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction"; export const executeStripeInvoiceAction = async ({ diff --git a/server/src/internal/billing/v2/execute/executeStripeSubAction.ts b/server/src/internal/billing/v2/execute/executeStripeSubAction.ts index 1dfea419e..e25f89b31 100644 --- a/server/src/internal/billing/v2/execute/executeStripeSubAction.ts +++ b/server/src/internal/billing/v2/execute/executeStripeSubAction.ts @@ -1,5 +1,5 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { StripeSubAction } from "../types"; +import type { StripeSubAction } from "../typesOld"; import { executeStripeSubscriptionUpdate } from "./executeStripeSubscriptionActions/executeStripeSubscriptionUpdate"; export const executeStripeSubAction = async ({ diff --git a/server/src/internal/billing/v2/execute/executeStripeSubscriptionActions/executeStripeSubscriptionUpdate.ts b/server/src/internal/billing/v2/execute/executeStripeSubscriptionActions/executeStripeSubscriptionUpdate.ts index 395a17f88..0a6b3a641 100644 --- a/server/src/internal/billing/v2/execute/executeStripeSubscriptionActions/executeStripeSubscriptionUpdate.ts +++ b/server/src/internal/billing/v2/execute/executeStripeSubscriptionActions/executeStripeSubscriptionUpdate.ts @@ -1,6 +1,6 @@ import { createStripeCli } from "@/external/connect/createStripeCli"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { StripeSubAction } from "../../types"; +import type { StripeSubAction } from "../../typesOld"; /** * Execute Stripe subscription item updates. diff --git a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/overrideProduct.ts b/server/src/internal/billing/v2/fetch/fetchAutumnUtils/overrideProduct.ts deleted file mode 100644 index f203bcb4e..000000000 --- a/server/src/internal/billing/v2/fetch/fetchAutumnUtils/overrideProduct.ts +++ /dev/null @@ -1,82 +0,0 @@ -// import type { PlanOverride } from "@autumn/shared"; -import { - type FullProduct, - InternalError, - type ProductItem, -} from "@autumn/shared"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import { getEntsWithFeature } from "../../../../products/entitlements/entitlementUtils"; -import { handleNewProductItems } from "../../../../products/product-items/productItemUtils/handleNewProductItems"; - -// 1. Only override base product -// 2. Assume features + prices are passed in together -export const overrideProduct = async ({ - ctx, - newItems, - products, -}: { - ctx: AutumnContext; - newItems?: ProductItem[]; - products: FullProduct[]; -}) => { - if (!newItems) { - return { - fullProducts: products, - customPrices: [], - customEnts: [], - }; - } - - if (products.length === 0) { - throw new InternalError({ - message: "[overrideProduct] products array is empty", - }); - } - - if (products.length > 1) { - throw new InternalError({ - message: "[overrideProduct] products array has more than one product", - }); - } - - const { db, logger, features } = ctx; - // const { price, features: planFeatures } = planOverride; - - // const newBasePriceItem = planToProductV2PriceItem({ - // price: price ?? null, - // features, - // }); - - // const featureItems = planFeaturesToItems({ - // planFeatures: planFeatures ?? [], - // features, - // }); - - // const newItems = [newBasePriceItem, ...featureItems]; - - const product = products[0]; - - const { prices, entitlements, customPrices, customEnts } = - await handleNewProductItems({ - db, - curPrices: product.prices, - curEnts: product.entitlements, - newItems, - features, - product, - logger, - isCustom: true, - }); - - const newFullProduct = { - ...product, - prices, - entitlements: getEntsWithFeature({ ents: entitlements, features }), - }; - - return { - fullProducts: [newFullProduct], - customPrices, - customEnts, - }; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeInvoiceAction.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeInvoiceAction.ts index acc70d3c6..c6e14fc89 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeInvoiceAction.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeInvoiceAction.ts @@ -3,7 +3,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { QuantityUpdateDetails, SubscriptionUpdateInvoiceAction, -} from "../../types"; +} from "../../typesOld"; /** * Aggregate invoice items and determine invoice creation strategy. diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts index 7e09edf1f..796910462 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeQuantityUpdateDetails.ts @@ -23,7 +23,7 @@ import { shouldProrate, } from "@/internal/products/prices/priceUtils/prorationConfigUtils"; import { notNullish } from "@/utils/genUtils"; -import type { QuantityUpdateDetails } from "../../types"; +import type { QuantityUpdateDetails } from "../../typesOld"; /** * Compute all details for a single feature quantity update. diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts deleted file mode 100644 index f9da01593..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts +++ /dev/null @@ -1,25 +0,0 @@ -// const { -// fullProducts: [newFullProduct], -// customPrices, -// customEnts, -// } = await overrideProduct({ -// ctx, -// newItems: body.items, -// products: [product], -// }); - -import type { SubscriptionUpdateV0Params } from "../../../../../../../shared"; -import type { AutumnContext } from "../../../../../honoUtils/HonoEnv"; -import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; - -export const computeSubscriptionUpdateCustomConfigurationPlan = ({ - ctx: AutumnContext, - updateSubscriptionContext, - params, -}: { - ctx: AutumnContext; - updateSubscriptionContext: UpdateSubscriptionContext; - params: SubscriptionUpdateV0Params; -}) => { - return {}; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts new file mode 100644 index 000000000..41cf48057 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan.ts @@ -0,0 +1,53 @@ +import { + cusProductToProduct, + type SubscriptionUpdateV0Params, +} from "@autumn/shared"; +import type { AutumnContext } from "@server/honoUtils/HonoEnv"; +import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; +import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct"; +import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct"; + +export const computeSubscriptionUpdateCustomPlan = async ({ + ctx, + subscriptionUpdateContext, + params, +}: { + ctx: AutumnContext; + subscriptionUpdateContext: UpdateSubscriptionContext; + params: SubscriptionUpdateV0Params; +}) => { + // 1. Compute the override plan + const { customerProduct } = subscriptionUpdateContext; + + const currentFullProduct = cusProductToProduct({ + cusProduct: customerProduct, + }); + + const { + fullProduct: customFullProduct, + customPrices, + customEnts, + } = await computeCustomFullProduct({ + ctx, + currentFullProduct, + customItems: params.items, + }); + + // 2. Compute the new customer product + const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({ + ctx, + subscriptionUpdateContext, + params, + fullProduct: customFullProduct, + }); + + // // 2. Compute the invoice action + // const invoiceAction = computeSubscriptionUpdateCustomPlanInvoiceAction({ + // ctx, + // updateSubscriptionContext, + // newFullCustomerProduct, + // params, + // }); + + return newFullCustomerProduct; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlanInvoiceAction.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlanInvoiceAction.ts new file mode 100644 index 000000000..3e7405a7f --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlanInvoiceAction.ts @@ -0,0 +1,60 @@ +import { + type FullCusProduct, + type SubscriptionUpdateV0Params, + secondsToMs, +} from "@autumn/shared"; +import type { AutumnContext } from "@server/honoUtils/HonoEnv"; +import { buildAutumnLineItems } from "../../../compute/computeAutumnUtils/buildAutumnLineItems"; +import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema"; +import { computeSubscriptionUpdateCustomPlanInvoiceRequired } from "./computeSubscriptionUpdateICustomPlanInvoiceRequired"; + +/** + * Computes the invoice action for a custom subscription update. + * + * Determines what invoice operations (create, prorate, void, etc.) are needed + * when a subscription is updated with custom item configurations. + * + * @param ctx - The Autumn request context + * @param updateSubscriptionContext - Context containing customer product and subscription details + * @param params - The subscription update parameters from the API request + * @returns The computed invoice action to be executed + */ +export const computeSubscriptionUpdateCustomPlanInvoiceAction = ({ + ctx, + updateSubscriptionContext, + params, + newFullCustomerProduct, +}: { + ctx: AutumnContext; + updateSubscriptionContext: UpdateSubscriptionContext; + params: SubscriptionUpdateV0Params; + newFullCustomerProduct: FullCusProduct; +}) => { + // 1. Early return and don't create an invoice + + const invoiceRequired = computeSubscriptionUpdateCustomPlanInvoiceRequired({ + ctx, + updateSubscriptionContext, + params, + }); + + if (!invoiceRequired) return undefined; + + const { customerProduct, stripeSubscription, testClockFrozenTime } = + updateSubscriptionContext; + + // 2. Calculate line items + const lineItems = buildAutumnLineItems({ + ctx, + newCusProducts: [newFullCustomerProduct], + ongoingCustomerProduct: customerProduct, + billingCycleAnchor: secondsToMs(stripeSubscription?.billing_cycle_anchor), + testClockFrozenTime, + }); + + // 3. + + console.log("New line items", lineItems); + + return {}; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts new file mode 100644 index 000000000..e09d2efd1 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities.ts @@ -0,0 +1,61 @@ +import { + cusProductToConvertedFeatureOptions, + type FeatureOptions, + type FullCusProduct, + type FullProduct, + InternalError, + isPrepaidPrice, + priceToFeature, + type SubscriptionUpdateV0Params, +} 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 computeSubscriptionUpdateFeatureQuantities = ({ + ctx, + fullProduct, + currentCustomerProduct, + params, +}: { + ctx: AutumnContext; + fullProduct: FullProduct; + currentCustomerProduct: FullCusProduct; + params: SubscriptionUpdateV0Params; +}) => { + 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/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateICustomPlanInvoiceRequired.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateICustomPlanInvoiceRequired.ts new file mode 100644 index 000000000..17e9dff0f --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateICustomPlanInvoiceRequired.ts @@ -0,0 +1,28 @@ +import type { SubscriptionUpdateV0Params } from "@autumn/shared"; +import type { AutumnContext } from "@server/honoUtils/HonoEnv"; +import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema"; + +/** + * Determines whether an invoice is required for a custom subscription update. + * + * Evaluates the subscription changes to decide if billing adjustments + * (prorations, charges, credits) necessitate creating an invoice. + * + * @param ctx - The Autumn request context + * @param updateSubscriptionContext - Context containing customer product and subscription details + * @param params - The subscription update parameters from the API request + * @returns `true` if an invoice is required, `false` otherwise + */ +export const computeSubscriptionUpdateCustomPlanInvoiceRequired = ({ + ctx, + updateSubscriptionContext, + params, +}: { + ctx: AutumnContext; + updateSubscriptionContext: UpdateSubscriptionContext; + params: SubscriptionUpdateV0Params; +}) => { + // 1. When to calculate invoice...? + + return false; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts new file mode 100644 index 000000000..f7ee79a35 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct.ts @@ -0,0 +1,63 @@ +import type { FullProduct, SubscriptionUpdateV0Params } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { cusProductToExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/cusProductToExistingRollovers"; +import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages"; +import { initFullCustomerProduct } from "@/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct"; +import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities"; +import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; + +export const computeSubscriptionUpdateNewCustomerProduct = async ({ + ctx, + subscriptionUpdateContext, + params, + fullProduct, +}: { + ctx: AutumnContext; + subscriptionUpdateContext: UpdateSubscriptionContext; + params: SubscriptionUpdateV0Params; + fullProduct: FullProduct; +}) => { + const { + customerProduct, + fullCustomer, + stripeSubscription, + stripeSubscriptionSchedule, + } = subscriptionUpdateContext; + + // 1. Get feature quantities + const existingUsages = cusProductToExistingUsages({ + cusProduct: customerProduct, + entityId: fullCustomer.entity?.id, + }); + + const existingRollovers = cusProductToExistingRollovers({ + cusProduct: customerProduct, + }); + + const featureQuantities = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: customerProduct, + params, + }); + + // 1. Compute the new full customer product + const newFullCustomerProduct = initFullCustomerProduct({ + ctx, + initContext: { + fullCustomer, + fullProduct, + featureQuantities, + existingUsages, + existingRollovers, + }, + initOptions: { + isCustom: true, + // resetCycleAnchor, + subscriptionId: stripeSubscription?.id, + subscriptionScheduleId: stripeSubscriptionSchedule?.id, + }, + }); + + return newFullCustomerProduct; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateResetCycleAnchor.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateResetCycleAnchor.ts new file mode 100644 index 000000000..73ee82298 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateResetCycleAnchor.ts @@ -0,0 +1,12 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; + +export const computeSubscriptionUpdateResetCycleAnchor = ({ + ctx, + subscriptionUpdateContext, +}: { + ctx: AutumnContext; + subscriptionUpdateContext: UpdateSubscriptionContext; +}) => { + return {}; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateTrialDetails.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateTrialDetails.ts new file mode 100644 index 000000000..1cc54661e --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateTrialDetails.ts @@ -0,0 +1,12 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema"; + +export const computeSubscriptionUpdateTrialDetails = ({ + ctx, + subscriptionUpdateContext, +}: { + ctx: AutumnContext; + subscriptionUpdateContext: UpdateSubscriptionContext; +}) => { + return {}; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts index 420bb0e6b..f3b5f8ae7 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts @@ -1,6 +1,6 @@ import type { SubscriptionUpdateV0Params } from "@shared/index"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { SubscriptionUpdatePlan } from "../../types"; +import type { SubscriptionUpdatePlan } from "../../typesOld"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent"; import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionUpdatePlanIntentMap"; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts index 45a01f6d8..ef4a29565 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts @@ -4,7 +4,7 @@ import { type SubscriptionUpdateV0Params, } from "@shared/index"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { SubscriptionUpdatePlan } from "../../types"; +import type { SubscriptionUpdatePlan } from "../../typesOld"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan"; import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts index 587f74924..c1797b06a 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts @@ -5,7 +5,7 @@ import { } from "@shared/index"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems"; -import type { SubscriptionUpdateQuantityPlan } from "../../types"; +import type { SubscriptionUpdateQuantityPlan } from "../../typesOld"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; import { computeInvoiceAction } from "./computeInvoiceAction"; import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails"; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts b/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts index 914640d33..a0873bb75 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts @@ -7,7 +7,7 @@ import { PriceService } from "@/internal/products/prices/PriceService"; import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions"; import { executeInvoiceAction } from "../../execute/executeInvoiceAction"; import { executeStripeSubAction } from "../../execute/executeStripeSubAction"; -import type { SubscriptionUpdatePlan } from "../../types"; +import type { SubscriptionUpdatePlan } from "../../typesOld"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; export const executeSubscriptionUpdate = async ({ diff --git a/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts index 673be600a..831013906 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts @@ -1,8 +1,4 @@ -import { - cusProductToProduct, - InternalError, - type SubscriptionUpdateV0Params, -} from "@shared/index"; +import { InternalError, type SubscriptionUpdateV0Params } from "@shared/index"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { mapOptionsList } from "@/internal/customers/attach/attachUtils/mapOptionsList"; import { CusService } from "../../../../customers/CusService"; @@ -47,10 +43,6 @@ export const fetchApiSubscriptionUpdateContext = async ({ }); } - const targetProduct = cusProductToProduct({ - cusProduct: targetCustomerProduct, - }); - const stripeSubscription = await fetchStripeSubscriptionForBilling({ ctx, fullCus: fullCustomer, @@ -84,7 +76,6 @@ export const fetchApiSubscriptionUpdateContext = async ({ return { fullCustomer, - product: targetProduct, customerProduct: targetCustomerProduct, stripeSubscription, stripeCustomer, diff --git a/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts b/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts index 1d5f02916..361909152 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts @@ -1,11 +1,11 @@ -import type { FullCusProduct, FullCustomer, FullProduct } from "@shared/index"; +import type { FullCusProduct, FullCustomer } from "@shared/index"; import type Stripe from "stripe"; export type UpdateSubscriptionContext = { fullCustomer: FullCustomer; - product: FullProduct; + // product: FullProduct; customerProduct: FullCusProduct; - stripeSubscription: Stripe.Subscription; + stripeSubscription?: Stripe.Subscription; stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule; stripeCustomer: Stripe.Customer; paymentMethod?: Stripe.PaymentMethod; diff --git a/server/src/internal/billing/v2/types.ts b/server/src/internal/billing/v2/typesOld.ts similarity index 100% rename from server/src/internal/billing/v2/types.ts rename to server/src/internal/billing/v2/typesOld.ts diff --git a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts index c956e0f2c..1e1cae739 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getUpgradeProductPreview.ts @@ -6,6 +6,7 @@ import { type FreeTrial, type FullCusProduct, isCusProductTrialing, + isPrepaidPrice, OnDecrease, OnIncrease, type PreviewLineItem, @@ -21,7 +22,6 @@ import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getI import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js"; import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js"; -import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { isFreeProduct } from "@/internal/products/productUtils.js"; import { mapToProductItems } from "@/internal/products/productV2Utils.js"; import { nullish } from "@/utils/genUtils.js"; @@ -103,7 +103,7 @@ const filterNoProratePrepaidItems = ({ const curPrice = curPrices.find( (p) => (p.config as UsagePriceConfig)?.internal_feature_id === - internal_feature_id && isPrepaidPrice({ price: p }), + internal_feature_id && isPrepaidPrice(p), ); const onDecrease = curPrice?.proration_config?.on_decrease; diff --git a/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts b/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts index 384a5edf7..b6e4ed225 100644 --- a/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts +++ b/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts @@ -3,14 +3,12 @@ import { cusProductToEnts, type Entity, type FullCusProduct, + isAllocatedPrice, isFixedPrice, + isPrepaidPrice, type Price, } from "@autumn/shared"; import type Stripe from "stripe"; -import { - isContUsePrice, - isPrepaidPrice, -} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { getPriceEntitlement, getPriceOptions, @@ -48,7 +46,7 @@ export const getQuantityToRemove = ({ let finalQuantity = 1; const fixedPriceMultiplier = cusProduct.quantity || 1; - if (isPrepaidPrice({ price })) { + if (isPrepaidPrice(price)) { const options = getPriceOptions(price, cusProduct.options); if (!options) return finalQuantity; @@ -57,7 +55,7 @@ export const getQuantityToRemove = ({ finalQuantity = options.upcoming_quantity || options.quantity || 1; } - if (isContUsePrice({ price })) { + if (isAllocatedPrice(price)) { const ents = cusProductToEnts({ cusProduct }); const relatedEnt = getPriceEntitlement(price, ents); const existingUsage = getExistingUsageFromCusProducts({ diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 3008b0bad..f4a9de754 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -120,7 +120,7 @@ export const cusEntsToPrepaidQuantity = ({ // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); - if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) continue; + if (!cusPrice || !isPrepaidPrice(cusPrice.price)) continue; // 3. Get quantity const options = cusEnt.customer_product.options.find( diff --git a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts index d356b38f5..7f2538d42 100644 --- a/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts +++ b/server/src/internal/invoices/previewItemUtils/getCurContUseItems.ts @@ -6,6 +6,7 @@ import { getFeatureInvoiceDescription, InternalError, type PreviewLineItem, + priceToFeature, stripeToAtmnAmount, type UsagePriceConfig, } from "@autumn/shared"; @@ -15,10 +16,7 @@ import { findPriceInStripeItems } from "@/external/stripe/stripeSubUtils/stripeS import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js"; import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; import { getExistingUsageFromCusProducts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { - priceToFeature, - priceToUsageModel, -} from "@/internal/products/prices/priceUtils/convertPrice.js"; +import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js"; import { formatUnixToDate } from "@/utils/genUtils.js"; import { calculateProrationAmount } from "../prorationUtils.js"; diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 8c6a200d7..12cc878c0 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -10,10 +10,12 @@ import { type IntervalConfig, isFixedPrice, isOneOffPrice, + isPrepaidPrice, isUsagePrice, type Organization, type PreviewLineItem, type Price, + priceToFeature, priceToInvoiceAmount, toProductItem, UsageModel, @@ -27,16 +29,12 @@ import { getAlignedUnix, getPeriodStartForEnd, } from "@/internal/products/prices/billingIntervalUtils2.js"; -import { - priceToFeature, - priceToUsageModel, -} from "@/internal/products/prices/priceUtils/convertPrice.js"; +import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js"; import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js"; import { formatUnixToDate, notNullish } from "@/utils/genUtils.js"; import type { AttachParams } from "../../customers/cusProducts/AttachParams.js"; import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js"; import { subtractIntervalForProration } from "../../products/prices/billingIntervalUtils.js"; -import { isPrepaidPrice } from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { formatPrice, @@ -242,7 +240,7 @@ export const getItemsForNewProduct = async ({ continue; } - if (withPrepaid && isPrepaidPrice({ price })) { + if (withPrepaid && isPrepaidPrice(price)) { const options = getPriceOptions(price, attachParams.optionsList); const quantity = notNullish(options?.quantity) ? options?.quantity : 1; diff --git a/server/src/internal/products/prices/priceUtils/convertPrice.ts b/server/src/internal/products/prices/priceUtils/convertPrice.ts index 5aef95397..a20dddf8d 100644 --- a/server/src/internal/products/prices/priceUtils/convertPrice.ts +++ b/server/src/internal/products/prices/priceUtils/convertPrice.ts @@ -1,16 +1,13 @@ import { BillingInterval, BillingType, - type EntitlementWithFeature, - type Feature, type FullProduct, isFixedPrice, type Price, type ProductOptions, UsageModel, - type UsagePriceConfig, } from "@autumn/shared"; -import { getBillingType, getPriceEntitlement } from "../priceUtils.js"; +import { getBillingType } from "../priceUtils.js"; export const priceToIntervalKey = (price: Price) => { return toIntervalKey({ @@ -52,31 +49,6 @@ export const intervalKeyToPrice = (intervalKey: string) => { }; }; -export const priceToFeature = ({ - price, - ents, - features, -}: { - price: Price; - ents?: EntitlementWithFeature[]; - features?: Feature[]; -}) => { - if (!features && !ents) { - throw new Error("priceToFeature requires either ents or features as arg"); - } - - if (features) { - return features.find( - (f) => - f.internal_id === - (price.config as UsagePriceConfig).internal_feature_id, - ); - } - - const ent = getPriceEntitlement(price, ents!); - return ent?.feature; -}; - export const priceToUsageModel = (price: Price) => { const billingType = getBillingType(price.config); if (isFixedPrice(price)) { diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts index cbbddd27d..e03e2a5d5 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts @@ -24,11 +24,6 @@ export const isContUsePrice = ({ price }: { price?: Price }) => { return billingType === BillingType.InArrearProrated; }; -export const isPrepaidPrice = ({ price }: { price: Price }) => { - const billingType = getBillingType(price.config); - return billingType === BillingType.UsageInAdvance; -}; - export const hasPrepaidPrice = ({ prices, excludeOneOff, diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils/convertUsagePrice.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils/convertUsagePrice.ts index 55baadb06..508c91894 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils/convertUsagePrice.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils/convertUsagePrice.ts @@ -1,11 +1,10 @@ import { BillingType, - Feature, - FullProduct, - Price, - UsagePriceConfig, + type FullProduct, + type Price, + priceToFeature, + type UsagePriceConfig, } from "@autumn/shared"; -import { priceToFeature } from "../convertPrice.js"; import { getBillingType } from "../../priceUtils.js"; export const usagePriceToProductName = ({ @@ -15,7 +14,7 @@ export const usagePriceToProductName = ({ price: Price; fullProduct: FullProduct; }) => { - let feature = priceToFeature({ + const feature = priceToFeature({ price, ents: fullProduct.entitlements, }); @@ -24,7 +23,7 @@ export const usagePriceToProductName = ({ return fullProduct.name; } - let billingType = getBillingType(price.config); + const billingType = getBillingType(price.config); const billingUnits = (price.config as UsagePriceConfig).billing_units; if ( billingType == BillingType.UsageInAdvance && diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index da031e622..751bafd1f 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -11,6 +11,7 @@ import { type FullCustomer, type FullProduct, getProductItemDisplay, + isPrepaidPrice, type Price, type ProductItem, toApiFeature, @@ -21,7 +22,6 @@ import { notNullish } from "@/utils/genUtils.js"; import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js"; import { sortProductItems } from "../../pricecn/pricecnUtils.js"; import { getLargestInterval } from "../../prices/priceUtils/priceIntervalUtils.js"; -import { isPrepaidPrice } from "../../prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; import { getItemType } from "../../product-items/productItemUtils/getItemType.js"; import { itemToPriceOrTiers } from "../../product-items/productItemUtils.js"; import { isFreeProduct, isOneOff } from "../../productUtils.js"; @@ -149,8 +149,7 @@ export const getProductProperties = ({ has_trial: hasFreeTrial, updateable: product.prices.some( (p: Price) => - isPrepaidPrice({ price: p }) && - p.config.interval !== BillingInterval.OneOff, + isPrepaidPrice(p) && p.config.interval !== BillingInterval.OneOff, ), }); }; diff --git a/server/src/utils/checkUtils/checkCustomerCorrect.ts b/server/src/utils/checkUtils/checkCustomerCorrect.ts index d90305aca..bb935f45f 100644 --- a/server/src/utils/checkUtils/checkCustomerCorrect.ts +++ b/server/src/utils/checkUtils/checkCustomerCorrect.ts @@ -11,6 +11,7 @@ import { type FullCustomer, isFixedPrice, isOneOffPrice, + isPrepaidPrice, type Organization, } from "@autumn/shared"; import type { DrizzleCli } from "@server/db/initDrizzle"; @@ -31,7 +32,6 @@ import { getPriceEntitlement, getPriceOptions, } from "@server/internal/products/prices/priceUtils"; -import { isPrepaidPrice } from "@server/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice"; import { isFreeProduct } from "@server/internal/products/productUtils"; import type Stripe from "stripe"; import { formatUnixToDateTime, nullish } from "../genUtils"; @@ -411,12 +411,12 @@ export const checkCusSubCorrect = async ({ withEntity: !!cusProduct.internal_entity_id, isCheckout: false, apiVersion, - productOptions: cusProduct.quantity - ? { - product_id: product.id, - quantity: Number(cusProduct.quantity || 1), - } - : undefined, + // productOptions: cusProduct.quantity + // ? { + // product_id: product.id, + // quantity: Number(cusProduct.quantity || 1), + // } + // : undefined, }); if (res?.lineItem && nullish(res.lineItem.quantity)) { @@ -445,8 +445,7 @@ export const checkCusSubCorrect = async ({ priceStr: `${product.id}-${formatPrice({ price })}`, stripeProdId: product.processor?.id, autumnPrice: price, - canSkip: - isPrepaidPrice({ price }) && res?.lineItem?.quantity === 0, + canSkip: isPrepaidPrice(price) && res?.lineItem?.quantity === 0, }); } } 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 new file mode 100644 index 000000000..bb46a8e3f --- /dev/null +++ b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts @@ -0,0 +1,748 @@ +import { describe, expect, test } from "bun:test"; +import type { SubscriptionUpdateV0Params } from "@autumn/shared"; +import { createMockCtx } from "@tests/utils/mockUtils/contextMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockFeature } from "@tests/utils/mockUtils/featureMocks"; +import { + createMockCustomerPrice, + createMockFixedPrice, + createMockPrepaidPrice, +} from "@tests/utils/mockUtils/priceMocks"; +import { createMockFullProduct } from "@tests/utils/mockUtils/productMocks"; +import chalk from "chalk"; +import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities"; + +// ============ TESTS ============ + +describe( + chalk.yellowBright("computeSubscriptionUpdateFeatureQuantities"), + () => { + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + // No options provided + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + expect(result[0].quantity).toBe(100); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 50 }], + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + expect(result[0].quantity).toBe(50); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 200 }], + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + expect(result).toHaveLength(1); + expect(result[0].feature_id).toBe("credits"); + expect(result[0].quantity).toBe(200); + }); + }); + + 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: SubscriptionUpdateV0Params = { + 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 = computeSubscriptionUpdateFeatureQuantities({ + 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 + }); + }); + + 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: SubscriptionUpdateV0Params = { + 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 = computeSubscriptionUpdateFeatureQuantities({ + 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("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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + expect(result).toHaveLength(1); + // 200 / 50 = 4 + expect(result[0].quantity).toBe(4); + }); + + 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: SubscriptionUpdateV0Params = { + 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 = computeSubscriptionUpdateFeatureQuantities({ + 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); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + // No options - should inherit from current + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + 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); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + 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); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + 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); + }); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 50 }], + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + // 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + expect(result).toHaveLength(0); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + // Empty features array - feature won't be found + const ctx = createMockCtx({ features: [] }); + + expect(() => + computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }), + ).toThrow("computing feature quantities for price"); + }); + + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + // No options in params either + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + // 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [], // Explicitly empty + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + options: [{ feature_id: "credits", quantity: 0 }], + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + 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: SubscriptionUpdateV0Params = { + customer_id: "cus_test", + product_id: "prod_test", + }; + + const ctx = createMockCtx({ features: [feature] }); + + const result = computeSubscriptionUpdateFeatureQuantities({ + ctx, + fullProduct, + currentCustomerProduct: cusProduct, + params, + }); + + expect(result).toHaveLength(1); + expect(result[0].quantity).toBe(75); + }); + }); + }, +); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts index ad6b2906f..4a363f8ef 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingRollover } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; @@ -18,7 +18,7 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => { balance: 5000, }); - const newCusProduct = createMockCusProduct({ + const newCusProduct = createMockCustomerProduct({ cusEntitlements: [cusEnt], }); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts index 6d9da2b31..7d5b3ec9a 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers2.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingRollover } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; @@ -15,7 +15,7 @@ describe(chalk.yellowBright("applyExistingRollovers (no matching cusEnt)"), () = balance: 100, }); - const newCusProduct = createMockCusProduct({ + const newCusProduct = createMockCustomerProduct({ cusEntitlements: [cusEnt], }); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts index 749e0a691..26f9986bd 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingRollover } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; @@ -17,7 +17,7 @@ describe(chalk.yellowBright("applyExistingRollovers (multiple rollovers same fea balance: 5000, }); - const newCusProduct = createMockCusProduct({ + const newCusProduct = createMockCustomerProduct({ cusEntitlements: [cusEnt], }); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts index e8e7ae4e4..c9ef205ed 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingRollover } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; @@ -23,7 +23,7 @@ describe(chalk.yellowBright("applyExistingRollovers (multiple cusEnts, one match balance: 200, }); - const newCusProduct = createMockCusProduct({ + const newCusProduct = createMockCustomerProduct({ cusEntitlements: [cusEntA, cusEntB], }); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts index 803cc6971..9b70e3fee 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingRollover } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; @@ -27,7 +27,7 @@ describe(chalk.yellowBright("applyExistingRollovers (duplicate internal_feature_ balance: 200, }); - const newCusProduct = createMockCusProduct({ + const newCusProduct = createMockCustomerProduct({ cusEntitlements: [cusEntFirst, cusEntSecond], }); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts index 6fe8d2c45..8e649cff4 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingRollover } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; @@ -17,7 +17,7 @@ describe(chalk.yellowBright("applyExistingRollovers (zero balance, positive enti balance: 10, }); - const newCusProduct = createMockCusProduct({ + const newCusProduct = createMockCustomerProduct({ cusEntitlements: [cusEnt], }); diff --git a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts index 857b6305a..4d2bee5ed 100644 --- a/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts +++ b/server/tests/unit-tests/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers7.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingRollover } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers"; @@ -17,7 +17,7 @@ describe(chalk.yellowBright("applyExistingRollovers (zero balance, all-zero enti balance: 10, }); - const newCusProduct = createMockCusProduct({ + const newCusProduct = createMockCustomerProduct({ cusEntitlements: [cusEnt], }); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts index 0f35c8fd1..29b794a29 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages1.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingUsages } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages"; @@ -19,7 +19,7 @@ describe(chalk.yellowBright("applyExistingUsages"), () => { balance: 5000, // Initial balance before applying existing usages }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [cusEnt], }); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts index 344297c97..467ea38d9 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingUsages } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import { createMockEntity } from "@tests/utils/mockUtils/entityMocks"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages"; @@ -30,7 +30,7 @@ describe( balance: 10, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [cusEntA, cusEntB], }); @@ -90,7 +90,7 @@ describe( balance: 10, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [cusEntA], }); @@ -143,7 +143,7 @@ describe( balance: 2, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [cusEntA1, cusEntA2], }); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts index cffc0114c..a18e85478 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { EntInterval, type ExistingUsages } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages"; @@ -33,7 +33,7 @@ describe( nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days from now }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [monthlyCusEnt, lifetimeCusEnt], // Monthly first in array }); @@ -82,7 +82,7 @@ describe( usageAllowed: true, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [payPerUseCusEnt, prepaidCusEnt], // Pay-per-use first in array }); @@ -135,7 +135,7 @@ describe( nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [payPerUseMonthly, prepaidMonthly], // Random order }); @@ -199,7 +199,7 @@ describe( }); // Add in random order - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [payPerUseMonthly, prepaidLifetime, prepaidMonthly], }); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts index eca99f181..aa1ef57ae 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ExistingUsages } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import chalk from "chalk"; import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages"; @@ -25,7 +25,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [entityScopedCusEnt], }); @@ -67,7 +67,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { // No entityFeatureId - not entity-scoped }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [nonEntityScopedCusEnt], }); @@ -111,7 +111,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [entityScopedCusEnt], }); @@ -161,7 +161,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { }, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [entityScopedCusEnt], }); diff --git a/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts b/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts index 0e8cb390b..db5eafae4 100644 --- a/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { EntInterval } from "@autumn/shared"; import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; -import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks"; import { createMockRollover } from "@tests/utils/mockUtils/rolloverMocks"; import chalk from "chalk"; import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages"; @@ -32,7 +32,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [lifetimeCusEnt, monthlyCusEnt], }); @@ -73,7 +73,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { }, }); - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [topLevelCusEnt, entityScopedCusEnt], }); @@ -125,7 +125,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { }), ]; - const cusProduct = createMockCusProduct({ + const cusProduct = createMockCustomerProduct({ cusEntitlements: [cusEntWithRollover], }); diff --git a/server/tests/utils/mockUtils/contextMocks.ts b/server/tests/utils/mockUtils/contextMocks.ts new file mode 100644 index 000000000..9395f1459 --- /dev/null +++ b/server/tests/utils/mockUtils/contextMocks.ts @@ -0,0 +1,12 @@ +import type { Feature } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const createMockCtx = ({ + features, +}: { + features: Feature[]; +}): AutumnContext => + ({ + features, + }) as AutumnContext; + diff --git a/server/tests/utils/mockUtils/cusProductMocks.ts b/server/tests/utils/mockUtils/cusProductMocks.ts index 04187f496..05cf6f41a 100644 --- a/server/tests/utils/mockUtils/cusProductMocks.ts +++ b/server/tests/utils/mockUtils/cusProductMocks.ts @@ -1,15 +1,21 @@ import { CollectionMethod, CusProductStatus, + type FeatureOptions, type FullCusProduct, type FullCustomerEntitlement, + type FullCustomerPrice, } from "@autumn/shared"; import { createMockProduct } from "./productMocks"; -export const createMockCusProduct = ({ - cusEntitlements, +export const createMockCustomerProduct = ({ + customerEntitlements = [], + customerPrices = [], + options = [], }: { - cusEntitlements: FullCustomerEntitlement[]; + customerEntitlements?: FullCustomerEntitlement[]; + customerPrices?: FullCustomerPrice[]; + options?: FeatureOptions[]; }): FullCusProduct => ({ id: "cus_prod_test", internal_product_id: "prod_internal", @@ -25,7 +31,7 @@ export const createMockCusProduct = ({ trial_ends_at: null, canceled_at: null, ended_at: null, - options: [], + options, free_trial_id: null, collection_method: CollectionMethod.ChargeAutomatically, subscription_ids: [], @@ -33,8 +39,8 @@ export const createMockCusProduct = ({ quantity: 1, api_semver: null, is_custom: false, - customer_prices: [], - customer_entitlements: cusEntitlements, + customer_prices: customerPrices, + customer_entitlements: customerEntitlements, product: createMockProduct(), free_trial: null, }); diff --git a/server/tests/utils/mockUtils/priceMocks.ts b/server/tests/utils/mockUtils/priceMocks.ts new file mode 100644 index 000000000..95b1b2cc7 --- /dev/null +++ b/server/tests/utils/mockUtils/priceMocks.ts @@ -0,0 +1,69 @@ +import { + BillingInterval, + BillWhen, + type FullCustomerPrice, + Infinite, + type Price, + PriceType, +} from "@autumn/shared"; + +export const createMockPrepaidPrice = ({ + id, + featureId, + internalFeatureId, + billingUnits = 1, +}: { + id: string; + featureId: string; + internalFeatureId?: string; + billingUnits?: number; +}): Price => + ({ + id, + internal_product_id: "prod_internal", + org_id: "org_test", + created_at: Date.now(), + billing_type: "usage_in_advance", + is_custom: false, + entitlement_id: null, + proration_config: null, + config: { + type: PriceType.Usage, + bill_when: BillWhen.InAdvance, + billing_units: billingUnits, + internal_feature_id: internalFeatureId ?? `internal_${featureId}`, + feature_id: featureId, + usage_tiers: [{ to: Infinite, amount: 10 }], + interval: BillingInterval.Month, + }, + }) as Price; + +export const createMockFixedPrice = ({ id }: { id: string }): Price => + ({ + id, + internal_product_id: "prod_internal", + org_id: "org_test", + created_at: Date.now(), + billing_type: "fixed_cycle", + is_custom: false, + entitlement_id: null, + proration_config: null, + config: { + type: PriceType.Fixed, + amount: 100, + interval: BillingInterval.Month, + }, + }) as Price; + +export const createMockCustomerPrice = ({ + price, +}: { + price: Price; +}): FullCustomerPrice => ({ + id: `cus_price_${price.id}`, + internal_customer_id: "cus_internal", + customer_product_id: "cus_prod_test", + created_at: Date.now(), + price_id: price.id, + price, +}); diff --git a/server/tests/utils/mockUtils/productMocks.ts b/server/tests/utils/mockUtils/productMocks.ts index 67cc19d84..39142f0f0 100644 --- a/server/tests/utils/mockUtils/productMocks.ts +++ b/server/tests/utils/mockUtils/productMocks.ts @@ -1,4 +1,4 @@ -import { AppEnv } from "@autumn/shared"; +import { AppEnv, type FullProduct, type Price } from "@autumn/shared"; export const createMockProduct = () => ({ id: "prod_test", @@ -17,5 +17,27 @@ export const createMockProduct = () => ({ archived: false, }); - - +export const createMockFullProduct = ({ + prices, +}: { + prices: Price[]; +}): FullProduct => + ({ + id: "prod_test", + name: "Test Product", + description: null, + is_add_on: false, + is_default: false, + version: 1, + group: "test_group", + env: AppEnv.Sandbox, + internal_id: "prod_internal", + org_id: "org_test", + created_at: Date.now(), + processor: null, + base_variant_id: null, + archived: false, + prices, + entitlements: [], + free_trial: null, + }) as FullProduct; diff --git a/shared/api/models.ts b/shared/api/models.ts index 904a178ad..2b03716aa 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -77,6 +77,7 @@ export * from "./billing/checkout/prevVersions/checkoutParamsV0.js"; export * from "./billing/checkout/prevVersions/checkoutParamsV0.js"; export * from "./billing/checkout/prevVersions/checkoutResponseV0.js"; export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js"; +export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js"; export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js"; export * from "./common/customerData.js"; export * from "./common/entityData.js"; diff --git a/shared/index.ts b/shared/index.ts index 2149ed3fb..f1f2a1538 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -100,8 +100,8 @@ export * from "./models/attachModels/attachFunctionResponse.js"; export * from "./models/billingModels/cusProductActions.js"; export * from "./models/billingModels/existingRollovers.js"; export * from "./models/billingModels/existingUsages.js"; -export * from "./models/billingModels/index.js"; -export * from "./models/billingModels/initFullCusProductContext.js"; + +export * from "./models/billingModels/initFullCustomerProductContext.js"; export * from "./models/billingModels/invoicingModels/lineItem.js"; // Billing Models export * from "./models/billingModels/newProductAction.js"; diff --git a/shared/models/billingModels/initFullCusProductContext.ts b/shared/models/billingModels/initFullCustomerProductContext.ts similarity index 76% rename from shared/models/billingModels/initFullCusProductContext.ts rename to shared/models/billingModels/initFullCustomerProductContext.ts index ac4c3a0ff..e51ac1309 100644 --- a/shared/models/billingModels/initFullCusProductContext.ts +++ b/shared/models/billingModels/initFullCustomerProductContext.ts @@ -1,6 +1,6 @@ +import type { ExistingRollover } from "@models/billingModels/existingRollovers"; import type { ApiVersion } from "../../api/versionUtils/ApiVersion"; import type { FullCustomer } from "../cusModels/fullCusModel"; -import type { AttachReplaceable } from "../cusProductModels/cusEntModels/replaceableSchema"; import type { CollectionMethod, CusProductStatus, @@ -9,17 +9,17 @@ import type { FeatureOptions } from "../cusProductModels/cusProductModels"; import type { FullProduct } from "../productModels/productModels"; import type { ExistingUsages } from "./existingUsages"; -export interface InitFullCusProductContext { - fullCus: FullCustomer; - product: FullProduct; +export interface InitFullCustomerProductContext { + fullCustomer: FullCustomer; + fullProduct: FullProduct; featureQuantities: FeatureOptions[]; - replaceables: AttachReplaceable[]; // For customer entitlements existingUsages?: ExistingUsages; + existingRollovers?: ExistingRollover[]; } -export interface InitFullCusProductOptions { +export interface InitFullCustomerProductOptions { subscriptionId?: string; subscriptionScheduleId?: string; isCustom?: boolean; diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 2a188057e..3535d9ef8 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -9,3 +9,4 @@ export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem"; export * from "./invoicingUtils/lineItemUtils/priceToLineAmount"; export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount"; export * from "./invoicingUtils/prorationUtils/applyProration"; +export * from "./usageUtils/roundUsageToNearestBillingUnit"; diff --git a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/featureUsageToDescription.ts b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/featureUsageToDescription.ts index 828c8bb6b..d161d535b 100644 --- a/shared/utils/billingUtils/invoicingUtils/descriptionUtils/featureUsageToDescription.ts +++ b/shared/utils/billingUtils/invoicingUtils/descriptionUtils/featureUsageToDescription.ts @@ -1,6 +1,6 @@ import type { Feature } from "../../../../models/featureModels/featureModels"; import { getSingularAndPlural, numberWithCommas } from "../../../displayUtils"; -import { roundUsageToNearestBillingUnit } from "../lineItemUtils/roundUsageToNearestBillingUnit"; +import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit"; /** * Generates base usage description for a feature. diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index e7fbed5ec..10369a24a 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -6,8 +6,10 @@ import { cusEntToStripeIds } from "../../../cusEntUtils/convertCusEntUtils/cusEn import { cusEntToInvoiceOverage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceOverage"; import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceUsage"; import { cusEntToCusPrice } from "../../../productUtils/convertUtils"; -import { isPrepaidPrice } from "../../../productUtils/priceUtils"; -import { isConsumablePrice } from "../../../productUtils/priceUtils/classifyPriceUtils"; +import { + isConsumablePrice, + isPrepaidPrice, +} from "../../../productUtils/priceUtils/classifyPriceUtils"; import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription"; import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount"; import { buildLineItem } from "./buildLineItem"; @@ -38,7 +40,7 @@ export const usagePriceToLineItem = ({ // 1. Get overage let overage = 0; - if (isPrepaidPrice({ price: cusPrice.price })) { + if (isPrepaidPrice(cusPrice.price)) { overage = cusEntToPrepaidQuantity({ cusEnt }); } else { overage = cusEntToInvoiceOverage({ cusEnt }); @@ -46,7 +48,7 @@ export const usagePriceToLineItem = ({ // 2. Get usage let usage = 0; - if (isPrepaidPrice({ price: cusPrice.price })) { + if (isPrepaidPrice(cusPrice.price)) { usage = cusEntToPrepaidQuantity({ cusEnt }); } else { usage = cusEntToInvoiceUsage({ cusEnt }); diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts index 6bbebf36c..866cbcbad 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -2,7 +2,7 @@ import { Decimal } from "decimal.js"; import type { Price } from "../../../../models/productModels/priceModels/priceModels"; import { Infinite } from "../../../../models/productModels/productEnums"; import { nullish } from "../../../utils"; -import { roundUsageToNearestBillingUnit } from "./roundUsageToNearestBillingUnit"; +import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit"; export const tiersToLineAmount = ({ price, diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/roundUsageToNearestBillingUnit.ts b/shared/utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit.ts similarity index 100% rename from shared/utils/billingUtils/invoicingUtils/lineItemUtils/roundUsageToNearestBillingUnit.ts rename to shared/utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit.ts diff --git a/shared/utils/cusEntUtils/balanceUtils.ts b/shared/utils/cusEntUtils/balanceUtils.ts index 990e03922..12f2d3fb9 100644 --- a/shared/utils/cusEntUtils/balanceUtils.ts +++ b/shared/utils/cusEntUtils/balanceUtils.ts @@ -109,8 +109,6 @@ export const getMaxOverage = ({ const usageLimit = cusEnt.entitlement.usage_limit; if (nullish(usageLimit)) return undefined; - // const cusPrice = cusEntToCusPrice({ cusEnt }); - // if (cusPrice && isPrepaidPrice({ price: cusPrice.price })) return undefined; if (!cusEnt.usage_allowed) return undefined; const maxOverage = new Decimal(usageLimit) diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts index 6e83cc2fe..5392b27f2 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts @@ -1,7 +1,7 @@ import { Decimal } from "decimal.js"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { cusEntToCusPrice } from "../../productUtils/convertUtils.js"; -import { isPrepaidPrice } from "../../productUtils/priceUtils.js"; +import { isPrepaidPrice } from "../../productUtils/priceUtils/classifyPriceUtils.js"; export const cusEntToPrepaidQuantity = ({ cusEnt, @@ -11,7 +11,7 @@ export const cusEntToPrepaidQuantity = ({ // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); - if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return 0; + if (!cusPrice || !isPrepaidPrice(cusPrice.price)) return 0; // 3. Get quantity const options = cusEnt.customer_product.options.find( diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index 28f7ef5ab..cb188d7b7 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -4,7 +4,7 @@ import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; -import { isPrepaidPrice } from "../productUtils/priceUtils.js"; +import { isPrepaidPrice } from "../productUtils/priceUtils/classifyPriceUtils.js"; export const formatCusEnt = ({ cusEnt, @@ -80,7 +80,7 @@ export const isPrepaidCusEnt = ({ }) => { // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); - if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return false; + if (!cusPrice || !isPrepaidPrice(cusPrice.price)) return false; // 3. Get quantity const options = cusEnt.customer_product.options.find( diff --git a/shared/utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature.ts b/shared/utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature.ts new file mode 100644 index 000000000..5773caa2d --- /dev/null +++ b/shared/utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature.ts @@ -0,0 +1,16 @@ +import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels"; +import type { Feature } from "@models/featureModels/featureModels"; +import { isPrepaidPrice } from "@utils/productUtils/priceUtils/classifyPriceUtils"; + +export const findPrepaidCusPriceByFeature = ({ + customerPrices, + feature, +}: { + customerPrices: FullCustomerPrice[]; + feature: Feature; +}) => { + return customerPrices.find((cp) => { + if (!isPrepaidPrice(cp.price)) return false; + return cp.price.config.internal_feature_id === feature.internal_id; + }); +}; diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts new file mode 100644 index 000000000..39d8e405e --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.ts @@ -0,0 +1,60 @@ +import type { + FeatureOptions, + FullCusProduct, +} from "@models/cusProductModels/cusProductModels"; +import type { Feature } from "@models/featureModels/featureModels"; +import type { Price } from "@models/productModels/priceModels/priceModels"; +import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit"; +import { findPrepaidCusPriceByFeature } from "@utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature"; +import { Decimal } from "decimal.js"; +import { cusProductToFeatureOptions } from "./cusProductToFeatureOptions"; + +/** + * Get the feature options from a customer product, converted to new price billing units + */ +export const cusProductToConvertedFeatureOptions = ({ + cusProduct, + feature, + newPrice, +}: { + cusProduct: FullCusProduct; + feature: Feature; + newPrice: Price; +}): FeatureOptions | undefined => { + const currentOption = cusProductToFeatureOptions({ cusProduct, feature }); + + if (!currentOption?.quantity) return undefined; + + const oldCusPrice = findPrepaidCusPriceByFeature({ + customerPrices: cusProduct.customer_prices, + feature, + }); + + // If no old price found, we can't interpret the stored quantity + if (!oldCusPrice) return undefined; + + const oldBillingUnits = oldCusPrice.price.config.billing_units ?? 1; + const newBillingUnits = newPrice.config.billing_units ?? 1; + + // 1. Multiply by old billing units to get actual quantity + const actualQuantity = new Decimal(currentOption.quantity) + .mul(oldBillingUnits) + .toNumber(); + + // 2. Round to nearest new billing unit + const roundedQuantity = roundUsageToNearestBillingUnit({ + usage: actualQuantity, + billingUnits: newBillingUnits, + }); + + // 3. Divide by new billing units + const convertedQuantity = new Decimal(roundedQuantity) + .div(newBillingUnits) + .toNumber(); + + return { + internal_feature_id: feature.internal_id, + feature_id: feature.id, + quantity: convertedQuantity, + }; +}; diff --git a/shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts b/shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts new file mode 100644 index 000000000..3243466c4 --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/cusProductToFeatureOptions.ts @@ -0,0 +1,23 @@ +import type { + FeatureOptions, + FullCusProduct, +} from "@models/cusProductModels/cusProductModels"; +import type { Feature } from "@models/featureModels/featureModels"; +/** + * Get the feature quantity for a cus product + * @param cusProduct - The cus product to get the feature quantity for + * @returns The feature quantity + */ +export const cusProductToFeatureOptions = ({ + cusProduct, + feature, +}: { + cusProduct: FullCusProduct; + feature: Feature; +}): FeatureOptions | undefined => { + return cusProduct.options.find( + (option) => + option.internal_feature_id === feature.internal_id || + option.feature_id === feature.id, + ); +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index fb11c0627..646c8a405 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -31,6 +31,8 @@ export * from "./cusPriceUtils/convertCusPriceUtils.js"; export * from "./cusPriceUtils/findCusPriceUtils.js"; // Cus product utils export * from "./cusProductUtils/classifyCusProduct.js"; +export * from "./cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.js"; +export * from "./cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js"; export * from "./cusProductUtils/convertCusProduct.js"; export * from "./cusProductUtils/cusProductConstants.js"; export * from "./cusProductUtils/cusProductUtils.js"; @@ -61,6 +63,7 @@ export * from "./productUtils/entUtils/formatEntUtils.js"; export * from "./productUtils/isProductUpgrade.js"; export * from "./productUtils/priceUtils/classifyPriceUtils.js"; export * from "./productUtils/priceUtils/convertAmountUtils.js"; +export * from "./productUtils/priceUtils/convertPriceUtils.js"; export * from "./productUtils/priceUtils/formatPriceUtils.js"; export * from "./productUtils/priceUtils.js"; export * from "./productV2Utils/mapToProductV2.js"; diff --git a/shared/utils/productUtils/priceUtils.ts b/shared/utils/productUtils/priceUtils.ts index b7eb504a8..6729b3985 100644 --- a/shared/utils/productUtils/priceUtils.ts +++ b/shared/utils/productUtils/priceUtils.ts @@ -48,11 +48,6 @@ export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => { return BillingType.UsageInArrear; }; -export const isPrepaidPrice = ({ price }: { price: Price }) => { - const billingType = getBillingType(price.config); - return billingType === BillingType.UsageInAdvance; -}; - export const hasPrepaidPrice = ({ prices, excludeOneOff, diff --git a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts index 7d1c894e7..c8b3b2c96 100644 --- a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts @@ -66,3 +66,10 @@ export const isAllocatedPrice = ( const billingType = getBillingType(price.config); return billingType === BillingType.InArrearProrated; }; + +export const isPrepaidPrice = ( + price: Price, +): price is Price & { config: UsagePriceConfig } => { + const billingType = getBillingType(price.config); + return billingType === BillingType.UsageInAdvance; +}; diff --git a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts index e69de29bb..2c9077d45 100644 --- a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts @@ -0,0 +1,30 @@ +import type { Feature } from "@models/featureModels/featureModels"; +import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; +import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; +import type { Price } from "@models/productModels/priceModels/priceModels"; +import { priceToEnt } from "@utils/productUtils/convertUtils"; + +export const priceToFeature = ({ + price, + ents, + features, +}: { + price: Price; + ents?: EntitlementWithFeature[]; + features?: Feature[]; +}) => { + if (!features && !ents) { + throw new Error("priceToFeature requires either ents or features as arg"); + } + + if (features) { + return features.find( + (f) => + f.internal_id === + (price.config as UsagePriceConfig).internal_feature_id, + ); + } + + const ent = priceToEnt({ price, entitlements: ents ?? [] }); + return ent?.feature; +};