@better-auth/stripe 1.4.16 → 1.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ import {
16
16
  import type { StripePlugin } from "../src";
17
17
  import { stripe } from "../src";
18
18
  import { stripeClient } from "../src/client";
19
+ import { customerMetadata, subscriptionMetadata } from "../src/metadata";
19
20
  import type { StripeOptions, Subscription } from "../src/types";
20
21
 
21
22
  describe("stripe type", () => {
@@ -99,6 +100,50 @@ describe("stripe type", () => {
99
100
  });
100
101
  });
101
102
 
103
+ describe("stripe - metadata helpers", () => {
104
+ it("customerMetadata.set protects internal fields", () => {
105
+ const result = customerMetadata.set(
106
+ { userId: "real", customerType: "user" },
107
+ { userId: "fake", custom: "value" },
108
+ );
109
+ expect(result.userId).toBe("real");
110
+ expect(result.customerType).toBe("user");
111
+ expect(result.custom).toBe("value");
112
+ });
113
+
114
+ it("customerMetadata.get extracts typed fields", () => {
115
+ const result = customerMetadata.get({
116
+ userId: "u1",
117
+ customerType: "organization",
118
+ extra: "ignored",
119
+ });
120
+ expect(result.userId).toBe("u1");
121
+ expect(result.customerType).toBe("organization");
122
+ expect(result).not.toHaveProperty("extra");
123
+ });
124
+
125
+ it("subscriptionMetadata.set protects internal fields", () => {
126
+ const result = subscriptionMetadata.set(
127
+ { userId: "u1", subscriptionId: "s1", referenceId: "r1" },
128
+ { subscriptionId: "fake" },
129
+ );
130
+ expect(result.subscriptionId).toBe("s1");
131
+ });
132
+
133
+ it("subscriptionMetadata.get extracts typed fields", () => {
134
+ const result = subscriptionMetadata.get({
135
+ userId: "u1",
136
+ subscriptionId: "s1",
137
+ referenceId: "r1",
138
+ extra: "ignored",
139
+ });
140
+ expect(result.userId).toBe("u1");
141
+ expect(result.subscriptionId).toBe("s1");
142
+ expect(result.referenceId).toBe("r1");
143
+ expect(result).not.toHaveProperty("extra");
144
+ });
145
+ });
146
+
102
147
  describe("stripe", () => {
103
148
  const mockStripe = {
104
149
  prices: {
@@ -1845,10 +1890,26 @@ describe("stripe", () => {
1845
1890
  });
1846
1891
 
1847
1892
  it("should prevent duplicate subscriptions with same plan and same seats", async () => {
1893
+ const starterPriceId = "price_starter_duplicate_test";
1894
+ const subscriptionId = "sub_duplicate_test_123";
1895
+
1896
+ const stripeOptionsWithPrice = {
1897
+ ...stripeOptions,
1898
+ subscription: {
1899
+ enabled: true,
1900
+ plans: [
1901
+ {
1902
+ name: "starter",
1903
+ priceId: starterPriceId,
1904
+ },
1905
+ ],
1906
+ },
1907
+ } satisfies StripeOptions;
1908
+
1848
1909
  const { client, auth, sessionSetter } = await getTestInstance(
1849
1910
  {
1850
1911
  database: memory,
1851
- plugins: [stripe(stripeOptions)],
1912
+ plugins: [stripe(stripeOptionsWithPrice)],
1852
1913
  },
1853
1914
  {
1854
1915
  disableTestUser: true,
@@ -1894,6 +1955,7 @@ describe("stripe", () => {
1894
1955
  update: {
1895
1956
  status: "active",
1896
1957
  seats: 3,
1958
+ stripeSubscriptionId: subscriptionId,
1897
1959
  },
1898
1960
  where: [
1899
1961
  {
@@ -1903,6 +1965,27 @@ describe("stripe", () => {
1903
1965
  ],
1904
1966
  });
1905
1967
 
1968
+ // Mock Stripe to return the existing subscription with the same price ID
1969
+ mockStripe.subscriptions.list.mockResolvedValue({
1970
+ data: [
1971
+ {
1972
+ id: subscriptionId,
1973
+ status: "active",
1974
+ items: {
1975
+ data: [
1976
+ {
1977
+ id: "si_duplicate_item",
1978
+ price: {
1979
+ id: starterPriceId,
1980
+ },
1981
+ quantity: 3,
1982
+ },
1983
+ ],
1984
+ },
1985
+ },
1986
+ ],
1987
+ });
1988
+
1906
1989
  const upgradeRes = await client.subscription.upgrade({
1907
1990
  plan: "starter",
1908
1991
  seats: 3,
@@ -1915,6 +1998,224 @@ describe("stripe", () => {
1915
1998
  expect(upgradeRes.error?.message).toContain("already subscribed");
1916
1999
  });
1917
2000
 
2001
+ it("should allow upgrade from monthly to annual billing for the same plan", async () => {
2002
+ const monthlyPriceId = "price_monthly_starter_123";
2003
+ const annualPriceId = "price_annual_starter_456";
2004
+ const subscriptionId = "sub_monthly_to_annual_123";
2005
+
2006
+ const stripeOptionsWithAnnual = {
2007
+ ...stripeOptions,
2008
+ subscription: {
2009
+ enabled: true,
2010
+ plans: [
2011
+ {
2012
+ name: "starter",
2013
+ priceId: monthlyPriceId,
2014
+ annualDiscountPriceId: annualPriceId,
2015
+ },
2016
+ ],
2017
+ },
2018
+ } satisfies StripeOptions;
2019
+
2020
+ const { client, auth, sessionSetter } = await getTestInstance(
2021
+ {
2022
+ database: memory,
2023
+ plugins: [stripe(stripeOptionsWithAnnual)],
2024
+ },
2025
+ {
2026
+ disableTestUser: true,
2027
+ clientOptions: {
2028
+ plugins: [stripeClient({ subscription: true })],
2029
+ },
2030
+ },
2031
+ );
2032
+ const ctx = await auth.$context;
2033
+
2034
+ const userRes = await client.signUp.email(testUser, { throw: true });
2035
+
2036
+ const headers = new Headers();
2037
+ await client.signIn.email(testUser, {
2038
+ throw: true,
2039
+ onSuccess: sessionSetter(headers),
2040
+ });
2041
+
2042
+ await client.subscription.upgrade({
2043
+ plan: "starter",
2044
+ seats: 1,
2045
+ fetchOptions: { headers },
2046
+ });
2047
+
2048
+ await ctx.adapter.update({
2049
+ model: "subscription",
2050
+ update: {
2051
+ status: "active",
2052
+ seats: 1,
2053
+ stripeSubscriptionId: subscriptionId,
2054
+ },
2055
+ where: [{ field: "referenceId", value: userRes.user.id }],
2056
+ });
2057
+
2058
+ mockStripe.subscriptions.list.mockResolvedValue({
2059
+ data: [
2060
+ {
2061
+ id: subscriptionId,
2062
+ status: "active",
2063
+ items: {
2064
+ data: [
2065
+ {
2066
+ id: "si_monthly_item",
2067
+ price: { id: monthlyPriceId },
2068
+ quantity: 1,
2069
+ },
2070
+ ],
2071
+ },
2072
+ },
2073
+ ],
2074
+ });
2075
+
2076
+ // Clear mocks before the upgrade call
2077
+ mockStripe.checkout.sessions.create.mockClear();
2078
+ mockStripe.billingPortal.sessions.create.mockClear();
2079
+
2080
+ const upgradeRes = await client.subscription.upgrade({
2081
+ plan: "starter",
2082
+ seats: 1,
2083
+ annual: true,
2084
+ subscriptionId,
2085
+ fetchOptions: { headers },
2086
+ });
2087
+
2088
+ // Should succeed and return a billing portal URL
2089
+ expect(upgradeRes.error).toBeNull();
2090
+ expect(upgradeRes.data?.url).toBeDefined();
2091
+
2092
+ // Verify billing portal was called with the annual price ID
2093
+ expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith(
2094
+ expect.objectContaining({
2095
+ flow_data: expect.objectContaining({
2096
+ type: "subscription_update_confirm",
2097
+ subscription_update_confirm: expect.objectContaining({
2098
+ items: expect.arrayContaining([
2099
+ expect.objectContaining({ price: annualPriceId }),
2100
+ ]),
2101
+ }),
2102
+ }),
2103
+ }),
2104
+ );
2105
+
2106
+ // Should use billing portal, not checkout (since user has existing subscription)
2107
+ expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
2108
+ expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalled();
2109
+ });
2110
+
2111
+ it.each([
2112
+ {
2113
+ name: "past",
2114
+ periodEnd: new Date(Date.now() - 24 * 60 * 60 * 1000),
2115
+ shouldAllow: true,
2116
+ },
2117
+ {
2118
+ name: "future",
2119
+ periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
2120
+ shouldAllow: false,
2121
+ },
2122
+ ])("should handle re-subscribing when periodEnd is in the $name", async ({
2123
+ periodEnd,
2124
+ shouldAllow,
2125
+ }) => {
2126
+ const starterPriceId = "price_starter_periodend_test";
2127
+ const subscriptionId = "sub_periodend_test_123";
2128
+
2129
+ const stripeOptionsWithPrice = {
2130
+ ...stripeOptions,
2131
+ subscription: {
2132
+ enabled: true,
2133
+ plans: [
2134
+ {
2135
+ name: "starter",
2136
+ priceId: starterPriceId,
2137
+ },
2138
+ ],
2139
+ },
2140
+ } satisfies StripeOptions;
2141
+
2142
+ const { client, auth, sessionSetter } = await getTestInstance(
2143
+ {
2144
+ database: memory,
2145
+ plugins: [stripe(stripeOptionsWithPrice)],
2146
+ },
2147
+ {
2148
+ disableTestUser: true,
2149
+ clientOptions: {
2150
+ plugins: [stripeClient({ subscription: true })],
2151
+ },
2152
+ },
2153
+ );
2154
+ const ctx = await auth.$context;
2155
+
2156
+ const userRes = await client.signUp.email(
2157
+ { ...testUser, email: `periodend-${periodEnd.getTime()}@email.com` },
2158
+ { throw: true },
2159
+ );
2160
+
2161
+ const headers = new Headers();
2162
+ await client.signIn.email(
2163
+ { ...testUser, email: `periodend-${periodEnd.getTime()}@email.com` },
2164
+ { throw: true, onSuccess: sessionSetter(headers) },
2165
+ );
2166
+
2167
+ await client.subscription.upgrade({
2168
+ plan: "starter",
2169
+ seats: 1,
2170
+ fetchOptions: { headers },
2171
+ });
2172
+
2173
+ await ctx.adapter.update({
2174
+ model: "subscription",
2175
+ update: {
2176
+ status: "active",
2177
+ seats: 1,
2178
+ periodEnd,
2179
+ stripeSubscriptionId: subscriptionId,
2180
+ },
2181
+ where: [{ field: "referenceId", value: userRes.user.id }],
2182
+ });
2183
+
2184
+ // Mock Stripe to return the existing subscription with the same price ID
2185
+ mockStripe.subscriptions.list.mockResolvedValue({
2186
+ data: [
2187
+ {
2188
+ id: subscriptionId,
2189
+ status: "active",
2190
+ items: {
2191
+ data: [
2192
+ {
2193
+ id: "si_periodend_item",
2194
+ price: {
2195
+ id: starterPriceId,
2196
+ },
2197
+ quantity: 1,
2198
+ },
2199
+ ],
2200
+ },
2201
+ },
2202
+ ],
2203
+ });
2204
+
2205
+ const upgradeRes = await client.subscription.upgrade({
2206
+ plan: "starter",
2207
+ seats: 1,
2208
+ fetchOptions: { headers },
2209
+ });
2210
+
2211
+ if (shouldAllow) {
2212
+ expect(upgradeRes.error).toBeNull();
2213
+ expect(upgradeRes.data?.url).toBeDefined();
2214
+ } else {
2215
+ expect(upgradeRes.error?.message).toContain("already subscribed");
2216
+ }
2217
+ });
2218
+
1918
2219
  it("should only call Stripe customers.create once for signup and upgrade", async () => {
1919
2220
  const { client, sessionSetter } = await getTestInstance(
1920
2221
  {
@@ -4804,4 +5105,100 @@ describe("stripe", () => {
4804
5105
  });
4805
5106
  });
4806
5107
  });
5108
+
5109
+ it("should upgrade existing active subscription even when canceled subscription exists for same referenceId", async () => {
5110
+ const { client, auth, sessionSetter } = await getTestInstance(
5111
+ {
5112
+ database: memory,
5113
+ plugins: [stripe(stripeOptions)],
5114
+ },
5115
+ {
5116
+ disableTestUser: true,
5117
+ clientOptions: {
5118
+ plugins: [stripeClient({ subscription: true })],
5119
+ },
5120
+ },
5121
+ );
5122
+ const ctx = await auth.$context;
5123
+
5124
+ // Create a user
5125
+ const userRes = await client.signUp.email({ ...testUser }, { throw: true });
5126
+
5127
+ const headers = new Headers();
5128
+ await client.signIn.email(
5129
+ { ...testUser },
5130
+ {
5131
+ throw: true,
5132
+ onSuccess: sessionSetter(headers),
5133
+ },
5134
+ );
5135
+
5136
+ // Update the user with the Stripe customer ID
5137
+ await ctx.adapter.update({
5138
+ model: "user",
5139
+ update: {
5140
+ stripeCustomerId: "cus_findone_test",
5141
+ },
5142
+ where: [
5143
+ {
5144
+ field: "id",
5145
+ value: userRes.user.id,
5146
+ },
5147
+ ],
5148
+ });
5149
+
5150
+ // Create a CANCELED subscription first (simulating old subscription)
5151
+ await ctx.adapter.create({
5152
+ model: "subscription",
5153
+ data: {
5154
+ plan: "starter",
5155
+ referenceId: userRes.user.id,
5156
+ stripeCustomerId: "cus_findone_test",
5157
+ stripeSubscriptionId: "sub_stripe_canceled",
5158
+ status: "canceled",
5159
+ },
5160
+ });
5161
+
5162
+ // Create an ACTIVE subscription (simulating current subscription)
5163
+ await ctx.adapter.create({
5164
+ model: "subscription",
5165
+ data: {
5166
+ plan: "starter",
5167
+ referenceId: userRes.user.id,
5168
+ stripeCustomerId: "cus_findone_test",
5169
+ stripeSubscriptionId: "sub_stripe_active",
5170
+ status: "active",
5171
+ periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
5172
+ },
5173
+ });
5174
+
5175
+ // Mock Stripe subscriptions.list to return the active subscription
5176
+ mockStripe.subscriptions.list.mockResolvedValueOnce({
5177
+ data: [
5178
+ {
5179
+ id: "sub_stripe_active",
5180
+ status: "active",
5181
+ items: {
5182
+ data: [
5183
+ {
5184
+ id: "si_test_item",
5185
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
5186
+ quantity: 1,
5187
+ },
5188
+ ],
5189
+ },
5190
+ },
5191
+ ],
5192
+ });
5193
+
5194
+ // Try to upgrade to premium (without providing subscriptionId)
5195
+ await client.subscription.upgrade({
5196
+ plan: "premium",
5197
+ fetchOptions: { headers },
5198
+ });
5199
+
5200
+ // Should use billing portal to upgrade existing subscription (not create new checkout)
5201
+ expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalled();
5202
+ expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
5203
+ });
4807
5204
  });
package/tsdown.config.ts CHANGED
@@ -5,4 +5,5 @@ export default defineConfig({
5
5
  format: ["esm"],
6
6
  entry: ["./src/index.ts", "./src/client.ts"],
7
7
  external: ["better-auth", "better-call", "@better-fetch/fetch", "stripe"],
8
+ sourcemap: true,
8
9
  });
package/CHANGELOG.md DELETED
@@ -1,22 +0,0 @@
1
- # @better-auth/stripe
2
-
3
- ## 1.3.4
4
-
5
- ### Patch Changes
6
-
7
- - ac6baba: chore: fix typo on `freeTrial`
8
- - c2fb1aa: Fix duplicate trials when switching plans
9
- - 2bd2fa9: Added support for listing organization members with pagination, sorting, and filtering, and improved client inference for additional organization fields. Also fixed date handling in rate limits and tokens, improved Notion OAuth user extraction, and ensured session is always set in context.
10
-
11
- Organization
12
-
13
- - Added listMembers API with pagination, sorting, and filtering.
14
- - Added membersLimit param to getFullOrganization.
15
- - Improved client inference for additional fields in organization schemas.
16
- - Bug Fixes
17
- - Fixed date handling by casting DB values to Date objects before using date methods.
18
- - Fixed Notion OAuth to extract user info correctly.
19
- - Ensured session is set in context when reading from cookie cache
20
-
21
- - Updated dependencies [2bd2fa9]
22
- - better-auth@1.3.4