chore(wip): initial scaffolding of update sub v2

This commit is contained in:
Charlie Lamb
2025-12-19 10:21:51 +00:00
parent 62113f2355
commit 6ec7c0f011
18 changed files with 436 additions and 162 deletions

View File

@@ -5,8 +5,8 @@ import { getFreeTrialForAttach } from "./fetchAutumnUtils/getFreeTrialForAttach"
import { getProductsForAttach } from "./fetchAutumnUtils/getProductsForAttach";
import { overrideProduct } from "./fetchAutumnUtils/overrideProduct";
import { resolveAttachActions } from "./fetchAutumnUtils/resolveAttachActions/resolveAttachActions";
import { fetchStripeCustomerForAttach } from "./fetchStripeUtils/fetchStripeCustomerForAttach";
import { fetchStripeSubForAttach } from "./fetchStripeUtils/fetchStripeSubForAttach";
import { fetchStripeCustomerForBilling } from "./fetchStripeUtils/fetchStripeCustomerForBilling";
import { fetchStripeSubscriptionForBilling } from "./fetchStripeUtils/fetchStripeSubscriptionForBilling";
export const fetchAttachContext = async ({
ctx,
@@ -67,17 +67,19 @@ export const fetchAttachContext = async ({
// 5. Get sub update context
// 5. Get stripe sub
const stripeSub = await fetchStripeSubForAttach({
const stripeSub = await fetchStripeSubscriptionForBilling({
ctx,
fullCus,
products: newFullProducts,
});
// 6. Get stripe customer
const { stripeCus, paymentMethod, now } = await fetchStripeCustomerForAttach({
ctx,
fullCus,
});
const { stripeCus, paymentMethod, now } = await fetchStripeCustomerForBilling(
{
ctx,
fullCus,
},
);
const cusProductActions = resolveAttachActions({
fullCus,

View File

@@ -7,7 +7,7 @@ import {
} from "../../../../../external/stripe/stripeCusUtils";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
export const fetchStripeCustomerForAttach = async ({
export const fetchStripeCustomerForBilling = async ({
ctx,
fullCus,
}: {

View File

@@ -6,7 +6,7 @@ import {
import { createStripeCli } from "../../../../../external/connect/createStripeCli";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
export const fetchStripeSubForAttach = async ({
export const fetchStripeSubscriptionForBilling = async ({
ctx,
fullCus,
products,

View File

@@ -1,22 +1,8 @@
import {
cusProductToProduct,
InternalError,
SubscriptionUpdateV0ParamsSchema,
secondsToMs,
} from "@autumn/shared";
import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { CusService } from "../../../customers/CusService";
import { EntitlementService } from "../../../products/entitlements/EntitlementService";
import { PriceService } from "../../../products/prices/PriceService";
import { cusProductToExistingUsages } from "../../billingUtils/handleExistingUsages/cusProductToExistingUsages";
import { initFullCusProduct } from "../../billingUtils/initFullCusProduct/initFullCusProduct";
import { buildAutumnLineItems } from "../compute/computeAutumnUtils/buildAutumnLineItems";
import { buildStripeSubAction } from "../compute/computeStripeUtils/buildStripeSubAction";
import { executeCusProductActions } from "../execute/executeAutumnActions/executeCusProductActions";
import { executeStripeSubAction } from "../execute/executeStripeSubAction";
import { overrideProduct } from "../fetch/fetchAutumnUtils/overrideProduct";
import { fetchStripeCustomerForAttach } from "../fetch/fetchStripeUtils/fetchStripeCustomerForAttach";
import { fetchStripeSubForAttach } from "../fetch/fetchStripeUtils/fetchStripeSubForAttach";
import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
import { executeSubscriptionUpdate } from "../subscriptionUpdate/execute/executeSubscriptionUpdate";
import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext";
export const handleApiSubscriptionUpdate = createRoute({
body: SubscriptionUpdateV0ParamsSchema,
@@ -24,140 +10,20 @@ export const handleApiSubscriptionUpdate = createRoute({
const ctx = c.get("ctx");
const body = c.req.valid("json");
const { db, org, env } = ctx;
const { customer_id: customerId, product_id: planId } = body;
// 1. Fetch the context
/**
* const apiSubscriptionUpdateContext = await fetchApiSubscriptionUpdateContext({
ctx,
body,
});
1. Full customer
2. Target customer product
3. Stripe subscription (if applicable)
4. Stripe schedule (if applicable)
5. Stripe customer
6. Payment method (if applicable)
7. Test clock frozen time (if applicable)
8. shouldDoUpdateQuantity -- if feature quantities is passed in, and there are no custom items, then we go down update quantity plan, if not do the compute update subscription plan
* if (apiSubscriptionUpdateContext.intent === "update_quantity") {
* const updateQuantityPlan = computeUpdateQuantityPlan({ ctx, context });
* } else if (apiSubscriptionUpdateContext.intent === "update_plan") {
* const updatePlanPlan = computeUpdateSubscriptionPlan({ ctx, context });
* }
* await executeBillingPlan({ ctx, plan });
*/
const fullCus = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env,
withSubs: true,
withEntities: true,
});
// 1. Find target customer product
const targetCusProduct = fullCus.customer_products.find(
(cp) => cp.product.id === planId,
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext(
ctx,
body,
);
if (!targetCusProduct) {
throw new InternalError({
message: `[api subscription update] Target cus product not found: ${planId}`,
});
}
const ongoingCusProductAction = {
action: "expire" as const,
cusProduct: targetCusProduct,
};
const curFullProduct = cusProductToProduct({
cusProduct: targetCusProduct!,
const subscriptionUpdatePlan = computeSubscriptionUpdatePlan(ctx, {
updateSubscriptionContext,
params: body,
});
const {
fullProducts: [newFullProduct],
customPrices,
customEnts,
} = await overrideProduct({
ctx,
newItems: body.items,
products: [curFullProduct],
});
await EntitlementService.insert({
db,
data: customEnts,
});
await PriceService.insert({
db,
data: customPrices,
});
const newCusProduct = initFullCusProduct({
ctx,
fullCus,
initContext: {
fullCus,
product: newFullProduct,
featureQuantities: [],
replaceables: [],
existingUsages: cusProductToExistingUsages({
cusProduct: targetCusProduct,
}),
},
});
// Get stripe subscription
const stripeSub = await fetchStripeSubForAttach({
ctx,
fullCus,
products: [],
targetCusProductId: targetCusProduct.id,
});
const { stripeCus, paymentMethod, testClockFrozenTime } =
await fetchStripeCustomerForAttach({
ctx,
fullCus,
});
const billingCycleAnchor = secondsToMs(stripeSub?.billing_cycle_anchor);
// 2. Cases: update feature quantity, update plan entirely
const autumnLineItems = buildAutumnLineItems({
ctx,
newCusProducts: [newCusProduct],
ongoingCusProductAction,
billingCycleAnchor,
testClockFrozenTime,
});
const stripeSubAction = buildStripeSubAction({
ctx,
stripeSub: stripeSub!,
fullCus,
paymentMethod,
ongoingCusProductAction,
newCusProducts: [newCusProduct],
});
ctx.logger.info("Executing stripe sub action");
await executeStripeSubAction({
ctx,
stripeSubAction,
});
ctx.logger.info("Executing cus product actions");
await executeCusProductActions({
ctx,
ongoingCusProductAction,
newCusProducts: [newCusProduct],
await executeSubscriptionUpdate(ctx, {
params: body,
updateSubscriptionContext,
subscriptionUpdatePlan,
});
return c.json({ success: true }, 200);

View File

@@ -0,0 +1,9 @@
// const {
// fullProducts: [newFullProduct],
// customPrices,
// customEnts,
// } = await overrideProduct({
// ctx,
// newItems: body.items,
// products: [product],
// });

View File

@@ -0,0 +1,15 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
/**
* Compute the intent for a subscription update
* @param params - The parameters for the subscription update
* @returns The intent for the subscription update
*/
export const computeSubscriptionUpdateIntent = (
params: SubscriptionUpdateV0Params,
): SubscriptionUpdateIntentEnum => {
if (params.options?.length && !params.items?.length)
return SubscriptionUpdateIntentEnum.UpdateQuantity;
return SubscriptionUpdateIntentEnum.UpdatePlan;
};

View File

@@ -0,0 +1,28 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { SubscriptionUpdatePlan } from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
import { getComputeSubscriptionUpdatePlanIntentMap } from "./computeSubscriptionUpdatePlanIntentMap";
/**
* Compute the subscription update plan
* @param ctx - The context
* @param params - The parameters for the subscription update
* @returns The subscription update plan
*/
export const computeSubscriptionUpdatePlan = (
ctx: AutumnContext,
{
updateSubscriptionContext,
params,
}: {
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
},
): SubscriptionUpdatePlan => {
const intent = computeSubscriptionUpdateIntent(params);
const computePlan = getComputeSubscriptionUpdatePlanIntentMap(intent);
return computePlan(ctx, { updateSubscriptionContext, params });
};

View File

@@ -0,0 +1,49 @@
import {
ErrCode,
RecaseError,
type SubscriptionUpdateV0Params,
} from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { SubscriptionUpdatePlan } from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan";
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
export type ComputeSubscriptionUpdatePlan = (
ctx: AutumnContext,
{
updateSubscriptionContext,
params,
}: {
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
},
) => SubscriptionUpdatePlan;
export type ComputeSubscriptionUpdatePlanIntentMap = Partial<
Record<SubscriptionUpdateIntentEnum, ComputeSubscriptionUpdatePlan>
>;
/**
* Map of intent to function to compute the subscription update plan
*/
const computeSubscriptionUpdatePlanIntentMap: ComputeSubscriptionUpdatePlanIntentMap =
{
[SubscriptionUpdateIntentEnum.UpdateQuantity]:
computeSubscriptionUpdateQuantityPlan,
};
export const getComputeSubscriptionUpdatePlanIntentMap = (
intent: SubscriptionUpdateIntentEnum,
): ComputeSubscriptionUpdatePlan => {
const plan = computeSubscriptionUpdatePlanIntentMap[intent];
if (!plan) {
throw new RecaseError({
message: `[Compute Subscription Update] Invalid intent: ${intent}`,
code: ErrCode.InvalidInputs,
statusCode: 500,
});
}
return plan;
};

View File

@@ -0,0 +1,100 @@
import {
OngoingCusProductActionEnum,
type SubscriptionUpdateV0Params,
secondsToMs,
} from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages";
import { initFullCusProduct } from "@/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct";
import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems";
import { buildStripeSubAction } from "../../compute/computeStripeUtils/buildStripeSubAction";
import {
SubscriptionUpdateQuantityAction,
type SubscriptionUpdateQuantityPlan,
} from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
export const computeSubscriptionUpdateQuantityPlan = (
ctx: AutumnContext,
{
updateSubscriptionContext,
params,
}: {
updateSubscriptionContext: UpdateSubscriptionContext;
params: SubscriptionUpdateV0Params;
},
): SubscriptionUpdateQuantityPlan => {
const { options } = params;
const {
customerProduct,
fullCustomer,
stripeSubscription,
testClockFrozenTime,
product,
paymentMethod,
} = updateSubscriptionContext;
const featureQuantities = {
old: customerProduct.options,
new: options || [],
};
const isUpgrade =
featureQuantities.new[0].quantity > featureQuantities.old[0].quantity;
const action = isUpgrade
? SubscriptionUpdateQuantityAction.Upgrade
: SubscriptionUpdateQuantityAction.Downgrade;
const billingCycleAnchor = secondsToMs(
stripeSubscription?.billing_cycle_anchor,
);
const ongoingCusProductAction = {
action: OngoingCusProductActionEnum.Expire,
cusProduct: customerProduct,
};
const autumnLineItems = buildAutumnLineItems({
ctx,
newCusProducts: [customerProduct],
ongoingCusProductAction,
billingCycleAnchor,
testClockFrozenTime,
});
const newCustomerProduct = initFullCusProduct({
ctx,
fullCus: fullCustomer,
initContext: {
fullCus: fullCustomer,
product,
featureQuantities: [],
replaceables: [],
existingUsages: cusProductToExistingUsages({
cusProduct: customerProduct,
}),
},
});
const stripeSubscriptionAction = buildStripeSubAction({
ctx,
stripeSub: stripeSubscription!,
fullCus: fullCustomer,
paymentMethod,
ongoingCusProductAction,
newCusProducts: [newCustomerProduct],
});
return {
intent: SubscriptionUpdateIntentEnum.UpdateQuantity,
customEntitlements: [],
customPrices: [],
featureQuantities,
action,
autumnLineItems,
stripeSubscriptionAction,
ongoingCusProductAction,
};
};

View File

@@ -0,0 +1,14 @@
import { z } from "zod/v4";
export enum SubscriptionUpdateIntentEnum {
UpdateQuantity = "update_quantity",
UpdatePlan = "update_plan",
}
export const ComputeSubscriptionUpdateResultSchema = z.object({
intent: SubscriptionUpdateIntentEnum,
});
export type ComputeSubscriptionUpdateResult = z.infer<
typeof ComputeSubscriptionUpdateResultSchema
>;

View File

@@ -0,0 +1,53 @@
import type { SubscriptionUpdateV0Params } from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
import { PriceService } from "@/internal/products/prices/PriceService";
import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions";
import { executeStripeSubAction } from "../../execute/executeStripeSubAction";
import type { SubscriptionUpdatePlan } from "../../types";
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
export const executeSubscriptionUpdate = async (
ctx: AutumnContext,
{
params,
updateSubscriptionContext,
subscriptionUpdatePlan,
}: {
params: SubscriptionUpdateV0Params;
updateSubscriptionContext: UpdateSubscriptionContext;
subscriptionUpdatePlan: SubscriptionUpdatePlan;
},
) => {
const { db, logger } = ctx;
const { customerProduct } = updateSubscriptionContext;
const {
customEntitlements,
customPrices,
ongoingCusProductAction,
stripeSubscriptionAction,
} = subscriptionUpdatePlan;
await EntitlementService.insert({
db,
data: customEntitlements,
});
await PriceService.insert({
db,
data: customPrices,
});
logger.info("Executing stripe sub action");
await executeStripeSubAction({
ctx,
stripeSubAction: stripeSubscriptionAction,
});
logger.info("Executing cus product actions");
await executeCusProductActions({
ctx,
ongoingCusProductAction,
newCusProducts: [customerProduct],
});
};

View File

@@ -0,0 +1,84 @@
import {
cusProductToProduct,
InternalError,
type SubscriptionUpdateV0Params,
} from "@shared/index";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "../../../../customers/CusService";
import { fetchStripeCustomerForBilling } from "../../fetch/fetchStripeUtils/fetchStripeCustomerForBilling";
import { fetchStripeSubscriptionForBilling } from "../../fetch/fetchStripeUtils/fetchStripeSubscriptionForBilling";
import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema";
/**
* Fetch the context for updating a subscription
* @param ctx - The context
* @param body - The body of the request
* @returns The update subscription context
* @example
* const context = await fetchApiSubscriptionUpdateContext(ctx, params);
*
* Returns:
* 1. Full customer
* 2. Target customer product
* 3. Stripe subscription (if applicable)
* 4. Stripe schedule (if applicable)
* 5. Stripe customer
* 6. Payment method (if applicable)
* 7. Test clock frozen time (if applicable)
*/
export const fetchApiSubscriptionUpdateContext = async (
ctx: AutumnContext,
params: SubscriptionUpdateV0Params,
): Promise<UpdateSubscriptionContext> => {
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,
});
const targetCustomerProduct = fullCustomer.customer_products.find(
(cp) => cp.product.id === productId,
);
if (!targetCustomerProduct) {
throw new InternalError({
message: `[API Subscription Update] Target customer product not found: ${productId}`,
});
}
const targetProduct = cusProductToProduct({
cusProduct: targetCustomerProduct,
});
const stripeSubscription = await fetchStripeSubscriptionForBilling({
ctx,
fullCus: fullCustomer,
products: [],
targetCusProductId: targetCustomerProduct.id,
});
const {
stripeCus: stripeCustomer,
paymentMethod,
testClockFrozenTime,
} = await fetchStripeCustomerForBilling({
ctx,
fullCus: fullCustomer,
});
return {
fullCustomer,
product: targetProduct,
customerProduct: targetCustomerProduct,
stripeSubscription,
stripeCustomer,
paymentMethod,
testClockFrozenTime,
};
};

View File

@@ -0,0 +1,13 @@
import type { FullCusProduct, FullCustomer, FullProduct } from "@shared/index";
import type Stripe from "stripe";
export type UpdateSubscriptionContext = {
fullCustomer: FullCustomer;
product: FullProduct;
customerProduct: FullCusProduct;
stripeSubscription?: Stripe.Subscription;
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
stripeCustomer: Stripe.Customer;
paymentMethod?: Stripe.PaymentMethod;
testClockFrozenTime?: number;
};

View File

@@ -1,11 +1,14 @@
import type {
AttachBodyV1,
Entitlement,
FeatureOptions,
FreeTrial,
FullCusProduct,
FullCustomer,
FullProduct,
LineItem,
OngoingCusProductAction,
Price,
ScheduledCusProductAction,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -67,3 +70,31 @@ export type AttachPlan = {
stripeSubAction: StripeSubAction;
stripeInvoiceAction?: StripeInvoiceAction;
};
export type BillingPlan = {
intent: "attach" | "update_quantity" | "update_plan" | "cancel" | "one_off";
};
export type BaseSubscriptionUpdatePlan = BillingPlan & {
intent: "update_quantity" | "update_plan";
customEntitlements: Entitlement[];
customPrices: Price[];
autumnLineItems: LineItem[];
stripeSubscriptionAction: StripeSubAction;
ongoingCusProductAction: OngoingCusProductAction;
};
export enum SubscriptionUpdateQuantityAction {
Upgrade = "upgrade",
Downgrade = "downgrade",
}
export type SubscriptionUpdateQuantityPlan = BaseSubscriptionUpdatePlan & {
featureQuantities: {
old: FeatureOptions[];
new: FeatureOptions[];
};
action: SubscriptionUpdateQuantityAction;
};
export type SubscriptionUpdatePlan = SubscriptionUpdateQuantityPlan;

View File

@@ -35,6 +35,6 @@
"@utils/*": ["../shared/utils/*"],
}
},
"include": ["src", "tests", "scripts", "emails", "experiments"],
"include": ["src", "tests", "scripts", "emails", "experiments", "src/internal/billing/v2/subscriptionUpdate", "../shared/api/billing/subscriptionUpdate/compute", "../shared/api/billing/subscriptionUpdate/fetch"],
"exclude": ["node_modules", "dist", "tests/archives"]
}

View File

@@ -3,6 +3,7 @@ import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProduc
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels";
import { CustomerDataSchema } from "../../common/customerData";
import { EntityDataSchema } from "../../models";
export const SubscriptionUpdateV0ParamsSchema = z.object({
// Customer / Entity Info
customer_id: z.string(),
@@ -12,14 +13,14 @@ export const SubscriptionUpdateV0ParamsSchema = z.object({
customer_data: CustomerDataSchema.optional(),
entity_data: EntityDataSchema.optional(),
options: z.array(FeatureOptionsSchema).nullish(),
options: z.array(FeatureOptionsSchema).nullish(), // used for update quantity etc (in api - feature_quantities)
invoice: z.boolean().optional(),
enable_product_immediately: z.boolean().optional(),
finalize_invoice: z.boolean().optional(),
// Reset billing cycle anchor?
items: z.array(ProductItemSchema).optional(),
items: z.array(ProductItemSchema).optional(), // used for custom configuration of a plan (in api - plan_override)
reset_billing_cycle_anchor: z.boolean().optional(),
new_billing_subscription: z.boolean().optional(),
prorate_billing: z.boolean().optional(),

View File

@@ -1,9 +1,15 @@
import z from "zod/v4";
import { FullCusProductSchema } from "../cusProductModels/cusProductModels";
export enum OngoingCusProductActionEnum {
Expire = "expire",
Cancel = "cancel",
Uncancel = "uncancel",
}
// What happens to the CURRENT active cus product
export const OngoingCusProductActionSchema = z.object({
action: z.literal(["expire", "cancel", "uncancel"]),
action: z.enum(OngoingCusProductActionEnum),
cusProduct: FullCusProductSchema,
});
export type OngoingCusProductAction = z.infer<

View File

@@ -17,7 +17,10 @@
"@utils/*": ["./utils/*"]
}
},
"include": ["./**/*"],
"include": [
"./**/*",
"../server/src/internal/billing/v2/update-subscription"
],
"types": ["node"],
"exclude": ["node_modules", "dist"]
}