diff --git a/server/src/internal/billing/v2/billingPlan.ts b/server/src/internal/billing/v2/billingPlan.ts index 6f7a48317..c491f8781 100644 --- a/server/src/internal/billing/v2/billingPlan.ts +++ b/server/src/internal/billing/v2/billingPlan.ts @@ -24,10 +24,21 @@ export const StripeSubscriptionActionSchema = z.discriminatedUnion("type", [ stripeSubscriptionId: z.string(), params: z.custom(), }), + z.object({ + type: z.literal("cancel_immediately"), + stripeSubscriptionId: z.string(), + }), + z.object({ + type: z.literal("cancel_at_period_end"), + stripeSubscriptionId: z.string(), + }), z.object({ type: z.literal("cancel"), stripeSubscriptionId: z.string(), }), + z.object({ + type: z.literal("none"), + }), ]); export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion( @@ -35,8 +46,7 @@ export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion( [ z.object({ type: z.literal("create"), - params: - z.custom(), + params: z.custom(), }), z.object({ type: z.literal("update"), diff --git a/server/src/internal/billing/v2/execute/executeStripeSubAction.ts b/server/src/internal/billing/v2/execute/executeStripeSubAction.ts index e25f89b31..6977de14c 100644 --- a/server/src/internal/billing/v2/execute/executeStripeSubAction.ts +++ b/server/src/internal/billing/v2/execute/executeStripeSubAction.ts @@ -1,5 +1,5 @@ import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { StripeSubAction } from "../typesOld"; +import type { StripeSubscriptionAction } from "../billingPlan"; import { executeStripeSubscriptionUpdate } from "./executeStripeSubscriptionActions/executeStripeSubscriptionUpdate"; export const executeStripeSubAction = async ({ @@ -7,7 +7,7 @@ export const executeStripeSubAction = async ({ stripeSubAction, }: { ctx: AutumnContext; - stripeSubAction: StripeSubAction; + stripeSubAction: StripeSubscriptionAction; }) => { const { logger } = ctx; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts index a46a19f57..82ba94075 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts @@ -2,6 +2,7 @@ import type { FullCusProduct } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type { FreeTrialPlan, + StripeSubscriptionAction, StripeSubscriptionScheduleAction, } from "@server/internal/billing/v2/billingPlan"; import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate"; @@ -21,9 +22,9 @@ export const buildStripeSubscriptionAction = ({ billingContext: UpdateSubscriptionContext; newCustomerProduct: FullCusProduct; stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction; - freeTrialPlan: FreeTrialPlan; + freeTrialPlan?: FreeTrialPlan; nowMs: number; -}) => { +}): StripeSubscriptionAction | undefined => { const { customerProduct, stripeSubscription } = billingContext; const subItemsUpdate = buildStripeSubscriptionItemsUpdate({ diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts index d02787848..bf9cc8294 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts @@ -10,6 +10,7 @@ import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscri import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; +import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec"; const getFinalCustomerProductsState = ({ billingContext, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts index 3c401ebee..23027fb06 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts @@ -5,6 +5,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; import type { FreeTrialPlan, + StripeSubscriptionAction, StripeSubscriptionScheduleAction, } from "@/internal/billing/v2/billingPlan"; @@ -20,7 +21,7 @@ export const buildStripeSubscriptionUpdateAction = ({ subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[]; freeTrialPlan?: FreeTrialPlan; stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction; -}) => { +}): StripeSubscriptionAction | undefined => { const { stripeSubscription } = billingContext; if (!stripeSubscription) { diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/buildStripeQuantityUpdateAction.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/buildStripeQuantityUpdateAction.ts deleted file mode 100644 index 3ca7bf610..000000000 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/buildStripeQuantityUpdateAction.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { - QuantityUpdateDetails, - StripeSubAction, -} from "@/internal/billing/v2/typesOld"; - -/** - * Builds Stripe subscription action from quantity update details. - * - * Maps each feature to either an update (if item exists) or create (if new). - * When updating existing items, applies the quantity difference rather than - * setting the absolute value, since a customer may have multiple customer - * products contributing to the same subscription item. - * - * @param quantityUpdateDetails - Array of quantity update details - * @param stripeSubscriptionId - Stripe subscription ID to update - * @returns Stripe subscription action with update items - */ -export const buildStripeQuantityUpdateAction = ({ - quantityUpdateDetails, - stripeSubscriptionId, -}: { - quantityUpdateDetails: QuantityUpdateDetails[]; - stripeSubscriptionId: string; -}): StripeSubAction => { - return { - type: "update" as const, - subId: stripeSubscriptionId, - items: quantityUpdateDetails.map((detail) => { - if (detail.existingStripeSubscriptionItem) { - const currentSubscriptionItemQuantity = - detail.existingStripeSubscriptionItem.quantity ?? 0; - const newSubscriptionItemQuantity = - currentSubscriptionItemQuantity + - detail.stripeSubscriptionItemQuantityDifference; - - return { - id: detail.existingStripeSubscriptionItem.id, - quantity: newSubscriptionItemQuantity, - }; - } - - return { - price: detail.stripePriceId, - quantity: detail.updatedFeatureQuantity, - }; - }), - }; -}; diff --git a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts index d2fc7f95a..ea148c1c6 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan.ts @@ -7,9 +7,9 @@ import { } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems"; +import { buildStripeSubscriptionAction } from "../../providers/stripe/actionBuilders/buildStripeSubscriptionAction"; import type { SubscriptionUpdateQuantityPlan } from "../../typesOld"; import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema"; -import { buildStripeQuantityUpdateAction } from "./buildStripeQuantityUpdateAction"; import { computeInvoiceAction } from "./computeInvoiceAction"; import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails"; import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"; @@ -23,8 +23,12 @@ export const computeSubscriptionUpdateQuantityPlan = ({ updateSubscriptionContext: UpdateSubscriptionContext; params: SubscriptionUpdateV0Params; }): SubscriptionUpdateQuantityPlan => { - const { customerProduct, stripeSubscription, testClockFrozenTime } = - updateSubscriptionContext; + const { + customerProduct, + stripeSubscription, + testClockFrozenTime, + currentEpochMs, + } = updateSubscriptionContext; if (!stripeSubscription) { throw new InternalError({ @@ -74,11 +78,19 @@ export const computeSubscriptionUpdateQuantityPlan = ({ testClockFrozenTime, }); - const stripeSubscriptionAction = buildStripeQuantityUpdateAction({ - quantityUpdateDetails, - stripeSubscriptionId: stripeSubscription.id, + const stripeSubscriptionAction = buildStripeSubscriptionAction({ + ctx, + billingContext: updateSubscriptionContext, + newCustomerProduct: customerProduct, + nowMs: currentEpochMs, }); + if (!stripeSubscriptionAction) { + throw new InternalError({ + message: `[Subscription Update] Stripe subscription action not found`, + }); + } + return { intent: SubscriptionUpdateIntentEnum.UpdateQuantity, featureQuantities, diff --git a/server/src/internal/billing/v2/typesOld.ts b/server/src/internal/billing/v2/typesOld.ts index 4897113f5..181436eab 100644 --- a/server/src/internal/billing/v2/typesOld.ts +++ b/server/src/internal/billing/v2/typesOld.ts @@ -11,7 +11,10 @@ import type { ScheduledCusProductAction, } from "@autumn/shared"; import type Stripe from "stripe"; -import type { StripeInvoiceAction } from "./billingPlan"; +import type { + StripeInvoiceAction, + StripeSubscriptionAction, +} from "./billingPlan"; export type AttachContext = { fullCus: FullCustomer; @@ -73,7 +76,7 @@ export type BillingPlan = { export type BaseSubscriptionUpdatePlan = BillingPlan & { intent: "update_quantity" | "update_plan"; autumnLineItems: LineItem[]; - stripeSubscriptionAction: StripeSubAction; + stripeSubscriptionAction: StripeSubscriptionAction; ongoingCusProductAction: OngoingCusProductAction; }; diff --git a/server/tests/unit-tests/billing/v2/buildStripeQuantityUpdateAction.test.ts b/server/tests/unit-tests/billing/v2/buildStripeQuantityUpdateAction.test.ts deleted file mode 100644 index 3817e658a..000000000 --- a/server/tests/unit-tests/billing/v2/buildStripeQuantityUpdateAction.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type Stripe from "stripe"; -import { buildStripeQuantityUpdateAction } from "@/internal/billing/v2/subscriptionUpdate/compute/buildStripeQuantityUpdateAction"; -import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld"; - -const createMockQuantityUpdateDetails = ( - overrides: Partial = {}, -): QuantityUpdateDetails => ({ - featureId: "messages", - internalFeatureId: "int_msg_123", - previousFeatureQuantity: 5, - updatedFeatureQuantity: 8, - quantityDifferenceForEntitlements: 3, - stripeSubscriptionItemQuantityDifference: 3, - shouldApplyProration: true, - shouldFinalizeInvoiceImmediately: true, - billingUnitsPerQuantity: 1, - calculatedProrationAmountDollars: 10, - subscriptionPeriodStartEpochMs: Date.now() - 86400000, - subscriptionPeriodEndEpochMs: Date.now() + 86400000 * 29, - stripeInvoiceItemDescription: "Messages upgrade", - customerPrice: {} as QuantityUpdateDetails["customerPrice"], - stripePriceId: "price_123", - existingStripeSubscriptionItem: undefined, - customerEntitlementId: "ent_123", - customerEntitlementBalanceChange: 3, - ...overrides, -}); - -const createMockStripeSubscriptionItem = ( - overrides: Partial = {}, -): Stripe.SubscriptionItem => - ({ - id: "si_123", - object: "subscription_item", - quantity: 5, - ...overrides, - }) as Stripe.SubscriptionItem; - -describe("buildStripeQuantityUpdateAction", () => { - test("should create new subscription item when no existing item", () => { - const details = createMockQuantityUpdateDetails({ - existingStripeSubscriptionItem: undefined, - updatedFeatureQuantity: 8, - }); - - const result = buildStripeQuantityUpdateAction({ - quantityUpdateDetails: [details], - stripeSubscriptionId: "sub_123", - }); - - expect(result.type).toBe("update"); - expect(result.items).toHaveLength(1); - expect(result.items![0].id).toBeUndefined(); - expect(result.items![0].price).toBe("price_123"); - expect(result.items![0].quantity).toBe(8); - }); - - test("should apply quantity difference when updating existing subscription item", () => { - const existingItem = createMockStripeSubscriptionItem({ - id: "si_existing", - quantity: 10, - }); - - const details = createMockQuantityUpdateDetails({ - existingStripeSubscriptionItem: existingItem, - previousFeatureQuantity: 5, - updatedFeatureQuantity: 8, - stripeSubscriptionItemQuantityDifference: 3, - }); - - const result = buildStripeQuantityUpdateAction({ - quantityUpdateDetails: [details], - stripeSubscriptionId: "sub_123", - }); - - expect(result.type).toBe("update"); - expect(result.items).toHaveLength(1); - expect(result.items![0].id).toBe("si_existing"); - expect(result.items![0].quantity).toBe(13); - }); - - test("should handle downgrade with quantity difference", () => { - const existingItem = createMockStripeSubscriptionItem({ - id: "si_existing", - quantity: 15, - }); - - const details = createMockQuantityUpdateDetails({ - existingStripeSubscriptionItem: existingItem, - previousFeatureQuantity: 10, - updatedFeatureQuantity: 6, - stripeSubscriptionItemQuantityDifference: -4, - }); - - const result = buildStripeQuantityUpdateAction({ - quantityUpdateDetails: [details], - stripeSubscriptionId: "sub_123", - }); - - expect(result.items![0].quantity).toBe(11); - }); - - test("should handle multiple quantity updates in single action", () => { - const existingItem1 = createMockStripeSubscriptionItem({ - id: "si_1", - quantity: 10, - }); - - const existingItem2 = createMockStripeSubscriptionItem({ - id: "si_2", - quantity: 20, - }); - - const details1 = createMockQuantityUpdateDetails({ - featureId: "messages", - existingStripeSubscriptionItem: existingItem1, - previousFeatureQuantity: 5, - updatedFeatureQuantity: 8, - stripeSubscriptionItemQuantityDifference: 3, - stripePriceId: "price_1", - }); - - const details2 = createMockQuantityUpdateDetails({ - featureId: "words", - existingStripeSubscriptionItem: existingItem2, - previousFeatureQuantity: 15, - updatedFeatureQuantity: 10, - stripeSubscriptionItemQuantityDifference: -5, - stripePriceId: "price_2", - }); - - const result = buildStripeQuantityUpdateAction({ - quantityUpdateDetails: [details1, details2], - stripeSubscriptionId: "sub_123", - }); - - expect(result.items).toHaveLength(2); - expect(result.items![0].quantity).toBe(13); - expect(result.items![1].quantity).toBe(15); - }); - - test("should handle zero difference correctly", () => { - const existingItem = createMockStripeSubscriptionItem({ - id: "si_existing", - quantity: 10, - }); - - const details = createMockQuantityUpdateDetails({ - existingStripeSubscriptionItem: existingItem, - previousFeatureQuantity: 5, - updatedFeatureQuantity: 5, - stripeSubscriptionItemQuantityDifference: 0, - }); - - const result = buildStripeQuantityUpdateAction({ - quantityUpdateDetails: [details], - stripeSubscriptionId: "sub_123", - }); - - expect(result.items![0].quantity).toBe(10); - }); - - test("should handle existing item with undefined quantity", () => { - const existingItem = createMockStripeSubscriptionItem({ - id: "si_existing", - quantity: undefined, - }); - - const details = createMockQuantityUpdateDetails({ - existingStripeSubscriptionItem: existingItem, - previousFeatureQuantity: 0, - updatedFeatureQuantity: 5, - stripeSubscriptionItemQuantityDifference: 5, - }); - - const result = buildStripeQuantityUpdateAction({ - quantityUpdateDetails: [details], - stripeSubscriptionId: "sub_123", - }); - - expect(result.items![0].quantity).toBe(5); - }); -});