fix: patch schedules

This commit is contained in:
johnyeo
2026-06-09 18:51:59 +01:00
parent 61340e416c
commit 0fd8109913
9 changed files with 366 additions and 48 deletions

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "autumn",

View File

@@ -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,

View File

@@ -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 } =

View File

@@ -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) =>

View File

@@ -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<ScheduledPhaseContext[]> =>
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({

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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<typeof UpdatePlanItemParamsV1Schema>;
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",

View File

@@ -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: