mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-25 16:36:34 -05:00
fix(stripe): allow billing interval change for same plan (#7542)
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user