diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts index 2350f6d680..973d090162 100644 --- a/packages/stripe/src/routes.ts +++ b/packages/stripe/src/routes.ts @@ -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( { diff --git a/packages/stripe/test/stripe.test.ts b/packages/stripe/test/stripe.test.ts index 5e8447f130..1aff955d24 100644 --- a/packages/stripe/test/stripe.test.ts +++ b/packages/stripe/test/stripe.test.ts @@ -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,