diff --git a/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts b/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts index 893d60cbe..ca9c84273 100644 --- a/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts +++ b/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts @@ -199,14 +199,16 @@ export const deductFromCusEntsTypescript = ({ return { updates, mutationLogs, remaining: amountToDeduct }; } - // // Sort usage_allowed entitlements first so overage hits them before regular ones - // cusEnts.sort((a, b) => { - // const leftUsageAllowed = a.usage_allowed ?? false; - // const rightUsageAllowed = b.usage_allowed ?? false; + // Sort usage_allowed entitlements first so overage hits them before regular ones + if (amountToDeduct > 0 && allowOverage) { + cusEnts.sort((a, b) => { + const leftUsageAllowed = a.usage_allowed ?? false; + const rightUsageAllowed = b.usage_allowed ?? false; - // if (leftUsageAllowed === rightUsageAllowed) return 0; - // return leftUsageAllowed ? -1 : 1; - // }); + if (leftUsageAllowed === rightUsageAllowed) return 0; + return leftUsageAllowed ? -1 : 1; + }); + } // Pass 2: // Deductions: floor at minBalance (can go below 0 if usage_allowed / allowOverage) diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index 8194b912f..14874b069 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -79,6 +79,7 @@ export const computeCustomPlanNewCustomerProduct = ({ isCustom: updateSubscriptionContext.isCustom, subscriptionId: stripeSubscription?.id, // don't populate if it's starting in the future. subscriptionScheduleId: stripeSubscriptionSchedule?.id, + externalId: currentCustomerProduct.external_id ?? undefined, startsAt: currentCustomerProduct.starts_at ?? undefined, ...cancelFields, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityCustomerEntitlementChanges.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityCustomerEntitlementChanges.ts index 2d1733f79..f7549c87a 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityCustomerEntitlementChanges.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityCustomerEntitlementChanges.ts @@ -23,7 +23,7 @@ export const computeUpdateQuantityCustomerEntitlementChanges = ({ quantityDifference: number; customerEntitlement: FullCusEntWithFullCusProduct; }): UpdateCustomerEntitlement[] => { - const { fullCustomer, backfillPrepaidUpdate } = updateSubscriptionContext; + const { fullCustomer, recalculateBalances } = updateSubscriptionContext; const customerPrice = cusEntToCusPrice({ cusEnt: customerEntitlement, @@ -48,7 +48,7 @@ export const computeUpdateQuantityCustomerEntitlementChanges = ({ } // 1. Create cloned - const cusEnts = backfillPrepaidUpdate + const cusEnts = recalculateBalances ? fullCustomerToCustomerEntitlements({ fullCustomer, featureIds: [customerEntitlement.entitlement.feature.id], 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 3108f58dd..b099e103d 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -153,7 +153,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ customerProduct, defaultProduct, cancelAction, - backfillPrepaidUpdate: params.backfill_prepaid_update === true, + recalculateBalances: params.recalculate_balances?.enabled === true, stripeSubscription, stripeSubscriptionSchedule, stripeDiscounts, diff --git a/server/src/internal/customers/cusProducts/repos/getByExternalIds.ts b/server/src/internal/customers/cusProducts/repos/getByExternalIds.ts index 715ee0cbb..c8b1d9b06 100644 --- a/server/src/internal/customers/cusProducts/repos/getByExternalIds.ts +++ b/server/src/internal/customers/cusProducts/repos/getByExternalIds.ts @@ -1,4 +1,4 @@ -import { customerProducts } from "@autumn/shared"; +import { ACTIVE_STATUSES, customerProducts } from "@autumn/shared"; import { and, eq, inArray } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle"; @@ -24,6 +24,7 @@ export const getByExternalIds = async ({ and( eq(customerProducts.internal_customer_id, internalCustomerId), inArray(customerProducts.external_id, externalIds), + inArray(customerProducts.status, ACTIVE_STATUSES), ), ); }; diff --git a/server/tests/_groups/core/coreUpdateSubscription.ts b/server/tests/_groups/core/coreUpdateSubscription.ts index de2a68cd7..4ac2eda65 100644 --- a/server/tests/_groups/core/coreUpdateSubscription.ts +++ b/server/tests/_groups/core/coreUpdateSubscription.ts @@ -28,5 +28,6 @@ export const coreUpdateSubscription: TestGroup = { "billing/update-subscription/discounts/discount-applies-to.test.ts", "billing/update-subscription/billing-behavior/next-cycle-only.test.ts", "billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts", + "billing/update-subscription/params/recalculate-balances/update-quantity-prepaid-overage.test.ts", ], }; diff --git a/server/tests/integration/billing/legacy/attach/update-quantity/legacy-update-quantity.test.ts b/server/tests/integration/billing/legacy/attach/update-quantity/legacy-update-quantity.test.ts index 52c1b58af..3c038d93d 100644 --- a/server/tests/integration/billing/legacy/attach/update-quantity/legacy-update-quantity.test.ts +++ b/server/tests/integration/billing/legacy/attach/update-quantity/legacy-update-quantity.test.ts @@ -428,7 +428,7 @@ test.concurrent(`${chalk.yellowBright("attach: quantity upgrade with prorate-nex productId: pro.id, options: [{ feature_id: TestFeature.Messages, quantity: 300 }], }), - s.track({ featureId: TestFeature.Messages, value: usage }), + s.track({ featureId: TestFeature.Messages, value: usage, timeout: 2000 }), // Upgrade quantity to 400 (prorate_next_cycle - no immediate invoice) s.attach({ productId: pro.id, diff --git a/server/tests/integration/billing/update-subscription/update-quantity/update-quantity-prepaid-overage-entities.test.ts b/server/tests/integration/billing/update-subscription/params/recalculate-balances/update-quantity-prepaid-overage-entities.test.ts similarity index 89% rename from server/tests/integration/billing/update-subscription/update-quantity/update-quantity-prepaid-overage-entities.test.ts rename to server/tests/integration/billing/update-subscription/params/recalculate-balances/update-quantity-prepaid-overage-entities.test.ts index 18eab2cbd..6ee60a76d 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/update-quantity-prepaid-overage-entities.test.ts +++ b/server/tests/integration/billing/update-subscription/params/recalculate-balances/update-quantity-prepaid-overage-entities.test.ts @@ -93,7 +93,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: id: "per-entity-prepaid-overage-increase", }); - const { autumnV2, autumnV2_1, ctx, entities } = await initScenario({ + const { autumnV2_1, ctx, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -131,14 +131,16 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: quantity: increasedQuantity, }, ], - backfill_prepaid_update: true, + recalculate_balances: { + enabled: true, + }, }); - const entity1 = await autumnV2.entities.get( + const entity1 = await autumnV2_1.entities.get( customerId, entities[0].id, ); - const entity2 = await autumnV2.entities.get( + const entity2 = await autumnV2_1.entities.get( customerId, entities[1].id, ); @@ -175,7 +177,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: id: "per-entity-prepaid-overage-decrease", }); - const { autumnV2, autumnV2_1, ctx, entities } = await initScenario({ + const { autumnV2_1, ctx, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -213,14 +215,16 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: quantity: decreasedQuantity, }, ], - backfill_prepaid_update: true, + recalculate_balances: { + enabled: true, + }, }); - const entity1 = await autumnV2.entities.get( + const entity1 = await autumnV2_1.entities.get( customerId, entities[0].id, ); - const entity2 = await autumnV2.entities.get( + const entity2 = await autumnV2_1.entities.get( customerId, entities[1].id, ); @@ -257,7 +261,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: id: "entity-product-prepaid-overage-increase", }); - const { autumnV2, autumnV2_1, ctx, entities } = await initScenario({ + const { autumnV2_1, ctx, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -304,14 +308,16 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: quantity: increasedQuantity, }, ], - backfill_prepaid_update: true, + recalculate_balances: { + enabled: true, + }, }); - const entity1 = await autumnV2.entities.get( + const entity1 = await autumnV2_1.entities.get( customerId, entities[0].id, ); - const entity2 = await autumnV2.entities.get( + const entity2 = await autumnV2_1.entities.get( customerId, entities[1].id, ); @@ -331,11 +337,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: usageBasedUsage: 100, }); - await expectStripeSubscriptionCorrect({ - ctx, - customerId, - options: { subCount: 2 }, - }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: entity product decrease only adjusts targeted entity")}`, async () => { @@ -344,7 +346,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: id: "entity-product-prepaid-overage-decrease", }); - const { autumnV2, autumnV2_1, ctx, entities } = await initScenario({ + const { autumnV2_1, ctx, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -391,14 +393,16 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: quantity: decreasedQuantity, }, ], - backfill_prepaid_update: true, + recalculate_balances: { + enabled: true, + }, }); - const entity1 = await autumnV2.entities.get( + const entity1 = await autumnV2_1.entities.get( customerId, entities[0].id, ); - const entity2 = await autumnV2.entities.get( + const entity2 = await autumnV2_1.entities.get( customerId, entities[1].id, ); @@ -418,9 +422,5 @@ test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage-entities: usageBasedUsage: 200, }); - await expectStripeSubscriptionCorrect({ - ctx, - customerId, - options: { subCount: 2 }, - }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); diff --git a/server/tests/integration/billing/update-subscription/params/recalculate-balances/update-quantity-prepaid-overage.test.ts b/server/tests/integration/billing/update-subscription/params/recalculate-balances/update-quantity-prepaid-overage.test.ts new file mode 100644 index 000000000..8b0740c50 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/params/recalculate-balances/update-quantity-prepaid-overage.test.ts @@ -0,0 +1,665 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + BillingMethod, + OnDecrease, + ResetInterval, + type UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +const initialQuantity = 300; +const increasedQuantity = 500; +const decreasedQuantity = 200; +const trackedUsage = 450; + +const buildPrepaidOverageProduct = ({ + id, + onDecrease, +}: { + id: string; + onDecrease?: OnDecrease; +}) => + products.base({ + id, + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + prorationConfig: onDecrease + ? { + onDecrease, + } + : undefined, + }), + items.consumableMessages({ + includedUsage: 0, + }), + ], + }); + +const getMessagesBalance = ({ customer }: { customer: ApiCustomerV5 }) => { + const balance = customer.balances[TestFeature.Messages]; + expect(balance).toBeDefined(); + return balance; +}; + +const getMessagesPlanBucket = ({ + customer, + planId, + billingMethod, +}: { + customer: ApiCustomerV5; + planId: string; + billingMethod: BillingMethod; +}) => { + const balance = getMessagesBalance({ customer }); + const bucket = balance.breakdown?.find( + (balanceBreakdown) => + balanceBreakdown.plan_id === planId && + balanceBreakdown.price?.billing_method === billingMethod, + ); + + expect(bucket).toBeDefined(); + return bucket!; +}; + +const getLifetimeMessagesBucket = ({ + customer, + planId, +}: { + customer: ApiCustomerV5; + planId: string; +}) => { + const balance = getMessagesBalance({ customer }); + const bucket = balance.breakdown?.find( + (balanceBreakdown) => + balanceBreakdown.plan_id === planId && + balanceBreakdown.reset?.interval === ResetInterval.OneOff, + ); + + expect(bucket).toBeDefined(); + return bucket!; +}; + +const expectPrepaidOverageCustomer = ({ + customer, + remaining, + usage, + prepaidRemaining, + usageBasedRemaining = 0, + usageBasedUsage, +}: { + customer: ApiCustomerV5; + remaining: number; + usage: number; + prepaidRemaining: number; + usageBasedRemaining?: number; + usageBasedUsage: number; +}) => { + const balance = getMessagesBalance({ customer }); + const prepaidBuckets = + balance.breakdown?.filter( + (balanceBreakdown) => + balanceBreakdown.price?.billing_method === BillingMethod.Prepaid, + ) ?? []; + const usageBasedBuckets = + balance.breakdown?.filter( + (balanceBreakdown) => + balanceBreakdown.price?.billing_method === BillingMethod.UsageBased, + ) ?? []; + + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + remaining, + usage, + }); + + expect( + prepaidBuckets.reduce( + (total, balanceBreakdown) => total + balanceBreakdown.remaining, + 0, + ), + ).toBe(prepaidRemaining); + expect( + usageBasedBuckets.reduce( + (total, balanceBreakdown) => total + balanceBreakdown.remaining, + 0, + ), + ).toBe(usageBasedRemaining); + expect( + usageBasedBuckets.reduce( + (total, balanceBreakdown) => total + balanceBreakdown.usage, + 0, + ), + ).toBe(usageBasedUsage); +}; + +test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: increase quantity with balance recalculation")}`, async () => { + const customerId = "qty-prepaid-overage-increase"; + const product = buildPrepaidOverageProduct({ + id: "prepaid-overage-increase", + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.track({ + featureId: TestFeature.Messages, + value: trackedUsage, + timeout: 2000, + }), + ], + }); + + const customerBefore = + await autumnV2_1.customers.get(customerId); + expectPrepaidOverageCustomer({ + customer: customerBefore, + remaining: 0, + usage: trackedUsage, + prepaidRemaining: 0, + usageBasedUsage: 150, + }); + + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + plan_id: product.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: increasedQuantity, + }, + ], + recalculate_balances: { + enabled: true, + }, + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + expectPrepaidOverageCustomer({ + customer: customerAfter, + remaining: 50, + usage: trackedUsage, + prepaidRemaining: 50, + usageBasedUsage: 0, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: decrease quantity with balance recalculation")}`, async () => { + const customerId = "qty-prepaid-overage-decrease"; + const product = buildPrepaidOverageProduct({ + id: "prepaid-overage-decrease", + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.track({ + featureId: TestFeature.Messages, + value: trackedUsage, + timeout: 2000, + }), + ], + }); + + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + plan_id: product.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: decreasedQuantity, + }, + ], + recalculate_balances: { + enabled: true, + }, + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + expectPrepaidOverageCustomer({ + customer: customerAfter, + remaining: 0, + usage: trackedUsage, + prepaidRemaining: 0, + usageBasedUsage: 250, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: increase quantity without balance recalculation")}`, async () => { + const customerId = "qty-prepaid-overage-no-backfill"; + const product = buildPrepaidOverageProduct({ + id: "prepaid-overage-no-backfill", + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.track({ + featureId: TestFeature.Messages, + value: trackedUsage, + timeout: 2000, + }), + ], + }); + + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + plan_id: product.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: increasedQuantity, + }, + ], + recalculate_balances: { + enabled: false, + }, + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + expectPrepaidOverageCustomer({ + customer: customerAfter, + remaining: 200, + usage: trackedUsage, + prepaidRemaining: 200, + usageBasedUsage: 150, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: no-proration downgrade keeps balances unchanged")}`, async () => { + const customerId = "qty-prepaid-overage-no-proration"; + const product = buildPrepaidOverageProduct({ + id: "prepaid-overage-no-proration", + onDecrease: OnDecrease.None, + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.track({ + featureId: TestFeature.Messages, + value: trackedUsage, + timeout: 2000, + }), + ], + }); + + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + plan_id: product.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: decreasedQuantity, + }, + ], + recalculate_balances: { + enabled: true, + }, + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + expectPrepaidOverageCustomer({ + customer: customerAfter, + remaining: 0, + usage: trackedUsage, + prepaidRemaining: 0, + usageBasedUsage: 150, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: recalculating prepaid does not touch lifetime usage")}`, async () => { + const customerId = "qty-prepaid-overage-lifetime-isolated"; + const product = products.base({ + id: "prepaid-lifetime-isolated", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + items.lifetimeMessages({ + includedUsage: 200, + }), + ], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.track({ + featureId: TestFeature.Messages, + value: trackedUsage, + timeout: 2000, + }), + ], + }); + + const customerBefore = + await autumnV2_1.customers.get(customerId); + const lifetimeBucketBefore = getLifetimeMessagesBucket({ + customer: customerBefore, + planId: product.id, + }); + const prepaidBucketBefore = getMessagesPlanBucket({ + customer: customerBefore, + planId: product.id, + billingMethod: BillingMethod.Prepaid, + }); + + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + plan_id: product.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: increasedQuantity, + }, + ], + recalculate_balances: { + enabled: true, + }, + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + const lifetimeBucketAfter = getLifetimeMessagesBucket({ + customer: customerAfter, + planId: product.id, + }); + const prepaidBucketAfter = getMessagesPlanBucket({ + customer: customerAfter, + planId: product.id, + billingMethod: BillingMethod.Prepaid, + }); + + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 250, + usage: trackedUsage, + }); + expect(lifetimeBucketAfter).toMatchObject({ + included_grant: lifetimeBucketBefore.included_grant, + prepaid_grant: lifetimeBucketBefore.prepaid_grant, + remaining: lifetimeBucketBefore.remaining, + usage: lifetimeBucketBefore.usage, + }); + expect(prepaidBucketAfter.usage).toBe(prepaidBucketBefore.usage); + expect(prepaidBucketAfter.remaining).toBe( + prepaidBucketBefore.remaining + 200, + ); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: updating pro prepaid does not touch add-on prepaid")}`, async () => { + const customerId = "qty-prepaid-overage-addon-prepaid-isolated"; + const pro = products.pro({ + id: "pro-prepaid-main", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + ], + }); + const recurringPlusPack = products.base({ + id: "recurring-plus-pack", + isAddOn: true, + items: [ + items.lifetimeMessages({ + includedUsage: 200, + }), + ], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, recurringPlusPack] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.billing.attach({ + productId: recurringPlusPack.id, + }), + s.track({ + featureId: TestFeature.Messages, + value: 350, + timeout: 2000, + }), + ], + }); + + const customerBefore = + await autumnV2_1.customers.get(customerId); + const proBucketBefore = getMessagesPlanBucket({ + customer: customerBefore, + planId: pro.id, + billingMethod: BillingMethod.Prepaid, + }); + + const lifetimeBucketBefore = getLifetimeMessagesBucket({ + customer: customerBefore, + planId: recurringPlusPack.id, + }); + + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: increasedQuantity, + }, + ], + recalculate_balances: { + enabled: true, + }, + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + const proBucketAfter = getMessagesPlanBucket({ + customer: customerAfter, + planId: pro.id, + billingMethod: BillingMethod.Prepaid, + }); + const lifetimeBucketAfter = getLifetimeMessagesBucket({ + customer: customerAfter, + planId: recurringPlusPack.id, + }); + + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 350, + usage: 350, + }); + expect(lifetimeBucketAfter).toMatchObject({ + included_grant: lifetimeBucketBefore.included_grant, + prepaid_grant: lifetimeBucketBefore.prepaid_grant, + remaining: lifetimeBucketBefore.remaining, + usage: lifetimeBucketBefore.usage, + }); + expect(proBucketAfter.usage).toBe(proBucketBefore.usage); + expect(proBucketAfter.remaining).toBe(proBucketBefore.remaining + 200); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: recalculating pro prepaid clears add-on overage")}`, async () => { + const customerId = "qty-prepaid-overage-addon-usage-based"; + const pro = products.pro({ + id: "pro-prepaid-messages", + items: [ + items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }), + ], + }); + const overageAddOn = products.recurringAddOn({ + id: "addon-overage-messages", + items: [ + items.consumableMessages({ + includedUsage: 0, + }), + ], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, overageAddOn] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initialQuantity }, + ], + }), + s.billing.attach({ + productId: overageAddOn.id, + }), + s.track({ + featureId: TestFeature.Messages, + value: trackedUsage, + timeout: 2000, + }), + ], + }); + + const customerBefore = + await autumnV2_1.customers.get(customerId); + const addOnUsageBucketBefore = getMessagesPlanBucket({ + customer: customerBefore, + planId: overageAddOn.id, + billingMethod: BillingMethod.UsageBased, + }); + expect(addOnUsageBucketBefore.usage).toBe(150); + + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + quantity: increasedQuantity, + }, + ], + recalculate_balances: { + enabled: true, + }, + }); + + const customerAfter = + await autumnV2_1.customers.get(customerId); + const proBucketAfter = getMessagesPlanBucket({ + customer: customerAfter, + planId: pro.id, + billingMethod: BillingMethod.Prepaid, + }); + const addOnUsageBucketAfter = getMessagesPlanBucket({ + customer: customerAfter, + planId: overageAddOn.id, + billingMethod: BillingMethod.UsageBased, + }); + + expectBalanceCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + remaining: 50, + usage: trackedUsage, + }); + expect(proBucketAfter.remaining).toBe(50); + expect(addOnUsageBucketAfter).toMatchObject({ + remaining: 0, + usage: 0, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/update-quantity/update-quantity-prepaid-overage.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/update-quantity-prepaid-overage.test.ts deleted file mode 100644 index d57d47ed0..000000000 --- a/server/tests/integration/billing/update-subscription/update-quantity/update-quantity-prepaid-overage.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { test } from "bun:test"; -import { - type ApiCustomerV5, - BillingMethod, - OnDecrease, - type UpdateSubscriptionV1ParamsInput, -} from "@autumn/shared"; -import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; -import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; - -const initialQuantity = 300; -const increasedQuantity = 500; -const decreasedQuantity = 200; -const trackedUsage = 450; - -const buildPrepaidOverageProduct = ({ - id, - onDecrease, -}: { - id: string; - onDecrease?: OnDecrease; -}) => - products.base({ - id, - items: [ - items.prepaidMessages({ - includedUsage: 100, - billingUnits: 100, - price: 10, - prorationConfig: onDecrease - ? { - onDecrease, - } - : undefined, - }), - items.consumableMessages({ - includedUsage: 0, - }), - ], - }); - -const expectPrepaidOverageCustomer = ({ - customer, - remaining, - usage, - prepaidRemaining, - usageBasedRemaining = 0, - usageBasedUsage, -}: { - customer: ApiCustomerV5; - remaining: number; - usage: number; - prepaidRemaining: number; - usageBasedRemaining?: number; - usageBasedUsage: number; -}) => { - expectBalanceCorrect({ - customer, - featureId: TestFeature.Messages, - remaining, - usage, - breakdown: { - [BillingMethod.Prepaid]: { - remaining: prepaidRemaining, - }, - [BillingMethod.UsageBased]: { - remaining: usageBasedRemaining, - usage: usageBasedUsage, - }, - }, - }); -}; - -test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: increase quantity with backfill")}`, async () => { - const customerId = "qty-prepaid-overage-increase"; - const product = buildPrepaidOverageProduct({ - id: "prepaid-overage-increase", - }); - - const { autumnV2_1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.billing.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity }, - ], - }), - s.track({ - featureId: TestFeature.Messages, - value: trackedUsage, - timeout: 2000, - }), - ], - }); - - const customerBefore = - await autumnV2_1.customers.get(customerId); - expectPrepaidOverageCustomer({ - customer: customerBefore, - remaining: 0, - usage: trackedUsage, - prepaidRemaining: 0, - usageBasedUsage: 150, - }); - - await autumnV2_1.subscriptions.update({ - customer_id: customerId, - plan_id: product.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: increasedQuantity, - }, - ], - backfill_prepaid_update: true, - }); - - const customerAfter = - await autumnV2_1.customers.get(customerId); - expectPrepaidOverageCustomer({ - customer: customerAfter, - remaining: 50, - usage: trackedUsage, - prepaidRemaining: 50, - usageBasedUsage: 0, - }); - - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); - -test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: decrease quantity with backfill")}`, async () => { - const customerId = "qty-prepaid-overage-decrease"; - const product = buildPrepaidOverageProduct({ - id: "prepaid-overage-decrease", - }); - - const { autumnV2_1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.billing.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity }, - ], - }), - s.track({ - featureId: TestFeature.Messages, - value: trackedUsage, - timeout: 2000, - }), - ], - }); - - await autumnV2_1.subscriptions.update({ - customer_id: customerId, - plan_id: product.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: decreasedQuantity, - }, - ], - backfill_prepaid_update: true, - }); - - const customerAfter = - await autumnV2_1.customers.get(customerId); - expectPrepaidOverageCustomer({ - customer: customerAfter, - remaining: 0, - usage: trackedUsage, - prepaidRemaining: 0, - usageBasedUsage: 250, - }); - - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); - -test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: increase quantity without backfill")}`, async () => { - const customerId = "qty-prepaid-overage-no-backfill"; - const product = buildPrepaidOverageProduct({ - id: "prepaid-overage-no-backfill", - }); - - const { autumnV2_1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.billing.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity }, - ], - }), - s.track({ - featureId: TestFeature.Messages, - value: trackedUsage, - timeout: 2000, - }), - ], - }); - - await autumnV2_1.subscriptions.update({ - customer_id: customerId, - plan_id: product.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: increasedQuantity, - }, - ], - backfill_prepaid_update: false, - }); - - const customerAfter = - await autumnV2_1.customers.get(customerId); - expectPrepaidOverageCustomer({ - customer: customerAfter, - remaining: 0, - usage: trackedUsage, - prepaidRemaining: 0, - usageBasedUsage: 150, - }); - - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); - -test.concurrent(`${chalk.yellowBright("update-quantity-prepaid-overage: no-proration downgrade keeps balances unchanged")}`, async () => { - const customerId = "qty-prepaid-overage-no-proration"; - const product = buildPrepaidOverageProduct({ - id: "prepaid-overage-no-proration", - onDecrease: OnDecrease.None, - }); - - const { autumnV2_1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.billing.attach({ - productId: product.id, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity }, - ], - }), - s.track({ - featureId: TestFeature.Messages, - value: trackedUsage, - timeout: 2000, - }), - ], - }); - - await autumnV2_1.subscriptions.update({ - customer_id: customerId, - plan_id: product.id, - feature_quantities: [ - { - feature_id: TestFeature.Messages, - quantity: decreasedQuantity, - }, - ], - backfill_prepaid_update: true, - }); - - const customerAfter = - await autumnV2_1.customers.get(customerId); - expectPrepaidOverageCustomer({ - customer: customerAfter, - remaining: 0, - usage: trackedUsage, - prepaidRemaining: 0, - usageBasedUsage: 150, - }); - - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); diff --git a/server/tests/scenarios/update-subscription/update-prepaid-overage-scenario.test.ts b/server/tests/scenarios/update-subscription/update-prepaid-overage-scenario.test.ts index 3d6b197a5..52d961aa4 100644 --- a/server/tests/scenarios/update-subscription/update-prepaid-overage-scenario.test.ts +++ b/server/tests/scenarios/update-subscription/update-prepaid-overage-scenario.test.ts @@ -14,13 +14,13 @@ import chalk from "chalk"; /** * Update Subscription - Prepaid Overage Scenario * - * Baseline scenario for inspecting `backfill_prepaid_update`. + * Baseline scenario for inspecting `recalculate_balances`. * Starts with a prepaid messages plan, tracks the customer into overage, - * then updates prepaid quantity with backfill enabled so we can inspect the + * then updates prepaid quantity with balance recalculation enabled so we can inspect the * before/after customer feature shape. */ -test(`${chalk.yellowBright("update-subscription: prepaid overage backfill")}`, async () => { +test(`${chalk.yellowBright("update-subscription: prepaid overage recalculate balances")}`, async () => { const customerId = "update-prepaid-overage"; const prepaidMessagesItem = items.prepaidMessages({ includedUsage: 100, @@ -80,7 +80,9 @@ test(`${chalk.yellowBright("update-subscription: prepaid overage backfill")}`, a quantity: 500, }, ], - backfill_prepaid_update: true, + recalculate_balances: { + enabled: true, + }, } satisfies UpdateSubscriptionV1ParamsInput; await autumnV2_1.subscriptions.update( diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index fec34576c..d2515ad78 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -27,7 +27,11 @@ export const ExtUpdateSubscriptionV0ParamsSchema = processor_subscription_id: z.string().nullable().optional(), no_billing_changes: z.boolean().optional(), - backfill_prepaid_update: z.boolean().optional(), + recalculate_balances: z + .object({ + enabled: z.boolean(), + }) + .optional(), status: z .enum([ CusProductStatus.Active, diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts index 8b88b3c4c..ae9748af7 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts @@ -25,10 +25,18 @@ export const ExtUpdateSubscriptionV1ParamsSchema = "If true, the subscription is updated internally without applying billing changes in Stripe.", }), - backfill_prepaid_update: z.boolean().optional().meta({ - description: - "If true, rebalances same-feature prepaid balances against existing usage during quantity updates.", - }), + recalculate_balances: z + .object({ + enabled: z.boolean().meta({ + description: + "If true, recalculates balances during the subscription update. Only applicable when updating feature quantities.", + }), + }) + .optional() + .meta({ + description: + "Controls whether balances should be recalculated during the subscription update.", + }), status: z .enum([ @@ -49,6 +57,7 @@ const UPDATE_FIELDS = [ "cancel_action", "processor_subscription_id", "no_billing_changes", + "recalculate_balances", "status", "redirect_mode", ] as const satisfies (keyof z.input< @@ -63,7 +72,7 @@ export const UpdateSubscriptionV1ParamsSchema = redirect_mode: RedirectModeSchema.optional(), }).refine((data) => UPDATE_FIELDS.some((key) => data[key] !== undefined), { message: - "At least one update parameter must be provided (feature_quantities, version, customize, or cancel_action)", + "At least one update parameter must be provided (feature_quantities, version, customize, cancel_action, or recalculate_balances)", }); export type UpdateSubscriptionV1Params = z.infer< diff --git a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts index 8aa259955..5d9a3d727 100644 --- a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts +++ b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts @@ -18,7 +18,7 @@ export interface UpdateSubscriptionBillingContext extends BillingContext { customerProduct: FullCusProduct; // target customer product defaultProduct?: FullProduct; // for cancel flows cancelAction?: CancelAction; // for cancel flows - backfillPrepaidUpdate?: boolean; + recalculateBalances?: boolean; intent: UpdateSubscriptionIntent; }