fix: type errors

This commit is contained in:
John Yeo
2026-02-24 19:40:10 +00:00
parent 9c59ad4865
commit 6a562422df
14 changed files with 395 additions and 24 deletions

View File

@@ -271,12 +271,14 @@ export const createStripeInArrearPrice = async ({
...productData,
...priceAmountData,
currency: org.default_currency || "usd",
recurring: {
interval: recurringData.interval,
interval_count: recurringData.interval_count,
meter: meter.id,
usage_type: "metered",
},
recurring: recurringData?.interval
? {
interval: recurringData.interval,
interval_count: recurringData.interval_count,
meter: meter.id,
usage_type: "metered",
}
: undefined,
nickname: `Autumn Price (${relatedEnt.feature.name})`,
});

View File

@@ -5,7 +5,7 @@ import {
type Organization,
type Price,
type Product,
TierBehavior,
priceToStripeTiersMode,
TierInfinite,
type UsagePriceConfig,
} from "@autumn/shared";
@@ -71,12 +71,14 @@ export const createStripePrepaid = async ({
}) => {
const relatedEnt = getPriceEntitlement(price, entitlements);
let recurringData;
let recurringData: Partial<Stripe.PriceCreateParams.Recurring> | undefined;
if (price.config!.interval !== BillingInterval.OneOff) {
recurringData = billingIntervalToStripe({
interval: price.config!.interval,
intervalCount: price.config!.interval_count,
});
recurringData = {
...billingIntervalToStripe({
interval: price.config!.interval,
intervalCount: price.config!.interval_count,
}),
};
}
const config = price.config as UsagePriceConfig;
@@ -114,8 +116,7 @@ export const createStripePrepaid = async ({
config.stripe_price_id = stripePrice.id;
} else {
const tiers = prepaidToStripeTiers({ price, org });
const tiersMode =
price.tier_behavior === TierBehavior.VolumeBased ? "volume" : "graduated";
const tiersMode = priceToStripeTiersMode({ price });
let priceAmountData = {};
if (tiers.length === 1) {

View File

@@ -25,7 +25,7 @@ export const billingIntervalToStripe = ({
}: {
interval: BillingInterval;
intervalCount?: number | null;
}): Stripe.PriceCreateParams.Recurring | Record<string, any> => {
}): Partial<Stripe.PriceCreateParams.Recurring> => {
const finalCount = intervalCount ?? 1;
switch (interval) {
case BillingInterval.Week:

View File

@@ -126,6 +126,7 @@ export const constructArrearItem = ({
featureId,
includedUsage = 10000,
price = 0.1,
tiers,
billingUnits = 1000,
config = {
on_increase: OnIncrease.ProrateImmediately,
@@ -140,6 +141,7 @@ export const constructArrearItem = ({
featureId: string;
includedUsage?: number;
price?: number;
tiers?: { amount: number; to: number | "inf" }[];
billingUnits?: number;
config?: ProductItemConfig;
rolloverConfig?: RolloverConfig;
@@ -152,7 +154,8 @@ export const constructArrearItem = ({
feature_id: featureId,
usage_model: UsageModel.PayPerUse,
included_usage: includedUsage,
price: price,
price: tiers ? undefined : price,
tiers: tiers,
billing_units: billingUnits,
interval: interval,
interval_count: intervalCount,

View File

@@ -100,7 +100,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toBeGreaterThan(0);
expect(preview.total).toEqual(30);
await autumnV1.billing.attach({
customer_id: customerId,
@@ -201,7 +201,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toBeGreaterThan(0);
expect(preview.total).toEqual(10 + 30);
await autumnV1.billing.attach({
customer_id: customerId,

View File

@@ -327,3 +327,98 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: quantity 0 → no
latestTotal: BASE_PRICE,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: 4 tiers with included usage — volume pricing at correct tier
// ═══════════════════════════════════════════════════════════════════════════════
/**
* 4 tiers (billingUnits = 100):
* Tier 1: 0-200 units @ $15/pack
* Tier 2: 201-500 units @ $10/pack
* Tier 3: 501-1000 units @ $7/pack
* Tier 4: 1001+ units @ $5/pack
*
* With 100 included (free) and 900 total quantity:
* Paid packs: (900 - 100) / 100 = 8 packs
* Volume: all 8 packs at tier 3 rate ($7) = $56
* Total: $20 base + $56 = $76
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 4 tiers, 100 included, 900 total → $76")}`, async () => {
const customerId = "attach-prepaid-volume-4tier-incl";
const quantity = 900;
const includedUsage = 100;
// 4-tier pricing structure
const fourTiers = [
{ to: 200, amount: 15 },
{ to: 500, amount: 10 },
{ to: 1000, amount: 7 },
{ to: "inf" as const, amount: 5 },
];
// Paid packs after free: (900 - 100) / 100 = 8 packs
// 8 packs falls into tier 3 (800 paid units = 501-1000 range)
// Volume pricing: all 8 packs at $7 = $56
const expectedPrepaidCost = 8 * 7;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: fourTiers,
});
const pro = products.pro({
id: "pro-volume-4tier",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// Preview must reflect volume pricing at tier 3
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
// Balance should equal total quantity (free + paid)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaidCost,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { type ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";

View File

@@ -0,0 +1,235 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { calculateProration } from "@tests/integration/billing/utils/proration/calculateProration";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: TIER BEHAVIOR TRANSITIONS
// ═══════════════════════════════════════════════════════════════════════════════
const BILLING_UNITS = 100;
// Tiers for prepaid pricing tests (same for graduated and volume)
const PREPAID_TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// 4.8 Graduated prepaid to volume prepaid (mid-cycle with proration)
test.concurrent(`${chalk.yellowBright("p2p: graduated prepaid to volume prepaid (mid-cycle proration)")}`, async () => {
const quantity = 800;
const basePrice = 20;
// Graduated calculation: 5 packs × $10 + 3 packs × $5 = $65
const graduatedCost = 5 * 10 + 3 * 5;
// Volume calculation: 8 packs × $5 (tier 2 rate for all) = $40
const volumeCost = 8 * 5;
const graduatedItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: PREPAID_TIERS,
});
const priceItem = items.monthlyPrice({ price: basePrice });
const pro = products.base({ id: "pro", items: [graduatedItem, priceItem] });
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "p2p-grad-to-vol-proration",
setup: [
s.customer({ paymentMethod: "success", testClock: true }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
],
});
// Verify initial invoice (base price + graduated prepaid)
const initialCustomer =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: initialCustomer,
count: 1,
latestTotal: basePrice + graduatedCost,
});
// Advance 15 days (mid-cycle)
const advancedTo = await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 15,
});
// Change to volume prepaid with same quantity
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: PREPAID_TIERS,
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [volumeItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Calculate prorated difference: credit old graduated, charge new volume
const proratedGraduated = await calculateProration({
customerId,
advancedTo,
amount: graduatedCost,
});
const proratedVolume = await calculateProration({
customerId,
advancedTo,
amount: volumeCost,
});
// Volume is cheaper, so this should be a credit (negative)
const expectedAmount = proratedVolume - proratedGraduated;
expect(preview.total).toEqual(expectedAmount);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Balance should remain at 800 (quantity unchanged)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// Tiers for consumable pricing tests
const CONSUMABLE_TIERS = [
{ to: 500, amount: 0.1 },
{ to: "inf" as const, amount: 0.05 },
];
// 4.9 Volume prepaid to graduated tiered consumable
test.concurrent(`${chalk.yellowBright("p2p: volume prepaid to graduated tiered consumable")}`, async () => {
const quantity = 800;
const basePrice = 20;
// Volume prepaid: 8 packs × $5 = $40
const volumeCost = 8 * 5;
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: PREPAID_TIERS,
});
const priceItem = items.monthlyPrice({ price: basePrice });
const pro = products.base({ id: "pro", items: [volumeItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-vol-to-tiered-cons",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
],
});
// Track some usage (600 of 800 = 200 remaining)
const messagesUsage = 600;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify customer has usage tracked
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerBefore.features[TestFeature.Messages].usage).toBe(
messagesUsage,
);
// Change to tiered consumable with 100 included
// tier_behavior should be undefined (defaults to graduated)
const tieredConsumableItem = items.tieredConsumableMessages({
includedUsage: 100,
billingUnits: 1,
tiers: CONSUMABLE_TIERS,
});
// Verify the item has no tier_behavior set (defaults to graduated)
expect(tieredConsumableItem.tier_behavior).toBeUndefined();
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [tieredConsumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should refund full prepaid amount ($40)
expect(preview.total).toBe(-volumeCost);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Usage should be preserved (600 tracked)
// New included usage is 100, so balance = 100 - 600 = -500 (in overage)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: tieredConsumableItem.included_usage,
balance: tieredConsumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -579,6 +579,31 @@ const consumableWords = ({
interval,
});
/**
* Tiered consumable messages - graduated pricing with tiers (pay-per-use).
* Default tiers: 0-500 units at $0.10/unit, 501+ at $0.05/unit.
* IMPORTANT: Last tier MUST have `to: "inf"` - Stripe requires a catch-all tier.
* Note: tier_behavior is undefined, defaulting to graduated pricing.
*/
const tieredConsumableMessages = ({
includedUsage = 0,
billingUnits = 1,
tiers = [
{ to: 500, amount: 0.1 },
{ to: "inf", amount: 0.05 },
],
}: {
includedUsage?: number;
billingUnits?: number;
tiers?: { to: number | "inf"; amount: number }[];
} = {}): LimitedItem =>
constructArrearItem({
featureId: TestFeature.Messages,
tiers: tiers as { to: number; amount: number }[],
billingUnits,
includedUsage,
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// ALLOCATED / SEATS (prorated billing)
// ═══════════════════════════════════════════════════════════════════
@@ -701,6 +726,7 @@ export const items = {
consumable,
consumableMessages,
consumableWords,
tieredConsumableMessages,
// Allocated
allocatedUsers,

View File

@@ -2,7 +2,7 @@ import type { UsageTier } from "@models/productModels/priceModels/priceConfig/us
import { Infinite } from "@models/productModels/productEnums";
import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit";
import { nullish } from "@utils/utils";
import Decimal from "decimal.js";
import { Decimal } from "decimal.js";
export const volumeTiersToLineAmount = ({
tiers,

View File

@@ -1,5 +1,4 @@
import type { Organization } from "@models/orgModels/orgTable";
import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import type { Price } from "@models/productModels/priceModels/priceModels";
import type { FullProduct } from "@models/productModels/productModels";
import { orgToCurrency } from "@utils/orgUtils/convertOrgUtils";
@@ -8,6 +7,7 @@ import { priceToStripePrepaidV2Tiers } from "@utils/productUtils/priceUtils/conv
import { priceToStripeProductName } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeProductName";
import { priceToStripeRecurringParams } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeRecurringParams";
import type Stripe from "stripe";
import { priceToStripeTiersMode } from "./priceToStripeTiersMode";
export const priceToStripeCreatePriceParams = ({
price,
@@ -41,9 +41,7 @@ export const priceToStripeCreatePriceParams = ({
};
const tiers = priceToStripePrepaidV2Tiers({ price, entitlement, org });
const tiersMode =
price.tier_behavior === TierBehavior.VolumeBased ? "volume" : "graduated";
const tiersMode = priceToStripeTiersMode({ price });
let priceAmountData = {};
if (tiers.length === 1) {

View File

@@ -0,0 +1,7 @@
import { type Price, TierBehavior } from "../../../..";
export const priceToStripeTiersMode = ({ price }: { price: Price }) => {
return price.tier_behavior === TierBehavior.VolumeBased
? "volume"
: "graduated";
};

View File

@@ -1,10 +1,12 @@
import { priceIsTieredOneOff } from "@utils/productUtils/priceUtils/classifyPrice/priceIsTieredOneOff.js";
import { priceToAllowanceInPacks } from "@utils/productUtils/priceUtils/convertPrice/priceToAllowanceInPacks.js";
import { priceToStripeCreatePriceParams } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.js";
import { priceToStripeTiersMode } from "./convertPrice/priceToStripeTiersMode.js";
export * from "./classifyPrice/priceIsTieredOneOff.js";
export * from "./classifyPriceUtils.js";
export * from "./convertAmountUtils.js";
export * from "./convertPrice/priceToStripeTiersMode.js";
export * from "./convertPriceUtils.js";
export * from "./findPrice/findPriceByFeatureId.js";
export * from "./formatPriceUtils.js";
@@ -14,6 +16,7 @@ export const priceUtils = {
convert: {
toAllowanceInPacks: priceToAllowanceInPacks,
toStripeCreatePriceParams: priceToStripeCreatePriceParams,
toStripeTiersMode: priceToStripeTiersMode,
},
isTieredOneOff: priceIsTieredOneOff,

View File

@@ -41,6 +41,7 @@ export const productItemToPlanItemParamsV1 = ({
billing_units: planItemV1.price.billing_units,
billing_method: planItemV1.price.billing_method,
max_purchase: planItemV1.price.max_purchase ?? undefined,
tier_behavior: planItemV1.price.tier_behavior ?? undefined,
}
: undefined,
proration: planItemV1.proration