@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.
- package/.turbo/turbo-build.log +10 -8
- package/dist/client.d.mts +2 -1
- package/dist/client.mjs +2 -1
- package/dist/client.mjs.map +1 -0
- package/dist/index-BTvn0abC.d.mts +2 -1
- package/dist/index.mjs +77 -51
- package/dist/index.mjs.map +1 -0
- package/package.json +6 -6
- package/src/client.ts +1 -1
- package/src/hooks.ts +10 -5
- package/src/index.ts +10 -6
- package/src/metadata.ts +94 -0
- package/src/routes.ts +83 -95
- package/test/stripe.test.ts +398 -1
- package/tsdown.config.ts +1 -0
- package/CHANGELOG.md +0 -22
package/test/stripe.test.ts
CHANGED
|
@@ -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(
|
|
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
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
|