fix(stripe): allow billing interval change for same plan (#7542)

This commit is contained in:
Taesu
2026-01-23 04:30:57 +09:00
committed by GitHub
parent b4c9f91003
commit f716e08f0e
2 changed files with 229 additions and 56 deletions

View File

@@ -502,20 +502,47 @@ export const upgradeSubscription = (options: StripeOptions) => {
return false;
});
// Get the current price ID from the active Stripe subscription
const stripeSubscriptionPriceId =
activeSubscription?.items.data[0]?.price.id;
// Also find any incomplete subscription that we can reuse
const incompleteSubscription = subscriptions.find(
(sub) => sub.status === "incomplete",
);
if (
activeOrTrialingSubscription &&
activeOrTrialingSubscription.status === "active" &&
activeOrTrialingSubscription.plan === ctx.body.plan &&
activeOrTrialingSubscription.seats === (ctx.body.seats || 1) &&
// Skip if periodEnd has passed, in case status is stale
(!activeOrTrialingSubscription.periodEnd ||
activeOrTrialingSubscription.periodEnd > new Date())
) {
const priceId = ctx.body.annual
? plan.annualDiscountPriceId
: plan.priceId;
const lookupKey = ctx.body.annual
? plan.annualDiscountLookupKey
: plan.lookupKey;
const resolvedPriceId = lookupKey
? await resolvePriceIdFromLookupKey(client, lookupKey)
: undefined;
const priceIdToUse = priceId || resolvedPriceId;
if (!priceIdToUse) {
throw ctx.error("BAD_REQUEST", {
message: "Price ID not found for the selected plan",
});
}
const isSamePlan = activeOrTrialingSubscription?.plan === ctx.body.plan;
const isSameSeats =
activeOrTrialingSubscription?.seats === (ctx.body.seats || 1);
const isSamePriceId = stripeSubscriptionPriceId === priceIdToUse;
const isSubscriptionStillValid =
!activeOrTrialingSubscription?.periodEnd ||
activeOrTrialingSubscription.periodEnd > new Date();
const isAlreadySubscribed =
activeOrTrialingSubscription?.status === "active" &&
isSamePlan &&
isSameSeats &&
isSamePriceId &&
isSubscriptionStillValid;
if (isAlreadySubscribed) {
throw APIError.from(
"BAD_REQUEST",
STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN,
@@ -552,32 +579,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
dbSubscription = activeOrTrialingSubscription;
}
// Resolve price ID if using lookup keys
let priceIdToUse: string | undefined = undefined;
if (ctx.body.annual) {
priceIdToUse = plan.annualDiscountPriceId;
if (!priceIdToUse && plan.annualDiscountLookupKey) {
priceIdToUse = await resolvePriceIdFromLookupKey(
client,
plan.annualDiscountLookupKey,
);
}
} else {
priceIdToUse = plan.priceId;
if (!priceIdToUse && plan.lookupKey) {
priceIdToUse = await resolvePriceIdFromLookupKey(
client,
plan.lookupKey,
);
}
}
if (!priceIdToUse) {
throw ctx.error("BAD_REQUEST", {
message: "Price ID not found for the selected plan",
});
}
const { url } = await client.billingPortal.sessions
.create({
customer: customerId,
@@ -686,24 +687,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
? { trial_period_days: plan.freeTrial.days }
: undefined;
let priceIdToUse: string | undefined = undefined;
if (ctx.body.annual) {
priceIdToUse = plan.annualDiscountPriceId;
if (!priceIdToUse && plan.annualDiscountLookupKey) {
priceIdToUse = await resolvePriceIdFromLookupKey(
client,
plan.annualDiscountLookupKey,
);
}
} else {
priceIdToUse = plan.priceId;
if (!priceIdToUse && plan.lookupKey) {
priceIdToUse = await resolvePriceIdFromLookupKey(
client,
plan.lookupKey,
);
}
}
const checkoutSession = await client.checkout.sessions
.create(
{

View File

@@ -1845,10 +1845,26 @@ describe("stripe", () => {
});
it("should prevent duplicate subscriptions with same plan and same seats", async () => {
const starterPriceId = "price_starter_duplicate_test";
const subscriptionId = "sub_duplicate_test_123";
const stripeOptionsWithPrice = {
...stripeOptions,
subscription: {
enabled: true,
plans: [
{
name: "starter",
priceId: starterPriceId,
},
],
},
} satisfies StripeOptions;
const { client, auth, sessionSetter } = await getTestInstance(
{
database: memory,
plugins: [stripe(stripeOptions)],
plugins: [stripe(stripeOptionsWithPrice)],
},
{
disableTestUser: true,
@@ -1894,6 +1910,7 @@ describe("stripe", () => {
update: {
status: "active",
seats: 3,
stripeSubscriptionId: subscriptionId,
},
where: [
{
@@ -1903,6 +1920,27 @@ describe("stripe", () => {
],
});
// Mock Stripe to return the existing subscription with the same price ID
mockStripe.subscriptions.list.mockResolvedValue({
data: [
{
id: subscriptionId,
status: "active",
items: {
data: [
{
id: "si_duplicate_item",
price: {
id: starterPriceId,
},
quantity: 3,
},
],
},
},
],
});
const upgradeRes = await client.subscription.upgrade({
plan: "starter",
seats: 3,
@@ -1915,6 +1953,116 @@ describe("stripe", () => {
expect(upgradeRes.error?.message).toContain("already subscribed");
});
it("should allow upgrade from monthly to annual billing for the same plan", async () => {
const monthlyPriceId = "price_monthly_starter_123";
const annualPriceId = "price_annual_starter_456";
const subscriptionId = "sub_monthly_to_annual_123";
const stripeOptionsWithAnnual = {
...stripeOptions,
subscription: {
enabled: true,
plans: [
{
name: "starter",
priceId: monthlyPriceId,
annualDiscountPriceId: annualPriceId,
},
],
},
} satisfies StripeOptions;
const { client, auth, sessionSetter } = await getTestInstance(
{
database: memory,
plugins: [stripe(stripeOptionsWithAnnual)],
},
{
disableTestUser: true,
clientOptions: {
plugins: [stripeClient({ subscription: true })],
},
},
);
const ctx = await auth.$context;
const userRes = await client.signUp.email(testUser, { throw: true });
const headers = new Headers();
await client.signIn.email(testUser, {
throw: true,
onSuccess: sessionSetter(headers),
});
await client.subscription.upgrade({
plan: "starter",
seats: 1,
fetchOptions: { headers },
});
await ctx.adapter.update({
model: "subscription",
update: {
status: "active",
seats: 1,
stripeSubscriptionId: subscriptionId,
},
where: [{ field: "referenceId", value: userRes.user.id }],
});
mockStripe.subscriptions.list.mockResolvedValue({
data: [
{
id: subscriptionId,
status: "active",
items: {
data: [
{
id: "si_monthly_item",
price: { id: monthlyPriceId },
quantity: 1,
},
],
},
},
],
});
// Clear mocks before the upgrade call
mockStripe.checkout.sessions.create.mockClear();
mockStripe.billingPortal.sessions.create.mockClear();
const upgradeRes = await client.subscription.upgrade({
plan: "starter",
seats: 1,
annual: true,
subscriptionId,
fetchOptions: { headers },
});
// Should succeed and return a billing portal URL
expect(upgradeRes.error).toBeNull();
expect(upgradeRes.data?.url).toBeDefined();
// Verify billing portal was called with the annual price ID
expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith(
expect.objectContaining({
flow_data: expect.objectContaining({
type: "subscription_update_confirm",
subscription_update_confirm: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ price: annualPriceId }),
]),
}),
}),
}),
);
// Should use billing portal, not checkout (since user has existing subscription)
expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalled();
});
it.each([
{
name: "past",
@@ -1930,10 +2078,26 @@ describe("stripe", () => {
periodEnd,
shouldAllow,
}) => {
const starterPriceId = "price_starter_periodend_test";
const subscriptionId = "sub_periodend_test_123";
const stripeOptionsWithPrice = {
...stripeOptions,
subscription: {
enabled: true,
plans: [
{
name: "starter",
priceId: starterPriceId,
},
],
},
} satisfies StripeOptions;
const { client, auth, sessionSetter } = await getTestInstance(
{
database: memory,
plugins: [stripe(stripeOptions)],
plugins: [stripe(stripeOptionsWithPrice)],
},
{
disableTestUser: true,
@@ -1963,10 +2127,36 @@ describe("stripe", () => {
await ctx.adapter.update({
model: "subscription",
update: { status: "active", seats: 1, periodEnd },
update: {
status: "active",
seats: 1,
periodEnd,
stripeSubscriptionId: subscriptionId,
},
where: [{ field: "referenceId", value: userRes.user.id }],
});
// Mock Stripe to return the existing subscription with the same price ID
mockStripe.subscriptions.list.mockResolvedValue({
data: [
{
id: subscriptionId,
status: "active",
items: {
data: [
{
id: "si_periodend_item",
price: {
id: starterPriceId,
},
quantity: 1,
},
],
},
},
],
});
const upgradeRes = await client.subscription.upgrade({
plan: "starter",
seats: 1,