From 6ec7c0f0115a6f20dce6995de9af83cc83bca3fa Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Fri, 19 Dec 2025 10:21:51 +0000 Subject: [PATCH] chore(wip): initial scaffolding of update sub v2 --- .../billing/v2/fetch/fetchAttachContext.ts | 16 +- ...ch.ts => fetchStripeCustomerForBilling.ts} | 2 +- ...s => fetchStripeSubscriptionForBilling.ts} | 2 +- .../handlers/handleApiSubscriptionUpdate.ts | 162 ++---------------- .../computeSubscriptionUpdateAllPlan.ts | 9 + .../computeSubscriptionUpdateIntent.ts | 15 ++ .../compute/computeSubscriptionUpdatePlan.ts | 28 +++ .../computeSubscriptionUpdatePlanIntentMap.ts | 49 ++++++ .../computeSubscriptionUpdateQuantityPlan.ts | 100 +++++++++++ .../computeSubscriptionUpdateSchema.ts | 14 ++ .../execute/executeSubscriptionUpdate.ts | 53 ++++++ .../fetchApiSubscriptionUpdateContext.ts | 84 +++++++++ .../fetch/updateSubscriptionContextSchema.ts | 13 ++ server/src/internal/billing/v2/types.ts | 31 ++++ server/tsconfig.json | 2 +- .../subscriptionUpdateV0Params.ts | 5 +- .../billingModels/ongoingCusProductAction.ts | 8 +- shared/tsconfig.json | 5 +- 18 files changed, 436 insertions(+), 162 deletions(-) rename server/src/internal/billing/v2/fetch/fetchStripeUtils/{fetchStripeCustomerForAttach.ts => fetchStripeCustomerForBilling.ts} (96%) rename server/src/internal/billing/v2/fetch/fetchStripeUtils/{fetchStripeSubForAttach.ts => fetchStripeSubscriptionForBilling.ts} (94%) create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts create mode 100644 server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts diff --git a/server/src/internal/billing/v2/fetch/fetchAttachContext.ts b/server/src/internal/billing/v2/fetch/fetchAttachContext.ts index b3802b6ab..530a3971a 100644 --- a/server/src/internal/billing/v2/fetch/fetchAttachContext.ts +++ b/server/src/internal/billing/v2/fetch/fetchAttachContext.ts @@ -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, diff --git a/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeCustomerForAttach.ts b/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeCustomerForBilling.ts similarity index 96% rename from server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeCustomerForAttach.ts rename to server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeCustomerForBilling.ts index 53e52a53f..bf1c98517 100644 --- a/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeCustomerForAttach.ts +++ b/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeCustomerForBilling.ts @@ -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, }: { diff --git a/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeSubForAttach.ts b/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeSubscriptionForBilling.ts similarity index 94% rename from server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeSubForAttach.ts rename to server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeSubscriptionForBilling.ts index aeb443a9d..e20a29599 100644 --- a/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeSubForAttach.ts +++ b/server/src/internal/billing/v2/fetch/fetchStripeUtils/fetchStripeSubscriptionForBilling.ts @@ -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, diff --git a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts b/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts index 11100f07a..c376fb5b7 100644 --- a/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts +++ b/server/src/internal/billing/v2/handlers/handleApiSubscriptionUpdate.ts @@ -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); diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts new file mode 100644 index 000000000..133fd731d --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateAllPlan.ts @@ -0,0 +1,9 @@ +// const { +// fullProducts: [newFullProduct], +// customPrices, +// customEnts, +// } = await overrideProduct({ +// ctx, +// newItems: body.items, +// products: [product], +// }); diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts new file mode 100644 index 000000000..217062849 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateIntent.ts @@ -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; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts new file mode 100644 index 000000000..dd5ddc25b --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan.ts @@ -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 }); +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts new file mode 100644 index 000000000..6f7d65a74 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlanIntentMap.ts @@ -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 +>; + +/** + * 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; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts new file mode 100644 index 000000000..4f3e0dc01 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts @@ -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, + }; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema.ts new file mode 100644 index 000000000..6e9ca9f64 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema.ts @@ -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 +>; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts b/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts new file mode 100644 index 000000000..16c8b235c --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/execute/executeSubscriptionUpdate.ts @@ -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], + }); +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts new file mode 100644 index 000000000..a485bf452 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext.ts @@ -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 => { + 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, + }; +}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts b/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts new file mode 100644 index 000000000..c595f63a5 --- /dev/null +++ b/server/src/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema.ts @@ -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; +}; diff --git a/server/src/internal/billing/v2/types.ts b/server/src/internal/billing/v2/types.ts index b1ff8b820..55e44b66a 100644 --- a/server/src/internal/billing/v2/types.ts +++ b/server/src/internal/billing/v2/types.ts @@ -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; diff --git a/server/tsconfig.json b/server/tsconfig.json index 833b5afa3..8a0d0d6a7 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -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"] } diff --git a/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts b/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts index 0db687395..4fc418665 100644 --- a/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts +++ b/shared/api/billing/subscriptionUpdate/subscriptionUpdateV0Params.ts @@ -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(), diff --git a/shared/models/billingModels/ongoingCusProductAction.ts b/shared/models/billingModels/ongoingCusProductAction.ts index 7c2f3cbea..8c02eeb6c 100644 --- a/shared/models/billingModels/ongoingCusProductAction.ts +++ b/shared/models/billingModels/ongoingCusProductAction.ts @@ -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< diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 727da44c8..e3c2fa833 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -17,7 +17,10 @@ "@utils/*": ["./utils/*"] } }, - "include": ["./**/*"], + "include": [ + "./**/*", + "../server/src/internal/billing/v2/update-subscription" + ], "types": ["node"], "exclude": ["node_modules", "dist"] }