cleaned up parse feature quantities params
This commit is contained in:
13
server/src/external/autumn/autumnCli.ts
vendored
13
server/src/external/autumn/autumnCli.ts
vendored
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -92,9 +92,9 @@ export const computeUpdateQuantityDetails = ({
|
||||
});
|
||||
|
||||
const billingPeriod = getLineItemBillingPeriod({
|
||||
anchor: billingCycleAnchorMs,
|
||||
anchorMs: billingCycleAnchorMs,
|
||||
price: customerPrice.price,
|
||||
now: currentEpochMs,
|
||||
nowMs: currentEpochMs,
|
||||
});
|
||||
|
||||
if (!billingPeriod) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export const handleUpdateSubscription = createRoute({
|
||||
params: body,
|
||||
});
|
||||
|
||||
const stripeBillingPlan = evaluateStripeBillingPlan({
|
||||
const stripeBillingPlan = await evaluateStripeBillingPlan({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
43
server/tests/billing/utils/expectCustomerInvoiceCorrect.ts
Normal file
43
server/tests/billing/utils/expectCustomerInvoiceCorrect.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,8 +25,6 @@ export const UpdateConfirmationInfo = ({
|
||||
newProduct: previewData?.product,
|
||||
});
|
||||
|
||||
console.log("previewData", previewData);
|
||||
|
||||
const hasPrepaidQuantityChanges = useHasPrepaidQuantityChanges(product, form);
|
||||
|
||||
const renderInfoBoxes = (): ReactNode[] => {
|
||||
|
||||
Reference in New Issue
Block a user