[GH-ISSUE #5781] referenceId uniqueness prevents resubscribe after canceling immediately. I want 1 user = 1 subscription #10350

Closed
opened 2026-04-13 06:25:32 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @rayyan-buildin2 on GitHub (Nov 5, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/5781

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

When using Better Auth with Stripe, the referenceId (defaulting to user ID) is marked unique in the DB. This causes problems when a user cancels a subscription immediately and tries to resubscribe to the same plan.

  1. User subscribes to a plan (e.g., "Starter") — subscription stored in DB with referenceId = user.id.
  2. User cancels subscription immediately in Stripe portal.
  3. DB record still exists with status = canceled.
  4. User tries to subscribe to the same plan again.
  5. Server throws duplicate key error due to unique constraint on referenceId.

Current vs. Expected behavior

  1. Users should be able to resubscribe to the same plan after canceling.

What version of Better Auth are you using?

"better-auth": "^1.3.34",

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:34 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T8103",
    "release": "24.6.0",
    "cpuCount": 8,
    "cpuModel": "Apple M1",
    "totalMemory": "8.00 GB",
    "freeMemory": "0.16 GB"
  },
  "node": {
    "version": "v22.17.1",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.16.1"
  },
  "frameworks": [
    {
      "name": "express",
      "version": "^5.1.0"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "^1.3.34",
    "config": {
      "secret": "[REDACTED]",
      "baseURL": "http://localhost:3000",
      "trustedOrigins": [
        "http://localhost:5173"
      ],
      "errorURL": "http://localhost:5173",
      "user": {
        "additionalFields": {
          "isOnboardingComplete": {
            "type": "boolean",
            "defaultValue": false,
            "required": false
          }
        }
      },
      "account": {
        "accountLinking": {
          "enabled": true,
          "allowDifferentEmails": false,
          "updateUserInfoOnLink": true
        }
      },
      "plugins": [
        {
          "name": "admin",
          "config": {
            "id": "admin",
            "hooks": {
              "after": [
                {}
              ]
            },
            "endpoints": {},
            "$ERROR_CODES": {
              "FAILED_TO_CREATE_USER": "Failed to create user",
              "USER_ALREADY_EXISTS": "User already exists.",
              "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "User already exists. Use another email.",
              "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself",
              "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users",
              "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users",
              "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users",
              "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]",
              "BANNED_USER": "You have been banned from this application",
              "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user",
              "NO_DATA_TO_UPDATE": "No data to update",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users",
              "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself",
              "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE": "You are not allowed to set a non-existent role value"
            },
            "schema": {
              "user": {
                "fields": {
                  "role": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banned": {
                    "type": "boolean",
                    "defaultValue": false,
                    "required": false,
                    "input": false
                  },
                  "banReason": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banExpires": {
                    "type": "date",
                    "required": false,
                    "input": false
                  }
                }
              },
              "session": {
                "fields": {
                  "impersonatedBy": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            },
            "options": {
              "defaultRole": "user",
              "adminRoles": [
                "admin"
              ]
            }
          }
        },
        {
          "name": "stripe",
          "config": {
            "id": "stripe",
            "endpoints": {},
            "schema": {
              "subscription": {
                "fields": {
                  "plan": {
                    "type": "string",
                    "required": true
                  },
                  "referenceId": {
                    "type": "string",
                    "required": true
                  },
                  "stripeCustomerId": {
                    "type": "string",
                    "required": false
                  },
                  "stripeSubscriptionId": {
                    "type": "string",
                    "required": false
                  },
                  "status": {
                    "type": "string",
                    "defaultValue": "incomplete"
                  },
                  "periodStart": {
                    "type": "date",
                    "required": false
                  },
                  "periodEnd": {
                    "type": "date",
                    "required": false
                  },
                  "trialStart": {
                    "type": "date",
                    "required": false
                  },
                  "trialEnd": {
                    "type": "date",
                    "required": false
                  },
                  "cancelAtPeriodEnd": {
                    "type": "boolean",
                    "required": false,
                    "defaultValue": false
                  },
                  "seats": {
                    "type": "number",
                    "required": false
                  }
                }
              },
              "user": {
                "fields": {
                  "stripeCustomerId": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            }
          }
        }
      ],
      "emailAndPassword": {
        "enabled": true,
        "requireEmailVerification": true
      },
      "emailVerification": {
        "sendOnSignUp": true,
        "autoSignInAfterVerification": true
      },
      "session": {
        "expiresIn": 604800,
        "updateAge": 86400
      },
      "advanced": {
        "defaultCookieAttributes": {
          "sameSite": "lax",
          "secure": false,
          "httpOnly": true
        }
      },
      "socialProviders": {
        "google": {
          "prompt": "select_account consent",
          "clientId": "[REDACTED]",
          "clientSecret": "[REDACTED]",
          "scope": [
            "openid",
            "email",
            "profile",
            "https://www.googleapis.com/auth/gmail.send"
          ],
          "accessType": "offline"
        }
      },
      "databaseHooks": {
        "account": {
          "create": {}
        }
      },
      "hooks": {}
    }
  }
}

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import {
  BETTER_AUTH_SECRET,
  BETTER_AUTH_URL,
  CORS_ORIGIN,
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
  IS_PROD,
  REQUIRE_EMAIL_VERIFICATION,
  STARTER_PRICE_ID,
  STRIPE_SECRET_KEY,
  STRIPE_WEBHOOK_SECRET,
} from "@/config/constants";
import {
  addProviderEmail,
  ProviderId,
} from "@/features/user/services/addProviderEmail.service";
import { MailClient } from "@/lib/mail-client";
import { client } from "@/lib/mongo";
import { stripe } from "@better-auth/stripe";
import { userRoleEnum } from "@packages/shared/schemas/user";
import { logger } from "@packages/shared/utils";
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { admin, createAuthMiddleware } from "better-auth/plugins";
import Stripe from "stripe";

const stripeClient = new Stripe(STRIPE_SECRET_KEY, {
  apiVersion: "2025-10-29.clover", // Latest API version as of Stripe SDK v19
});

export const auth = betterAuth({
  database: mongodbAdapter(client),
  secret: BETTER_AUTH_SECRET,
  baseURL: BETTER_AUTH_URL,
  trustedOrigins: [CORS_ORIGIN],
  errorURL: CORS_ORIGIN,
  user: {
    additionalFields: {
      isOnboardingComplete: {
        type: "boolean",
        defaultValue: false,
        required: false,
      },
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      allowDifferentEmails: false,
      updateUserInfoOnLink: true,
    },
  },
  plugins: [
    admin({
      defaultRole: userRoleEnum.USER,
      adminRoles: [userRoleEnum.ADMIN],
    }),
    stripe({
      stripeClient,
      stripeWebhookSecret: STRIPE_WEBHOOK_SECRET,
      createCustomerOnSignUp: true,
      subscription: {
        enabled: true,
        plans: [
          {
            name: "starter",
            priceId: STARTER_PRICE_ID,
          },
        ],
      },
    }),
  ],
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: REQUIRE_EMAIL_VERIFICATION,
    sendResetPassword: async ({ user, url, token }, request) => {
      await MailClient.sendPasswordResetEmail(user.email, user.name, url);
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url, token }, request) => {
      await MailClient.sendVerificationEmail(user.email, user.name, url);
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // 1 day
  },
  advanced: {
    defaultCookieAttributes: {
      sameSite: "lax",
      secure: IS_PROD,
      httpOnly: true,
    },
  },
  socialProviders: {
    google: {
      prompt: "select_account consent",
      clientId: GOOGLE_CLIENT_ID,
      clientSecret: GOOGLE_CLIENT_SECRET,
      scope: [
        "openid",
        "email",
        "profile",
        "https://www.googleapis.com/auth/gmail.send",
      ],
      accessType: "offline", // Required to get refresh tokens
    },
    // microsoft: {
    //   clientId: MICROSOFT_CLIENT_ID,
    //   clientSecret: MICROSOFT_CLIENT_SECRET,
    //   tenantId: "common",
    //   prompt: "select_account",
    // },
  },
  databaseHooks: {
    account: {
      create: {
        after: async (account) => {
          // skip if providerId is credentials
          if (account.providerId === "credentials") {
            return;
          }

          // Use service to add provider email
          await addProviderEmail(
            account.id,
            account.providerId as ProviderId,
            account.accessToken || undefined
          );
        },
      },
    },
  },
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path === "/error") {
        const { error } = ctx.query as { error?: string };
        logger.error(new Error("Auth error"), "AUTH");

        let redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent(
          "Authentication Error"
        )}&error_description=${encodeURIComponent("Failed to authenticate")}`;

        if (error === "banned") {
          redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent(
            "You have been banned"
          )}&error_description=${encodeURIComponent("Contact support team")}`;
        }

        throw ctx.redirect(redirectUrl);
      }
    }),
  },
});

// Use the shared types for consistency
export type Session = typeof auth.$Infer.Session.session;
export type User = typeof auth.$Infer.Session.user;

Additional context

referenceId is currently unique across all subscriptions, not just active/trialing ones.
Better Auth docs mention that only one active or trialing subscription per reference ID is allowed, but the unique DB constraint may be too strict, blocking canceled subscriptions.

subscriptionSchema.index({ referenceId: 1 }, { unique: true });
subscriptionSchema.index({ plan: 1 });
subscriptionSchema.index({ status: 1 });

{
"name": "server",
"main": "src/index.ts",
"type": "module",
"scripts": {
"build": "tsdown",
"check-types": "tsc -b",
"compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"docker:start": "docker compose up -d",
"docker:watch": "docker compose up",
"docker:stop": "docker compose stop",
"docker:down": "docker compose down"
},
"dependencies": {
"@better-auth/stripe": "^1.3.34",
"@google/generative-ai": "^0.24.1",
"@orpc/client": "^1.10.4",
"@orpc/server": "^1.10.4",
"@packages/shared": "workspace:*",
"better-auth": "^1.3.34",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"googleapis": "^160.0.0",
"minio": "^8.0.6",
"mongoose": "^8.19.3",
"stripe": "^19.2.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/node": "^22.19.0",
"knip": "^5.67.1",
"tsdown": "^0.15.12",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

Originally created by @rayyan-buildin2 on GitHub (Nov 5, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/5781 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce When using Better Auth with Stripe, the referenceId (defaulting to user ID) is marked unique in the DB. This causes problems when a user cancels a subscription immediately and tries to resubscribe to the same plan. 1. User subscribes to a plan (e.g., "Starter") — subscription stored in DB with referenceId = user.id. 2. User cancels subscription immediately in Stripe portal. 3. DB record still exists with status = canceled. 4. User tries to subscribe to the same plan again. 5. Server throws duplicate key error due to unique constraint on referenceId. ### Current vs. Expected behavior 1. Users should be able to resubscribe to the same plan after canceling. ### What version of Better Auth are you using? "better-auth": "^1.3.34", ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:34 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T8103", "release": "24.6.0", "cpuCount": 8, "cpuModel": "Apple M1", "totalMemory": "8.00 GB", "freeMemory": "0.16 GB" }, "node": { "version": "v22.17.1", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.16.1" }, "frameworks": [ { "name": "express", "version": "^5.1.0" } ], "databases": null, "betterAuth": { "version": "^1.3.34", "config": { "secret": "[REDACTED]", "baseURL": "http://localhost:3000", "trustedOrigins": [ "http://localhost:5173" ], "errorURL": "http://localhost:5173", "user": { "additionalFields": { "isOnboardingComplete": { "type": "boolean", "defaultValue": false, "required": false } } }, "account": { "accountLinking": { "enabled": true, "allowDifferentEmails": false, "updateUserInfoOnLink": true } }, "plugins": [ { "name": "admin", "config": { "id": "admin", "hooks": { "after": [ {} ] }, "endpoints": {}, "$ERROR_CODES": { "FAILED_TO_CREATE_USER": "Failed to create user", "USER_ALREADY_EXISTS": "User already exists.", "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "User already exists. Use another email.", "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself", "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions", "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users", "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users", "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions", "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users", "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]", "BANNED_USER": "You have been banned from this application", "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user", "NO_DATA_TO_UPDATE": "No data to update", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users", "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself", "YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE": "You are not allowed to set a non-existent role value" }, "schema": { "user": { "fields": { "role": { "type": "string", "required": false, "input": false }, "banned": { "type": "boolean", "defaultValue": false, "required": false, "input": false }, "banReason": { "type": "string", "required": false, "input": false }, "banExpires": { "type": "date", "required": false, "input": false } } }, "session": { "fields": { "impersonatedBy": { "type": "string", "required": false } } } }, "options": { "defaultRole": "user", "adminRoles": [ "admin" ] } } }, { "name": "stripe", "config": { "id": "stripe", "endpoints": {}, "schema": { "subscription": { "fields": { "plan": { "type": "string", "required": true }, "referenceId": { "type": "string", "required": true }, "stripeCustomerId": { "type": "string", "required": false }, "stripeSubscriptionId": { "type": "string", "required": false }, "status": { "type": "string", "defaultValue": "incomplete" }, "periodStart": { "type": "date", "required": false }, "periodEnd": { "type": "date", "required": false }, "trialStart": { "type": "date", "required": false }, "trialEnd": { "type": "date", "required": false }, "cancelAtPeriodEnd": { "type": "boolean", "required": false, "defaultValue": false }, "seats": { "type": "number", "required": false } } }, "user": { "fields": { "stripeCustomerId": { "type": "string", "required": false } } } } } } ], "emailAndPassword": { "enabled": true, "requireEmailVerification": true }, "emailVerification": { "sendOnSignUp": true, "autoSignInAfterVerification": true }, "session": { "expiresIn": 604800, "updateAge": 86400 }, "advanced": { "defaultCookieAttributes": { "sameSite": "lax", "secure": false, "httpOnly": true } }, "socialProviders": { "google": { "prompt": "select_account consent", "clientId": "[REDACTED]", "clientSecret": "[REDACTED]", "scope": [ "openid", "email", "profile", "https://www.googleapis.com/auth/gmail.send" ], "accessType": "offline" } }, "databaseHooks": { "account": { "create": {} } }, "hooks": {} } } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGIN, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_PROD, REQUIRE_EMAIL_VERIFICATION, STARTER_PRICE_ID, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, } from "@/config/constants"; import { addProviderEmail, ProviderId, } from "@/features/user/services/addProviderEmail.service"; import { MailClient } from "@/lib/mail-client"; import { client } from "@/lib/mongo"; import { stripe } from "@better-auth/stripe"; import { userRoleEnum } from "@packages/shared/schemas/user"; import { logger } from "@packages/shared/utils"; import { betterAuth } from "better-auth"; import { mongodbAdapter } from "better-auth/adapters/mongodb"; import { admin, createAuthMiddleware } from "better-auth/plugins"; import Stripe from "stripe"; const stripeClient = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-10-29.clover", // Latest API version as of Stripe SDK v19 }); export const auth = betterAuth({ database: mongodbAdapter(client), secret: BETTER_AUTH_SECRET, baseURL: BETTER_AUTH_URL, trustedOrigins: [CORS_ORIGIN], errorURL: CORS_ORIGIN, user: { additionalFields: { isOnboardingComplete: { type: "boolean", defaultValue: false, required: false, }, }, }, account: { accountLinking: { enabled: true, allowDifferentEmails: false, updateUserInfoOnLink: true, }, }, plugins: [ admin({ defaultRole: userRoleEnum.USER, adminRoles: [userRoleEnum.ADMIN], }), stripe({ stripeClient, stripeWebhookSecret: STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "starter", priceId: STARTER_PRICE_ID, }, ], }, }), ], emailAndPassword: { enabled: true, requireEmailVerification: REQUIRE_EMAIL_VERIFICATION, sendResetPassword: async ({ user, url, token }, request) => { await MailClient.sendPasswordResetEmail(user.email, user.name, url); }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url, token }, request) => { await MailClient.sendVerificationEmail(user.email, user.name, url); }, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // 1 day }, advanced: { defaultCookieAttributes: { sameSite: "lax", secure: IS_PROD, httpOnly: true, }, }, socialProviders: { google: { prompt: "select_account consent", clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET, scope: [ "openid", "email", "profile", "https://www.googleapis.com/auth/gmail.send", ], accessType: "offline", // Required to get refresh tokens }, // microsoft: { // clientId: MICROSOFT_CLIENT_ID, // clientSecret: MICROSOFT_CLIENT_SECRET, // tenantId: "common", // prompt: "select_account", // }, }, databaseHooks: { account: { create: { after: async (account) => { // skip if providerId is credentials if (account.providerId === "credentials") { return; } // Use service to add provider email await addProviderEmail( account.id, account.providerId as ProviderId, account.accessToken || undefined ); }, }, }, }, hooks: { before: createAuthMiddleware(async (ctx) => { if (ctx.path === "/error") { const { error } = ctx.query as { error?: string }; logger.error(new Error("Auth error"), "AUTH"); let redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent( "Authentication Error" )}&error_description=${encodeURIComponent("Failed to authenticate")}`; if (error === "banned") { redirectUrl = `${CORS_ORIGIN}/?error=${encodeURIComponent( "You have been banned" )}&error_description=${encodeURIComponent("Contact support team")}`; } throw ctx.redirect(redirectUrl); } }), }, }); // Use the shared types for consistency export type Session = typeof auth.$Infer.Session.session; export type User = typeof auth.$Infer.Session.user; ``` ### Additional context referenceId is currently unique across all subscriptions, not just active/trialing ones. Better Auth docs mention that only one active or trialing subscription per reference ID is allowed, but the unique DB constraint may be too strict, blocking canceled subscriptions. subscriptionSchema.index({ referenceId: 1 }, { unique: true }); subscriptionSchema.index({ plan: 1 }); subscriptionSchema.index({ status: 1 }); { "name": "server", "main": "src/index.ts", "type": "module", "scripts": { "build": "tsdown", "check-types": "tsc -b", "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "docker:start": "docker compose up -d", "docker:watch": "docker compose up", "docker:stop": "docker compose stop", "docker:down": "docker compose down" }, "dependencies": { "@better-auth/stripe": "^1.3.34", "@google/generative-ai": "^0.24.1", "@orpc/client": "^1.10.4", "@orpc/server": "^1.10.4", "@packages/shared": "workspace:*", "better-auth": "^1.3.34", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", "googleapis": "^160.0.0", "minio": "^8.0.6", "mongoose": "^8.19.3", "stripe": "^19.2.1", "zod": "^4.1.12" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.5", "@types/node": "^22.19.0", "knip": "^5.67.1", "tsdown": "^0.15.12", "tsx": "^4.20.6", "typescript": "^5.9.3" } }
GiteaMirror added the lockedbug labels 2026-04-13 06:25:32 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Nov 5, 2025):

(tag @better-auth-agent if you would like an answer from the Agent)

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3492318211 --> @better-auth-agent[bot] commented on GitHub (Nov 5, 2025): (tag @better-auth-agent if you would like an answer from the Agent) <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@dosubot[bot] commented on GitHub (Nov 5, 2025):

The unique constraint on referenceId in the Better Auth Stripe plugin applies to all subscriptions, regardless of status. This means that when a user cancels a subscription, the canceled record still blocks any new subscription with the same referenceId, causing the duplicate key error you’re seeing. This is stricter than the intended business logic, which is to allow only one active or trialing subscription per user but permit resubscription after cancellation.

There’s currently no built-in config or hook in Better Auth v1.3.34 to scope the uniqueness constraint to only active/trialing subscriptions. To achieve the behavior you want (1 user = 1 active subscription, but allow resubscription after cancel), you have a few options:

  • Change the unique index in your database to a compound or partial index, such as { referenceId: 1, status: 1 } and enforce uniqueness only for active/trialing statuses. This aligns the DB constraint with the business logic, but requires a migration and may depend on your DB’s support for partial indexes (MongoDB supports this with a partialFilterExpression).
  • As a workaround, manually delete or archive canceled/incomplete subscriptions in your DB to unblock resubscription, but be aware this can affect your audit/history.
  • If you need to keep all records, enforce the “only one active/trialing subscription per user” rule at the application level, not with a DB unique constraint.

This is a known issue and has been discussed in several threads (example, example). There is no official fix in the core package yet, so any solution will require a schema change or custom logic in your project. Be careful with migrations and test thoroughly to avoid allowing multiple active subscriptions per user.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

<!-- gh-comment-id:3492346923 --> @dosubot[bot] commented on GitHub (Nov 5, 2025): <!-- Answer --> The unique constraint on `referenceId` in the Better Auth Stripe plugin applies to all subscriptions, regardless of status. This means that when a user cancels a subscription, the canceled record still blocks any new subscription with the same `referenceId`, causing the duplicate key error you’re seeing. This is stricter than the intended business logic, which is to allow only one active or trialing subscription per user but permit resubscription after cancellation. There’s currently no built-in config or hook in Better Auth v1.3.34 to scope the uniqueness constraint to only active/trialing subscriptions. To achieve the behavior you want (1 user = 1 active subscription, but allow resubscription after cancel), you have a few options: - Change the unique index in your database to a compound or partial index, such as `{ referenceId: 1, status: 1 }` and enforce uniqueness only for active/trialing statuses. This aligns the DB constraint with the business logic, but requires a migration and may depend on your DB’s support for partial indexes (MongoDB supports this with a partialFilterExpression). - As a workaround, manually delete or archive canceled/incomplete subscriptions in your DB to unblock resubscription, but be aware this can affect your audit/history. - If you need to keep all records, enforce the “only one active/trialing subscription per user” rule at the application level, not with a DB unique constraint. This is a known issue and has been discussed in several threads ([example](https://github.com/better-auth/better-auth/issues/2929), [example](https://github.com/better-auth/better-auth/issues/3979)). There is no official fix in the core package yet, so any solution will require a schema change or custom logic in your project. Be careful with migrations and test thoroughly to avoid allowing multiple active subscriptions per user. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/20fd4ca2-7612-4f65-ba37-1042469fa1c5?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/20fd4ca2-7612-4f65-ba37-1042469fa1c5?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/20fd4ca2-7612-4f65-ba37-1042469fa1c5?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/20fd4ca2-7612-4f65-ba37-1042469fa1c5?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/20fd4ca2-7612-4f65-ba37-1042469fa1c5?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/20fd4ca2-7612-4f65-ba37-1042469fa1c5?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/20fd4ca2-7612-4f65-ba37-1042469fa1c5?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/5781)
Author
Owner

@bytaesu commented on GitHub (Nov 7, 2025):

Would you like to try subscription.restore? If referenceId were unique, you would get a database error when resubscribing after a subscription is completely ended. Therefore, I believe there is no issue with this part. If you continue to experience specific problems, please leave a comment! 🙂

Image
<!-- gh-comment-id:3503099159 --> @bytaesu commented on GitHub (Nov 7, 2025): Would you like to try `subscription.restore`? If `referenceId` were unique, you would get a database error when resubscribing after a subscription is completely ended. Therefore, I believe there is no issue with this part. If you continue to experience specific problems, please leave a comment! 🙂 <img width="1209" height="189" alt="Image" src="https://github.com/user-attachments/assets/0194a878-e759-4747-abe3-4ccddb194fca" />
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10350