refactor: third iteration of update subscription cleanup
This commit is contained in:
@@ -51,8 +51,8 @@ export const computeStripeInvoiceAction = ({
|
|||||||
description: detail.stripeInvoiceItemDescription,
|
description: detail.stripeInvoiceItemDescription,
|
||||||
amount: Math.round(detail.calculatedProrationAmountDollars * 100),
|
amount: Math.round(detail.calculatedProrationAmountDollars * 100),
|
||||||
period: {
|
period: {
|
||||||
start: msToSeconds(detail.subscriptionPeriodStartEpochMs),
|
start: msToSeconds(detail.billingPeriod.start),
|
||||||
end: msToSeconds(detail.subscriptionPeriodEndEpochMs),
|
end: msToSeconds(detail.billingPeriod.end),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
cusProductToProduct,
|
cusProductToProduct,
|
||||||
extractBillingPeriod,
|
|
||||||
type FeatureOptions,
|
type FeatureOptions,
|
||||||
findFeatureByInternalId,
|
findFeatureByInternalId,
|
||||||
|
findFeatureOptionsByFeature,
|
||||||
InternalError,
|
InternalError,
|
||||||
type LineItemContext,
|
type LineItemContext,
|
||||||
|
orgToCurrency,
|
||||||
|
secondsToMs,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { usagePriceToLineDescription } from "@autumn/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription";
|
import { usagePriceToLineDescription } from "@autumn/shared/utils/billingUtils/invoicingUtils/descriptionUtils/usagePriceToLineDescription";
|
||||||
|
import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils";
|
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld";
|
import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld";
|
||||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||||
import { calculateEntitlementChange } from "./quantityUpdateUtils/calculateEntitlementChange";
|
import { calculateCustomerEntitlementChange } from "./quantityUpdateUtils/calculateCustomerEntitlementChange";
|
||||||
import { calculateProrationAmount } from "./quantityUpdateUtils/calculateProrationAmount";
|
import { calculateProrationAmount } from "./quantityUpdateUtils/calculateProrationAmount";
|
||||||
import { calculateQuantityDifferences } from "./quantityUpdateUtils/calculateQuantityDifferences";
|
import { calculateQuantityDifferences } from "./quantityUpdateUtils/calculateQuantityDifferences";
|
||||||
import { resolvePriceForQuantityUpdate } from "./quantityUpdateUtils/resolvePriceForQuantityUpdate";
|
import { resolvePriceForQuantityUpdate } from "./quantityUpdateUtils/resolvePriceForQuantityUpdate";
|
||||||
@@ -32,18 +35,16 @@ import { resolvePriceForQuantityUpdate } from "./quantityUpdateUtils/resolvePric
|
|||||||
*/
|
*/
|
||||||
export const computeQuantityUpdateDetails = ({
|
export const computeQuantityUpdateDetails = ({
|
||||||
ctx,
|
ctx,
|
||||||
previousOptions,
|
|
||||||
updatedOptions,
|
updatedOptions,
|
||||||
updateSubscriptionContext,
|
updateSubscriptionContext,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
previousOptions: FeatureOptions;
|
|
||||||
updatedOptions: FeatureOptions;
|
updatedOptions: FeatureOptions;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||||
}): QuantityUpdateDetails => {
|
}): QuantityUpdateDetails => {
|
||||||
const { customerProduct, stripeSubscription, currentEpochMs } =
|
const { customerProduct, stripeSubscription, currentEpochMs } =
|
||||||
updateSubscriptionContext;
|
updateSubscriptionContext;
|
||||||
const { features } = ctx;
|
const { features, org } = ctx;
|
||||||
|
|
||||||
const internalFeatureId = updatedOptions.internal_feature_id;
|
const internalFeatureId = updatedOptions.internal_feature_id;
|
||||||
const featureId = updatedOptions.feature_id;
|
const featureId = updatedOptions.feature_id;
|
||||||
@@ -71,6 +72,11 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousOptions = findFeatureOptionsByFeature({
|
||||||
|
featureOptions: customerProduct.options,
|
||||||
|
feature,
|
||||||
|
});
|
||||||
|
|
||||||
const quantityDifferences = calculateQuantityDifferences({
|
const quantityDifferences = calculateQuantityDifferences({
|
||||||
previousOptions,
|
previousOptions,
|
||||||
updatedOptions,
|
updatedOptions,
|
||||||
@@ -82,13 +88,28 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
isUpgrade: quantityDifferences.isUpgrade,
|
isUpgrade: quantityDifferences.isUpgrade,
|
||||||
});
|
});
|
||||||
|
|
||||||
const billingPeriod = extractBillingPeriod({
|
const billingCycleAnchorMs = secondsToMs(
|
||||||
stripeSubscription,
|
stripeSubscription.billing_cycle_anchor,
|
||||||
interval: priceConfiguration.priceConfig.interval,
|
);
|
||||||
intervalCount: priceConfiguration.priceConfig.interval_count,
|
|
||||||
currentEpochMs,
|
if (!billingCycleAnchorMs) {
|
||||||
|
throw new InternalError({
|
||||||
|
message: `[Quantity Update] Invalid billing_cycle_anchor: ${stripeSubscription.billing_cycle_anchor}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingPeriod = getLineItemBillingPeriod({
|
||||||
|
anchor: billingCycleAnchorMs,
|
||||||
|
price: priceConfiguration.price,
|
||||||
|
now: currentEpochMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!billingPeriod) {
|
||||||
|
throw new InternalError({
|
||||||
|
message: `[Quantity Update] Billing period not found for price: ${priceConfiguration.price.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const calculatedProrationAmountDollars = calculateProrationAmount({
|
const calculatedProrationAmountDollars = calculateProrationAmount({
|
||||||
updateSubscriptionContext,
|
updateSubscriptionContext,
|
||||||
previousOptions,
|
previousOptions,
|
||||||
@@ -100,11 +121,13 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
|
|
||||||
const product = cusProductToProduct({ cusProduct: customerProduct });
|
const product = cusProductToProduct({ cusProduct: customerProduct });
|
||||||
|
|
||||||
|
const currency = orgToCurrency({ org });
|
||||||
|
|
||||||
const lineItemContext: LineItemContext = {
|
const lineItemContext: LineItemContext = {
|
||||||
price: priceConfiguration.price,
|
price: priceConfiguration.price,
|
||||||
product,
|
product,
|
||||||
feature,
|
feature,
|
||||||
currency: "usd",
|
currency,
|
||||||
direction: "charge",
|
direction: "charge",
|
||||||
now: currentEpochMs,
|
now: currentEpochMs,
|
||||||
billingTiming: "in_advance",
|
billingTiming: "in_advance",
|
||||||
@@ -120,7 +143,7 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
stripeItems: stripeSubscription.items.data,
|
stripeItems: stripeSubscription.items.data,
|
||||||
}) as Stripe.SubscriptionItem | undefined;
|
}) as Stripe.SubscriptionItem | undefined;
|
||||||
|
|
||||||
const entitlementChange = calculateEntitlementChange({
|
const entitlementChange = calculateCustomerEntitlementChange({
|
||||||
quantityDifferenceForEntitlements:
|
quantityDifferenceForEntitlements:
|
||||||
quantityDifferences.quantityDifferenceForEntitlements,
|
quantityDifferences.quantityDifferenceForEntitlements,
|
||||||
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
||||||
@@ -137,6 +160,7 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
return {
|
return {
|
||||||
featureId,
|
featureId,
|
||||||
internalFeatureId,
|
internalFeatureId,
|
||||||
|
billingPeriod,
|
||||||
previousFeatureQuantity: previousOptions.quantity,
|
previousFeatureQuantity: previousOptions.quantity,
|
||||||
updatedFeatureQuantity: updatedOptions.quantity,
|
updatedFeatureQuantity: updatedOptions.quantity,
|
||||||
quantityDifferenceForEntitlements:
|
quantityDifferenceForEntitlements:
|
||||||
@@ -148,9 +172,6 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
priceConfiguration.shouldFinalizeInvoiceImmediately,
|
priceConfiguration.shouldFinalizeInvoiceImmediately,
|
||||||
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
||||||
calculatedProrationAmountDollars,
|
calculatedProrationAmountDollars,
|
||||||
subscriptionPeriodStartEpochMs:
|
|
||||||
billingPeriod.subscriptionPeriodStartEpochMs,
|
|
||||||
subscriptionPeriodEndEpochMs: billingPeriod.subscriptionPeriodEndEpochMs,
|
|
||||||
stripeInvoiceItemDescription,
|
stripeInvoiceItemDescription,
|
||||||
customerPrice: priceConfiguration.customerPrice,
|
customerPrice: priceConfiguration.customerPrice,
|
||||||
stripePriceId: priceConfiguration.price.config.stripe_price_id,
|
stripePriceId: priceConfiguration.price.config.stripe_price_id,
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { InternalError, type SubscriptionUpdateV0Params } from "@autumn/shared";
|
||||||
findFeatureOptionsByFeature,
|
|
||||||
InternalError,
|
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { BillingPlan } from "../../billingPlan";
|
import type { BillingPlan } from "../../billingPlan";
|
||||||
import { buildStripeSubscriptionAction } from "../../providers/stripe/actionBuilders/buildStripeSubscriptionAction";
|
import { buildStripeSubscriptionAction } from "../../providers/stripe/actionBuilders/buildStripeSubscriptionAction";
|
||||||
@@ -30,19 +26,13 @@ export const computeSubscriptionUpdateQuantityPlan = ({
|
|||||||
|
|
||||||
const newOptions = params.options || [];
|
const newOptions = params.options || [];
|
||||||
|
|
||||||
const quantityUpdateDetails = newOptions.map((updatedOption) => {
|
const quantityUpdateDetails = newOptions.map((updatedOptions) =>
|
||||||
const previousOption = findFeatureOptionsByFeature({
|
computeQuantityUpdateDetails({
|
||||||
featureOptions: customerProduct.options,
|
|
||||||
featureId: updatedOption.feature_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return computeQuantityUpdateDetails({
|
|
||||||
ctx,
|
ctx,
|
||||||
previousOptions: previousOption,
|
updatedOptions,
|
||||||
updatedOptions: updatedOption,
|
|
||||||
updateSubscriptionContext,
|
updateSubscriptionContext,
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
const customerProductWithNewOptions = {
|
const customerProductWithNewOptions = {
|
||||||
...customerProduct,
|
...customerProduct,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cus
|
|||||||
* @param customerEntitlements - Array of all entitlements for this customer product
|
* @param customerEntitlements - Array of all entitlements for this customer product
|
||||||
* @returns Entitlement ID and balance change to apply
|
* @returns Entitlement ID and balance change to apply
|
||||||
*/
|
*/
|
||||||
export const calculateEntitlementChange = ({
|
export const calculateCustomerEntitlementChange = ({
|
||||||
quantityDifferenceForEntitlements,
|
quantityDifferenceForEntitlements,
|
||||||
billingUnitsPerQuantity,
|
billingUnitsPerQuantity,
|
||||||
customerPrice,
|
customerPrice,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
applyProration,
|
applyProration,
|
||||||
type extractBillingPeriod,
|
type BillingPeriod,
|
||||||
type FeatureOptions,
|
type FeatureOptions,
|
||||||
priceToLineAmount,
|
priceToLineAmount,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
@@ -36,11 +36,10 @@ export const calculateProrationAmount = ({
|
|||||||
updatedOptions: FeatureOptions;
|
updatedOptions: FeatureOptions;
|
||||||
priceConfiguration: ReturnType<typeof resolvePriceForQuantityUpdate>;
|
priceConfiguration: ReturnType<typeof resolvePriceForQuantityUpdate>;
|
||||||
quantityDifferences: ReturnType<typeof calculateQuantityDifferences>;
|
quantityDifferences: ReturnType<typeof calculateQuantityDifferences>;
|
||||||
billingPeriod: ReturnType<typeof extractBillingPeriod>;
|
billingPeriod: BillingPeriod;
|
||||||
}): number | undefined => {
|
}): number | undefined => {
|
||||||
const { stripeSubscription, currentEpochMs } = updateSubscriptionContext;
|
const { stripeSubscription, currentEpochMs } = updateSubscriptionContext;
|
||||||
const { price, billingUnitsPerQuantity, shouldApplyProration } =
|
const { price, billingUnitsPerQuantity } = priceConfiguration;
|
||||||
priceConfiguration;
|
|
||||||
const { isUpgrade } = quantityDifferences;
|
const { isUpgrade } = quantityDifferences;
|
||||||
|
|
||||||
if (!stripeSubscription) {
|
if (!stripeSubscription) {
|
||||||
@@ -49,7 +48,7 @@ export const calculateProrationAmount = ({
|
|||||||
|
|
||||||
const isTrialing = stripeSubscription.status === "trialing";
|
const isTrialing = stripeSubscription.status === "trialing";
|
||||||
|
|
||||||
if (!shouldApplyProration || isTrialing) {
|
if (isTrialing) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +75,7 @@ export const calculateProrationAmount = ({
|
|||||||
|
|
||||||
const proratedAmountDollars = applyProration({
|
const proratedAmountDollars = applyProration({
|
||||||
now: currentEpochMs,
|
now: currentEpochMs,
|
||||||
billingPeriod: {
|
billingPeriod,
|
||||||
start: billingPeriod.subscriptionPeriodStartEpochMs,
|
|
||||||
end: billingPeriod.subscriptionPeriodEndEpochMs,
|
|
||||||
},
|
|
||||||
amount: amountDifferenceDollars.toNumber(),
|
amount: amountDifferenceDollars.toNumber(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|||||||
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
|
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
|
||||||
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
|
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
|
||||||
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
|
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
|
||||||
import { mapOptionsList } from "@/internal/customers/attach/attachUtils/mapOptionsList";
|
|
||||||
import { CusService } from "../../../../customers/CusService";
|
import { CusService } from "../../../../customers/CusService";
|
||||||
|
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
|
||||||
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
|
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
|
||||||
import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema";
|
||||||
|
|
||||||
@@ -74,11 +74,11 @@ export const fetchApiSubscriptionUpdateContext = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (params.options) {
|
if (params.options) {
|
||||||
params.options = mapOptionsList({
|
params.options = parseFeatureQuantitiesParams({
|
||||||
optionsInput: params.options,
|
optionsInput: params.options,
|
||||||
features,
|
features,
|
||||||
prices: targetCustomerProduct.customer_prices.map((cp) => cp.price),
|
prices: targetCustomerProduct.customer_prices.map((cp) => cp.price),
|
||||||
curCusProduct: targetCustomerProduct,
|
currentCustomerProduct: targetCustomerProduct,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type AttachBodyV1,
|
type AttachBodyV1,
|
||||||
|
BillingPeriodSchema,
|
||||||
type FeatureOptions,
|
type FeatureOptions,
|
||||||
type FreeTrial,
|
type FreeTrial,
|
||||||
type FullCusProduct,
|
type FullCusProduct,
|
||||||
@@ -96,8 +97,7 @@ export const QuantityUpdateDetailsSchema = z.object({
|
|||||||
billingUnitsPerQuantity: z.number(),
|
billingUnitsPerQuantity: z.number(),
|
||||||
|
|
||||||
calculatedProrationAmountDollars: z.number().optional(),
|
calculatedProrationAmountDollars: z.number().optional(),
|
||||||
subscriptionPeriodStartEpochMs: z.number(),
|
billingPeriod: BillingPeriodSchema,
|
||||||
subscriptionPeriodEndEpochMs: z.number(),
|
|
||||||
|
|
||||||
stripeInvoiceItemDescription: z.string(),
|
stripeInvoiceItemDescription: z.string(),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
ErrCode,
|
||||||
|
type Feature,
|
||||||
|
type FeatureOptions,
|
||||||
|
type FullCusProduct,
|
||||||
|
nullish,
|
||||||
|
type Price,
|
||||||
|
type UsagePriceConfig,
|
||||||
|
} 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export const parseFeatureQuantitiesParams = ({
|
||||||
|
optionsInput,
|
||||||
|
features,
|
||||||
|
prices,
|
||||||
|
currentCustomerProduct,
|
||||||
|
}: {
|
||||||
|
optionsInput?: FeatureOptions[];
|
||||||
|
features: Feature[];
|
||||||
|
prices: Price[];
|
||||||
|
currentCustomerProduct?: FullCusProduct;
|
||||||
|
}): FeatureOptions[] => {
|
||||||
|
const parsedOptions = parseAndNormalizeOptions({
|
||||||
|
optionsInput,
|
||||||
|
features,
|
||||||
|
prices,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isOneOff(prices) || isFreeProduct(prices)) {
|
||||||
|
return parsedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasPrepaidPrice) {
|
||||||
|
mergedOptions.push(existingOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedOptions;
|
||||||
|
};
|
||||||
@@ -7,10 +7,14 @@ import {
|
|||||||
type UsagePriceConfig,
|
type UsagePriceConfig,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { Decimal } from "decimal.js";
|
import { Decimal } from "decimal.js";
|
||||||
|
import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams.js";
|
||||||
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
|
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
|
||||||
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
|
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
|
||||||
import RecaseError from "@/utils/errorUtils.js";
|
import RecaseError from "@/utils/errorUtils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Can now use {@link parseFeatureQuantitiesParams} instead
|
||||||
|
*/
|
||||||
export const mapOptionsList = ({
|
export const mapOptionsList = ({
|
||||||
optionsInput,
|
optionsInput,
|
||||||
features,
|
features,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export * from "./intervalUtils/intervalArithmetic";
|
|||||||
// Invoicing utils
|
// Invoicing utils
|
||||||
export * from "./invoicingUtils/cusProductToArrearLineItems";
|
export * from "./invoicingUtils/cusProductToArrearLineItems";
|
||||||
export * from "./invoicingUtils/cusProductToLineItems";
|
export * from "./invoicingUtils/cusProductToLineItems";
|
||||||
export * from "./invoicingUtils/extractBillingPeriod";
|
|
||||||
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem";
|
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem";
|
||||||
export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem";
|
export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem";
|
||||||
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount";
|
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount";
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { InternalError } from "@api/errors/base/InternalError";
|
|
||||||
import type { BillingInterval } from "@models/productModels/intervals/billingInterval";
|
|
||||||
import { secondsToMs } from "@utils/common/unixUtils";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
import { getCycleEnd } from "../cycleUtils/getCycleEnd";
|
|
||||||
import { getCycleStart } from "../cycleUtils/getCycleStart";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the current billing period for a subscription.
|
|
||||||
*
|
|
||||||
* Uses the subscription's billing_cycle_anchor and the price's interval configuration
|
|
||||||
* to calculate the billing period in-house, rather than relying on Stripe subscription
|
|
||||||
* item periods.
|
|
||||||
*
|
|
||||||
* @param stripeSubscription - Stripe subscription object (for billing_cycle_anchor)
|
|
||||||
* @param interval - The billing interval from the price config
|
|
||||||
* @param intervalCount - Number of intervals per cycle (default: 1)
|
|
||||||
* @param currentEpochMs - Current timestamp in milliseconds
|
|
||||||
* @returns Start and end timestamps in milliseconds
|
|
||||||
*/
|
|
||||||
export const extractBillingPeriod = ({
|
|
||||||
stripeSubscription,
|
|
||||||
interval,
|
|
||||||
intervalCount = 1,
|
|
||||||
currentEpochMs,
|
|
||||||
}: {
|
|
||||||
stripeSubscription: Stripe.Subscription;
|
|
||||||
interval: BillingInterval;
|
|
||||||
intervalCount?: number;
|
|
||||||
currentEpochMs: number;
|
|
||||||
}): {
|
|
||||||
subscriptionPeriodStartEpochMs: number;
|
|
||||||
subscriptionPeriodEndEpochMs: number;
|
|
||||||
} => {
|
|
||||||
const billingCycleAnchorMs = secondsToMs(
|
|
||||||
stripeSubscription.billing_cycle_anchor,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!billingCycleAnchorMs) {
|
|
||||||
throw new InternalError({
|
|
||||||
message: `[Billing] Invalid billing_cycle_anchor: ${stripeSubscription.billing_cycle_anchor}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionPeriodStartEpochMs = getCycleStart({
|
|
||||||
anchor: billingCycleAnchorMs,
|
|
||||||
interval,
|
|
||||||
intervalCount,
|
|
||||||
now: currentEpochMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscriptionPeriodEndEpochMs = getCycleEnd({
|
|
||||||
anchor: billingCycleAnchorMs,
|
|
||||||
interval,
|
|
||||||
intervalCount,
|
|
||||||
now: currentEpochMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscriptionPeriodStartEpochMs,
|
|
||||||
subscriptionPeriodEndEpochMs,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { InternalError } from "@api/errors";
|
||||||
|
import type { FeatureOptions } from "@models/cusProductModels/cusProductModels";
|
||||||
|
import type { Feature } from "@models/featureModels/featureModels";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the feature options for a feature
|
||||||
|
* @param featureOptions - The feature options to search through
|
||||||
|
* @param feature - The feature to find the options for
|
||||||
|
* @returns The feature options, or undefined if not found
|
||||||
|
*/
|
||||||
|
export const findFeatureOptionsByFeature = ({
|
||||||
|
featureOptions,
|
||||||
|
feature,
|
||||||
|
}: {
|
||||||
|
featureOptions: FeatureOptions[];
|
||||||
|
feature: Feature;
|
||||||
|
}) => {
|
||||||
|
const options = featureOptions.find(
|
||||||
|
(oldOption) =>
|
||||||
|
oldOption.internal_feature_id === feature.internal_id ||
|
||||||
|
oldOption.feature_id === feature.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
throw new InternalError({
|
||||||
|
message: `[Find Feature Options By Feature] Cannot find feature options for feature: ${feature.id}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
};
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { InternalError } from "@api/errors";
|
|
||||||
import type { FeatureOptions } from "@models/cusProductModels/cusProductModels";
|
|
||||||
|
|
||||||
export const findFeatureOptionsByFeature = ({
|
|
||||||
featureOptions,
|
|
||||||
featureId,
|
|
||||||
}: {
|
|
||||||
featureOptions: FeatureOptions[];
|
|
||||||
featureId: string;
|
|
||||||
}) => {
|
|
||||||
const previousOption = featureOptions.find(
|
|
||||||
(oldOption) => oldOption.feature_id === featureId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!previousOption) {
|
|
||||||
throw new InternalError({
|
|
||||||
message: `[Find Feature Options By Feature] Cannot find feature options for feature: ${featureId}.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return previousOption;
|
|
||||||
};
|
|
||||||
@@ -40,7 +40,7 @@ export * from "./cusProductUtils/cusProductConstants.js";
|
|||||||
export * from "./cusProductUtils/cusProductUtils.js";
|
export * from "./cusProductUtils/cusProductUtils.js";
|
||||||
export * from "./cusProductUtils/filterCusProductUtils.js";
|
export * from "./cusProductUtils/filterCusProductUtils.js";
|
||||||
export * from "./cusProductUtils/filterCusProductUtils.js";
|
export * from "./cusProductUtils/filterCusProductUtils.js";
|
||||||
export * from "./cusProductUtils/findFeatureOptionsByFeature.js";
|
export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js";
|
||||||
export * from "./cusProductUtils/getCusProductFromCustomer.js";
|
export * from "./cusProductUtils/getCusProductFromCustomer.js";
|
||||||
export * from "./cusProductUtils/productIdToCusProduct.js";
|
export * from "./cusProductUtils/productIdToCusProduct.js";
|
||||||
// Cus utils
|
// Cus utils
|
||||||
|
|||||||
Reference in New Issue
Block a user