From 0fc3f01ae1670286fbe940041e5917a8817b1f06 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 28 Apr 2026 16:56:18 +0100 Subject: [PATCH] fix: entity on description / additional overrides on update subscription --- server/perf/load-test/setup.ts | 2 +- .../stripeLegacySeederMiddleware.ts | 3 ++ .../compute/customPlan/computeCustomPlan.ts | 3 ++ .../computeCustomPlanNewCustomerProduct.ts | 11 +++--- .../setupUpdateSubscriptionBillingContext.ts | 8 ++++- .../applyExistingRollovers.ts | 3 -- .../customerProductToArrearLineItems.ts | 6 ++++ .../updateSubscriptionBillingContext.ts | 14 ++++++++ .../billingModels/lineItem/lineItemContext.ts | 2 ++ .../filterCustomerProductsByFeatureId.ts | 22 ++++++++++++ .../priceUtils/findTier/findTierByOptions.ts | 36 +++++++++++++++++++ .../priceUtils/findTier/findTierByQuantity.ts | 23 ++++++++++++ 12 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByFeatureId.ts create mode 100644 shared/utils/productUtils/priceUtils/findTier/findTierByOptions.ts create mode 100644 shared/utils/productUtils/priceUtils/findTier/findTierByQuantity.ts diff --git a/server/perf/load-test/setup.ts b/server/perf/load-test/setup.ts index e0ac2d6ba..cc2b36315 100644 --- a/server/perf/load-test/setup.ts +++ b/server/perf/load-test/setup.ts @@ -2,7 +2,7 @@ * Load test setup — creates products and 500 customers with Stripe payment methods. * * Run: cd server && bun loadtest:setup - * or: ENV_FILE=.env infisical run --env=dev -- bun perf/load-test/setup.ts + * or: ENV_FILE=.env infisical run --recursive --env=dev -- bun perf/load-test/setup.ts */ import { loadLocalEnv } from "../../src/utils/envUtils.js"; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts index 59419505d..3611d09ab 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeLegacySeederMiddleware.ts @@ -63,6 +63,9 @@ export const stripeLegacySeederMiddleware = async ( ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); + logger.warn( + `Stripe legacy webhook signature verification failed: ${message}`, + ); return c.json({ error: `Webhook Error: ${message}` }, 400); } diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts index 7c0c806c2..7e3d1b440 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -42,6 +42,9 @@ export const computeCustomPlan = async ({ newCustomerProducts: [newFullCustomerProduct], deletedCustomerProduct: customerProduct, billingContext: updateSubscriptionContext, + + includeArrearLineItems: + updateSubscriptionContext.chargeExistingOverages === true, }); // If customer product is canceling, compute the scheduled product to delete diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index 14874b069..05fd771a6 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -32,6 +32,7 @@ export const computeCustomPlanNewCustomerProduct = ({ trialContext, cancelAction, billingVersion, + skipExistingUsageCarry, } = updateSubscriptionContext; const cancelFields = computeCancelFields({ @@ -65,10 +66,12 @@ export const computeCustomPlanNewCustomerProduct = ({ trialEndsAt: trialContext?.trialEndsAt ?? undefined, billingVersion: billingVersion, - existingUsagesConfig: { - fromCustomerProduct: customerProduct, - carryAllConsumableFeatures: true, - }, + existingUsagesConfig: skipExistingUsageCarry + ? undefined + : { + fromCustomerProduct: customerProduct, + carryAllConsumableFeatures: true, + }, existingRolloversConfig: { fromCustomerProduct: customerProduct, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 5b4a6f750..678518a80 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -137,7 +137,10 @@ export const setupUpdateSubscriptionBillingContext = async ({ }); const invoiceMode = setupInvoiceModeContext({ params }); - const isCustom = hasCustomItems(params.customize); + const isCustom = + contextOverride.forceIsCustom !== undefined + ? contextOverride.forceIsCustom + : hasCustomItems(params.customize); const defaultProduct = await setupDefaultProductContext({ ctx, @@ -210,5 +213,8 @@ export const setupUpdateSubscriptionBillingContext = async ({ prorationBehavior: params.proration_behavior, outgoingCustomerProduct: customerProduct, }), + + chargeExistingOverages: contextOverride.chargeExistingOverages, + skipExistingUsageCarry: contextOverride.skipExistingUsageCarry, }; }; diff --git a/server/src/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers.ts b/server/src/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers.ts index 8caf71f2d..129cb4359 100644 --- a/server/src/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers.ts +++ b/server/src/internal/billing/v2/utils/handleExistingRollovers/applyExistingRollovers.ts @@ -36,9 +36,6 @@ export const applyExistingRollovers = ({ id: generateId("roll"), cus_ent_id: targetCusEnt.id, }); - console.log( - `Added rollover with balance ${existingRollover.balance} to new cus ent: ${targetCusEnt.id}-${targetCusEnt.entitlement.feature.name}`, - ); } else continue; } }; diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts index a2a8620d4..9b09dc010 100644 --- a/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts @@ -1,5 +1,6 @@ import type { BillingContext, UpdateCustomerEntitlement } from "@autumn/shared"; import { + customerProductToEntity, cusPriceToCusEntWithCusProduct, cusProductToPrices, EntInterval, @@ -47,6 +48,10 @@ export const customerProductToArrearLineItems = ({ updateCustomerEntitlements: UpdateCustomerEntitlement[]; } => { const lineItems: LineItem[] = []; + const entity = customerProductToEntity({ + customerProduct, + entities: billingContext.fullCustomer.entities, + }); let filteredPrices = cusProductToPrices({ cusProduct: customerProduct }); @@ -98,6 +103,7 @@ export const customerProductToArrearLineItems = ({ currency: billingContext.stripeCustomer?.currency ?? orgToCurrency({ org: ctx.org }), + entity, customerProduct, customerPrice: cusPrice, }; diff --git a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts index 5d9a3d727..5d799ad12 100644 --- a/shared/models/billingModels/context/updateSubscriptionBillingContext.ts +++ b/shared/models/billingModels/context/updateSubscriptionBillingContext.ts @@ -21,6 +21,20 @@ export interface UpdateSubscriptionBillingContext extends BillingContext { recalculateBalances?: boolean; intent: UpdateSubscriptionIntent; + + /** + * Mirror of `UpdateSubscriptionBillingContextOverride.chargeExistingOverages`. + * Read by `computeCustomPlan` to decide whether to call + * `buildAutumnLineItems` with `includeArrearLineItems: true`. + */ + chargeExistingOverages?: boolean; + + /** + * Mirror of `UpdateSubscriptionBillingContextOverride.skipExistingUsageCarry`. + * Read by `computeCustomPlanNewCustomerProduct` to decide whether to carry + * consumable usages forward when initializing the new customer_product. + */ + skipExistingUsageCarry?: boolean; } export interface UpdateSubscriptionBillingContextOverrides { diff --git a/shared/models/billingModels/lineItem/lineItemContext.ts b/shared/models/billingModels/lineItem/lineItemContext.ts index 11577b545..6457515f3 100644 --- a/shared/models/billingModels/lineItem/lineItemContext.ts +++ b/shared/models/billingModels/lineItem/lineItemContext.ts @@ -3,6 +3,7 @@ import { FullCustomerEntitlementSchema } from "../../cusProductModels/cusEntMode import { FullCustomerPriceSchema } from "../../cusProductModels/cusPriceModels/cusPriceModels"; import { FullCusProductSchema } from "../../cusProductModels/cusProductModels"; import { FeatureSchema } from "../../featureModels/featureModels"; +import { EntitySchema } from "../../cusModels/entityModels/entityModels"; import { PriceSchema } from "../../productModels/priceModels/priceModels"; import { ProductSchema } from "../../productModels/productModels"; @@ -25,6 +26,7 @@ export const LineItemContextSchema = z.object({ discountable: z.boolean().optional(), // If true, let Stripe auto-apply discounts to this line item // Entity references (optional - not all line items have these) + entity: EntitySchema.optional(), customerProduct: FullCusProductSchema.optional(), customerPrice: FullCustomerPriceSchema.optional(), customerEntitlement: FullCustomerEntitlementSchema.optional(), diff --git a/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByFeatureId.ts b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByFeatureId.ts new file mode 100644 index 000000000..1d3986724 --- /dev/null +++ b/shared/utils/cusProductUtils/filterCustomerProducts/filterCustomerProductsByFeatureId.ts @@ -0,0 +1,22 @@ +import type { FullCusProduct } from "@models/cusProductModels/cusProductModels"; + +/** + * Filter customer products to those that have at least one customer_price + * linked to a price for the given feature_id. + * + * "Paid for feature X" semantics — the customer is being billed for usage of + * this feature on the cusProduct. + */ +export const filterCustomerProductsByFeatureId = ({ + customerProducts, + featureId, +}: { + customerProducts: FullCusProduct[]; + featureId: string; +}) => { + return customerProducts.filter((customerProduct) => + customerProduct.customer_prices.some( + (customerPrice) => customerPrice.price?.config?.feature_id === featureId, + ), + ); +}; diff --git a/shared/utils/productUtils/priceUtils/findTier/findTierByOptions.ts b/shared/utils/productUtils/priceUtils/findTier/findTierByOptions.ts new file mode 100644 index 000000000..3f028571b --- /dev/null +++ b/shared/utils/productUtils/priceUtils/findTier/findTierByOptions.ts @@ -0,0 +1,36 @@ +import type { FeatureOptions } from "@models/cusProductModels/cusProductModels"; +import type { + UsagePriceConfig, + UsageTier, +} from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; +import type { Price } from "@models/productModels/priceModels/priceModels"; +import { findTierByQuantity } from "./findTierByQuantity"; + +/** + * Find the volume-tier the customer is currently on, given a prepaid `Price` + * and the matching `FeatureOptions` entry from `cusProduct.options`. + * + * Convention (Autumn prepaid): + * - `options.quantity` is in PACKS and EXCLUDES the entitlement allowance. + * - Tier `to` boundaries on the price are paid-only — they also EXCLUDE the + * allowance. + * - Lookup quantity = `options.quantity * billing_units` (paid credits). + * + * Returns the matching tier (or undefined when no tier covers the quantity — + * shouldn't happen if the tier list ends with `Infinite`). + */ +export const findTierByOptions = ({ + price, + options, +}: { + price: Price; + options: FeatureOptions | undefined; +}): UsageTier | undefined => { + const config = price.config as UsagePriceConfig; + const billingUnits = config.billing_units ?? 1; + const paidQuantity = (options?.quantity ?? 0) * billingUnits; + return findTierByQuantity({ + tiers: config.usage_tiers ?? [], + quantity: paidQuantity, + }); +}; diff --git a/shared/utils/productUtils/priceUtils/findTier/findTierByQuantity.ts b/shared/utils/productUtils/priceUtils/findTier/findTierByQuantity.ts new file mode 100644 index 000000000..19d2946a1 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/findTier/findTierByQuantity.ts @@ -0,0 +1,23 @@ +import type { UsageTier } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; +import { Infinite } from "@models/productModels/productEnums"; + +/** + * Find the volume-tier that covers the given quantity. Walks tiers in order + * and returns the first one whose `to` is ≥ quantity (or the final Infinite + * tier). Returns undefined only if no Infinite tier exists and quantity + * exceeds every bound — tier shapes are expected to end with `to: Infinite`, + * so this is rare. + */ +export const findTierByQuantity = ({ + tiers, + quantity, +}: { + tiers: UsageTier[]; + quantity: number; +}): UsageTier | undefined => { + for (const tier of tiers) { + if (tier.to === Infinite || tier.to === -1) return tier; + if (typeof tier.to === "number" && quantity <= tier.to) return tier; + } + return undefined; +};