feat: 🎸 carry_over_usages wired into attach v2

This commit is contained in:
amianthus
2026-03-04 09:13:18 +00:00
parent 1bfebfb6b7
commit 3d44e82694
9 changed files with 518 additions and 5 deletions

View File

@@ -1,10 +1,11 @@
import type { AttachBillingContext } from "@autumn/shared";
import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared";
import {
CusProductStatus,
deduplicateArray,
type FullCusProduct,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { applyCarryOverUsageFeatureIds } from "@/internal/billing/v2/utils/handleCarryOvers/carryOverUtils";
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
/**
@@ -16,9 +17,11 @@ import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCus
export const computeAttachNewCustomerProduct = ({
ctx,
attachBillingContext,
params = {} as AttachParamsV1,
}: {
ctx: AutumnContext;
attachBillingContext: AttachBillingContext;
params?: AttachParamsV1;
}): FullCusProduct => {
const {
attachProduct,
@@ -41,6 +44,7 @@ export const computeAttachNewCustomerProduct = ({
const currentCustomerEntitlements =
currentCustomerProduct?.customer_entitlements ?? [];
// LEGACY: carry_from_previous flag on entitlements
const featuresToCarryUsagesFor = deduplicateArray(
currentCustomerEntitlements
.filter((ce) => {
@@ -49,6 +53,13 @@ export const computeAttachNewCustomerProduct = ({
.map((ce) => ce.entitlement.feature.id),
);
// carry_over_usages param override — replaces legacy list with consumable feature ids if necessary
const consumableFeatureIdsToCarry = applyCarryOverUsageFeatureIds({
params,
currentCustomerEntitlements,
featuresToCarryUsagesFor,
});
// Determine if this is a scheduled product (downgrade)
const isScheduled = planTiming === "end_of_cycle";
@@ -56,7 +67,7 @@ export const computeAttachNewCustomerProduct = ({
!isScheduled && currentCustomerProduct
? {
fromCustomerProduct: currentCustomerProduct,
consumableFeatureIdsToCarry: featuresToCarryUsagesFor,
consumableFeatureIdsToCarry,
}
: undefined;

View File

@@ -40,6 +40,7 @@ export const computeAttachPlan = ({
const newCustomerProduct = computeAttachNewCustomerProduct({
ctx,
attachBillingContext,
params,
});
const updateCustomerProduct = computeAttachTransitionUpdates({

View File

@@ -6,6 +6,7 @@ import type {
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { handleAttachInvoiceModeErrors } from "@/internal/billing/v2/actions/attach/errors/handleAttachInvoiceModeErrors";
import { handleCarryOverBalancesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverBalancesErrors";
import { handleCarryOverUsagesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverUsagesErrors";
import { handleCurrentCustomerProductErrors } from "@/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors";
import { handleNewBillingSubscriptionErrors } from "@/internal/billing/v2/actions/attach/errors/handleNewBillingSubscriptionErrors";
import { handleScheduledSwitchOneOffErrors } from "@/internal/billing/v2/actions/attach/errors/handleScheduledSwitchOneOffErrors";
@@ -57,7 +58,10 @@ export const handleAttachV2Errors = async ({
// 8. Carry over balances errors (non-consumable features, downgrade block)
handleCarryOverBalancesErrors({ ctx, params, billingContext });
// 9. Proration behavior errors (none restrictions)
// 9. Carry over usages errors (non-consumable features, downgrade block)
handleCarryOverUsagesErrors({ ctx, params, billingContext });
// 10. Proration behavior errors (none restrictions)
handleProrationBehaviorErrors({
billingContext,
currentCustomerProduct: billingContext.currentCustomerProduct,
@@ -65,14 +69,14 @@ export const handleAttachV2Errors = async ({
params,
});
// 9. Subscription ID uniqueness
// 11. Subscription ID uniqueness
await handleSubscriptionIdErrors({
db: ctx.db,
internalCustomerId: billingContext.fullCustomer.internal_id,
subscriptionIds: [billingContext.externalId],
});
// 9. Custom line items errors (only valid for subscription updates)
// 12. Custom line items errors (only valid for subscription updates)
handleCustomLineItemsErrors({
params,
billingContext,

View File

@@ -0,0 +1,63 @@
import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared";
import {
ErrCode,
featureUtils,
isBooleanFeature,
RecaseError,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
/**
* Validates carry_over_usages params.
*
* - Only valid for immediate switches — errors on scheduled/downgrade.
* - Boolean and allocated (continuous_use) features cannot have usages carried over.
*/
export const handleCarryOverUsagesErrors = ({
ctx,
params,
billingContext,
}: {
ctx: AutumnContext;
params: AttachParamsV1;
billingContext: AttachBillingContext;
}) => {
const carryOver = params.carry_over_usages;
if (!carryOver?.enabled) return;
if (billingContext.planTiming !== "immediate") {
throw new RecaseError({
message:
"carry_over_usages is only supported for immediate plan switches (upgrades). It cannot be used with scheduled downgrades.",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const featureIds = carryOver.feature_ids;
if (!featureIds?.length) return;
for (const featureId of featureIds) {
const feature = featureUtils.find.byId({
features: ctx.features,
featureId,
errorOnNotFound: true,
});
if (isBooleanFeature({ feature })) {
throw new RecaseError({
message: `carry_over_usages is not supported for boolean features. Feature '${featureId}' is a boolean (static) feature and does not have a consumable usage.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (featureUtils.isAllocated(feature)) {
throw new RecaseError({
message: `carry_over_usages is not supported for non-consumable features. Feature '${featureId}' is a non-consumable (allocated) feature and does not have a consumable usage to carry over.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
}
};

View File

@@ -0,0 +1,46 @@
import type { AttachParamsV1, FullCustomerEntitlement } from "@autumn/shared";
import {
deduplicateArray,
featureUtils,
isBooleanCusEnt,
isUnlimitedCusEnt,
} from "@autumn/shared";
/** Mutates featuresToCarryUsagesFor with consumable feature IDs from carry_over_usages params and returns it. */
export const applyCarryOverUsageFeatureIds = ({
params,
currentCustomerEntitlements,
featuresToCarryUsagesFor,
}: {
params: AttachParamsV1;
currentCustomerEntitlements: FullCustomerEntitlement[];
featuresToCarryUsagesFor: string[];
}): string[] => {
const carryOverUsages = params.carry_over_usages;
if (!carryOverUsages?.enabled) return featuresToCarryUsagesFor;
const allConsumableFeatureIds = deduplicateArray(
currentCustomerEntitlements
.filter(
(ce) =>
!isBooleanCusEnt({ cusEnt: ce }) &&
!isUnlimitedCusEnt(ce) &&
!featureUtils.isAllocated(ce.entitlement.feature),
)
.map((ce) => ce.entitlement.feature.id),
);
const overrideFeatureIds = carryOverUsages.feature_ids
? allConsumableFeatureIds.filter((id) =>
carryOverUsages.feature_ids!.includes(id),
)
: allConsumableFeatureIds;
featuresToCarryUsagesFor.splice(
0,
featuresToCarryUsagesFor.length,
...overrideFeatureIds,
);
return featuresToCarryUsagesFor;
};

View File

@@ -0,0 +1,172 @@
/**
* Carry Over Usages - Basic Tests
*
* Tests for carry_over_usages: { enabled: true } on immediate plan upgrades.
*
* Key behaviors:
* - Existing usage is deducted from the new plan's allowance on upgrade
* - Zero usage is a silent no-op (new plan starts at full allowance)
* - New plan balance is clamped to zero — cannot go negative from carried usage
*/
import { test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Basic usage carry over (deduction)
//
// Pro: 50 messages, 40 used (balance=10)
// Upgrade to Premium (200) with carry_over_usages: { enabled: true }
// Expected: balance = 160 (200 - 40 carried usage), usage = 40
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("carry-over-usage 1: existing usage is deducted from new plan allowance on upgrade")}`, async () => {
const proMessages = items.monthlyMessages({ includedUsage: 50 });
const premiumMessages = items.monthlyMessages({ includedUsage: 200 });
const pro = products.pro({ id: "pro", items: [proMessages] });
const premium = products.premium({ id: "premium", items: [premiumMessages] });
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
customerId: "carry-over-usage-basic1",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
});
// Track 40 units (balance: 50 → 10)
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 40,
});
// Wait for Redis → Postgres sync
await new Promise((resolve) => setTimeout(resolve, 2000));
// Upgrade to Premium with carry_over_usages enabled
await autumnV2_1.billing.attach({
customer_id: customerId,
plan_id: premium.id,
carry_over_usages: { enabled: true },
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Balance = 200 (Premium allowance) - 40 (carried usage) = 160
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 160,
usage: 40,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Zero usage — nothing to carry
//
// Pro: 50 messages, 0 used (balance=50, nothing consumed)
// Upgrade to Premium (200) with carry_over_usages: { enabled: true }
// Expected: balance = 200 (no deduction — usage was zero)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("carry-over-usage 2: zero usage is a no-op — new plan starts at full allowance")}`, async () => {
const proMessages = items.monthlyMessages({ includedUsage: 50 });
const premiumMessages = items.monthlyMessages({ includedUsage: 200 });
const pro = products.pro({ id: "pro", items: [proMessages] });
const premium = products.premium({ id: "premium", items: [premiumMessages] });
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
customerId: "carry-over-usage-zero",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
});
// No tracking — usage stays at 0
// Upgrade with carry_over_usages — zero usage is a silent no-op
await autumnV2_1.billing.attach({
customer_id: customerId,
plan_id: premium.id,
carry_over_usages: { enabled: true },
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Balance = 200 only (no deduction — nothing was used)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Clamp to zero — usage exceeds new plan allowance
//
// Pro: 50 messages, 50 used (balance=0, all used)
// Upgrade to Premium (30 messages) with carry_over_usages: { enabled: true }
// Expected: balance = 0 (50 usage > 30 new allowance — clamped, not negative)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("carry-over-usage 3: balance is clamped to zero when carried usage exceeds new plan allowance")}`, async () => {
const proMessages = items.monthlyMessages({ includedUsage: 50 });
const premiumMessages = items.monthlyMessages({ includedUsage: 30 });
const pro = products.pro({ id: "pro", items: [proMessages] });
const premium = products.premium({ id: "premium", items: [premiumMessages] });
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
customerId: "carry-over-usage-clamp",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
});
// Exhaust all 50 allowance (balance: 50 → 0)
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
// Wait for Redis → Postgres sync
await new Promise((resolve) => setTimeout(resolve, 2000));
// Upgrade with carry_over_usages — 50 usage > 30 new allowance, must clamp to 0
await autumnV2_1.billing.attach({
customer_id: customerId,
plan_id: premium.id,
carry_over_usages: { enabled: true },
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Balance = 0 (clamped — cannot go negative)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 0,
});
});

View File

@@ -0,0 +1,107 @@
/**
* Carry Over Usages — Error Cases
*
* carry_over_usages validation:
* - Scheduled/downgrade switches are blocked → InvalidRequest
* - Allocated (continuous_use) features in feature_ids are blocked → InvalidRequest
*/
import { test } from "bun:test";
import { ErrCode } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Downgrade with carry_over_usages is blocked
//
// Premium: 500 messages, 100 used (balance=400)
// Downgrade to Pro (100) with carry_over_usages: { enabled: true }
// Expected: InvalidRequest — carry_over_usages only supports immediate upgrades
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("carry-over-usage-errors 1: carry_over_usages on scheduled downgrade returns InvalidRequest")}`, async () => {
const premiumMessages = items.monthlyMessages({ includedUsage: 500 });
const proMessages = items.monthlyMessages({ includedUsage: 100 });
const premium = products.premium({ id: "premium", items: [premiumMessages] });
const pro = products.pro({ id: "pro", items: [proMessages] });
const { customerId, autumnV2_1 } = await initScenario({
customerId: "carry-over-usage-err-downgrade",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [s.attach({ productId: premium.id, timeout: 4000 })],
});
// Track some usage so there's something to (not) carry
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
// Downgrade with carry_over_usages — should be rejected
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV2_1.billing.attach({
customer_id: customerId,
plan_id: pro.id,
carry_over_usages: { enabled: true },
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Allocated feature in feature_ids → error
//
// Pro with allocated seats (prorated billing)
// Attach Premium with carry_over_usages: { enabled: true, feature_ids: ["users"] }
// Expected: InvalidRequest — allocated features have no consumable usage to carry
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("carry-over-usage-errors 2: allocated feature in feature_ids returns InvalidRequest")}`, async () => {
const proItems = [
items.monthlyMessages({ includedUsage: 100 }),
items.allocatedUsers({ includedUsage: 5 }),
];
const premiumItems = [
items.monthlyMessages({ includedUsage: 500 }),
items.allocatedUsers({ includedUsage: 10 }),
];
const pro = products.pro({ id: "pro", items: proItems });
const premium = products.premium({ id: "premium", items: premiumItems });
const { customerId, autumnV2_1 } = await initScenario({
customerId: "carry-over-usage-err-allocated",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV2_1.billing.attach({
customer_id: customerId,
plan_id: premium.id,
carry_over_usages: {
enabled: true,
feature_ids: [TestFeature.Users],
},
});
},
});
});

View File

@@ -0,0 +1,94 @@
/**
* Carry Over Usages — feature_ids Filter Tests
*
* When feature_ids is provided, only the listed features have their usage
* carried over. All other consumable features start at their full new allowance.
*/
import { test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: feature_ids filter — only listed feature has usage carried over
//
// Pro: 50 messages (30 used), 100 words (60 used)
// Upgrade to Premium (200 messages, 300 words)
// with carry_over_usages: { enabled: true, feature_ids: ["messages"] }
// Expected:
// messages: balance = 170 (200 - 30 carried usage), usage = 30
// words: balance = 300 (full new allowance — words not in feature_ids), usage = 0
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("carry-over-usage-feature-ids 1: only feature_ids listed have usage carried — other features start at full new allowance")}`, async () => {
const proMessages = items.monthlyMessages({ includedUsage: 50 });
const proWords = items.monthlyWords({ includedUsage: 100 });
const premiumMessages = items.monthlyMessages({ includedUsage: 200 });
const premiumWords = items.monthlyWords({ includedUsage: 300 });
const pro = products.pro({ id: "pro", items: [proMessages, proWords] });
const premium = products.premium({
id: "premium",
items: [premiumMessages, premiumWords],
});
const { customerId, autumnV2_1, autumnV1 } = await initScenario({
customerId: "carry-over-usage-feature-ids1",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
});
// Track 30 messages and 60 words
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Words,
value: 60,
});
// Wait for Redis → Postgres sync
await new Promise((resolve) => setTimeout(resolve, 2000));
// Upgrade with carry_over_usages scoped to messages only
await autumnV2_1.billing.attach({
customer_id: customerId,
plan_id: premium.id,
carry_over_usages: {
enabled: true,
feature_ids: [TestFeature.Messages],
},
});
await new Promise((resolve) => setTimeout(resolve, 2000));
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// messages: 200 - 30 carried usage = 170
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 170,
usage: 30,
});
// words: full new allowance (not in feature_ids — usage not carried)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
balance: 300,
usage: 0,
});
});

View File

@@ -48,6 +48,21 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({
.meta({
description: "Whether to carry over balances from the previous plan.",
}),
carry_over_usages: z
.object({
enabled: z.boolean().meta({
description: "Whether to carry over usages from the previous plan.",
}),
feature_ids: z.array(z.string()).optional().meta({
description:
"The IDs of the features to carry over usages for. If left undefined, all consumable features will be carried over.",
}),
})
.optional()
.meta({
description: "Whether to carry over usages from the previous plan.",
}),
});
export type AttachParamsV1 = z.infer<typeof AttachParamsV1Schema>;