diff --git a/bun.lock b/bun.lock index fa06d1144..71ad2200c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "autumn", diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts index 41d9472af..1091aecb4 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts @@ -1,12 +1,100 @@ import type { AttachParamsV1, BillingContextOverride, + Entitlement, + FullCustomer, + FullProduct, MultiAttachParamsV0, + UpdateSubscriptionV1Params, +} from "@autumn/shared"; +import { + BillingVersion, + cusProductToProduct, + isCustomizePlanPatchStyle, + type PatchContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { setupPatchContext } from "@/internal/billing/v2/setup/patch"; +import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; +import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils"; import { ProductService } from "@/internal/products/ProductService"; import { setupCustomFullProduct } from "../../../setup/setupCustomFullProduct"; +const patchContextToFullProduct = ({ + ctx, + patchContext, +}: { + ctx: AutumnContext; + patchContext: PatchContext; +}): FullProduct => { + const fullProduct = cusProductToProduct({ + cusProduct: patchContext.finalCustomerProduct, + }); + + return { + ...fullProduct, + prices: [...fullProduct.prices, ...patchContext.customPrices], + entitlements: getEntsWithFeature({ + ents: [ + ...fullProduct.entitlements, + ...(patchContext.customEntitlements as Entitlement[]), + ], + features: ctx.features, + }), + }; +}; + +const setupAttachPatchProductContext = ({ + ctx, + params, + fullCustomer, + fullProduct, + currentEpochMs, +}: { + ctx: AutumnContext; + params: AttachParamsV1 | MultiAttachParamsV0["plans"][number]; + fullCustomer: FullCustomer; + fullProduct: FullProduct; + currentEpochMs?: number; +}) => { + if (!isCustomizePlanPatchStyle(params.customize)) return undefined; + + const baseCustomerProduct = initFullCustomerProduct({ + ctx, + initContext: { + fullCustomer, + fullProduct, + featureQuantities: [], + resetCycleAnchor: currentEpochMs ?? Date.now(), + freeTrial: null, + now: currentEpochMs ?? Date.now(), + billingVersion: BillingVersion.V2, + }, + }); + + const patchParams: UpdateSubscriptionV1Params = { + customer_id: fullCustomer.id ?? fullCustomer.internal_id, + plan_id: params.plan_id, + customize: params.customize, + version: params.version, + }; + + const patchContext = setupPatchContext({ + ctx, + params: patchParams, + customerProduct: baseCustomerProduct, + fullProduct, + }); + + if (!patchContext) return undefined; + + return { + fullProduct: patchContextToFullProduct({ ctx, patchContext }), + customPrices: patchContext.customPrices, + customEnts: patchContext.customEntitlements, + }; +}; + /** * Loads the product being attached, handling version and custom items params. */ @@ -14,10 +102,14 @@ export const setupAttachProductContext = async ({ ctx, params, contextOverride = {}, + fullCustomer, + currentEpochMs, }: { ctx: AutumnContext; params: AttachParamsV1 | MultiAttachParamsV0["plans"][number]; contextOverride?: BillingContextOverride; + fullCustomer?: FullCustomer; + currentEpochMs?: number; }) => { const { productContext } = contextOverride; if (productContext) return productContext; @@ -35,6 +127,20 @@ export const setupAttachProductContext = async ({ logger: ctx.logger, }); + if (fullCustomer) { + const patchProductContext = setupAttachPatchProductContext({ + ctx, + params, + fullCustomer, + fullProduct, + currentEpochMs, + }); + + if (patchProductContext) { + return patchProductContext; + } + } + // 2. Handle custom items if provided const { fullProduct: customFullProduct, diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts index b2102ab76..37ba77a28 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -14,8 +14,8 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupAttachProductContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachProductContext"; import { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; -import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling"; +import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext"; @@ -131,6 +131,7 @@ export const setupImmediateMultiProductBillingContext = async ({ customize: plan.customize, version: plan.version, }, + fullCustomer, }); const { currentCustomerProduct, scheduledCustomerProduct } = diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index 165b4d389..302709dad 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -114,6 +114,8 @@ export const setupCreateScheduleBillingContext = async ({ const scheduledPhaseContexts = await setupScheduledProductsContext({ ctx, phases: futurePhases, + fullCustomer: billingContext.fullCustomer, + currentEpochMs: billingContext.currentEpochMs, }); const scheduledCustomPrices = scheduledPhaseContexts.flatMap((phase) => diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts index 769e5536c..36f0ef5ed 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupScheduledProductsContext.ts @@ -1,5 +1,6 @@ import type { CreateScheduleParamsV0, + FullCustomer, ScheduledPhaseContext, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -11,9 +12,13 @@ import { validateCreateSchedulePhasePlans } from "../errors/validateCreateSchedu export const setupScheduledProductsContext = async ({ ctx, phases, + fullCustomer, + currentEpochMs, }: { ctx: AutumnContext; phases: CreateScheduleParamsV0["phases"][number][]; + fullCustomer: FullCustomer; + currentEpochMs: number; }): Promise => Promise.all( phases.map(async (phase, index) => { @@ -28,6 +33,8 @@ export const setupScheduledProductsContext = async ({ } = await setupAttachProductContext({ ctx, params: plan, + fullCustomer, + currentEpochMs, }); const featureQuantities = setupFeatureQuantitiesContext({ diff --git a/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts b/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts index bbfd348b0..f6e3f7bdf 100644 --- a/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts +++ b/server/tests/integration/billing/create-schedule/params/create-schedule-customize.test.ts @@ -1,11 +1,13 @@ import { expect, test } from "bun:test"; import { + BillingMethod, CusProductStatus, customerEntitlements, customerProducts, ms, schedulePhases, } from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { items } from "@tests/utils/fixtures/items"; import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; @@ -16,10 +18,14 @@ import chalk from "chalk"; import { eq } from "drizzle-orm"; import { getCustomerProductEntitlementBalances, + getCustomerProductFeaturePriceAmounts, getCustomerProductPriceAmounts, getRequiredScheduleId, } from "../utils/createScheduleTestHelpers"; +// Contract: V2.2 schedule customize accepts PATCH-style add_items/remove_items. +// Contract: patched items/prices apply to immediate and future cusProducts, including Stripe. + test.concurrent( `${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, async () => { @@ -76,6 +82,180 @@ test.concurrent( }, ); +test.concurrent( + `${chalk.yellowBright("create-schedule: patch customize applies to immediate customer products and Stripe")}`, + async () => { + const base = products.base({ + id: "create-schedule-patch-immediate", + items: [ + items.monthlyPrice(), + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyWords({ includedUsage: 50 }), + ], + }); + + const { customerId, autumnV2_2, ctx } = await initScenario({ + customerId: "create-schedule-patch-immediate", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const response = await autumnV2_2.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: Date.now(), + plans: [ + { + plan_id: base.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 42 }), + remove_items: [{ feature_id: TestFeature.Messages }], + add_items: [itemsV2.dashboard()], + }, + }, + ], + }, + ], + }); + + const customerProductId = response.phases[0]!.customer_product_ids[0]!; + const customerProduct = await ctx.db.query.customerProducts.findFirst({ + where: eq(customerProducts.id, customerProductId), + }); + + expect(customerProduct?.is_custom).toBe(true); + expect( + await getCustomerProductPriceAmounts({ ctx, customerProductId }), + ).toEqual([42]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Words, balance: 50 }, + { feature_id: TestFeature.Dashboard, balance: 0 }, + ]), + ); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId, + }), + ).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ feature_id: TestFeature.Messages }), + ]), + ); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + +test.concurrent( + `${chalk.yellowBright("create-schedule: patch customize applies to future customer products and Stripe schedule")}`, + async () => { + const base = products.base({ + id: "create-schedule-patch-future", + items: [ + items.monthlyPrice(), + items.monthlyMessages({ includedUsage: 100 }), + items.prepaid({ + featureId: TestFeature.Words, + price: 10, + billingUnits: 100, + }), + ], + }); + + const { customerId, autumnV2_2, ctx } = await initScenario({ + customerId: "create-schedule-patch-future", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const now = Date.now(); + const response = await autumnV2_2.billing.createSchedule({ + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: base.id }], + }, + { + starts_at: now + ms.days(30), + plans: [ + { + plan_id: base.id, + customize: { + remove_items: [ + { + feature_id: TestFeature.Words, + billing_method: BillingMethod.Prepaid, + }, + ], + add_items: [ + itemsV2.prepaidWords({ amount: 7, billingUnits: 100 }), + ], + }, + feature_quantities: [ + { + feature_id: TestFeature.Words, + quantity: 300, + }, + ], + }, + ], + }, + ], + }); + + const futureCustomerProductId = + response.phases[1]!.customer_product_ids[0]!; + const futureCustomerProduct = await ctx.db.query.customerProducts.findFirst( + { + where: eq(customerProducts.id, futureCustomerProductId), + }, + ); + + expect(futureCustomerProduct?.is_custom).toBe(true); + expect( + await getCustomerProductPriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual([20]); + expect( + await getCustomerProductFeaturePriceAmounts({ + ctx, + customerProductId: futureCustomerProductId, + featureId: TestFeature.Words, + }), + ).toEqual([7]); + expect( + await getCustomerProductEntitlementBalances({ + ctx, + customerProductId: futureCustomerProductId, + }), + ).toEqual( + expect.arrayContaining([ + { feature_id: TestFeature.Messages, balance: 100 }, + { feature_id: TestFeature.Words, balance: 300 }, + ]), + ); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + }, +); + test.concurrent( `${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, async () => { diff --git a/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts b/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts index 9dbb2d9fc..06ec73be5 100644 --- a/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts +++ b/server/tests/integration/billing/create-schedule/utils/createScheduleTestHelpers.ts @@ -51,6 +51,40 @@ export const getCustomerProductPriceAmounts = async ({ .filter((amount): amount is number => typeof amount === "number") .sort((a, b) => a - b); +export const getCustomerProductFeaturePriceAmounts = async ({ + ctx, + customerProductId, + featureId, +}: { + ctx: Ctx; + customerProductId: string; + featureId: string; +}) => + ( + await ctx.db + .select({ config: prices.config }) + .from(customerPrices) + .innerJoin(prices, eq(customerPrices.price_id, prices.id)) + .where(eq(customerPrices.customer_product_id, customerProductId)) + ) + .flatMap((row) => { + const config = row.config; + if ( + !config || + !("feature_id" in config) || + config.feature_id !== featureId || + !("usage_tiers" in config) || + !Array.isArray(config.usage_tiers) + ) { + return []; + } + + return config.usage_tiers + .map((tier) => tier.amount) + .filter((amount): amount is number => typeof amount === "number"); + }) + .sort((a, b) => a - b); + export const getCustomerProductEntitlementBalances = async ({ ctx, customerProductId, diff --git a/shared/api/billing/common/customizePlan/customizePlanV1.ts b/shared/api/billing/common/customizePlan/customizePlanV1.ts index 9ff2f6b7f..dd0b7d21a 100644 --- a/shared/api/billing/common/customizePlan/customizePlanV1.ts +++ b/shared/api/billing/common/customizePlan/customizePlanV1.ts @@ -5,6 +5,7 @@ import { PlanItemFilterSchema } from "@api/products/items/filter/planItemFilter" import { ResetInterval } from "@models/productModels/intervals/resetInterval"; import { z } from "zod/v4"; +/** Deprecated: use remove_items and add_items to replace plan items. */ export const UpdatePlanItemParamsV1Schema = z .object({ filter: PlanItemFilterSchema.meta({ @@ -19,15 +20,17 @@ export const UpdatePlanItemParamsV1Schema = z description: "Override the matched item's reset interval. Use 'one_off' for non-resetting balances.", }), - }) - .meta({ - title: "UpdatePlanItem", - description: - "Deprecated. Use remove_items and add_items to replace plan items.", - deprecated: true, - }); + }) + .meta({ + title: "UpdatePlanItem", + description: + "Deprecated. Use remove_items and add_items to replace plan items.", + deprecated: true, + }); -export type UpdatePlanItemParamsV1 = z.infer; +export type UpdatePlanItemParamsV1 = z.infer< + typeof UpdatePlanItemParamsV1Schema +>; export const CustomizePlanV1Schema = z .object({ @@ -35,22 +38,22 @@ export const CustomizePlanV1Schema = z description: "Override the base price of the plan. Pass null to remove the base price.", }), - items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ - description: - "Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.", - }), + items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ + description: + "Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.", + }), add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ description: "Items to add to the plan.", }), remove_items: z.array(PlanItemFilterSchema).optional().meta({ description: "Filters selecting items to remove from the plan.", }), - update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ - description: - "Deprecated. Use remove_items and add_items to replace matched plan items.", - internal: true, - deprecated: true, - }), + update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({ + description: + "Deprecated. Use remove_items and add_items to replace matched plan items.", + internal: true, + deprecated: true, + }), free_trial: FreeTrialParamsV1Schema.nullable().optional().meta({ description: "Override the plan's default free trial. Pass an object to set a custom trial, or null to remove the trial entirely.", @@ -64,10 +67,10 @@ export const CustomizePlanV1Schema = z data.add_items !== undefined || data.remove_items !== undefined || data.update_items !== undefined, - { - message: - "When using customize, at least one of price, items, add_items, remove_items, deprecated update_items, or free_trial must be provided", - }, + { + message: + "When using customize, at least one of price, items, add_items, remove_items, deprecated update_items, or free_trial must be provided", + }, ) .refine( (data) => @@ -77,10 +80,10 @@ export const CustomizePlanV1Schema = z data.remove_items !== undefined || data.update_items !== undefined) ), - { - message: - "customize.items (PUT-style) cannot be combined with add_items / remove_items / deprecated update_items (PATCH-style); pick one approach", - }, + { + message: + "customize.items (PUT-style) cannot be combined with add_items / remove_items / deprecated update_items (PATCH-style); pick one approach", + }, ) .meta({ title: "CustomizePlan", diff --git a/shared/api/billing/createSchedule/createScheduleParamsV0.ts b/shared/api/billing/createSchedule/createScheduleParamsV0.ts index b47caa563..a0173d280 100644 --- a/shared/api/billing/createSchedule/createScheduleParamsV0.ts +++ b/shared/api/billing/createSchedule/createScheduleParamsV0.ts @@ -1,31 +1,15 @@ import { FeatureQuantityParamsV0Schema } from "@api/billing/common/featureQuantity/featureQuantityParamsV0"; import { InvoiceModeParamsSchema } from "@api/billing/common/invoiceModeParams"; import { RedirectModeSchema } from "@api/billing/common/redirectMode"; -import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice"; -import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1"; import { z } from "zod/v4"; import { AttachDiscountSchema } from "../attachV2/attachDiscount"; import { BillingBehaviorSchema } from "../common/billingBehavior"; import { BillingCycleAnchorSchema } from "../common/billingCycleAnchor"; +import { CustomizePlanV1Schema } from "../common/customizePlan/customizePlanV1"; -const CreateScheduleCustomizePlanSchema = z - .object({ - price: BasePriceParamsSchema.nullable().optional().meta({ - description: - "Override the base price of the plan. Pass null to remove the base price.", - }), - items: z.array(CreatePlanItemParamsV1Schema).optional().meta({ - description: "Override the items in the plan.", - }), - }) - .strict() - .refine( - (customize) => - customize.items !== undefined || customize.price !== undefined, - { - message: "When using customize, either items or price must be provided", - }, - ); +const CreateScheduleCustomizePlanSchema = CustomizePlanV1Schema.omit({ + free_trial: true, +}); export const CreateSchedulePlanSchema = z.object({ plan_id: z.string().meta({ @@ -39,7 +23,7 @@ export const CreateSchedulePlanSchema = z.object({ }), customize: CreateScheduleCustomizePlanSchema.optional().meta({ description: - "Customize the plan to schedule. Can override the price, items, or both.", + "Customize the plan to schedule. Can override price, replace items, or patch items with add_items, remove_items, and update_items.", }), subscription_id: z.string().optional().meta({ description: