From f2fa01110df9facb369641d385cc882c58628827 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 27 Feb 2026 17:08:27 +0000 Subject: [PATCH] fix agent issues --- .../allocatedToStripeItemSpec.ts | 9 +- .../cusEntToInlineStripePrice.ts | 19 +- .../prepaidToStripeItemSpec.ts | 12 +- .../stripeItemSpecToStripeParam.ts | 51 +++- .../attach-oneoff-prepaid-tiers.test.ts | 219 ++++++++++++++++++ .../attach-prepaid-volume-entities.test.ts | 8 +- .../helpers/validateSchedulePhases.ts | 6 +- .../helpers/validateSubState.ts | 19 +- .../utils/expectStripeSubCorrect/types.ts | 2 +- .../verifySubscription.ts | 2 +- 10 files changed, 306 insertions(+), 41 deletions(-) create mode 100644 server/tests/integration/billing/attach/new-plan/prepaid/attach-oneoff-prepaid-tiers.test.ts diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts index ada299a45..37b617160 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusEntToInlineStripePrice.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusEntToInlineStripePrice.ts index d918377a0..4b894a550 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusEntToInlineStripePrice.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusEntToInlineStripePrice.ts @@ -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, }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts index 7f6eec8bc..5a60e4ba8 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts index 49ee260e9..7b34d99e6 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts @@ -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; diff --git a/server/tests/integration/billing/attach/new-plan/prepaid/attach-oneoff-prepaid-tiers.test.ts b/server/tests/integration/billing/attach/new-plan/prepaid/attach-oneoff-prepaid-tiers.test.ts new file mode 100644 index 000000000..d715c5daf --- /dev/null +++ b/server/tests/integration/billing/attach/new-plan/prepaid/attach-oneoff-prepaid-tiers.test.ts @@ -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(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( + 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( + 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(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: expectedTotal2, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 1, + latestTotal: expectedTotal1, + }); +}); diff --git a/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts index 24c95dca0..2b9039593 100644 --- a/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts +++ b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts @@ -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({ diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSchedulePhases.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSchedulePhases.ts index cef9c83ae..76889200b 100644 --- a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSchedulePhases.ts +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSchedulePhases.ts @@ -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; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSubState.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSubState.ts index ae05c26d6..60924abf2 100644 --- a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSubState.ts +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSubState.ts @@ -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; } diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/types.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/types.ts index 29baac162..6972cf257 100644 --- a/server/tests/integration/billing/utils/expectStripeSubCorrect/types.ts +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/types.ts @@ -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[]; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/verifySubscription.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/verifySubscription.ts index 59c14cf8b..adea6d83c 100644 --- a/server/tests/integration/billing/utils/expectStripeSubCorrect/verifySubscription.ts +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/verifySubscription.ts @@ -102,7 +102,7 @@ export const verifySubscription = async ({ sub, scenario, cancelAtSeconds, - shouldBeCanceled: options?.shouldBeCanceled, + shouldBeCanceling: options?.shouldBeCanceling, debug, });