From a73cd1c2ac102160cdd583fed3a724c9d43099a2 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 29 Apr 2026 16:24:45 +0100 Subject: [PATCH] fix: legacy update quantity --- .../setupUpdateSubscriptionBillingContext.ts | 4 +- .../customers/cancel/handleCancelV2.ts | 3 - .../deleteEntity/cancelEntitySubscriptions.ts | 4 +- server/tests/_groups/temp.ts | 1 + ...paid-volume-v1-price-zero-quantity.test.ts | 184 ++++++++++++++++++ .../context/billingContextOverride.ts | 2 - 6 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 server/tests/_temp/prepaid-volume-v1-price-zero-quantity.test.ts diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 5b4a6f750..92689609a 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -197,9 +197,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ trialContext, isCustom, - billingVersion: contextOverride.billingVersion - ? contextOverride.billingVersion - : (customerProduct.billing_version ?? BillingVersion.V2), + billingVersion: contextOverride.billingVersion ?? BillingVersion.V2, skipBillingChanges, diff --git a/server/src/internal/customers/cancel/handleCancelV2.ts b/server/src/internal/customers/cancel/handleCancelV2.ts index b143f1195..8290cb0dd 100644 --- a/server/src/internal/customers/cancel/handleCancelV2.ts +++ b/server/src/internal/customers/cancel/handleCancelV2.ts @@ -60,9 +60,6 @@ export const handleCancelV2 = createRoute({ const billingContext = await setupUpdateSubscriptionBillingContext({ ctx, params: updateSubscriptionBody, - contextOverride: { - inheritBillingVersion: true, - }, }); logUpdateSubscriptionContext({ ctx, billingContext }); diff --git a/server/src/internal/entities/actions/deleteEntity/cancelEntitySubscriptions.ts b/server/src/internal/entities/actions/deleteEntity/cancelEntitySubscriptions.ts index b7aa1342c..25b2825c2 100644 --- a/server/src/internal/entities/actions/deleteEntity/cancelEntitySubscriptions.ts +++ b/server/src/internal/entities/actions/deleteEntity/cancelEntitySubscriptions.ts @@ -47,9 +47,7 @@ export const cancelSubsForEntity = async ({ proration_behavior: "none", redirect_mode: "if_required", }, - contextOverride: { - inheritBillingVersion: true, - }, + options: { skipAutumnCheckout: true, }, diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 112f64db3..54695f07c 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -5,6 +5,7 @@ export const temp: TestGroup = { description: "Failed tests to triage and fix", tier: "domain", paths: [ + "_temp/prepaid-volume-v1-price-zero-quantity.test.ts", "balances/check/loose/loose-2.test.ts", "balances/check/loose/entities/entity-loose-2.test.ts", // "balances/track/loose/loose-unlimited.test.ts", diff --git a/server/tests/_temp/prepaid-volume-v1-price-zero-quantity.test.ts b/server/tests/_temp/prepaid-volume-v1-price-zero-quantity.test.ts new file mode 100644 index 000000000..6cbfbbedc --- /dev/null +++ b/server/tests/_temp/prepaid-volume-v1-price-zero-quantity.test.ts @@ -0,0 +1,184 @@ +/** + * TDD repro for Mintlify billing.update selecting the old V1 prepaid Stripe price. + * + * Red-failure mode: + * - billing.update emits the stored V1 flat-tier price with quantity 0. + * + * Green-success criteria: + * - billing.update keeps the entity-scoped prepaid item on the V2 inline price path. + */ + +import { expect, test } from "bun:test"; +import { + BillingVersion, + type UpdateSubscriptionV1ParamsInput, + type UsagePriceConfig, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; + +const INCLUDED_USAGE = 250; +const BILLING_UNITS = 1; +const MINTLIFY_VOLUME_TIERS = [ + { to: 500, amount: 0, flat_amount: 125 }, + { to: 1250, amount: 0, flat_amount: 300 }, + { to: 2750, amount: 0, flat_amount: 633 }, + { to: 4750, amount: 0, flat_amount: 1045 }, + { to: 7250, amount: 0, flat_amount: 1523 }, + { to: 9750, amount: 0, flat_amount: 1950 }, + { to: 14750, amount: 0, flat_amount: 2802 }, + { to: 19750, amount: 0, flat_amount: 3555 }, + { to: 24750, amount: 0, flat_amount: 4207 }, + { to: 34750, amount: 0, flat_amount: 5560 }, + { to: 49750, amount: 0, flat_amount: 7462 }, + { to: "inf" as const, amount: 0, flat_amount: 10465 }, +]; + +type StripeSubscriptionItemSummary = { + id: string; + priceId: string; + quantity: number | undefined; + inlinePrice: string | undefined; + autumnPriceId: string | undefined; +}; + +const summarizeSubscriptionItems = ( + subscription: Stripe.Subscription, +): StripeSubscriptionItemSummary[] => + subscription.items.data.map((item) => ({ + id: item.id, + priceId: item.price.id, + quantity: item.quantity ?? undefined, + inlinePrice: item.metadata?.inline_price, + autumnPriceId: item.metadata?.autumn_price_id, + })); + +const getLatestStripeSubscription = async ({ + ctx, + stripeCustomerId, +}: { + ctx: Awaited>["ctx"]; + stripeCustomerId: string; +}) => { + const subscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + limit: 5, + }); + + const subscription = subscriptions.data.find( + (sub) => sub.status !== "canceled", + ); + expect(subscription).toBeDefined(); + + return subscription!; +}; + +test(`${chalk.yellowBright("prepaid volume: billing.update does not swap entity-scoped V2 inline price for V1 quantity 0 price")}`, async () => { + const customerId = "temp-prepaid-volume-v1-price-zero-quantity"; + + const plan = products.base({ + id: "mintlify-prepaid-volume", + items: [ + items.volumePrepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + tiers: MINTLIFY_VOLUME_TIERS, + }), + ], + }); + + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [plan] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: plan.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: INCLUDED_USAGE }, + ], + }), + ], + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const cusProduct = fullCustomer.customer_products[0]!; + const prepaidCustomerPrice = cusProduct.customer_prices.find( + (cusPrice) => cusPrice.price.config.feature_id === TestFeature.Messages, + ); + expect(prepaidCustomerPrice).toBeDefined(); + + const prepaidConfig = prepaidCustomerPrice!.price.config as UsagePriceConfig; + const v1StripePriceId = prepaidConfig.stripe_price_id; + expect(v1StripePriceId).toBeDefined(); + + const stripeCustomerId = + fullCustomer.processor?.id ?? fullCustomer.processor?.processor_id; + expect(stripeCustomerId).toBeDefined(); + + const subscriptionBefore = await getLatestStripeSubscription({ + ctx, + stripeCustomerId: stripeCustomerId!, + }); + const itemsBefore = summarizeSubscriptionItems(subscriptionBefore); + console.log("stripe subscription items before billing.update", itemsBefore); + + expect( + itemsBefore.some( + (item) => item.inlinePrice === "true" && item.priceId !== v1StripePriceId, + ), + ).toBe(true); + + await CusProductService.update({ + ctx, + cusProductId: cusProduct.id, + updates: { + billing_version: BillingVersion.V1, + }, + }); + + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: plan.id, + feature_quantities: [ + { feature_id: TestFeature.Messages, quantity: INCLUDED_USAGE }, + ], + recalculate_balances: { + enabled: true, + }, + redirect_mode: "if_required", + }); + + const subscriptionAfter = await getLatestStripeSubscription({ + ctx, + stripeCustomerId: stripeCustomerId!, + }); + const itemsAfter = summarizeSubscriptionItems(subscriptionAfter); + console.log("stripe subscription items after billing.update", itemsAfter); + + expect( + itemsAfter.some( + (item) => item.priceId === v1StripePriceId && item.quantity === 0, + ), + ).toBe(false); + expect( + itemsAfter.some( + (item) => item.inlinePrice === "true" && item.priceId !== v1StripePriceId, + ), + ).toBe(true); +}); diff --git a/shared/models/billingModels/context/billingContextOverride.ts b/shared/models/billingModels/context/billingContextOverride.ts index 3e7f11544..744c55247 100644 --- a/shared/models/billingModels/context/billingContextOverride.ts +++ b/shared/models/billingModels/context/billingContextOverride.ts @@ -46,6 +46,4 @@ export interface UpdateSubscriptionBillingContextOverride customPrices?: Price[]; customEnts?: Entitlement[]; }; - - inheritBillingVersion?: boolean; }