diff --git a/server/src/internal/billing/billingRouter.ts b/server/src/internal/billing/billingRouter.ts index 4d87b7558..f24c2f9f6 100644 --- a/server/src/internal/billing/billingRouter.ts +++ b/server/src/internal/billing/billingRouter.ts @@ -4,7 +4,7 @@ import type { HonoEnv } from "../../honoUtils/HonoEnv.js"; import { handleAttach } from "./attach/handleAttach.js"; import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js"; import { handleSetupPayment } from "./handlers/handleSetupPayment.js"; -import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js"; + import { handleUpdateSubscription } from "./v2/updateSubscription/handleUpdateSubscription.js"; export const billingRouter = new Hono(); @@ -12,7 +12,6 @@ export const billingRouter = new Hono(); billingRouter.post("/setup_payment", ...handleSetupPayment); billingRouter.post("/checkout", ...handleCheckoutV2); billingRouter.post("/attach", ...handleAttach); -billingRouter.post("/attach_v2", ...handleAttachV2); billingRouter.post("/subscriptions/update", ...handleUpdateSubscription); billingRouter.post( diff --git a/server/src/internal/billing/v2/compute/computeStripeUtils/computeShouldCreateStripeCheckout.ts b/server/src/internal/billing/v2/compute/computeStripeUtils/computeShouldCreateStripeCheckout.ts deleted file mode 100644 index fb75455e9..000000000 --- a/server/src/internal/billing/v2/compute/computeStripeUtils/computeShouldCreateStripeCheckout.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - cusProductsToPrices, - cusProductToPrices, - type FullCusProduct, - isFreeProduct, -} from "@autumn/shared"; -import { notNullish } from "../../../../../utils/genUtils"; -import type { AttachContext } from "../../typesOld"; - -export const computeShouldCreateStripeCheckout = ({ - attachContext, - newCusProducts, -}: { - attachContext: AttachContext; - newCusProducts: FullCusProduct[]; -}) => { - const { body } = attachContext; - - // 1. If force_checkout, create checkout - if (body.force_checkout) - return { - shouldCreate: true, - reason: "force_checkout", - }; - - // 2. If invoice is true, don't create checkout - if (body.invoice) - return { - shouldCreate: false, - reason: "invoice", - }; - - // 3. If has payment method, don't create checkout - const hasPaymentMethod = notNullish(attachContext.paymentMethod); - if (hasPaymentMethod) - return { - shouldCreate: false, - reason: "has_payment_method", - }; - - // 4. If new cus products are free, don't create checkout - const newPrices = cusProductsToPrices({ cusProducts: newCusProducts }); - const newIsFree = isFreeProduct({ prices: newPrices }); - if (newIsFree) - return { - shouldCreate: false, - reason: "new products are free", - }; - - // 5. If there's an ongoing cus product and it's not free, don't create checkout (?) - const ongoingCusProduct = attachContext.ongoingCusProductAction?.cusProduct; - const ongoingPrices = ongoingCusProduct - ? cusProductToPrices({ cusProduct: ongoingCusProduct }) - : []; - const ongoingIsFree = ongoingPrices - ? isFreeProduct({ prices: ongoingPrices }) - : false; - - if (ongoingCusProduct && !ongoingIsFree) - return { - shouldCreate: false, - reason: "ongoing cus product is not free", - }; - - return { - shouldCreate: true, - reason: "passed all checks", - }; -}; diff --git a/server/src/internal/billing/v2/handlers/handleAttachV2.ts b/server/src/internal/billing/v2/handlers/handleAttachV2.ts deleted file mode 100644 index 29caf1156..000000000 --- a/server/src/internal/billing/v2/handlers/handleAttachV2.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - AffectedResource, - ApiVersion, - AttachBodyV0Schema, - AttachBodyV1Schema, -} from "@autumn/shared"; -import { createRoute } from "../../../../honoMiddlewares/routeHandler"; - -export const handleAttachV2 = createRoute({ - versionedBody: { - latest: AttachBodyV1Schema, - [ApiVersion.V2_0]: AttachBodyV0Schema, - }, - resource: AffectedResource.Attach, - handler: async (c) => { - const ctx = c.get("ctx"); - const body = c.req.valid("json"); - - return c.json({ success: true }, 200); - // // Step 1: Fetch autumn state - // const attachContext = await fetchAttachContext({ - // ctx, - // body, - // }); - - // // Step 2: Compute attach plan (no external calls) - // const attachPlan = await computeAttachPlan({ - // ctx, - // attachContext, - // }); - - // // Step 3: Execute attach actions - // const attachResponse = await executeAttachActions({ - // ctx, - // attachContext, - // attachPlan, - // }); - - // ctx.logger.info(`attach completed!`); - // return c.json({ success: true, attachResponse }, 200); - }, -}); - -// Phase 1: FETCH (all external state upfront) -// ├── Stripe: customer, subscription, schedule, payment method -// └── Autumn DB: customer, products, existing cus products - -// Phase 2: COMPUTE (pure Autumn logic, zero external calls) -// ├── Resolve actions -// ├── Init new cus products (using sub anchor from Phase 1) -// ├── Build line items -// └── Determine Stripe operations needed - -// Phase 3: EXECUTE STRIPE (all Stripe writes together) -// ├── Create/pay invoice -// ├── Update/create subscription -// └── Update/create schedule - -// Phase 4: EXECUTE AUTUMN (all DB writes together) -// ├── Insert new cus products -// ├── Update ongoing cus product status -// └── Delete scheduled cus product if needed diff --git a/server/src/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts similarity index 100% rename from server/src/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling.ts rename to server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts diff --git a/server/src/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts similarity index 100% rename from server/src/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling.ts rename to server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts diff --git a/server/src/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling.ts similarity index 100% rename from server/src/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling.ts rename to server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling.ts diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts new file mode 100644 index 000000000..db62028eb --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts @@ -0,0 +1,51 @@ +import type { FullCusProduct, FullCustomer } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { fetchStripeCustomerForBilling } from "./fetchStripeCustomerForBilling"; +import { fetchStripeSubscriptionForBilling } from "./fetchStripeSubscriptionForBilling"; +import { fetchStripeSubscriptionScheduleForBilling } from "./fetchStripeSubscriptionScheduleForBilling"; + +export const setupStripeBillingContext = async ({ + ctx, + fullCustomer, + targetCustomerProduct, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + targetCustomerProduct: FullCusProduct; +}) => { + const stripeSubscription = await fetchStripeSubscriptionForBilling({ + ctx, + fullCus: fullCustomer, + products: [], + targetCusProductId: targetCustomerProduct.id, + }); + + const stripeSubscriptionSchedule = + await fetchStripeSubscriptionScheduleForBilling({ + ctx, + fullCus: fullCustomer, + subscriptionScheduleId: + typeof stripeSubscription?.schedule === "string" + ? stripeSubscription.schedule + : undefined, + products: [], + targetCusProductId: targetCustomerProduct.id, + }); + + const { + stripeCus: stripeCustomer, + paymentMethod, + testClockFrozenTime, + } = await fetchStripeCustomerForBilling({ + ctx, + fullCus: fullCustomer, + }); + + return { + stripeSubscription, + stripeSubscriptionSchedule, + stripeCustomer, + paymentMethod, + testClockFrozenTime, + }; +}; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts index 962ac2f39..e74d17985 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -9,6 +9,7 @@ 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 ({ @@ -49,6 +50,13 @@ 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, diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index cd7290569..0aa9fcaaf 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -36,6 +36,8 @@ export const computeCustomPlanNewCustomerProduct = ({ cusProduct: customerProduct, }); + console.log("New customer product feature quantities", featureQuantities); + // Compute the new full customer product const newFullCustomerProduct = initFullCustomerProduct({ ctx, diff --git a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts index f2bbb4e86..ccca7bdcd 100644 --- a/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handlePreviewUpdateSubscription.ts @@ -3,7 +3,7 @@ import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/strip import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { computeUpdateSubscriptionPlan } from "./compute/computeUpdateSubscriptionPlan"; -import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext"; +import { setupUpdateSubscriptionBillingContext } from "./setup/setupUpdateSubscriptionBillingContext"; export const handlePreviewUpdateSubscription = createRoute({ body: UpdateSubscriptionV0ParamsSchema, @@ -12,7 +12,7 @@ export const handlePreviewUpdateSubscription = createRoute({ const body = c.req.valid("json"); const updateSubscriptionBillingContext = - await fetchUpdateSubscriptionBillingContext({ + await setupUpdateSubscriptionBillingContext({ ctx, params: body, }); diff --git a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts index aa61ad29e..f1de68bc7 100644 --- a/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts +++ b/server/src/internal/billing/v2/updateSubscription/handleUpdateSubscription.ts @@ -3,7 +3,7 @@ import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/updateSubsc import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { executeBillingPlan } from "../execute/executeBillingPlan"; import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan"; -import { fetchUpdateSubscriptionBillingContext } from "../updateSubscription/fetch/fetchUpdateSubscriptionBillingContext"; +import { setupUpdateSubscriptionBillingContext } from "./setup/setupUpdateSubscriptionBillingContext"; export const handleUpdateSubscription = createRoute({ body: UpdateSubscriptionV0ParamsSchema, @@ -11,7 +11,7 @@ export const handleUpdateSubscription = createRoute({ const ctx = c.get("ctx"); const body = c.req.valid("json"); - const billingContext = await fetchUpdateSubscriptionBillingContext({ + const billingContext = await setupUpdateSubscriptionBillingContext({ ctx, params: body, }); diff --git a/server/src/internal/billing/v2/updateSubscription/fetch/fetchTargetCusProductForUpdate.ts b/server/src/internal/billing/v2/updateSubscription/setup/findTargetCustomerProduct.ts similarity index 93% rename from server/src/internal/billing/v2/updateSubscription/fetch/fetchTargetCusProductForUpdate.ts rename to server/src/internal/billing/v2/updateSubscription/setup/findTargetCustomerProduct.ts index 7a46af869..cd61900b7 100644 --- a/server/src/internal/billing/v2/updateSubscription/fetch/fetchTargetCusProductForUpdate.ts +++ b/server/src/internal/billing/v2/updateSubscription/setup/findTargetCustomerProduct.ts @@ -4,7 +4,7 @@ import { type UpdateSubscriptionV0Params, } from "@autumn/shared"; -export const fetchTargetCusProductForUpdate = ({ +export const findTargetCustomerProduct = ({ params, fullCustomer, }: { diff --git a/server/src/internal/billing/v2/updateSubscription/fetch/fetchUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts similarity index 64% rename from server/src/internal/billing/v2/updateSubscription/fetch/fetchUpdateSubscriptionBillingContext.ts rename to server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index ee7445350..44428399c 100644 --- a/server/src/internal/billing/v2/updateSubscription/fetch/fetchUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -5,13 +5,11 @@ import { type UpdateSubscriptionV0Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling"; -import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling"; -import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling"; +import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; import { CusService } from "../../../../customers/CusService"; import type { UpdateSubscriptionBillingContext } from "../../billingContext"; import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams"; -import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate"; +import { findTargetCustomerProduct } from "./findTargetCustomerProduct"; /** * Fetch the context for updating a subscription @@ -19,7 +17,7 @@ import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate * @param body - The body of the request * @returns The update subscription context */ -export const fetchUpdateSubscriptionBillingContext = async ({ +export const setupUpdateSubscriptionBillingContext = async ({ ctx, params, }: { @@ -39,7 +37,7 @@ export const fetchUpdateSubscriptionBillingContext = async ({ entityId: params.entity_id ?? undefined, }); - const targetCustomerProduct = fetchTargetCusProductForUpdate({ + const targetCustomerProduct = findTargetCustomerProduct({ params, fullCustomer, }); @@ -54,32 +52,16 @@ export const fetchUpdateSubscriptionBillingContext = async ({ cusProduct: targetCustomerProduct, }); - const stripeSubscription = await fetchStripeSubscriptionForBilling({ - ctx, - fullCus: fullCustomer, - products: [], - targetCusProductId: targetCustomerProduct.id, - }); - - const stripeSubscriptionSchedule = - await fetchStripeSubscriptionScheduleForBilling({ - ctx, - fullCus: fullCustomer, - subscriptionScheduleId: - typeof stripeSubscription?.schedule === "string" - ? stripeSubscription.schedule - : undefined, - products: [], - targetCusProductId: targetCustomerProduct.id, - }); - const { - stripeCus: stripeCustomer, + stripeSubscription, + stripeSubscriptionSchedule, + stripeCustomer, paymentMethod, testClockFrozenTime, - } = await fetchStripeCustomerForBilling({ + } = await setupStripeBillingContext({ ctx, - fullCus: fullCustomer, + fullCustomer, + targetCustomerProduct, }); const featureQuantities = parseFeatureQuantitiesParams({ @@ -90,7 +72,6 @@ export const fetchUpdateSubscriptionBillingContext = async ({ }); const currentEpochMs = testClockFrozenTime ?? Date.now(); - const billingCycleAnchorMs = secondsToMs( stripeSubscription?.billing_cycle_anchor, ); diff --git a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts index cbb9f8418..253448bc8 100644 --- a/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts +++ b/server/tests/unit-tests/billing/compute-feature-quantities/compute-subscription-update-feature-quantities.test.ts @@ -552,7 +552,7 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { expect(result).toHaveLength(0); }); - test("skips price when feature not found (no error thrown)", () => { + test("throws error when feature not found for price", () => { const price = createMockPrepaidPrice({ id: "price_credits", featureId: "credits", @@ -569,15 +569,14 @@ describe(chalk.yellowBright("parseFeatureQuantitiesParams"), () => { // Empty features array - feature won't be found const ctx = createMockCtx({ features: [] }); - // Should not throw, just skip the price - const result = parseFeatureQuantitiesParams({ - ctx, - featureQuantitiesParams: params, - fullProduct, - currentCustomerProduct: cusProduct, - }); - - expect(result).toHaveLength(0); + expect(() => + parseFeatureQuantitiesParams({ + ctx, + featureQuantitiesParams: params, + fullProduct, + currentCustomerProduct: cusProduct, + }), + ).toThrow("Feature not found for price"); }); test("neither current nor new has quantity → feature not included", () => { diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx index a749bb8fd..64f702900 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateTestSheet.tsx @@ -1146,6 +1146,26 @@ function SheetContent({
+ {/* Current Customer Product Options */} + {cusProduct.options && cusProduct.options.length > 0 ? ( +
+
+

Current Options

+
+
+ {cusProduct.options.map((option) => ( +
+ {option.feature_id} + {option.quantity} +
+ ))} +
+
+ ) : null} + {/* Request Body Display */}