fix: entity on description / additional overrides on update subscription
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ export const computeCustomPlanNewCustomerProduct = ({
|
||||
trialContext,
|
||||
cancelAction,
|
||||
billingVersion,
|
||||
skipExistingUsageCarry,
|
||||
} = updateSubscriptionContext;
|
||||
|
||||
const cancelFields = computeCancelFields({
|
||||
@@ -65,7 +66,9 @@ export const computeCustomPlanNewCustomerProduct = ({
|
||||
trialEndsAt: trialContext?.trialEndsAt ?? undefined,
|
||||
billingVersion: billingVersion,
|
||||
|
||||
existingUsagesConfig: {
|
||||
existingUsagesConfig: skipExistingUsageCarry
|
||||
? undefined
|
||||
: {
|
||||
fromCustomerProduct: customerProduct,
|
||||
carryAllConsumableFeatures: true,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user