cleaned up parse feature quantities params

This commit is contained in:
John Yeo
2026-01-07 13:49:51 +00:00
parent 298c172d6e
commit 2ed5268877
36 changed files with 1503 additions and 1299 deletions

View File

@@ -696,8 +696,19 @@ export class AutumnInt {
};
subscriptions = {
update: async (params: UpdateSubscriptionV0Params) => {
update: async (
params: UpdateSubscriptionV0Params,
{ timeout }: { timeout?: number } = {},
) => {
const data = await this.post(`/subscriptions/update`, params);
if (timeout) {
await new Promise((resolve) => setTimeout(resolve, timeout));
}
return data;
},
previewUpdate: async (params: UpdateSubscriptionV0Params) => {
const data = await this.post(`/subscriptions/preview_update`, params);
return data;
},
};

View File

@@ -1,4 +1,8 @@
import type { FullCusProduct, FullProduct } from "@autumn/shared";
import type {
FeatureOptions,
FullCusProduct,
FullProduct,
} from "@autumn/shared";
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
import type Stripe from "stripe";
import { z } from "zod/v4";
@@ -21,10 +25,13 @@ export interface BillingContext {
// Timestamps...
currentEpochMs: number;
billingCycleAnchorMs?: number;
billingCycleAnchorMs: number | "now";
// Invoice mode
invoiceMode?: InvoiceMode;
// Feature quantities
featureQuantities: FeatureOptions[];
}
export interface UpdateSubscriptionBillingContext extends BillingContext {

View File

@@ -1,73 +0,0 @@
import { secondsToMs } from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import type { AttachContext } from "../typesOld";
import { buildAutumnLineItems } from "./computeAutumnUtils/buildAutumnLineItems";
import { buildNewCusProducts } from "./computeAutumnUtils/buildNewCusProducts";
/**
* Shared logic by attach, cancel and
*/
export const computeAttachPlan = async ({
ctx,
attachContext,
}: {
ctx: AutumnContext;
attachContext: AttachContext;
}) => {
const {
fullCus,
products,
ongoingCusProductAction,
scheduledCusProductAction,
} = attachContext;
// 1. Build new cus products
const newCusProducts = buildNewCusProducts({
ctx,
attachContext,
});
const billingCycleAnchor = secondsToMs(
attachContext.stripeSub?.billing_cycle_anchor,
);
const testClockFrozenTime = attachContext.testClockFrozenTime;
// When to build checkout action?
// 2. Build autumn line items
const lineItems = buildAutumnLineItems({
ctx,
newCusProducts,
ongoingCustomerProduct: ongoingCusProductAction?.cusProduct,
billingCycleAnchor,
testClockFrozenTime,
});
// 3. Build updateOneOff action
// const updateOneOffAction = buildUpdateOneOffAction({
// ctx,
// attachContext,
// newCusProducts,
// });
// 5. Build stripe sub action
const stripeSubAction = undefined;
// 6. Build stripe invoice action
// const stripeInvoiceAction = buildStripeInvoiceAction({
// attachContext,
// lineItems,
// stripeSubAction,
// newCusProducts,
// });
return {
lineItems,
ongoingCusProductAction,
scheduledCusProductAction,
newCusProducts,
stripeSubAction,
};
};

View File

@@ -18,16 +18,15 @@ export const buildAutumnLineItems = ({
billingContext: BillingContext;
}) => {
// billingCycleAnchor = billingCycleAnchor ?? now;
const billingCycleAnchor = billingContext.billingCycleAnchorMs;
const now = billingContext.currentEpochMs;
const { billingCycleAnchorMs, currentEpochMs } = billingContext;
const { org } = ctx;
const { org, logger } = ctx;
const arrearLineItems = deletedCustomerProduct
? cusProductToArrearLineItems({
cusProduct: deletedCustomerProduct,
billingCycleAnchor: billingCycleAnchor!,
now,
billingCycleAnchorMs,
nowMs: currentEpochMs,
org,
})
: [];
@@ -36,20 +35,22 @@ export const buildAutumnLineItems = ({
const deletedLineItems = deletedCustomerProduct
? cusProductToLineItems({
cusProduct: deletedCustomerProduct,
now,
billingCycleAnchor: billingCycleAnchor!,
nowMs: currentEpochMs,
billingCycleAnchorMs,
direction: "refund",
org,
logger,
})
: [];
const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) =>
cusProductToLineItems({
cusProduct: newCustomerProduct,
now,
billingCycleAnchor: billingCycleAnchor!,
nowMs: currentEpochMs,
billingCycleAnchorMs,
direction: "charge",
org,
logger,
}),
);

View File

@@ -23,15 +23,16 @@ export const executeAutumnBillingPlan = async ({
customFreeTrial,
} = autumnBillingPlan;
await EntitlementService.insert({
db,
data: customEntitlements,
});
await PriceService.insert({
db,
data: customPrices,
});
await EntitlementService.insert({
db,
data: customEntitlements,
});
if (customFreeTrial) {
await FreeTrialService.insert({
db,

View File

@@ -8,9 +8,12 @@ import { buildStripeSubscriptionAction } from "../../../providers/stripe/actionB
import type {
AutumnBillingPlan,
StripeBillingPlan,
StripeInvoiceAction,
StripeInvoiceItemsAction,
} from "../../../types/billingPlan";
import { initStripeResourcesForBillingPlan } from "../utils/common/initStripeResourcesForProducts";
export const evaluateStripeBillingPlan = ({
export const evaluateStripeBillingPlan = async ({
ctx,
billingContext,
autumnBillingPlan,
@@ -18,7 +21,13 @@ export const evaluateStripeBillingPlan = ({
ctx: AutumnContext;
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
}): StripeBillingPlan => {
}): Promise<StripeBillingPlan> => {
await initStripeResourcesForBillingPlan({
ctx,
autumnBillingPlan,
billingContext,
});
const finalFullCustomer = autumnBillingPlanToFinalFullCustomer({
billingContext,
autumnBillingPlan,
@@ -32,14 +41,21 @@ export const evaluateStripeBillingPlan = ({
const { lineItems } = autumnBillingPlan;
const stripeInvoiceAction = buildStripeInvoiceAction({
lineItems,
});
const subscriptionActionIsCreate =
stripeSubscriptionAction?.type === "create";
const stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({
lineItems,
billingContext,
});
let stripeInvoiceAction: StripeInvoiceAction | undefined;
let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined;
if (!subscriptionActionIsCreate) {
stripeInvoiceAction = buildStripeInvoiceAction({
lineItems,
});
stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({
lineItems,
billingContext,
});
}
// Build stripe subscription schedule action
const stripeSubscriptionScheduleAction =

View File

@@ -11,6 +11,7 @@ import type {
StripeSubscriptionAction,
} from "@/internal/billing/v2/types/billingPlan";
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
@@ -107,6 +108,15 @@ export const executeStripeSubscriptionAction = async ({
const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired;
if (latestStripeInvoice) {
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: latestStripeInvoice,
fullProducts: billingContext.fullProducts,
fullCustomer: billingContext.fullCustomer,
});
}
if (deferBillingPlan) {
await insertMetadataFromBillingPlan({
ctx,

View File

@@ -1,22 +1,32 @@
import type { FullCustomer, FullProduct } from "@autumn/shared";
import { cusProductToProduct } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { checkStripeProductExists } from "@/internal/products/productUtils";
export const createStripeResourcesForProducts = async ({
export const initStripeResourcesForBillingPlan = async ({
ctx,
fullProducts,
fullCustomer,
autumnBillingPlan,
billingContext,
}: {
ctx: AutumnContext;
fullProducts: FullProduct[];
fullCustomer: FullCustomer;
autumnBillingPlan: AutumnBillingPlan;
billingContext: BillingContext;
}) => {
const { db, org, env, logger } = ctx;
// For each insert customer product
const { fullCustomer } = billingContext;
const { insertCustomerProducts } = autumnBillingPlan;
const newProducts = insertCustomerProducts.flatMap((cp) =>
cusProductToProduct({ cusProduct: cp }),
);
const batchProductUpdates = [];
for (const product of fullProducts) {
for (const product of newProducts) {
batchProductUpdates.push(
checkStripeProductExists({
db,
@@ -38,7 +48,7 @@ export const createStripeResourcesForProducts = async ({
const internalEntityId = fullCustomer.entity?.internal_id;
for (const product of fullProducts) {
for (const product of newProducts) {
for (const price of product.prices) {
batchPriceUpdates.push(
createStripePriceIFNotExist({

View File

@@ -5,6 +5,8 @@ import {
entToOptions,
type FeatureOptions,
type FullCusProduct,
formatPrice,
InternalError,
isAllocatedCusEnt,
isOneOffPrice,
notNullish,
@@ -84,15 +86,21 @@ export const customerProductToStripeItemSpecs = ({
const { lineItem } = stripeItem;
if (!lineItem.price) {
throw new InternalError({
message: `Autumn price ${formatPrice({ price })} has no stripe price id`,
});
}
if (isOneOffPrice(price)) {
oneOffItems.push({
stripePriceId: lineItem?.price ?? "",
stripePriceId: lineItem.price,
quantity: lineItem?.quantity,
autumnPrice: price,
});
} else {
recurringItems.push({
stripePriceId: lineItem?.price ?? "",
stripePriceId: lineItem.price,
quantity: lineItem?.quantity,
autumnPrice: price,
});

View File

@@ -1,15 +1,14 @@
import {
CusProductStatus,
cusProductToProduct,
secondsToMs,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@server/internal/billing/v2/billingContext";
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { computeCustomPlanFreeTrial } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFreeTrial";
import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
export const computeCustomPlan = async ({
@@ -21,7 +20,7 @@ export const computeCustomPlan = async ({
updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}) => {
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
const { customerProduct } = updateSubscriptionContext;
const currentFullProduct = cusProductToProduct({
cusProduct: customerProduct,
@@ -46,15 +45,14 @@ export const computeCustomPlan = async ({
fullProduct: customFullProduct,
});
updateSubscriptionContext.billingCycleAnchorMs =
freeTrialPlan.trialEndsAt ??
secondsToMs(stripeSubscription?.billing_cycle_anchor);
if (freeTrialPlan.trialEndsAt) {
updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt;
}
// 3. Compute the new customer product
const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({
ctx,
updateSubscriptionContext,
params,
fullProduct: customFullProduct,
freeTrialPlan,
});

View File

@@ -1,61 +0,0 @@
import {
cusProductToConvertedFeatureOptions,
type FeatureOptions,
type FullCusProduct,
type FullProduct,
InternalError,
isPrepaidPrice,
priceToFeature,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
/**
* Compute the feature quantities for a subscription update
*/
export const computeCustomPlanFeatureQuantities = ({
ctx,
fullProduct,
currentCustomerProduct,
params,
}: {
ctx: AutumnContext;
fullProduct: FullProduct;
currentCustomerProduct: FullCusProduct;
params: UpdateSubscriptionV0Params;
}) => {
const newFeatureQuantities: FeatureOptions[] = [];
for (const price of fullProduct.prices) {
if (!isPrepaidPrice(price)) continue;
const feature = priceToFeature({
price,
features: ctx.features,
});
if (!feature)
throw new InternalError({
message: `computing feature quantities for price ${price.id} but no feature found`,
});
const newFeatureQuantity = paramsToFeatureOptions({
params,
price,
feature,
});
// Convert current quantity from old price's billing units to new price's billing units
const currentFeatureQuantity = cusProductToConvertedFeatureOptions({
cusProduct: currentCustomerProduct,
feature,
newPrice: price,
});
const featureQuantity = newFeatureQuantity ?? currentFeatureQuantity;
if (featureQuantity) newFeatureQuantities.push(featureQuantity);
}
return newFeatureQuantities;
};

View File

@@ -1,7 +1,6 @@
import type { FullProduct, UpdateSubscriptionV0Params } from "@autumn/shared";
import type { FullProduct } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { computeCustomPlanFeatureQuantities } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFeatureQuantities";
import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
@@ -9,13 +8,11 @@ import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCus
export const computeCustomPlanNewCustomerProduct = ({
ctx,
params,
updateSubscriptionContext,
fullProduct,
freeTrialPlan,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
fullProduct: FullProduct;
freeTrialPlan: FreeTrialPlan;
@@ -27,9 +24,9 @@ export const computeCustomPlanNewCustomerProduct = ({
stripeSubscriptionSchedule,
billingCycleAnchorMs,
currentEpochMs,
featureQuantities,
} = updateSubscriptionContext;
// 1. Get feature quantities
const existingUsages = cusProductToExistingUsages({
cusProduct: customerProduct,
entityId: fullCustomer.entity?.id,
@@ -39,14 +36,7 @@ export const computeCustomPlanNewCustomerProduct = ({
cusProduct: customerProduct,
});
const featureQuantities = computeCustomPlanFeatureQuantities({
ctx,
fullProduct,
currentCustomerProduct: customerProduct,
params,
});
// 1. Compute the new full customer product
// Compute the new full customer product
const newFullCustomerProduct = initFullCustomerProduct({
ctx,

View File

@@ -92,9 +92,9 @@ export const computeUpdateQuantityDetails = ({
});
const billingPeriod = getLineItemBillingPeriod({
anchor: billingCycleAnchorMs,
anchorMs: billingCycleAnchorMs,
price: customerPrice.price,
now: currentEpochMs,
nowMs: currentEpochMs,
});
if (!billingPeriod) {

View File

@@ -26,7 +26,7 @@ export const fetchUpdateSubscriptionBillingContext = async ({
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
}): Promise<UpdateSubscriptionBillingContext> => {
const { db, org, env, features } = ctx;
const { db, org, env } = ctx;
const { customer_id: customerId, product_id: productId } = params;
const fullCustomer = await CusService.getFull({
@@ -82,16 +82,15 @@ export const fetchUpdateSubscriptionBillingContext = async ({
fullCus: fullCustomer,
});
if (params.options) {
params.options = parseFeatureQuantitiesParams({
optionsInput: params.options,
features,
prices: targetCustomerProduct.customer_prices.map((cp) => cp.price),
currentCustomerProduct: targetCustomerProduct,
});
}
const featureQuantities = parseFeatureQuantitiesParams({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: targetCustomerProduct,
});
const currentEpochMs = testClockFrozenTime ?? Date.now();
const billingCycleAnchorMs = secondsToMs(
stripeSubscription?.billing_cycle_anchor,
);
@@ -115,7 +114,8 @@ export const fetchUpdateSubscriptionBillingContext = async ({
paymentMethod,
currentEpochMs,
billingCycleAnchorMs,
billingCycleAnchorMs: billingCycleAnchorMs ?? "now",
invoiceMode,
featureQuantities,
};
};

View File

@@ -1,5 +1,6 @@
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { computeUpdateSubscriptionPlan } from "./compute/computeUpdateSubscriptionPlan";
import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext";
@@ -22,20 +23,25 @@ export const handlePreviewUpdateSubscription = createRoute({
params: body,
});
const stripeBillingPlan = evaluateStripeBillingPlan({
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext: updateSubscriptionBillingContext,
autumnBillingPlan,
});
// Convert to preview response
return c.json(
{
const previewResponse = billingPlanToPreviewResponse({
ctx,
billingContext: updateSubscriptionBillingContext,
billingPlan: {
autumn: autumnBillingPlan,
stripe: stripeBillingPlan,
},
200,
);
});
return c.json({
...previewResponse,
autumn: autumnBillingPlan,
stripe: stripeBillingPlan,
});
},
});

View File

@@ -22,7 +22,7 @@ export const handleUpdateSubscription = createRoute({
params: body,
});
const stripeBillingPlan = evaluateStripeBillingPlan({
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext,
autumnBillingPlan,

View File

@@ -1,25 +1,37 @@
import type { BillingPreviewResponse } from "@autumn/shared";
import {
type BillingPreviewResponse,
orgToCurrency,
sumValues,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
export const billingPlanToPreviewResponse = ({
ctx,
billingContext,
billingPlan,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
billingPlan: BillingPlan;
}): BillingPreviewResponse => {
// 1. Get lines
const { fullCustomer } = billingContext;
const autumnBillingPlan = billingPlan.autumn;
const previewLineItems = autumnBillingPlan.lineItems.map((line) => ({
description: line.description,
amount: line.finalAmount,
}));
// const previewLineItems = autumnBillingPlan.lineItems.map((line) => ({
// description: line.description,
// amount: line.amount,
// }));
const total = sumValues(previewLineItems.map((line) => line.amount));
// const total = autumnBillingPlanLines.reduce(
// (acc, line) => acc + line.amount,
// 0,
// );
const currency = orgToCurrency({ org: ctx.org });
// return {
// customer_id: billingPlan.customer_id,
// };
return {
customer_id: fullCustomer.id || "",
line_items: previewLineItems,
total,
currency,
} satisfies BillingPreviewResponse;
};

View File

@@ -1,145 +1,64 @@
import {
ErrCode,
type Feature,
cusProductToConvertedFeatureOptions,
type FeatureOptions,
type FullCusProduct,
nullish,
type Price,
type UsagePriceConfig,
type FullProduct,
isPrepaidPrice,
priceToFeature,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
/**
* Parses and normalizes feature quantity options for billing.
*
* Converts raw quantity input to billing units and merges with existing options
* from the current customer product (for recurring products only).
*
* @param optionsInput - Raw feature options with quantities
* @param features - Available features to validate against
* @param prices - Product prices to find prepaid price config
* @param currentCustomerProduct - Existing customer product for merging options
* @returns Normalized feature options with internal_feature_id and adjusted quantities
* Parses feature quantities from params, iterating over all prepaid prices.
* For each prepaid price, uses new quantity from params or falls back to existing subscription.
*/
export const parseFeatureQuantitiesParams = ({
optionsInput,
features,
prices,
ctx,
featureQuantitiesParams,
fullProduct,
currentCustomerProduct,
}: {
optionsInput?: FeatureOptions[];
features: Feature[];
prices: Price[];
ctx: AutumnContext;
featureQuantitiesParams: UpdateSubscriptionV0Params;
fullProduct: FullProduct;
currentCustomerProduct?: FullCusProduct;
}): FeatureOptions[] => {
const parsedOptions = parseAndNormalizeOptions({
optionsInput,
features,
prices,
});
const options: FeatureOptions[] = [];
if (isOneOff(prices) || isFreeProduct(prices)) {
return parsedOptions;
}
for (const price of fullProduct.prices) {
if (!isPrepaidPrice(price)) continue;
return mergeWithExistingOptions({
parsedOptions,
currentCustomerProduct,
prices,
});
};
const parseAndNormalizeOptions = ({
optionsInput,
features,
prices,
}: {
optionsInput?: FeatureOptions[];
features: Feature[];
prices: Price[];
}): FeatureOptions[] => {
const result: FeatureOptions[] = [];
for (const options of optionsInput || []) {
const feature = features.find(
(feature) => feature.id === options.feature_id,
);
if (!feature) {
throw new RecaseError({
message: `Feature ${options.feature_id} passed into options but not found`,
code: ErrCode.FeatureNotFound,
});
}
const prepaidPrice = findPrepaidPrice({
prices,
internalFeatureId: feature.internal_id,
const feature = priceToFeature({
price,
features: ctx.features,
errorOnNotFound: true,
});
if (!prepaidPrice) {
throw new RecaseError({
message: `No prepaid price found for feature ${feature.id}`,
code: ErrCode.PriceNotFound,
});
}
const config = prepaidPrice.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
if (nullish(options.quantity)) {
throw new RecaseError({
message: `Quantity is required for feature ${feature.id}`,
code: ErrCode.InvalidOptions,
});
}
const normalizedQuantity = new Decimal(options.quantity)
.div(billingUnits)
.ceil()
.toNumber();
result.push({
...options,
internal_feature_id: feature.internal_id,
quantity: normalizedQuantity,
});
}
return result;
};
const mergeWithExistingOptions = ({
parsedOptions,
currentCustomerProduct,
prices,
}: {
parsedOptions: FeatureOptions[];
currentCustomerProduct?: FullCusProduct;
prices: Price[];
}): FeatureOptions[] => {
const existingOptions = currentCustomerProduct?.options || [];
const mergedOptions = [...parsedOptions];
for (const existingOption of existingOptions) {
const alreadyIncluded = parsedOptions.some(
(option) => option.feature_id === existingOption.feature_id,
);
if (alreadyIncluded) continue;
const hasPrepaidPrice = findPrepaidPrice({
prices,
internalFeatureId: existingOption.internal_feature_id!,
// Get new feature quantity from params
const newFeatureQuantity = paramsToFeatureOptions({
params: featureQuantitiesParams,
price,
feature,
});
if (hasPrepaidPrice) {
mergedOptions.push(existingOption);
// Get current feature quantity from existing subscription
const currentFeatureQuantity = currentCustomerProduct
? cusProductToConvertedFeatureOptions({
cusProduct: currentCustomerProduct,
feature,
newPrice: price,
})
: undefined;
// Prefer new quantity, fall back to current
const featureQuantity = newFeatureQuantity ?? currentFeatureQuantity;
if (featureQuantity) {
options.push(featureQuantity);
}
}
return mergedOptions;
return options;
};

View File

@@ -7,13 +7,12 @@ import {
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams.js";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
/**
* @deprecated Can now use {@link parseFeatureQuantitiesParams} instead
* @deprecated Use parseFeatureQuantitiesParams from billing/v2/utils instead
*/
export const mapOptionsList = ({
optionsInput,

View File

@@ -1,4 +1,8 @@
import { test } from "bun:test";
import { expect, test } from "bun:test";
import {
expectCustomerFeatureCorrect,
expectCustomerFeatureExists,
} from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
@@ -32,43 +36,41 @@ test.concurrent(`${chalk.yellowBright("custom-plan: update free plan")}`, async
{ timeout: 2000 },
);
await autumnV1.subscriptions.update({
// 1. Test update free plan preview
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
});
test.concurrent(`${chalk.yellowBright("custom-plan: something else")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const dashboardItem = items.dashboard();
const wordsItem = items.monthlyWords({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "custom-plan-something-else",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
const messagesUsage = 100;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
expectCustomerFeatureExists({
customer,
featureId: TestFeature.Dashboard,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: wordsItem.included_usage,
balance: wordsItem.included_usage,
usage: 0,
});
});

View File

@@ -1,141 +0,0 @@
import { beforeAll, describe, test } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
describe(`${chalk.yellowBright("custom-plan: update free plan")}`, () => {
const testCase = "custom-plan-update-free-plan";
const messagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 500,
});
const dashboardItem = constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
});
const wordsItem = constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 100,
});
const free = constructProduct({
type: "free",
items: [messagesItem],
});
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
console.log("FILE 2 - describe 1 started at:", new Date().toISOString());
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [free],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: free.id,
});
});
const messagesUsage = 100;
test("should add boolean and metered feature to free plan", async () => {
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
});
});
describe(`${chalk.yellowBright("custom-plan: something else")}`, () => {
const testCase = "custom-plan-update-free-plan";
const messagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 500,
});
const dashboardItem = constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
});
const wordsItem = constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 100,
});
const free = constructProduct({
type: "free",
items: [messagesItem],
});
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [free],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: free.id,
});
});
const messagesUsage = 100;
test("should add boolean and metered feature to free plan", async () => {
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem, wordsItem],
});
});
});

View File

@@ -0,0 +1,308 @@
import { expect, test } from "bun:test";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
import chalk from "chalk";
// 1. Adding a monthly base price to free product
test.concurrent(`${chalk.yellowBright("free-to-paid: add monthly base price")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 300 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "free-to-paid-monthly-base",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
},
{ timeout: 2000 },
);
const priceItem = items.monthlyPrice();
// Preview should show $20 charge
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, priceItem],
});
expect(preview.total).toEqual(20);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [messagesItem, priceItem],
});
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - 100,
usage: 100,
});
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
});
// 2. Adding monthly base price + consumable to free product
test.concurrent(`${chalk.yellowBright("free-to-paid: add monthly base + consumable")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "free-to-paid-monthly-consumable",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
// Track some usage before update
const messagesUsage = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
const priceItem = items.monthlyPrice();
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: free.id,
items: [consumableItem, priceItem],
});
expect(preview.total).toEqual(20);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [consumableItem, priceItem],
});
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
});
// 3. Adding annual base price + monthly consumable to free product
test.concurrent(`${chalk.yellowBright("free-to-paid: add annual base + monthly consumable")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initTestScenario({
customerId: "free-to-paid-annual-consumable",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
const priceItem = items.annualPrice();
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: free.id,
items: [consumableItem, priceItem],
});
expect(preview.total).toEqual(200);
await autumnV1.subscriptions.update(
{
customer_id: customerId,
product_id: free.id,
items: [consumableItem, priceItem],
},
{ timeout: 2000 },
);
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage,
usage: 0,
});
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 200,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4. Updating free feature item to consumable
test.concurrent(`${chalk.yellowBright("free-to-paid: update free item to consumable")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "free-to-paid-to-consumable",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
// Track some usage first
const messagesUsage = 50;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Update to consumable (pay-per-use after included usage)
const consumableItem = items.consumableMessages({ includedUsage: 100 });
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: free.id,
items: [consumableItem],
});
// No immediate charge - consumable bills in arrears
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [consumableItem],
});
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// No invoice - consumable bills in arrears, no immediate charge
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 5. Updating free feature item to prepaid
test.concurrent(`${chalk.yellowBright("free-to-paid: update free item to prepaid")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initTestScenario({
customerId: "free-to-paid-to-prepaid",
products: [free],
attachProducts: [free.id],
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
// Track some usage first
const messagesUsage = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Update to prepaid (purchase units upfront)
const prepaidItem = items.prepaidMessages();
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: free.id,
items: [prepaidItem],
});
// No immediate charge for switching to prepaid model
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
items: [prepaidItem],
options: [
{
feature_id: TestFeature.Messages,
quantity: 100,
},
],
});
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100 - messagesUsage,
usage: messagesUsage,
});
});

View File

@@ -5,6 +5,26 @@ import { AutumnInt } from "@/external/autumn/autumnCli";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
export const expectCustomerFeatureExists = async ({
customerId,
customer: providedCustomer,
featureId,
}: {
customerId?: string;
customer?: Customer;
featureId: string;
}) => {
const customer = providedCustomer
? providedCustomer
: await defaultAutumn.customers.get(customerId!);
const feature = customer.features[featureId];
expect(feature).toBeDefined();
};
const ONE_HOUR_MS = 60 * 60 * 1000;
export const expectCustomerFeatureCorrect = async ({
customerId,
customer: providedCustomer,
@@ -12,13 +32,15 @@ export const expectCustomerFeatureCorrect = async ({
includedUsage,
balance,
usage,
resetsAt,
}: {
customerId?: string;
customer?: Customer;
featureId: string;
includedUsage: number;
balance: number;
usage: number;
includedUsage?: number;
balance?: number;
usage?: number;
resetsAt?: number;
}) => {
const customer = providedCustomer
? providedCustomer
@@ -30,4 +52,12 @@ export const expectCustomerFeatureCorrect = async ({
balance,
usage,
});
if (resetsAt !== undefined) {
const actualResetsAt = feature.next_reset_at ?? 0;
expect(actualResetsAt).toBeDefined();
expect(Math.abs(actualResetsAt - resetsAt)).toBeLessThanOrEqual(
ONE_HOUR_MS,
);
}
};

View File

@@ -0,0 +1,43 @@
import { expect } from "bun:test";
import { ApiVersion } from "@autumn/shared";
import type { Customer } from "autumn-js";
import { AutumnInt } from "@/external/autumn/autumnCli";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
/**
* Check customer invoice count and optionally the latest invoice details
*/
export const expectCustomerInvoiceCorrect = async ({
customerId,
customer: providedCustomer,
count,
latestTotal,
latestStatus,
}: {
customerId?: string;
customer?: Customer;
count: number;
latestTotal?: number;
latestStatus?: "paid" | "draft" | "open" | "void";
}) => {
const customer = providedCustomer
? providedCustomer
: await defaultAutumn.customers.get(customerId!);
const invoices = customer.invoices;
if (invoices === undefined) {
throw new Error("No invoices found");
}
expect(invoices.length).toBe(count);
if (latestTotal !== undefined && invoices.length > 0) {
expect(invoices[0].total).toBe(latestTotal);
}
if (latestStatus !== undefined && invoices.length > 0) {
expect(invoices[0].status).toBe(latestStatus);
}
};

View File

@@ -1,27 +0,0 @@
import { expect } from "bun:test";
import type { AutumnInt } from "@/external/autumn/autumnCli";
export const expectCustomerFeatureCorrect = async ({
autumn,
customerId,
featureId,
includedUsage,
balance,
usage,
}: {
autumn: AutumnInt;
customerId: string;
featureId: string;
includedUsage: number;
balance: number;
usage: number;
}) => {
const customer = await autumn.customers.get(customerId);
const feature = customer.features[featureId];
expect(feature).toMatchObject({
included_usage: includedUsage,
balance,
usage,
});
};

View File

@@ -1,4 +1,6 @@
import { BillingInterval, type LimitedItem } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import {
constructArrearItem,
constructArrearProratedItem,
@@ -32,8 +34,11 @@ const monthlyMessages = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({ featureId: TestFeature.Messages, includedUsage });
} = {}): LimitedItem =>
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage,
}) as LimitedItem;
/**
* Monthly words - resets each billing cycle
@@ -43,8 +48,11 @@ const monthlyWords = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({ featureId: TestFeature.Words, includedUsage });
} = {}): LimitedItem =>
constructFeatureItem({
featureId: TestFeature.Words,
includedUsage,
}) as LimitedItem;
/**
* Monthly credits - resets each billing cycle
@@ -54,8 +62,11 @@ const monthlyCredits = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({ featureId: TestFeature.Credits, includedUsage });
} = {}): LimitedItem =>
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage,
}) as LimitedItem;
/**
* Unlimited messages - no usage cap
@@ -75,12 +86,12 @@ const lifetimeMessages = ({
includedUsage = 100,
}: {
includedUsage?: number;
} = {}) =>
} = {}): LimitedItem =>
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage,
interval: null,
});
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// PREPAID (purchase units upfront)
@@ -88,19 +99,21 @@ const lifetimeMessages = ({
/**
* Prepaid messages - purchase units upfront ($10/unit)
* @param includedUsage - Free units before purchase required (default: 0)
* @param includedUsage - Free units before purchase required (default: 0), billing units are 100
*/
const prepaidMessages = ({
includedUsage = 0,
billingUnits = 100,
}: {
includedUsage?: number;
} = {}) =>
billingUnits?: number;
} = {}): LimitedItem =>
constructPrepaidItem({
featureId: TestFeature.Messages,
price: 10,
billingUnits: 1,
billingUnits,
includedUsage,
});
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// CONSUMABLE / PAY-PER-USE (overage pricing)
@@ -114,13 +127,13 @@ const consumableMessages = ({
includedUsage = 0,
}: {
includedUsage?: number;
} = {}) =>
} = {}): LimitedItem =>
constructArrearItem({
featureId: TestFeature.Messages,
includedUsage,
price: 0.1,
billingUnits: 1,
});
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// ALLOCATED / SEATS (prorated billing)
@@ -134,11 +147,35 @@ const allocatedUsers = ({
includedUsage = 0,
}: {
includedUsage?: number;
} = {}) =>
} = {}): LimitedItem =>
constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 10,
includedUsage,
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// BASE PRICES
// ═══════════════════════════════════════════════════════════════════
/**
* Monthly base price item
* @param price - Monthly price (default: 20)
*/
const monthlyPrice = ({ price = 20 }: { price?: number } = {}) =>
constructPriceItem({
price,
interval: BillingInterval.Month,
});
/**
* Annual base price item
* @param price - Annual price (default: 200)
*/
const annualPrice = ({ price = 200 }: { price?: number } = {}) =>
constructPriceItem({
price,
interval: BillingInterval.Year,
});
// ═══════════════════════════════════════════════════════════════════
@@ -164,4 +201,8 @@ export const items = {
// Allocated
allocatedUsers,
// Base prices
monthlyPrice,
annualPrice,
} as const;

View File

@@ -1,5 +1,8 @@
import type { ProductItem, ProductV2 } from "@autumn/shared";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
import {
constructProduct,
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
/**
* Base product - no base price, customizable defaults
@@ -20,6 +23,47 @@ const base = ({
is_default: isDefault,
});
/**
* Pro product - $20/month base price
* @param items - Product items (features)
* @param id - Product ID (default: "pro")
*/
const pro = ({
items,
id = "pro",
}: {
items: ProductItem[];
id?: string;
}): ProductV2 =>
constructProduct({
id,
items: [...items],
type: "pro",
isDefault: false,
});
/**
* Pro annual product - $200/year base price
* @param items - Product items (features)
* @param id - Product ID (default: "pro-annual")
*/
const proAnnual = ({
items,
id = "pro-annual",
}: {
items: ProductItem[];
id?: string;
}): ProductV2 =>
constructProduct({
id,
items: [...items],
type: "pro",
isAnnual: true,
isDefault: false,
});
export const products = {
base,
pro,
proAnnual,
} as const;

View File

@@ -24,13 +24,13 @@ export const getCycleStart = ({
now,
floor,
}: {
anchor: number;
anchor: number | "now";
interval: BillingInterval | EntInterval;
intervalCount?: number;
now: number; // milliseconds since epoch;
floor?: number | undefined;
}): number => {
const anchorDate = new UTCDate(anchor);
const anchorDate = anchor === "now" ? new UTCDate(now) : new UTCDate(anchor);
const nowDate = new UTCDate(now);
const intervalFunctions = getCycleIntervalFunctions({ interval });

View File

@@ -5,30 +5,29 @@ import { getCycleEnd } from "./getCycleEnd";
import { getCycleStart } from "./getCycleStart";
export const getLineItemBillingPeriod = ({
anchor,
anchorMs,
price,
now,
nowMs,
}: {
anchor: number;
anchorMs: number | "now";
price: Price;
now: number;
nowMs: number;
}): BillingPeriod | undefined => {
if (isOneOffPrice(price)) return undefined;
const { interval, interval_count: intervalCount } = price.config;
return {
start: getCycleStart({
anchor,
interval: price.config.interval,
intervalCount: price.config.interval_count,
now,
}),
const start = getCycleStart({
anchor: anchorMs,
interval,
intervalCount,
now: nowMs,
});
const end = getCycleEnd({
anchor: anchorMs,
interval,
intervalCount,
now: nowMs,
});
end: getCycleEnd({
anchor,
interval,
intervalCount,
now,
}),
};
return { start, end };
};

View File

@@ -10,13 +10,13 @@ import { usagePriceToLineItem } from "./lineItemBuilders/usagePriceToLineItem";
export const cusProductToArrearLineItems = ({
cusProduct,
billingCycleAnchor,
now,
billingCycleAnchorMs,
nowMs,
org,
}: {
cusProduct: FullCusProduct;
billingCycleAnchor: number;
now: number;
billingCycleAnchorMs: number | "now";
nowMs: number;
org: Organization;
}) => {
let lineItems: LineItem[] = [];
@@ -28,9 +28,9 @@ export const cusProductToArrearLineItems = ({
// Calculate billing period
const billingPeriod = getLineItemBillingPeriod({
anchor: billingCycleAnchor,
anchorMs: billingCycleAnchorMs,
price,
now,
nowMs,
});
const cusEnt = cusPriceToCusEntWithCusProduct({
@@ -53,7 +53,7 @@ export const cusProductToArrearLineItems = ({
billingPeriod,
direction: "charge",
billingTiming: "in_arrear",
now,
now: nowMs,
currency: orgToCurrency({ org }),
};

View File

@@ -1,3 +1,4 @@
import { formatMs } from "@utils/common";
import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels";
@@ -29,29 +30,43 @@ export type LineItemDirection = "charge" | "refund";
*/
export const cusProductToLineItems = ({
cusProduct,
now,
billingCycleAnchor,
nowMs,
billingCycleAnchorMs,
direction,
org,
logger,
}: {
cusProduct: FullCusProduct;
now: number;
billingCycleAnchor: number;
nowMs: number;
billingCycleAnchorMs: number | "now";
direction: "charge" | "refund";
org: Organization;
// biome-ignore lint/suspicious/noExplicitAny: Logger type defined in server package
logger: any;
}): LineItem[] => {
let lineItems: LineItem[] = [];
logger.debug(
`Building line items for customer product: ${cusProduct.product.id} (${direction})`,
);
logger.debug(
`Billing cycle anchor: ${formatMs(billingCycleAnchorMs)}, now: ${formatMs(nowMs)}`,
);
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
// Calculate billing period
const billingPeriod = getLineItemBillingPeriod({
anchor: billingCycleAnchor,
anchorMs: billingCycleAnchorMs,
price,
now,
nowMs,
});
logger.debug(
`Billing period: ${formatMs(billingPeriod?.start)} - ${formatMs(billingPeriod?.end)}`,
);
// Build line item context
const context: LineItemContext = {
price,
@@ -61,7 +76,7 @@ export const cusProductToLineItems = ({
billingPeriod,
direction,
billingTiming: "in_advance",
now,
now: nowMs,
currency: orgToCurrency({ org }),
};

View File

@@ -40,12 +40,19 @@ export const buildLineItem = ({
}
// 3. Return LineItem
return LineItemSchema.parse({
const lineItemData = {
amount,
description,
context,
stripePriceId,
stripeProductId,
chargeImmediately,
} satisfies LineItemCreate);
} satisfies LineItemCreate;
const result = LineItemSchema.safeParse(lineItemData);
if (!result.success) {
throw result.error;
}
return result.data;
};

View File

@@ -5,7 +5,7 @@ export const formatMsToDate = (
options?: { withTimezone?: boolean },
) => {
if (!unixDate) {
return "undefined unix date";
return "undefined";
}
return format(new Date(unixDate), "dd MMM yyyy");
};
@@ -18,7 +18,7 @@ export const formatMs = (
return "now";
}
if (!unixDate) {
return "undefined unix date";
return "undefined";
}
let formatString = options?.excludeSeconds

View File

@@ -1,3 +1,4 @@
import { InternalError } from "@api/errors/base/InternalError";
import type { Feature } from "@models/featureModels/featureModels";
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
@@ -13,30 +14,59 @@ import {
} from "@utils/billingUtils";
import { priceToEnt } from "@utils/productUtils/convertProductUtils";
export const priceToFeature = ({
// Overload: errorOnNotFound = true → guaranteed Feature
export function priceToFeature(params: {
price: Price;
ents?: EntitlementWithFeature[];
features?: Feature[];
errorOnNotFound: true;
}): Feature;
// Overload: errorOnNotFound = false/undefined → Feature | undefined
export function priceToFeature(params: {
price: Price;
ents?: EntitlementWithFeature[];
features?: Feature[];
errorOnNotFound?: false;
}): Feature | undefined;
// Implementation
export function priceToFeature({
price,
ents,
features,
errorOnNotFound,
}: {
price: Price;
ents?: EntitlementWithFeature[];
features?: Feature[];
}) => {
errorOnNotFound?: boolean;
}): Feature | undefined {
if (!features && !ents) {
throw new Error("priceToFeature requires either ents or features as arg");
}
let result: Feature | undefined;
if (features) {
return features.find(
result = features.find(
(f) =>
f.internal_id ===
(price.config as UsagePriceConfig).internal_feature_id,
);
} else {
const ent = priceToEnt({ price, entitlements: ents ?? [] });
result = ent?.feature;
}
const ent = priceToEnt({ price, entitlements: ents ?? [] });
return ent?.feature;
};
if (errorOnNotFound && !result) {
throw new InternalError({
message: `Feature not found for price ${price.id}`,
});
}
return result;
}
export const priceToProrationConfig = ({
price,

View File

@@ -25,8 +25,6 @@ export const UpdateConfirmationInfo = ({
newProduct: previewData?.product,
});
console.log("previewData", previewData);
const hasPrepaidQuantityChanges = useHasPrepaidQuantityChanges(product, form);
const renderInfoBoxes = (): ReactNode[] => {