chore: subscription action updates in subscription update endpoint

This commit is contained in:
Charlie Lamb
2025-12-24 10:11:32 +00:00
parent ba91e953a3
commit 89ac3f6880
9 changed files with 43 additions and 247 deletions

View File

@@ -24,10 +24,21 @@ export const StripeSubscriptionActionSchema = z.discriminatedUnion("type", [
stripeSubscriptionId: z.string(),
params: z.custom<import("stripe").Stripe.SubscriptionUpdateParams>(),
}),
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<import("stripe").Stripe.SubscriptionScheduleUpdateParams>(),
params: z.custom<import("stripe").Stripe.SubscriptionCreateParams>(),
}),
z.object({
type: z.literal("update"),

View File

@@ -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;

View File

@@ -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({

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,
};
}),
};
};

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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> = {},
): 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> = {},
): 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);
});
});