chore: subscription action updates in subscription update endpoint
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user