restructured context

This commit is contained in:
John Yeo
2026-01-07 14:41:48 +00:00
parent 4fb29d910a
commit baf7fdbd4c
9 changed files with 135 additions and 91 deletions

View File

@@ -1,7 +1,9 @@
import type {
Entitlement,
FeatureOptions,
FullCusProduct,
FullProduct,
Price,
} from "@autumn/shared";
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
import type Stripe from "stripe";
@@ -32,6 +34,10 @@ export interface BillingContext {
// Feature quantities
featureQuantities: FeatureOptions[];
// Unforunately, need to add custom prices, custom entitlements and free trial here, because it's determined in the setup step.
customPrices: Price[];
customEnts: Entitlement[];
}
export interface UpdateSubscriptionBillingContext extends BillingContext {

View File

@@ -1,9 +1,9 @@
import type { FullProduct, ProductItem } from "@autumn/shared";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import { getEntsWithFeature } from "../../../../products/entitlements/entitlementUtils";
import { handleNewProductItems } from "../../../../products/product-items/productItemUtils/handleNewProductItems";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems";
export const computeCustomFullProduct = async ({
export const setupCustomFullProduct = async ({
ctx,
customItems,
currentFullProduct,

View File

@@ -11,10 +11,10 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
/**
* Parses feature quantities from params, iterating over all prepaid prices.
* Sets up feature quantities context 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 = ({
export const setupFeatureQuantitiesContext = ({
ctx,
featureQuantitiesParams,
fullProduct,

View File

@@ -0,0 +1,28 @@
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { CusService } from "@server/internal/customers/CusService";
export const setupFullCustomerContext = async ({
ctx,
params,
autoCreateCustomer = false,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
autoCreateCustomer?: boolean;
}) => {
const { db, org, env } = ctx;
const { customer_id: customerId } = params;
const fullCustomer = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env,
withSubs: true,
withEntities: true,
entityId: params.entity_id ?? undefined,
});
return fullCustomer;
};

View File

@@ -1,6 +1,5 @@
import {
CusProductStatus,
cusProductToProduct,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
@@ -9,8 +8,6 @@ import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutum
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 { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams";
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
export const computeCustomPlan = async ({
ctx,
@@ -21,23 +18,10 @@ export const computeCustomPlan = async ({
updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}) => {
const { customerProduct } = updateSubscriptionContext;
const { customerProduct, customPrices, customEnts } =
updateSubscriptionContext;
const currentFullProduct = cusProductToProduct({
cusProduct: customerProduct,
});
const {
fullProduct: customFullProduct,
customPrices,
customEnts,
} = await computeCustomFullProduct({
ctx,
currentFullProduct,
customItems: params.items,
});
updateSubscriptionContext.fullProducts = [customFullProduct];
const customFullProduct = updateSubscriptionContext.fullProducts[0];
// 2. Compute the custom trial details
const { freeTrialPlan, customFreeTrial } = computeCustomPlanFreeTrial({
@@ -50,13 +34,6 @@ export const computeCustomPlan = async ({
updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt;
}
updateSubscriptionContext.featureQuantities = parseFeatureQuantitiesParams({
ctx,
featureQuantitiesParams: params,
fullProduct: customFullProduct,
currentCustomerProduct: customerProduct,
}); // re-parse feature quantities for new custom product
// 3. Compute the new customer product
const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({
ctx,

View File

@@ -1,15 +1,10 @@
import {
cusProductToProduct,
InternalError,
secondsToMs,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { secondsToMs, type UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { CusService } from "../../../../customers/CusService";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
import { setupUpdateSubscriptionProductContext } from "@/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionProductContext";
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
import { findTargetCustomerProduct } from "./findTargetCustomerProduct";
/**
* Fetch the context for updating a subscription
@@ -24,32 +19,23 @@ export const setupUpdateSubscriptionBillingContext = async ({
ctx: AutumnContext;
params: UpdateSubscriptionV0Params;
}): Promise<UpdateSubscriptionBillingContext> => {
const { db, org, env } = ctx;
const { customer_id: customerId, product_id: productId } = params;
const fullCustomer = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env,
withSubs: true,
withEntities: true,
entityId: params.entity_id ?? undefined,
});
const targetCustomerProduct = findTargetCustomerProduct({
const fullCustomer = await setupFullCustomerContext({
ctx,
params,
});
const { customerProduct, fullProduct, customPrices, customEnts } =
await setupUpdateSubscriptionProductContext({
ctx,
fullCustomer,
params,
});
if (!targetCustomerProduct) {
throw new InternalError({
message: `[API Subscription Update] Target customer product not found: ${productId}`,
});
}
const fullProduct = cusProductToProduct({
cusProduct: targetCustomerProduct,
const featureQuantities = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: customerProduct,
});
const {
@@ -61,14 +47,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
} = await setupStripeBillingContext({
ctx,
fullCustomer,
targetCustomerProduct,
});
const featureQuantities = parseFeatureQuantitiesParams({
ctx,
featureQuantitiesParams: params,
fullProduct,
currentCustomerProduct: targetCustomerProduct,
targetCustomerProduct: customerProduct,
});
const currentEpochMs = testClockFrozenTime ?? Date.now();
@@ -88,7 +67,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
return {
fullCustomer,
fullProducts: [fullProduct],
customerProduct: targetCustomerProduct,
customerProduct,
stripeSubscription,
stripeSubscriptionSchedule,
stripeCustomer,
@@ -98,5 +77,8 @@ export const setupUpdateSubscriptionBillingContext = async ({
billingCycleAnchorMs: billingCycleAnchorMs ?? "now",
invoiceMode,
featureQuantities,
customPrices,
customEnts,
};
};

View File

@@ -0,0 +1,51 @@
import {
cusProductToProduct,
type FullCustomer,
InternalError,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupCustomFullProduct } from "../../setup/setupCustomFullProduct";
import { findTargetCustomerProduct } from "./findTargetCustomerProduct";
export const setupUpdateSubscriptionProductContext = async ({
ctx,
fullCustomer,
params,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
params: UpdateSubscriptionV0Params;
}) => {
const targetCustomerProduct = findTargetCustomerProduct({
params,
fullCustomer,
});
if (!targetCustomerProduct) {
throw new InternalError({
message: `[API Subscription Update] Target customer product not found: ${params.product_id}`,
});
}
const fullProduct = cusProductToProduct({
cusProduct: targetCustomerProduct,
});
const {
fullProduct: customFullProduct,
customPrices,
customEnts,
} = await setupCustomFullProduct({
ctx,
currentFullProduct: fullProduct,
customItems: params.items,
});
return {
customerProduct: targetCustomerProduct,
fullProduct: customFullProduct,
customPrices,
customEnts,
};
};

View File

@@ -12,7 +12,7 @@ import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
/**
* @deprecated Use parseFeatureQuantitiesParams from billing/v2/utils instead
* @deprecated Use setupFeatureQuantitiesContext from billing/v2/setup instead
*/
export const mapOptionsList = ({
optionsInput,

View File

@@ -10,11 +10,11 @@ import {
} from "@tests/utils/mockUtils/priceMocks";
import { createMockFullProduct } from "@tests/utils/mockUtils/productMocks";
import chalk from "chalk";
import { parseFeatureQuantitiesParams } from "@/internal/billing/v2/utils/parseFeatureQuantitiesParams";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
// ============ TESTS ============
describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => {
describe("basic quantity inheritance", () => {
test("1. current has quantity, new params has none → uses current", () => {
const feature = createMockFeature({
@@ -47,7 +47,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -81,7 +81,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -123,7 +123,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -203,7 +203,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
features: [creditsFeature, seatsFeature, storageFeature],
});
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -246,7 +246,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -282,7 +282,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -317,7 +317,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -373,7 +373,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -429,7 +429,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -477,7 +477,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -517,7 +517,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -542,7 +542,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -570,7 +570,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [] });
expect(() =>
parseFeatureQuantitiesParams({
setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -601,7 +601,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -643,7 +643,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -686,7 +686,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,
@@ -731,7 +731,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => {
const ctx = createMockCtx({ features: [feature] });
const result = parseFeatureQuantitiesParams({
const result = setupFeatureQuantitiesContext({
ctx,
featureQuantitiesParams: params,
fullProduct,