Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {
addCusProductToCusEnt,
customerPriceToCustomerEntitlement,
type FeatureOptions,
findCusPriceByFeature,
findFeatureByInternalId,
findFeatureOptionsByFeature,
findPrepaidQuantityTargetPrice,
InternalError,
isOneOffPrice,
isPrepaidPrice,
Expand Down Expand Up @@ -80,13 +80,28 @@ export const computeUpdateQuantityDetails = ({
updatedOptions,
});

const customerPrice = findCusPriceByFeature({
internalFeatureId: internalFeatureId,
cusPrices: customerProduct.customer_prices.filter((cp) =>
isPrepaidPrice(cp.price),
// Tie-break: pick the prepaid price the quantity resolves to — a recurring
// prepaid wins over a one-off prepaid sibling of the same feature.
const prepaidCustomerPrices = customerProduct.customer_prices.filter(
(customerPriceCandidate) => isPrepaidPrice(customerPriceCandidate.price),
);
const targetPrice = findPrepaidQuantityTargetPrice({
Comment thread
capy-ai[bot] marked this conversation as resolved.
prices: prepaidCustomerPrices.map(
(customerPriceCandidate) => customerPriceCandidate.price,
),
errorOnNotFound: true,
internalFeatureId,
featureId,
});
const customerPrice = prepaidCustomerPrices.find(
(customerPriceCandidate) =>
customerPriceCandidate.price.id === targetPrice?.id,
);

if (!customerPrice) {
throw new InternalError({
message: `Customer price not found for internal_feature_id: ${internalFeatureId}`,
});
}

if (isOneOffPrice(customerPrice.price)) {
throw new RecaseError({
Expand Down Expand Up @@ -143,6 +158,7 @@ export const computeUpdateQuantityDetails = ({
ctx,
billingContext: updateSubscriptionContext,
customerProduct,
prepaidCustomerEntitlement: cusEntWithCusProduct,
feature,
billingPeriod,
quantityDifferenceForEntitlements:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import {
billingContextToCurrency,
cloneEntitlementWithUpdatedQuantity,
cusEntToCusPrice,
cusProductToCusEnts,
type Feature,
type FullCusEntWithFullCusProduct,
type FullCusProduct,
findPrepaidCustomerEntitlement,
InternalError,
type LineItem,
type LineItemContext,
Expand All @@ -23,6 +22,7 @@ export const computeUpdateQuantityLineItems = ({
ctx,
billingContext,
customerProduct,
prepaidCustomerEntitlement,
feature,
billingPeriod,
quantityDifferenceForEntitlements,
Expand All @@ -31,24 +31,13 @@ export const computeUpdateQuantityLineItems = ({
ctx: AutumnContext;
billingContext: BillingContext;
customerProduct: FullCusProduct;
prepaidCustomerEntitlement: FullCusEntWithFullCusProduct;
feature: Feature;
billingPeriod?: BillingPeriod;
quantityDifferenceForEntitlements: number;
currentEpochMs: number;
}) => {
const { org } = ctx;
const customerEntitlements = cusProductToCusEnts({ customerProduct });

const prepaidCustomerEntitlement = findPrepaidCustomerEntitlement({
customerEntitlements,
feature,
});

if (!prepaidCustomerEntitlement) {
throw new InternalError({
message: `[Quantity Update] Prepaid customer entitlement not found for feature: ${feature.internal_id}`,
});
}

const customerPrice = cusEntToCusPrice({
cusEnt: prepaidCustomerEntitlement,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type AutumnBillingPlan,
findPrepaidQuantityTargetPrice,
isOneOffPrice,
notNullish,
type UpdateSubscriptionBillingContext,
Expand All @@ -17,15 +18,19 @@ export const computeUpdateQuantityPlan = ({
}): AutumnBillingPlan => {
const { customerProduct, featureQuantities } = updateSubscriptionContext;

// One-off prepaid mutations belong to the ManualTopUp intent. setupFeature-
// QuantitiesContext synthesizes placeholders for every prepaid price (incl.
// one-off), so filter them out here before computeUpdateQuantityDetails.
// One-off prepaid mutations belong to the ManualTopUp intent. Drop an option
// only when the feature's prepaid tie-break target is one-off — a recurring
// prepaid sibling of the same feature wins the quantity instead.
const customerPrices = customerProduct.customer_prices.map(
(customerPrice) => customerPrice.price,
);
const newOptions = featureQuantities.filter((option) => {
const cusPrice = customerProduct.customer_prices.find(
(cp) =>
cp.price.config.internal_feature_id === option.internal_feature_id,
);
return cusPrice ? !isOneOffPrice(cusPrice.price) : true;
const targetPrice = findPrepaidQuantityTargetPrice({
prices: customerPrices,
internalFeatureId: option.internal_feature_id,
featureId: option.feature_id,
});
return targetPrice ? !isOneOffPrice(targetPrice) : true;
});

const quantityUpdateDetails = newOptions.map((updatedOptions) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import type {
import {
cusProductToProduct,
ErrCode,
findPrepaidQuantityTargetPrice,
isCustomerProductOneOff,
isCustomerProductPaidRecurring,
isOneOffPrice,
isPrepaidPrice,
productsAreSame,
RecaseError,
resolveFreeTrialParam,
Expand Down Expand Up @@ -36,15 +36,14 @@ const blockOneOffQuantityChangeOutsideManualTopUp = ({
if (featureQuantities.length === 0) return;

for (const fq of featureQuantities) {
const oneOffPrepaidPrice = customerProduct.customer_prices
.map((cp) => cp.price)
.find((price) => {
if (!isOneOffPrice(price) || !isPrepaidPrice(price)) return false;
const config = price.config as { feature_id?: string };
return config.feature_id === fq.feature_id;
});

if (!oneOffPrepaidPrice) continue;
// Tie-break aware: only guard features whose prepaid quantity actually
// resolves to a one-off price — a recurring prepaid sibling wins instead.
const targetPrice = findPrepaidQuantityTargetPrice({
prices: customerProduct.customer_prices.map((cp) => cp.price),
featureId: fq.feature_id,
});
if (!targetPrice || !isOneOffPrice(targetPrice)) continue;
const oneOffPrepaidPrice = targetPrice;

const currentOption = customerProduct.options.find(
(o) => o.feature_id === fq.feature_id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { hasCustomItems } from "@api/billing/common/customizePlan/customizePlanV1";
import {
type CheckoutMode,
customerProductHasOneOffPrepaidForFeature,
cusProductToPrices,
customerProductHasPrepaidPrice,
type FullCusProduct,
findPrepaidQuantityTargetPrice,
isCustomerProductOneOff,
isOneOffPrice,
resolveFreeTrialParam,
UpdateSubscriptionIntent,
type UpdateSubscriptionV1Params,
Expand Down Expand Up @@ -37,19 +39,21 @@ export const setupUpdateSubscriptionIntent = ({
return UpdateSubscriptionIntent.UpdateLicenseQuantity;

// ManualTopUp wins over UpdateQuantity (and CancelAction/None): once we know
// this isn't a plan restructure, any feature_quantities entry targeting a
// one-off prepaid price on a recurring host routes here. handleManualTopUpErrors
// then rejects extra fields with "Update too complex to perform."
// this isn't a plan restructure, a feature_quantities entry whose prepaid
// tie-break target is a one-off price on a recurring host routes here.
// A recurring prepaid of the same feature wins the tie-break instead.
if (
!isCustomerProductOneOff(customerProduct) &&
featureQuantitiesParams.length > 0
) {
const targetsOneOffPrepaid = featureQuantitiesParams.some((fq) =>
customerProductHasOneOffPrepaidForFeature({
customerProduct,
const prices = cusProductToPrices({ cusProduct: customerProduct });
const targetsOneOffPrepaid = featureQuantitiesParams.some((fq) => {
const targetPrice = findPrepaidQuantityTargetPrice({
prices,
featureId: fq.feature_id,
}),
);
});
return targetPrice !== undefined && isOneOffPrice(targetPrice);
});

if (targetsOneOffPrepaid) return UpdateSubscriptionIntent.ManualTopUp;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type FeatureQuantityParamsV0,
type FullCusProduct,
type FullProduct,
isLosingPrepaidQuantityPrice,
isOneOffPrice,
isPrepaidPrice,
priceToEnt,
Expand Down Expand Up @@ -41,6 +42,11 @@ export const setupFeatureQuantitiesContext = ({
for (const price of fullProduct.prices) {
if (!isPrepaidPrice(price)) continue;

// Tie-break: when the same feature has recurring + one-off prepaid prices,
// only the winning price consumes the feature-keyed quantity entry.
if (isLosingPrepaidQuantityPrice({ price, prices: fullProduct.prices }))
continue;

// const feature = priceToFeature({
// price,
// features: ctx.features,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getStartingBalance,
type InitCustomerEntitlementContext,
isBooleanEntitlement,
isLosingPrepaidQuantityPrice,
isUnlimitedEntitlement,
} from "@autumn/shared";
import { initCustomerEntitlementEntities } from "./initCustomerEntitlementEntities";
Expand Down Expand Up @@ -42,10 +43,21 @@ export const initCustomerEntitlementBalance = ({
prices: initContext.fullProduct?.prices ?? [],
});

const options = entToOptions({
ent: entitlement,
options: featureQuantities,
});
// Tie-break: a losing prepaid price (e.g. one-off alongside a recurring
// prepaid of the same feature) must not read the feature-keyed quantity.
const priceLosesQuantity =
price &&
isLosingPrepaidQuantityPrice({
price,
prices: initContext.fullProduct?.prices ?? [],
});

const options = priceLosesQuantity
? undefined
: entToOptions({
ent: entitlement,
options: featureQuantities,
});

const startingBalance = getStartingBalance({
entitlement,
Expand Down
Loading
Loading