From f95d5f1c2ccb7f96b189fdb7bc6cfe8d0044d607 Mon Sep 17 00:00:00 2001 From: Robin Wieruch Date: Thu, 24 Sep 2026 01:53:35 +0200 Subject: [PATCH] fix(stripe): sync seats after organization resubscription (#11347) --- .changeset/fresh-seats-resubscribe.md | 5 + packages/stripe/src/index.ts | 7 + .../stripe/test/seat-based-billing.test.ts | 123 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 .changeset/fresh-seats-resubscribe.md diff --git a/.changeset/fresh-seats-resubscribe.md b/.changeset/fresh-seats-resubscribe.md new file mode 100644 index 0000000000..3a25684cf2 --- /dev/null +++ b/.changeset/fresh-seats-resubscribe.md @@ -0,0 +1,5 @@ +--- +"@better-auth/stripe": patch +--- + +Fix automatic seat synchronization after an organization cancels and resubscribes, so member changes update the active or trialing subscription even when canceled subscriptions remain in its history. diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index cd5b3d1e9c..d070e5372a 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -201,6 +201,8 @@ export const stripe = (options: O) => { const seatPlanNames = new Set( seatPlans.map((p) => p.name.toLowerCase()), ); + // Canceled subscriptions remain in the organization's history. + // Select a current subscription before checking its seat plan. const dbSub = await ctx.adapter.findOne({ model: "subscription", where: [ @@ -208,6 +210,11 @@ export const stripe = (options: O) => { field: "referenceId", value: data.organization.id, }, + { + field: "status", + operator: "in", + value: ["active", "trialing"], + }, ], }); if ( diff --git a/packages/stripe/test/seat-based-billing.test.ts b/packages/stripe/test/seat-based-billing.test.ts index afd819d355..1fe9e5bd66 100644 --- a/packages/stripe/test/seat-based-billing.test.ts +++ b/packages/stripe/test/seat-based-billing.test.ts @@ -896,6 +896,129 @@ describe("seat-based billing", () => { }); describe("seat sync on member changes", () => { + /** + * @see https://www.better-auth.com/docs/plugins/stripe#schema + */ + test.for([ + "active", + "trialing", + "canceled", + ] as const)("should select an eligible subscription from history when the replacement is %s", async (status, { + stripeMock, + }) => { + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [organization(), stripe(buildSeatPlanOptions(stripeMock))], + }); + const { headers } = await signInWithTestUser(); + const ctx = await auth.$context; + const org = await auth.api.createOrganization({ + body: { name: "Resubscribed Org", slug: "resubscribed-org" }, + headers, + }); + const orgId = org!.id; + await ctx.adapter.update({ + model: "organization", + where: [{ field: "id", value: orgId }], + update: { stripeCustomerId: "cus_seat_org" }, + }); + + // Cancellation keeps the old row; a replacement subscription gets + // its own row with the same organization reference. + const canceled = await ctx.adapter.create({ + model: "subscription", + data: { + referenceId: orgId, + stripeCustomerId: "cus_seat_org", + stripeSubscriptionId: "sub_canceled", + status: "canceled", + plan: "team", + seats: 5, + }, + }); + const replacement = await ctx.adapter.create({ + model: "subscription", + data: { + referenceId: orgId, + stripeCustomerId: "cus_seat_org", + stripeSubscriptionId: "sub_replacement", + status, + plan: "team", + seats: 1, + }, + }); + const stripeSubscription = { + id: "sub_replacement", + status, + items: { + data: [ + { + id: "si_seat", + price: { id: "price_team_seat" }, + quantity: 1, + }, + ], + }, + }; + stripeMock.subscriptions.retrieve.mockResolvedValue(stripeSubscription); + const user = await ctx.adapter.create({ + model: "user", + data: { email: "resubscribed-member@test.com", name: "New Member" }, + }); + + const member = await auth.api.addMember({ + body: { organizationId: orgId, userId: user.id, role: "member" }, + }); + if (status !== "canceled") { + expect( + stripeMock.subscriptions.retrieve, + ).toHaveBeenCalledExactlyOnceWith("sub_replacement"); + expect(stripeMock.subscriptions.update).toHaveBeenCalledExactlyOnceWith( + "sub_replacement", + { + items: [{ id: "si_seat", quantity: 2 }], + proration_behavior: "create_prorations", + }, + ); + expect( + await ctx.adapter.findOne({ + model: "subscription", + where: [{ field: "id", value: replacement.id }], + }), + ).toMatchObject({ seats: 2 }); + stripeSubscription.items.data[0]!.quantity = 2; + } + + await auth.api.removeMember({ + body: { organizationId: orgId, memberIdOrEmail: member!.id }, + headers, + }); + if (status === "canceled") { + expect(stripeMock.subscriptions.retrieve).not.toHaveBeenCalled(); + expect(stripeMock.subscriptions.update).not.toHaveBeenCalled(); + } else { + expect(stripeMock.subscriptions.update).toHaveBeenCalledTimes(2); + expect(stripeMock.subscriptions.update).toHaveBeenLastCalledWith( + "sub_replacement", + { + items: [{ id: "si_seat", quantity: 1 }], + proration_behavior: "create_prorations", + }, + ); + } + expect( + await ctx.adapter.findOne({ + model: "subscription", + where: [{ field: "id", value: replacement.id }], + }), + ).toMatchObject({ seats: 1 }); + expect( + await ctx.adapter.findOne({ + model: "subscription", + where: [{ field: "id", value: canceled.id }], + }), + ).toMatchObject({ status: "canceled", seats: 5 }); + }); + test("should sync seat quantity when a member accepts an invitation", async ({ stripeMock, }) => {