fix: entity on description / additional overrides on update subscription

This commit is contained in:
John Yeo
2026-04-28 16:56:18 +01:00
parent 19d68d38df
commit 0fc3f01ae1
12 changed files with 124 additions and 9 deletions

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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;
}
};

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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,
),
);
};

View File

@@ -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,
});
};

View File

@@ -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;
};