mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
fix(stripe): replace customers.search with customers.list for regional compatibility
The Stripe Search API (/v1/customers/search) is unavailable in India and
other regions. This replaces all 3 usages of customers.search with
customers.list + client-side filtering, which is universally available.
- User lookups: filter by email via customers.list({email}), then exclude
organization customers client-side
- Org lookups: list all customers, filter by organizationId metadata client-side
- Remove now-unused escapeStripeSearchValue utility
Closes #7959
This commit is contained in:
@@ -22,7 +22,7 @@ import type {
|
||||
Subscription,
|
||||
WithStripeCustomerId,
|
||||
} from "./types";
|
||||
import { escapeStripeSearchValue, getPlans, isActiveOrTrialing } from "./utils";
|
||||
import { getPlans, isActiveOrTrialing } from "./utils";
|
||||
|
||||
declare module "@better-auth/core" {
|
||||
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
||||
@@ -304,12 +304,16 @@ export const stripe = <O extends StripeOptions>(options: O) => {
|
||||
|
||||
try {
|
||||
// Check if user customer already exists in Stripe by email
|
||||
const existingCustomers = await client.customers.search({
|
||||
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
|
||||
limit: 1,
|
||||
const existingCustomers = await client.customers.list({
|
||||
email: user.email,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
let stripeCustomer = existingCustomers.data[0];
|
||||
let stripeCustomer = existingCustomers.data.find(
|
||||
(c) =>
|
||||
c.metadata?.[customerMetadata.keys.customerType] !==
|
||||
"organization",
|
||||
);
|
||||
|
||||
// If user customer exists, link it to prevent duplicate creation
|
||||
if (stripeCustomer) {
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
WithStripeCustomerId,
|
||||
} from "./types";
|
||||
import {
|
||||
escapeStripeSearchValue,
|
||||
getPlanByName,
|
||||
getPlans,
|
||||
isActiveOrTrialing,
|
||||
@@ -335,12 +334,14 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
||||
if (!customerId) {
|
||||
try {
|
||||
// First, search for existing organization customer by organizationId
|
||||
const existingOrgCustomers = await client.customers.search({
|
||||
query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}"`,
|
||||
limit: 1,
|
||||
const existingOrgCustomers = await client.customers.list({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
let stripeCustomer = existingOrgCustomers.data[0];
|
||||
let stripeCustomer = existingOrgCustomers.data.find(
|
||||
(c) =>
|
||||
c.metadata?.[customerMetadata.keys.organizationId] === org.id,
|
||||
);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
// Get custom params if provided
|
||||
@@ -415,12 +416,16 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
||||
if (!customerId) {
|
||||
try {
|
||||
// Try to find existing user Stripe customer by email
|
||||
const existingCustomers = await client.customers.search({
|
||||
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
|
||||
limit: 1,
|
||||
const existingCustomers = await client.customers.list({
|
||||
email: user.email,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
let stripeCustomer = existingCustomers.data[0];
|
||||
let stripeCustomer = existingCustomers.data.find(
|
||||
(c) =>
|
||||
c.metadata?.[customerMetadata.keys.customerType] !==
|
||||
"organization",
|
||||
);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await client.customers.create({
|
||||
|
||||
@@ -41,17 +41,6 @@ export function isStripePendingCancel(stripeSub: Stripe.Subscription): boolean {
|
||||
return !!(stripeSub.cancel_at_period_end || stripeSub.cancel_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a value for use in Stripe search queries.
|
||||
* Stripe search query uses double quotes for string values,
|
||||
* and double quotes within the value need to be escaped with backslash.
|
||||
*
|
||||
* @see https://docs.stripe.com/search#search-query-language
|
||||
*/
|
||||
export function escapeStripeSearchValue(value: string): string {
|
||||
return value.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the quantity for a subscription by checking the seat item first,
|
||||
* then falling back to the plan item's quantity.
|
||||
|
||||
@@ -15,7 +15,6 @@ describe("seat-based billing", () => {
|
||||
customers: {
|
||||
create: vi.fn().mockResolvedValue({ id: "cus_seat_org" }),
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
search: vi.fn().mockResolvedValue({ data: [] }),
|
||||
retrieve: vi.fn().mockResolvedValue({
|
||||
id: "cus_seat_org",
|
||||
name: "Seat Org",
|
||||
@@ -74,7 +73,7 @@ describe("seat-based billing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStripe.subscriptions.list.mockResolvedValue({ data: [] });
|
||||
mockStripe.customers.search.mockResolvedValue({ data: [] });
|
||||
mockStripe.customers.list.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
describe("checkout with auto-managed seats", async () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ describe("stripe - organization customer", () => {
|
||||
customers: {
|
||||
create: vi.fn().mockResolvedValue({ id: "cus_org_mock123" }),
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
search: vi.fn().mockResolvedValue({ data: [] }),
|
||||
retrieve: vi.fn().mockResolvedValue({
|
||||
id: "cus_org_mock123",
|
||||
email: "org@email.com",
|
||||
|
||||
@@ -152,7 +152,6 @@ describe("stripe", () => {
|
||||
customers: {
|
||||
create: vi.fn().mockResolvedValue({ id: "cus_mock123" }),
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
search: vi.fn().mockResolvedValue({ data: [] }),
|
||||
retrieve: vi.fn().mockResolvedValue({
|
||||
id: "cus_mock123",
|
||||
email: "test@email.com",
|
||||
@@ -2539,8 +2538,8 @@ describe("stripe", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Mock customers.search to find existing user customer
|
||||
mockStripe.customers.search.mockResolvedValueOnce({
|
||||
// Mock customers.list to find existing user customer
|
||||
mockStripe.customers.list.mockResolvedValueOnce({
|
||||
data: [{ id: "cus_test_123" }],
|
||||
});
|
||||
|
||||
@@ -3621,7 +3620,7 @@ describe("stripe", () => {
|
||||
const existingEmail = "duplicate-email@example.com";
|
||||
const existingCustomerId = "cus_stripe_existing_456";
|
||||
|
||||
mockStripe.customers.search.mockResolvedValueOnce({
|
||||
mockStripe.customers.list.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: existingCustomerId,
|
||||
@@ -3662,10 +3661,10 @@ describe("stripe", () => {
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
// Should check for existing user customer by email (excluding organization customers)
|
||||
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
||||
query: `email:"${existingEmail}" AND -metadata["customerType"]:"organization"`,
|
||||
limit: 1,
|
||||
// Should check for existing user customer by email
|
||||
expect(mockStripe.customers.list).toHaveBeenCalledWith({
|
||||
email: existingEmail,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Should NOT create duplicate customer
|
||||
@@ -3684,7 +3683,7 @@ describe("stripe", () => {
|
||||
it("should CREATE customer only when user has no stripeCustomerId and none exists in Stripe", async () => {
|
||||
const newEmail = "brand-new@example.com";
|
||||
|
||||
mockStripe.customers.search.mockResolvedValueOnce({
|
||||
mockStripe.customers.list.mockResolvedValueOnce({
|
||||
data: [],
|
||||
});
|
||||
|
||||
@@ -3724,10 +3723,10 @@ describe("stripe", () => {
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
// Should check for existing user customer first (excluding organization customers)
|
||||
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
||||
query: `email:"${newEmail}" AND -metadata["customerType"]:"organization"`,
|
||||
limit: 1,
|
||||
// Should check for existing user customer first by email
|
||||
expect(mockStripe.customers.list).toHaveBeenCalledWith({
|
||||
email: newEmail,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Should create new customer (this is correct behavior)
|
||||
@@ -3760,10 +3759,15 @@ describe("stripe", () => {
|
||||
const orgCustomerId = "cus_org_123";
|
||||
|
||||
// Mock: Only organization customer exists with this email
|
||||
// The search query includes `-metadata['customerType']:'organization'`
|
||||
// so this should NOT be returned
|
||||
mockStripe.customers.search.mockResolvedValueOnce({
|
||||
data: [], // Organization customer is excluded by the search query
|
||||
// The client-side filter excludes organization customers
|
||||
mockStripe.customers.list.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: orgCustomerId,
|
||||
email: sharedEmail,
|
||||
metadata: { customerType: "organization" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockStripe.customers.create.mockResolvedValueOnce({
|
||||
@@ -3802,10 +3806,10 @@ describe("stripe", () => {
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
// Should search with query that EXCLUDES organization customers
|
||||
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
||||
query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
|
||||
limit: 1,
|
||||
// Should list customers by email and filter client-side
|
||||
expect(mockStripe.customers.list).toHaveBeenCalledWith({
|
||||
email: sharedEmail,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Should create NEW user customer (not use org customer)
|
||||
@@ -3836,9 +3840,18 @@ describe("stripe", () => {
|
||||
const sharedEmail = "both-exist@example.com";
|
||||
const existingUserCustomerId = "cus_user_existing_789";
|
||||
|
||||
// Mock: Search returns ONLY user customer (org customer excluded by query)
|
||||
mockStripe.customers.search.mockResolvedValueOnce({
|
||||
// Mock: List returns both org and user customers; client-side filter picks user
|
||||
mockStripe.customers.list.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "cus_org_same_email",
|
||||
email: sharedEmail,
|
||||
name: "Org Customer",
|
||||
metadata: {
|
||||
customerType: "organization",
|
||||
organizationId: "org_123",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: existingUserCustomerId,
|
||||
email: sharedEmail,
|
||||
@@ -3882,10 +3895,10 @@ describe("stripe", () => {
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
// Should search excluding organization customers
|
||||
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
||||
query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
|
||||
limit: 1,
|
||||
// Should list customers by email and filter client-side
|
||||
expect(mockStripe.customers.list).toHaveBeenCalledWith({
|
||||
email: sharedEmail,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Should NOT create new customer - use existing user customer
|
||||
@@ -3906,7 +3919,7 @@ describe("stripe", () => {
|
||||
const orgEmail = "org@example.com";
|
||||
const orgId = "org_test_123";
|
||||
|
||||
mockStripe.customers.search.mockResolvedValueOnce({
|
||||
mockStripe.customers.list.mockResolvedValueOnce({
|
||||
data: [],
|
||||
});
|
||||
|
||||
@@ -3949,12 +3962,11 @@ describe("stripe", () => {
|
||||
// Manually trigger the organization customer creation flow
|
||||
// by calling the internal function (simulating what hooks do)
|
||||
const stripeClient = stripeOptions.stripeClient;
|
||||
const searchResult = await stripeClient.customers.search({
|
||||
query: `email:'${orgEmail}' AND metadata['customerType']:'organization'`,
|
||||
limit: 1,
|
||||
const listResult = await stripeClient.customers.list({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (searchResult.data.length === 0) {
|
||||
if (listResult.data.length === 0) {
|
||||
await stripeClient.customers.create({
|
||||
email: orgEmail,
|
||||
name: "Test Organization",
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import type Stripe from "stripe";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StripeOptions } from "../src/types";
|
||||
import { escapeStripeSearchValue, resolvePlanItem } from "../src/utils";
|
||||
|
||||
describe("escapeStripeSearchValue", () => {
|
||||
it("should escape double quotes", () => {
|
||||
expect(escapeStripeSearchValue('test"value')).toBe('test\\"value');
|
||||
});
|
||||
|
||||
it("should handle strings without quotes", () => {
|
||||
expect(escapeStripeSearchValue("simple")).toBe("simple");
|
||||
});
|
||||
|
||||
it("should escape multiple quotes", () => {
|
||||
expect(escapeStripeSearchValue('"a" and "b"')).toBe('\\"a\\" and \\"b\\"');
|
||||
});
|
||||
});
|
||||
import { resolvePlanItem } from "../src/utils";
|
||||
|
||||
describe("resolvePlanItem", () => {
|
||||
const options = {
|
||||
|
||||
Reference in New Issue
Block a user