fix agent issues

This commit is contained in:
John Yeo
2026-02-27 17:08:27 +00:00
parent 8077c6dfe6
commit f2fa01110d
10 changed files with 306 additions and 41 deletions

View File

@@ -1,6 +1,7 @@
import {
cusEntToBillingObjects,
type FullCusEntWithFullCusProduct,
InternalError,
type StripeItemSpec,
type UsagePriceConfig,
} from "@autumn/shared";
@@ -21,10 +22,16 @@ export const allocatedToStripeItemSpec = ({
const { price, product } = billing;
const config = price.config as UsagePriceConfig;
if (!config.stripe_price_id) {
throw new InternalError({
message: `[allocatedToStripeItemSpec] config.stripe_price_id is empty for autumn price: ${price.id}`,
});
}
const existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
return {
stripePriceId: config.stripe_price_id!,
stripePriceId: config.stripe_price_id,
quantity: existingUsage,
autumnPrice: price,
autumnProduct: product,

View File

@@ -1,18 +1,16 @@
import {
cusEntsToAllowance,
cusEntToPrepaidInvoiceOverage,
type FullCusEntWithFullCusProduct,
InternalError,
isVolumePrice,
type Organization,
orgToCurrency,
priceToLineAmount,
type StripeInlinePrice,
} from "@autumn/shared";
import { cusEntsToPrepaidQuantity } from "@shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity";
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
import { atmnToStripeAmountDecimal } from "@shared/utils/productUtils/priceUtils/convertAmountUtils";
import { priceToStripeRecurringParams } from "@shared/utils/productUtils/priceUtils/convertPrice/priceToStripeRecurringParams";
import { Decimal } from "decimal.js";
/**
* Builds a flat inline Stripe price for an entity-scoped prepaid item.
@@ -44,21 +42,14 @@ export const cusEntToInlineStripePrice = ({
});
}
// 1. Get overage (purchased quantity in feature units, excluding allowance)
let overage = cusEntsToPrepaidQuantity({
cusEnts: [cusEnt],
sumAcrossEntities: false,
// 1. Get overage (purchased quantity in feature units
const overage = cusEntToPrepaidInvoiceOverage({
cusEnt,
useUpcomingQuantity: true,
});
// 2. Get allowance
const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] });
// 3. Volume pricing: total quantity determines the tier, entire amount is charged
if (isVolumePrice(price)) {
overage = new Decimal(overage).add(allowance).toNumber();
}
// 4. Calculate total dollar amount using tier logic
const totalAmount = priceToLineAmount({
price,
@@ -74,7 +65,7 @@ export const cusEntToInlineStripePrice = ({
return {
product: productId,
currency,
recurring: recurring!,
...(recurring && { recurring }),
unit_amount_decimal: totalStripeAmount,
};
};

View File

@@ -4,6 +4,7 @@ import {
featureOptionUtils,
InternalError,
isPrepaidPrice,
priceUtils,
type StripeItemSpec,
type UsagePriceConfig,
} from "@autumn/shared";
@@ -36,8 +37,9 @@ export const prepaidToStripeItemSpec = ({
const config = price.config as UsagePriceConfig;
const isEntityScoped = notNullish(cusProduct.internal_entity_id);
const isTieredOneOff = priceUtils.isTieredOneOff({ price, product });
if (isEntityScoped) {
if (isEntityScoped || isTieredOneOff) {
const inlinePrice = cusEntToInlineStripePrice({
cusEnt: cusEntWithCusProduct,
org: ctx.org,
@@ -53,6 +55,12 @@ export const prepaidToStripeItemSpec = ({
};
}
if (!config.stripe_prepaid_price_v2_id) {
throw new InternalError({
message: `[prepaidToStripeItemSpec] Price ${price.id} has no stripe_prepaid_price_v2_id`,
});
}
const quantity = featureOptionUtils.convert.toV2StripeQuantity({
featureOptions: options ?? undefined,
price,
@@ -60,7 +68,7 @@ export const prepaidToStripeItemSpec = ({
});
return {
stripePriceId: config.stripe_prepaid_price_v2_id!,
stripePriceId: config.stripe_prepaid_price_v2_id,
quantity,
autumnPrice: price,
autumnEntitlement: entitlement,

View File

@@ -1,33 +1,68 @@
import type { StripeInlinePrice, StripeItemSpec } from "@autumn/shared";
import {
InternalError,
type StripeInlinePrice,
type StripeItemSpec,
} from "@autumn/shared";
import type Stripe from "stripe";
type StoredPriceParam = { price: string };
type RecurringInlinePriceParam = {
price_data: Stripe.SubscriptionCreateParams.Item["price_data"];
};
/**
* Returns the price param for a StripeItemSpec — either a stored price ID or inline price_data.
* For inline prices, asserts that `recurring` is present (one-off items should not reach this path).
*/
const toPriceParam = ({
const toRecurringPriceParam = ({
spec,
}: {
spec: StripeItemSpec;
}): { price: string } | { price_data: StripeInlinePrice } => {
}): StoredPriceParam | RecurringInlinePriceParam => {
if (spec.stripeInlinePrice) {
return { price_data: spec.stripeInlinePrice };
if (!spec.stripeInlinePrice.recurring) {
throw new InternalError({
message:
"stripeItemSpecToSubscriptionItem called with non-recurring inline price — one-off items should use the invoice path",
code: "inline_price_missing_recurring",
});
}
return {
price_data: {
...spec.stripeInlinePrice,
recurring: spec.stripeInlinePrice.recurring,
},
};
}
return { price: spec.stripePriceId! };
};
/** Converts a StripeItemSpec to a Stripe subscription item param (create or update). */
/** Converts a StripeItemSpec to a Stripe subscription item param (create or update).
* Only call with recurring specs — one-off items use a separate invoice path. */
export const stripeItemSpecToSubscriptionItem = ({
spec,
}: {
spec: StripeItemSpec;
}): Stripe.SubscriptionCreateParams.Item => {
return {
...toPriceParam({ spec }),
...toRecurringPriceParam({ spec }),
...(spec.quantity !== undefined && { quantity: spec.quantity }),
...(spec.metadata && { metadata: spec.metadata }),
};
};
/** Returns a price param without recurring validation — for checkout line items. */
const toPriceParam = ({
spec,
}: {
spec: StripeItemSpec;
}): StoredPriceParam | { price_data: StripeInlinePrice } => {
if (spec.stripeInlinePrice) {
return { price_data: spec.stripeInlinePrice };
}
return { price: spec.stripePriceId! };
};
/** Converts a StripeItemSpec to a Stripe checkout session line item. */
export const stripeItemSpecToCheckoutLineItem = ({
spec,
@@ -36,7 +71,7 @@ export const stripeItemSpecToCheckoutLineItem = ({
}): Stripe.Checkout.SessionCreateParams.LineItem => {
return {
...toPriceParam({ spec }),
quantity: spec.quantity ?? 0,
quantity: spec.quantity,
};
};
@@ -47,7 +82,7 @@ export const stripeItemSpecToPhaseItem = ({
spec: StripeItemSpec;
}): Stripe.SubscriptionScheduleUpdateParams.Phase.Item => {
return {
...toPriceParam({ spec }),
...toRecurringPriceParam({ spec }),
...(spec.quantity !== undefined && { quantity: spec.quantity }),
...(spec.metadata && { metadata: spec.metadata }),
} as Stripe.SubscriptionScheduleUpdateParams.Phase.Item;

View File

@@ -0,0 +1,219 @@
/**
* Attach One-Off Prepaid with Tiered Pricing (Attach V2)
*
* Tests for attaching one-off products with included usage and tiered pricing
* via the direct attach flow (customer already has payment method).
*
* Test 1: Customer-level attach with tiered one-off
* - Included usage: 100 units (1 free pack)
* - Tiered pricing: 0-500 @ $10/pack, 501+ @ $5/pack
* - Request 800 units → 1 free + 5×$10 + 2×$5 = $60 prepaid
* - Total: $10 base + $60 prepaid = $70
*
* Test 2: Entity-level attach with tiered one-off
* - Same tiered pricing, attached to two entities with different quantities
* - Entity 1: 300 units → 1 free + 2×$10 = $20 prepaid
* - Entity 2: 800 units → 1 free + 5×$10 + 2×$5 = $60 prepaid
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
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";
const BILLING_UNITS = 100;
const INCLUDED_USAGE = 100;
const BASE_PRICE = 10;
const TIERS = [
{ to: 500 as const, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Customer-level one-off with tiered pricing
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("oneoff-prepaid-tiers: customer-level tiered one-off")}`, async () => {
const customerId = "oneoff-tiers-customer";
const quantity = 800;
// 800 total = 8 packs: 1 free (includedUsage) + 7 paid
// Tier 1 (0-500): 5 paid packs × $10 = $50
// Tier 2 (501+): 2 paid packs × $5 = $10
// Prepaid total: $60
const expectedPrepaidCost = 5 * 10 + 2 * 5;
const expectedTotal = BASE_PRICE + expectedPrepaidCost;
const tieredOneOffItem = items.tieredOneOffMessages({
includedUsage: INCLUDED_USAGE,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const oneOff = products.oneOff({
id: "one-off-tiered-cus",
items: [tieredOneOffItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: oneOff.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(expectedTotal);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: oneOff.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: oneOff.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Entity-level one-off with tiered pricing
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("oneoff-prepaid-tiers: entity-level tiered one-off")}`, async () => {
const customerId = "oneoff-tiers-entity";
const quantity1 = 300;
const quantity2 = 800;
// Entity 1: 300 total = 3 packs: 1 free + 2 paid
// All 2 paid packs in tier 1 (0-500): 2 × $10 = $20
const expectedPrepaidCost1 = 2 * 10;
const expectedTotal1 = BASE_PRICE + expectedPrepaidCost1;
// Entity 2: 800 total = 8 packs: 1 free + 7 paid
// Tier 1 (0-500): 5 paid packs × $10 = $50
// Tier 2 (501+): 2 paid packs × $5 = $10
const expectedPrepaidCost2 = 5 * 10 + 2 * 5;
const expectedTotal2 = BASE_PRICE + expectedPrepaidCost2;
const tieredOneOffItem = items.tieredOneOffMessages({
includedUsage: INCLUDED_USAGE,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const oneOff = products.oneOff({
id: "one-off-tiered-ent",
items: [tieredOneOffItem],
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [],
});
// Attach to entity 1
const preview1 = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: oneOff.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
});
expect(preview1.total).toBe(expectedTotal1);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: oneOff.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
redirect_mode: "if_required",
});
// Attach to entity 2
const preview2 = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: oneOff.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
});
expect(preview2.total).toBe(expectedTotal2);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: oneOff.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
redirect_mode: "if_required",
});
// Verify entity 1 balance
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectProductActive({ customer: entity1, productId: oneOff.id });
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: quantity1,
usage: 0,
});
// Verify entity 2 balance
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
await expectProductActive({ customer: entity2, productId: oneOff.id });
expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: quantity2,
usage: 0,
});
// Verify invoices: 2 total (one per entity attach)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: expectedTotal2,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
invoiceIndex: 1,
latestTotal: expectedTotal1,
});
});

View File

@@ -184,12 +184,12 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: volume wi
const quantity1 = 800;
const quantity2 = 400;
// Entity 1: 600 purchased → 6 packs → tier 2 → 6×$5 = $30
const purchasedPacks1 = (quantity1 - includedUsage) / BILLING_UNITS;
// Entity 1: 800 (including included usage) purchased
const purchasedPacks1 = quantity1 / BILLING_UNITS;
const volExpected1 = purchasedPacks1 * 5; // tier 2 rate (>5 packs)
// Entity 2: 200 purchased → 2 packs → tier 1 → 2×$10 = $20
const purchasedPacks2 = (quantity2 - includedUsage) / BILLING_UNITS;
// Entity 2: 400 (including included usage) purchased
const purchasedPacks2 = quantity2 / BILLING_UNITS;
const volExpected2 = purchasedPacks2 * 10; // tier 1 rate (≤5 packs)
const volItem = items.volumePrepaidMessages({

View File

@@ -21,7 +21,11 @@ export const validateSchedulePhases = async ({
scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
debug?: boolean;
}) => {
if (!sub.schedule) return;
expect(
sub.schedule,
`Expected subscription ${sub.id} to have a schedule for phase validation`,
).not.toBeNull();
if (!sub.schedule) return; // type narrowing only — expect above will fail first
const scheduleId =
typeof sub.schedule === "string" ? sub.schedule : sub.schedule.id;

View File

@@ -1,6 +1,7 @@
import { expect } from "bun:test";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import type Stripe from "stripe";
import { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { PhaseScenario } from "../classifyPhaseScenario";
/**
@@ -52,30 +53,30 @@ export const validateSubState = async ({
sub,
scenario,
cancelAtSeconds,
shouldBeCanceled,
shouldBeCanceling,
debug,
}: {
ctx: TestContext;
sub: Stripe.Subscription;
scenario: PhaseScenario;
cancelAtSeconds?: number;
shouldBeCanceled?: boolean;
shouldBeCanceling?: boolean;
debug?: boolean;
}) => {
// Explicit override takes priority
if (shouldBeCanceled === true) {
if (shouldBeCanceling === true) {
expect(
sub.cancel_at !== null ||
sub.canceled_at !== null ||
sub.cancel_at_period_end,
isStripeSubscriptionCanceling(sub),
"Expected subscription to be canceling",
).toBe(true);
return;
}
if (shouldBeCanceled === false) {
expect(sub.cancel_at).toBeNull();
expect(sub.canceled_at).toBeNull();
if (shouldBeCanceling === false) {
expect(
isStripeSubscriptionCanceling(sub),
"Expected subscription to NOT be canceling",
).toBe(false);
return;
}

View File

@@ -2,7 +2,7 @@ import type { BillingVersion } from "@autumn/shared";
export type ExpectStripeSubOptions = {
status?: "active" | "trialing";
shouldBeCanceled?: boolean;
shouldBeCanceling?: boolean;
subId?: string;
subCount?: number;
rewards?: string[];

View File

@@ -102,7 +102,7 @@ export const verifySubscription = async ({
sub,
scenario,
cancelAtSeconds,
shouldBeCanceled: options?.shouldBeCanceled,
shouldBeCanceling: options?.shouldBeCanceling,
debug,
});