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 { import type {
Entitlement,
FeatureOptions, FeatureOptions,
FullCusProduct, FullCusProduct,
FullProduct, FullProduct,
Price,
} from "@autumn/shared"; } from "@autumn/shared";
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel"; import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
import type Stripe from "stripe"; import type Stripe from "stripe";
@@ -32,6 +34,10 @@ export interface BillingContext {
// Feature quantities // Feature quantities
featureQuantities: FeatureOptions[]; 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 { export interface UpdateSubscriptionBillingContext extends BillingContext {

View File

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

View File

@@ -11,10 +11,10 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions"; 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. * For each prepaid price, uses new quantity from params or falls back to existing subscription.
*/ */
export const parseFeatureQuantitiesParams = ({ export const setupFeatureQuantitiesContext = ({
ctx, ctx,
featureQuantitiesParams, featureQuantitiesParams,
fullProduct, 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 { import {
CusProductStatus, CusProductStatus,
cusProductToProduct,
type UpdateSubscriptionV0Params, type UpdateSubscriptionV0Params,
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv"; 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 type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { computeCustomPlanFreeTrial } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFreeTrial"; import { computeCustomPlanFreeTrial } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFreeTrial";
import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; 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 ({ export const computeCustomPlan = async ({
ctx, ctx,
@@ -21,23 +18,10 @@ export const computeCustomPlan = async ({
updateSubscriptionContext: UpdateSubscriptionBillingContext; updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params; params: UpdateSubscriptionV0Params;
}) => { }) => {
const { customerProduct } = updateSubscriptionContext; const { customerProduct, customPrices, customEnts } =
updateSubscriptionContext;
const currentFullProduct = cusProductToProduct({ const customFullProduct = updateSubscriptionContext.fullProducts[0];
cusProduct: customerProduct,
});
const {
fullProduct: customFullProduct,
customPrices,
customEnts,
} = await computeCustomFullProduct({
ctx,
currentFullProduct,
customItems: params.items,
});
updateSubscriptionContext.fullProducts = [customFullProduct];
// 2. Compute the custom trial details // 2. Compute the custom trial details
const { freeTrialPlan, customFreeTrial } = computeCustomPlanFreeTrial({ const { freeTrialPlan, customFreeTrial } = computeCustomPlanFreeTrial({
@@ -50,13 +34,6 @@ export const computeCustomPlan = async ({
updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt; 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 // 3. Compute the new customer product
const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({ const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({
ctx, ctx,

View File

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

View File

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