diff --git a/server/src/internal/billing/v2/billingContext.ts b/server/src/internal/billing/v2/billingContext.ts index 4ec4a5bac..b07d761d1 100644 --- a/server/src/internal/billing/v2/billingContext.ts +++ b/server/src/internal/billing/v2/billingContext.ts @@ -1,7 +1,9 @@ import type { + Entitlement, FeatureOptions, FullCusProduct, FullProduct, + Price, } from "@autumn/shared"; import type { FullCustomer } from "@shared/models/cusModels/fullCusModel"; import type Stripe from "stripe"; @@ -32,6 +34,10 @@ export interface BillingContext { // Feature quantities featureQuantities: FeatureOptions[]; + + // Unforunately, need to add custom prices, custom entitlements and free trial here, because it's determined in the setup step. + customPrices: Price[]; + customEnts: Entitlement[]; } export interface UpdateSubscriptionBillingContext extends BillingContext { diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/computeCustomFullProduct.ts b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts similarity index 74% rename from server/src/internal/billing/v2/compute/computeAutumnUtils/computeCustomFullProduct.ts rename to server/src/internal/billing/v2/setup/setupCustomFullProduct.ts index f43ed032b..d67279799 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/computeCustomFullProduct.ts +++ b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts @@ -1,9 +1,9 @@ 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"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems"; -export const computeCustomFullProduct = async ({ +export const setupCustomFullProduct = async ({ ctx, customItems, currentFullProduct, diff --git a/server/src/internal/billing/v2/utils/parseFeatureQuantitiesParams.ts b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts similarity index 92% rename from server/src/internal/billing/v2/utils/parseFeatureQuantitiesParams.ts rename to server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts index b7a3ab2a6..4d8eee01f 100644 --- a/server/src/internal/billing/v2/utils/parseFeatureQuantitiesParams.ts +++ b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts @@ -11,10 +11,10 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions"; /** - * Parses feature quantities from params, iterating over all prepaid prices. + * Sets up feature quantities context from params, iterating over all prepaid prices. * For each prepaid price, uses new quantity from params or falls back to existing subscription. */ -export const parseFeatureQuantitiesParams = ({ +export const setupFeatureQuantitiesContext = ({ ctx, featureQuantitiesParams, fullProduct, diff --git a/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts b/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts new file mode 100644 index 000000000..8d53cc639 --- /dev/null +++ b/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts @@ -0,0 +1,28 @@ +import type { UpdateSubscriptionV0Params } from "@autumn/shared"; +import type { AutumnContext } from "@server/honoUtils/HonoEnv"; +import { CusService } from "@server/internal/customers/CusService"; + +export const setupFullCustomerContext = async ({ + ctx, + params, + autoCreateCustomer = false, +}: { + ctx: AutumnContext; + params: UpdateSubscriptionV0Params; + autoCreateCustomer?: boolean; +}) => { + const { db, org, env } = ctx; + const { customer_id: customerId } = params; + + const fullCustomer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + withSubs: true, + withEntities: true, + entityId: params.entity_id ?? undefined, + }); + + return fullCustomer; +}; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts index e74d17985..038cbc4c0 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -1,6 +1,5 @@ import { CusProductStatus, - cusProductToProduct, type UpdateSubscriptionV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; @@ -9,8 +8,6 @@ import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutum import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan"; import { computeCustomPlanFreeTrial } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFreeTrial"; import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; -import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams"; -import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct"; export const computeCustomPlan = async ({ ctx, @@ -21,23 +18,10 @@ export const computeCustomPlan = async ({ updateSubscriptionContext: UpdateSubscriptionBillingContext; params: UpdateSubscriptionV0Params; }) => { - const { customerProduct } = updateSubscriptionContext; + const { customerProduct, customPrices, customEnts } = + updateSubscriptionContext; - const currentFullProduct = cusProductToProduct({ - cusProduct: customerProduct, - }); - - const { - fullProduct: customFullProduct, - customPrices, - customEnts, - } = await computeCustomFullProduct({ - ctx, - currentFullProduct, - customItems: params.items, - }); - - updateSubscriptionContext.fullProducts = [customFullProduct]; + const customFullProduct = updateSubscriptionContext.fullProducts[0]; // 2. Compute the custom trial details const { freeTrialPlan, customFreeTrial } = computeCustomPlanFreeTrial({ @@ -50,13 +34,6 @@ export const computeCustomPlan = async ({ updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt; } - updateSubscriptionContext.featureQuantities = parseFeatureQuantitiesParams({ - ctx, - featureQuantitiesParams: params, - fullProduct: customFullProduct, - currentCustomerProduct: customerProduct, - }); // re-parse feature quantities for new custom product - // 3. Compute the new customer product const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({ ctx, diff --git a/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 44428399c..e43f1476d 100644 --- a/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -1,15 +1,10 @@ -import { - cusProductToProduct, - InternalError, - secondsToMs, - type UpdateSubscriptionV0Params, -} from "@autumn/shared"; +import { secondsToMs, type UpdateSubscriptionV0Params } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; -import { CusService } from "../../../../customers/CusService"; +import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; +import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; +import { setupUpdateSubscriptionProductContext } from "@/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionProductContext"; import type { UpdateSubscriptionBillingContext } from "../../billingContext"; -import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams"; -import { findTargetCustomerProduct } from "./findTargetCustomerProduct"; /** * Fetch the context for updating a subscription @@ -24,32 +19,23 @@ export const setupUpdateSubscriptionBillingContext = async ({ ctx: AutumnContext; params: UpdateSubscriptionV0Params; }): Promise => { - const { db, org, env } = ctx; - const { customer_id: customerId, product_id: productId } = params; - - const fullCustomer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env, - withSubs: true, - withEntities: true, - entityId: params.entity_id ?? undefined, - }); - - const targetCustomerProduct = findTargetCustomerProduct({ + const fullCustomer = await setupFullCustomerContext({ + ctx, params, - fullCustomer, }); - if (!targetCustomerProduct) { - throw new InternalError({ - message: `[API Subscription Update] Target customer product not found: ${productId}`, + const { customerProduct, fullProduct, customPrices, customEnts } = + await setupUpdateSubscriptionProductContext({ + ctx, + fullCustomer, + params, }); - } - const fullProduct = cusProductToProduct({ - cusProduct: targetCustomerProduct, + const featureQuantities = setupFeatureQuantitiesContext({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: customerProduct, }); const { @@ -61,14 +47,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ } = await setupStripeBillingContext({ ctx, fullCustomer, - targetCustomerProduct, - }); - - const featureQuantities = parseFeatureQuantitiesParams({ - ctx, - featureQuantitiesParams: params, - fullProduct, - currentCustomerProduct: targetCustomerProduct, + targetCustomerProduct: customerProduct, }); const currentEpochMs = testClockFrozenTime ?? Date.now(); @@ -88,7 +67,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ return { fullCustomer, fullProducts: [fullProduct], - customerProduct: targetCustomerProduct, + customerProduct, stripeSubscription, stripeSubscriptionSchedule, stripeCustomer, @@ -98,5 +77,8 @@ export const setupUpdateSubscriptionBillingContext = async ({ billingCycleAnchorMs: billingCycleAnchorMs ?? "now", invoiceMode, featureQuantities, + + customPrices, + customEnts, }; }; diff --git a/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts new file mode 100644 index 000000000..0a2b27ad0 --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts @@ -0,0 +1,51 @@ +import { + cusProductToProduct, + type FullCustomer, + InternalError, + type UpdateSubscriptionV0Params, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { setupCustomFullProduct } from "../../setup/setupCustomFullProduct"; +import { findTargetCustomerProduct } from "./findTargetCustomerProduct"; + +export const setupUpdateSubscriptionProductContext = async ({ + ctx, + fullCustomer, + params, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + params: UpdateSubscriptionV0Params; +}) => { + const targetCustomerProduct = findTargetCustomerProduct({ + params, + fullCustomer, + }); + + if (!targetCustomerProduct) { + throw new InternalError({ + message: `[API Subscription Update] Target customer product not found: ${params.product_id}`, + }); + } + + const fullProduct = cusProductToProduct({ + cusProduct: targetCustomerProduct, + }); + + const { + fullProduct: customFullProduct, + customPrices, + customEnts, + } = await setupCustomFullProduct({ + ctx, + currentFullProduct: fullProduct, + customItems: params.items, + }); + + return { + customerProduct: targetCustomerProduct, + fullProduct: customFullProduct, + customPrices, + customEnts, + }; +}; diff --git a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts index 53454e49a..e80d3adf4 100644 --- a/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts +++ b/server/src/internal/customers/attach/attachUtils/mapOptionsList.ts @@ -12,7 +12,7 @@ import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; /** - * @deprecated Use parseFeatureQuantitiesParams from billing/v2/utils instead + * @deprecated Use setupFeatureQuantitiesContext from billing/v2/setup instead */ export const mapOptionsList = ({ optionsInput, diff --git a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts index 253448bc8..e4272c687 100644 --- a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts +++ b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts @@ -10,11 +10,11 @@ import { } from "@tests/utils/mockUtils/priceMocks"; import { createMockFullProduct } from "@tests/utils/mockUtils/productMocks"; import chalk from "chalk"; -import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams"; +import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; // ============ TESTS ============ -describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { +describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { describe("basic quantity inheritance", () => { test("1. current has quantity, new params has none → uses current", () => { const feature = createMockFeature({ @@ -47,7 +47,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -81,7 +81,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -123,7 +123,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -203,7 +203,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { features: [creditsFeature, seatsFeature, storageFeature], }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -246,7 +246,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -282,7 +282,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -317,7 +317,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -373,7 +373,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -429,7 +429,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -477,7 +477,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -517,7 +517,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -542,7 +542,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -570,7 +570,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [] }); expect(() => - parseFeatureQuantitiesParams({ + setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -601,7 +601,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -643,7 +643,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -686,7 +686,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct, @@ -731,7 +731,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { const ctx = createMockCtx({ features: [feature] }); - const result = parseFeatureQuantitiesParams({ + const result = setupFeatureQuantitiesContext({ ctx, featureQuantitiesParams: params, fullProduct,