feat: added recalculate_balances

This commit is contained in:
John Yeo
2026-03-18 11:51:24 +00:00
parent c3fdaf67d7
commit 38084d7678
14 changed files with 734 additions and 345 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
),
);
};

View File

@@ -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",
],
};

View File

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

View File

@@ -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<ApiEntityV2>(
const entity1 = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
entities[0].id,
);
const entity2 = await autumnV2.entities.get<ApiEntityV2>(
const entity2 = await autumnV2_1.entities.get<ApiEntityV2>(
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<ApiEntityV2>(
const entity1 = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
entities[0].id,
);
const entity2 = await autumnV2.entities.get<ApiEntityV2>(
const entity2 = await autumnV2_1.entities.get<ApiEntityV2>(
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<ApiEntityV2>(
const entity1 = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
entities[0].id,
);
const entity2 = await autumnV2.entities.get<ApiEntityV2>(
const entity2 = await autumnV2_1.entities.get<ApiEntityV2>(
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<ApiEntityV2>(
const entity1 = await autumnV2_1.entities.get<ApiEntityV2>(
customerId,
entities[0].id,
);
const entity2 = await autumnV2.entities.get<ApiEntityV2>(
const entity2 = await autumnV2_1.entities.get<ApiEntityV2>(
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 });
});

View File

@@ -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<ApiCustomerV5>(customerId);
expectPrepaidOverageCustomer({
customer: customerBefore,
remaining: 0,
usage: trackedUsage,
prepaidRemaining: 0,
usageBasedUsage: 150,
});
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<ApiCustomerV5>(customerId);
const addOnUsageBucketBefore = getMessagesPlanBucket({
customer: customerBefore,
planId: overageAddOn.id,
billingMethod: BillingMethod.UsageBased,
});
expect(addOnUsageBucketBefore.usage).toBe(150);
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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 });
});

View File

@@ -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<ApiCustomerV5>(customerId);
expectPrepaidOverageCustomer({
customer: customerBefore,
remaining: 0,
usage: trackedUsage,
prepaidRemaining: 0,
usageBasedUsage: 150,
});
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(customerId);
expectPrepaidOverageCustomer({
customer: customerAfter,
remaining: 0,
usage: trackedUsage,
prepaidRemaining: 0,
usageBasedUsage: 150,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -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<UpdateSubscriptionV1ParamsInput>(

View File

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

View File

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

View File

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