feat: added recalculate_balances
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user