From 2bf056a1664cc1cfeb800d4e00407bca24ec9133 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Fri, 13 Mar 2026 14:53:42 +0530 Subject: [PATCH 1/5] Releasing v1.0.0-beta.4 --- packages/better-auth/CHANGELOG.md | 21 + packages/better-auth/package.json | 2 +- packages/better-auth/src/client.ts | 3 + packages/better-auth/src/hooks.ts | 11 +- packages/better-auth/src/index.ts | 154 ++-- packages/better-auth/src/metadata.ts | 21 - packages/better-auth/src/routes.ts | 845 +++++++++++++-------- packages/better-auth/src/types.ts | 8 +- packages/better-auth/src/utils.ts | 6 +- packages/better-auth/src/version.ts | 1 + packages/better-auth/test/hooks.test.ts | 10 +- packages/better-auth/test/metadata.test.ts | 89 +-- packages/better-auth/test/routes.test.ts | 141 +++- packages/better-auth/test/utils.test.ts | 5 + packages/better-auth/test/webhook.test.ts | 54 +- 15 files changed, 812 insertions(+), 559 deletions(-) create mode 100644 packages/better-auth/src/version.ts diff --git a/packages/better-auth/CHANGELOG.md b/packages/better-auth/CHANGELOG.md index 95acabd..89e1de3 100644 --- a/packages/better-auth/CHANGELOG.md +++ b/packages/better-auth/CHANGELOG.md @@ -1,3 +1,24 @@ +### v1.0.0-beta.4 (2026-03-16) +* * * + +### Feature: +- Added `createSubscription` route to initiate a new Chargebee hosted checkout session. +- Added `updateSubscription` route to update an existing subscription via hosted page. +- Added `listActiveSubscriptions` route (`GET /subscription/list`) to retrieve the caller's active/trialing subscriptions enriched with plan `limits` and `itemPriceId`. +- Added a `user.delete` database hook that automatically cancels active Chargebee subscriptions and cleans up local subscription records when a user is deleted. +- Introduced `version.ts` to track and expose the package version, used to set the `__clientIdentifier` on the Chargebee client. +- Added `getOrCreateCustomerId` shared helper to deduplicate customer creation logic across routes, with race-condition protection via a fresh-read guard. + +### Bug: +- Webhook handler no longer returns early when no matching plan is found for an `itemPriceId`; the subscription is still tracked and a warning is logged instead. + +### Improvement: +- Added a startup warning when `webhookUsername` / `webhookPassword` are not configured, alerting that the webhook endpoint is unauthenticated. +- User's name is now split into `first_name` / `last_name` when creating a Chargebee customer. +- Extracted `isActiveOrTrialing` helper and used it consistently across hooks and routes. +- Renamed `onEvent` to `webhookHandler`, which now receives the typed `WebhookHandler` instance — use `handler.on(WebhookEventType.X, fn)` for per-event listeners with full type safety. + + ### v1.0.0-beta.2 (2026-02-05) * * * diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 6c5f859..71db172 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,7 +1,7 @@ { "name": "@chargebee/better-auth", "author": "DX Chargebee", - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "type": "module", "main": "dist/index.mjs", "types": "dist/index.d.mts", diff --git a/packages/better-auth/src/client.ts b/packages/better-auth/src/client.ts index 66e2fef..0846562 100644 --- a/packages/better-auth/src/client.ts +++ b/packages/better-auth/src/client.ts @@ -32,8 +32,11 @@ export const chargebeeClient = < > >, pathMethods: { + "/subscription/create": "POST", + "/subscription/update": "POST", "/subscription/cancel": "POST", "/subscription/portal": "POST", + "/subscription/list": "GET", }, } satisfies BetterAuthClientPlugin; diff --git a/packages/better-auth/src/hooks.ts b/packages/better-auth/src/hooks.ts index 2ebd52b..4189677 100644 --- a/packages/better-auth/src/hooks.ts +++ b/packages/better-auth/src/hooks.ts @@ -11,7 +11,7 @@ import type { Subscription, SubscriptionOptions, } from "./types"; -import { getPlanByItemPriceId } from "./utils"; +import { getPlanByItemPriceId, isActiveOrTrialing } from "./utils"; /** * Find organization or user by chargebeeCustomerId. @@ -112,9 +112,9 @@ export async function onSubscriptionCreated( if (!plan) { ctx.context.logger.warn( - `Chargebee webhook warning: No matching plan found for itemPriceId: ${itemPriceId}`, + `Chargebee webhook warning: No matching plan found for itemPriceId: ${itemPriceId}. ` + + `Subscription will still be tracked but plan-specific features won't apply.`, ); - return; } const seats = primaryItem.quantity || 1; @@ -221,9 +221,8 @@ export async function onSubscriptionUpdated( where: [{ field: "chargebeeCustomerId", value: customerId }], }); if (subs.length > 1) { - const activeSub = subs.find( - (sub: Subscription) => - sub.status === "active" || sub.status === "in_trial", + const activeSub = subs.find((sub: Subscription) => + isActiveOrTrialing(sub), ); if (!activeSub) { ctx.context.logger.warn( diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index bd29d21..8444e6d 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -6,11 +6,14 @@ import { cancelSubscription, cancelSubscriptionCallback, createPortalSession, + createSubscription, getWebhookEndpoint, - upgradeSubscription, + listActiveSubscriptions, + updateSubscription, } from "./routes"; import { getSchema } from "./schema"; import type { ChargebeeOptions, WithChargebeeCustomerId } from "./types"; +import { VERSION } from "./version"; declare module "@better-auth/core" { interface BetterAuthPluginRegistry { @@ -22,22 +25,32 @@ declare module "@better-auth/core" { export const chargebee = (options: O) => { const cb = options.chargebeeClient; - // @ts-expect-error - __clientIdentifier is not typed - cb.__clientIdentifier("better-auth 1.0.0-beta.3"); + // @ts-expect-error - __clientIdentifier is not typed + cb.__clientIdentifier(`better-auth ${VERSION}`); return { id: "chargebee", schema: getSchema(options), endpoints: { chargebeeWebhook: getWebhookEndpoint(options), - upgradeSubscription: upgradeSubscription(options), + createSubscription: createSubscription(options), + updateSubscription: updateSubscription(options), cancelSubscription: cancelSubscription(options), cancelSubscriptionCallback: cancelSubscriptionCallback(options), createPortalSession: createPortalSession(options), + listActiveSubscriptions: listActiveSubscriptions(options), }, options: options as NoInfer, $ERROR_CODES: CHARGEBEE_ERROR_CODES, init(ctx) { + if (!options.webhookUsername || !options.webhookPassword) { + ctx.logger.warn( + "Chargebee plugin: webhookUsername and webhookPassword are not configured. " + + "The webhook endpoint is unauthenticated and anyone can POST fake events. " + + "Set webhookUsername and webhookPassword in your chargebee plugin options.", + ); + } + return { options: { databaseHooks: { @@ -85,7 +98,6 @@ export const chargebee = (options: O) => { `Error creating Chargebee customer for user ${user.id}:`, e, ); - // Silently fail — don't break user signup for billing sync issues } }, }, @@ -95,88 +107,82 @@ export const chargebee = (options: O) => { try { await cb.customer.update(user.chargebeeCustomerId, { email: user.email, + first_name: user.name?.split(" ")[0], + last_name: user.name?.split(" ").slice(1).join(" "), }); } catch { // Silently fail — don't break auth for billing sync issues } }, }, - }, - delete: { - async before(user: User & WithChargebeeCustomerId) { - // Clean up user's subscriptions before deleting user - try { - // Find all subscriptions for this user - const subscriptions = await ctx.adapter.findMany<{ - id: string; - chargebeeSubscriptionId: string | null; - }>({ - model: "subscription", - where: [ - { - field: "referenceId", - value: user.id, - }, - ], - }); - - // Cancel and delete each subscription - for (const subscription of subscriptions) { - // Cancel in Chargebee first (if subscription exists there) - if (subscription.chargebeeSubscriptionId) { - try { - await cb.subscription.cancel( - subscription.chargebeeSubscriptionId, - { - end_of_term: false, // Cancel immediately - }, - ); - ctx.logger.info( - `Cancelled Chargebee subscription ${subscription.chargebeeSubscriptionId}`, - ); - } catch (e) { - // Log but continue - subscription might already be cancelled - const errorMessage = - e instanceof Error ? e.message : String(e); - ctx.logger.warn( - `Failed to cancel subscription in Chargebee: ${errorMessage}`, - ); - } - } - - // Delete subscription items - await ctx.adapter.deleteMany({ - model: "subscriptionItem", - where: [ - { - field: "subscriptionId", - value: subscription.id, - }, - ], - }); - - // Delete subscription - await ctx.adapter.deleteMany({ + delete: { + async before(user: User & WithChargebeeCustomerId) { + try { + const subscriptions = await ctx.adapter.findMany<{ + id: string; + chargebeeSubscriptionId: string | null; + }>({ model: "subscription", where: [ { - field: "id", - value: subscription.id, + field: "referenceId", + value: user.id, }, ], }); - } - ctx.logger.info( - `Cleaned up ${subscriptions.length} subscription(s) for user ${user.id}`, - ); - } catch (e) { - ctx.logger.error( - `Error cleaning up subscriptions for user ${user.id}:`, - e, - ); - // Don't throw - allow user deletion to proceed - } + for (const subscription of subscriptions) { + if (subscription.chargebeeSubscriptionId) { + try { + await cb.subscription.cancel( + subscription.chargebeeSubscriptionId, + { + end_of_term: false, + }, + ); + ctx.logger.info( + `Cancelled Chargebee subscription ${subscription.chargebeeSubscriptionId}`, + ); + } catch (e) { + const errorMessage = + e instanceof Error ? e.message : String(e); + ctx.logger.warn( + `Failed to cancel subscription in Chargebee: ${errorMessage}`, + ); + } + } + + await ctx.adapter.deleteMany({ + model: "subscriptionItem", + where: [ + { + field: "subscriptionId", + value: subscription.id, + }, + ], + }); + + await ctx.adapter.deleteMany({ + model: "subscription", + where: [ + { + field: "id", + value: subscription.id, + }, + ], + }); + } + + ctx.logger.info( + `Cleaned up ${subscriptions.length} subscription(s) for user ${user.id}`, + ); + } catch (e) { + ctx.logger.error( + `Error cleaning up subscriptions for user ${user.id}:`, + e, + ); + } + }, }, }, }, diff --git a/packages/better-auth/src/metadata.ts b/packages/better-auth/src/metadata.ts index 51dbdfa..f72db76 100644 --- a/packages/better-auth/src/metadata.ts +++ b/packages/better-auth/src/metadata.ts @@ -27,24 +27,3 @@ export const customerMetadata = { }; }, }; - -export const subscriptionMetadata = { - set( - userMetadata: Record | undefined, - values: { referenceId: string; subscriptionId: string; plan: string }, - ): Record { - const result = { ...(userMetadata || {}) }; - result.referenceId = values.referenceId; - result.subscriptionId = values.subscriptionId; - result.plan = values.plan; - return result; - }, - - get(metadata: Record | undefined) { - return { - referenceId: metadata?.referenceId, - subscriptionId: metadata?.subscriptionId, - plan: metadata?.plan, - }; - }, -}; diff --git a/packages/better-auth/src/routes.ts b/packages/better-auth/src/routes.ts index c2a314d..d4fd1d2 100644 --- a/packages/better-auth/src/routes.ts +++ b/packages/better-auth/src/routes.ts @@ -12,11 +12,11 @@ import type { ChargebeeOptions, Subscription, SubscriptionOptions, - WebhookEvent, WithChargebeeCustomerId, } from "./types"; import { getPlanByItemPriceId, + getPlans, getReferenceId, getUrl, isActiveOrTrialing, @@ -49,6 +49,9 @@ export function getWebhookEndpoint(options: ChargebeeOptions) { ctx as any, ); + // Let user register custom event listeners on the handler + options.webhookHandler?.(handler); + // Handle the webhook request using the typed handler await handler.handle({ body: ctx.body, @@ -59,26 +62,397 @@ export function getWebhookEndpoint(options: ChargebeeOptions) { response: undefined, // We'll handle the response ourselves }); - // Call user-defined event handler if provided - if (options.onEvent) { + return ctx.json({ received: true }); + }, + ); +} + +/** + * Shared helper to find or create a Chargebee customer for a user or organization. + * Returns the Chargebee customer ID. + */ +async function getOrCreateCustomerId( + ctx: any, + options: ChargebeeOptions, + customerType: "user" | "organization", + referenceId: string, + metadata?: Record, + existingCustomerId?: string | null, +): Promise { + const cb = options.chargebeeClient; + const { user } = ctx.context.session; + + if (existingCustomerId) return existingCustomerId; + + if (customerType === "organization") { + const org = (await ctx.context.adapter.findOne({ + model: "organization", + where: [{ field: "id", value: referenceId }], + })) as (Organization & WithChargebeeCustomerId) | null; + + if (!org) { + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.ORGANIZATION_NOT_FOUND.message, + }); + } + + if (org.chargebeeCustomerId) return org.chargebeeCustomerId; + + try { + let extraCreateParams: Record = {}; + if (options.organization?.getCustomerCreateParams) { + extraCreateParams = await options.organization.getCustomerCreateParams( + org, + ctx, + ); + } + + const customerResult = await cb.customer.create({ + first_name: org.name, + meta_data: { + organizationId: org.id, + customerType: "organization", + ...metadata, + }, + ...extraCreateParams, + }); + + const chargebeeCustomer = customerResult.customer; + + // Re-read org to guard against concurrent requests + const freshOrg = (await ctx.context.adapter.findOne({ + model: "organization", + where: [{ field: "id", value: org.id }], + })) as (Organization & WithChargebeeCustomerId) | null; + + if (freshOrg?.chargebeeCustomerId) { try { - await options.onEvent(ctx.body as WebhookEvent); - } catch (error) { - ctx.context.logger.error("Error in custom onEvent handler:", error); + await cb.customer.delete(chargebeeCustomer.id); + } catch { + ctx.context.logger.warn( + `Failed to clean up duplicate Chargebee customer ${chargebeeCustomer.id}`, + ); } + return freshOrg.chargebeeCustomerId; } - return ctx.json({ received: true }); + await ctx.context.adapter.update({ + model: "organization", + update: { chargebeeCustomerId: chargebeeCustomer.id }, + where: [{ field: "id", value: org.id }], + }); + + await options.organization?.onCustomerCreate?.( + { + chargebeeCustomer, + organization: { + ...org, + chargebeeCustomerId: chargebeeCustomer.id, + }, + }, + ctx, + ); + + return chargebeeCustomer.id; + } catch (e) { + ctx.context.logger.error("Error creating customer", e); + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER.message, + }); + } + } else { + // User customer + if (user.chargebeeCustomerId) return user.chargebeeCustomerId; + + try { + const customerList = await cb.customer.list({ + email: { is: user.email }, + limit: 1, + }); + + let chargebeeCustomer = customerList?.list?.find( + (item) => item.customer.meta_data?.customerType !== "organization", + )?.customer; + + if (!chargebeeCustomer) { + const customerResult = await cb.customer.create({ + email: user.email, + first_name: user.name?.split(" ")[0], + last_name: user.name?.split(" ").slice(1).join(" "), + meta_data: { + userId: user.id, + customerType: "user", + ...metadata, + }, + }); + chargebeeCustomer = customerResult.customer; + } + + // Re-read user to guard against concurrent requests + const freshUser = (await ctx.context.adapter.findOne({ + model: "user", + where: [{ field: "id", value: user.id }], + })) as ({ id: string } & WithChargebeeCustomerId) | null; + + if (freshUser?.chargebeeCustomerId) { + try { + await cb.customer.delete(chargebeeCustomer.id); + } catch { + ctx.context.logger.warn( + `Failed to clean up duplicate Chargebee customer ${chargebeeCustomer.id}`, + ); + } + return freshUser.chargebeeCustomerId; + } + + await ctx.context.adapter.update({ + model: "user", + update: { chargebeeCustomerId: chargebeeCustomer.id }, + where: [{ field: "id", value: user.id }], + }); + + return chargebeeCustomer.id; + } catch (e) { + ctx.context.logger.error("Error creating customer", e); + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER.message, + }); + } + } +} + +/** + * Create a new subscription endpoint. + * Uses Chargebee checkoutNewForItems to initiate a brand-new subscription. + */ +export function createSubscription(options: ChargebeeOptions) { + const cb = options.chargebeeClient; + const subscriptionOptions = options.subscription as SubscriptionOptions; + + return createAuthEndpoint( + "/subscription/create", + { + method: "POST", + body: z.object({ + itemPriceId: z.union([z.string(), z.array(z.string())]), + successUrl: z.string(), + cancelUrl: z.string(), + returnUrl: z.string().optional(), + referenceId: z.string().optional(), + customerType: z.enum(["user", "organization"]).optional(), + seats: z.number().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + disableRedirect: z.boolean().optional(), + trialEnd: z.number().optional(), + }), + metadata: { + openapi: { + operationId: "createSubscription", + }, + }, + use: [ + sessionMiddleware, + referenceMiddleware(subscriptionOptions, "create-subscription"), + originCheck((c) => { + return [c.body.successUrl as string, c.body.cancelUrl as string]; + }), + ], + }, + async (ctx) => { + const { user, session } = ctx.context.session; + const customerType = ctx.body.customerType || "user"; + const referenceId = + ctx.body.referenceId || + getReferenceId(ctx.context.session, customerType, options); + + // Email verification check + if (!user.emailVerified && subscriptionOptions.requireEmailVerification) { + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED.message, + }); + } + + // Normalize itemPriceId to array + const itemPriceIds = Array.isArray(ctx.body.itemPriceId) + ? ctx.body.itemPriceId + : [ctx.body.itemPriceId]; + + if (!itemPriceIds.length) { + throw new APIError("BAD_REQUEST", { + message: "At least one item price ID is required", + }); + } + + const primaryItemPriceId = itemPriceIds[0]; + if (!primaryItemPriceId) { + throw new APIError("BAD_REQUEST", { + message: "Invalid item price ID", + }); + } + + const plan = await getPlanByItemPriceId(options, primaryItemPriceId); + + // Find or create customer + const customerId = await getOrCreateCustomerId( + ctx, + options, + customerType, + referenceId, + ctx.body.metadata, + ); + + // Check if user already has an active subscription + const existingSubscriptions = + await ctx.context.adapter.findMany({ + model: "subscription", + where: [{ field: "referenceId", value: referenceId }], + }); + + const activeOrTrialingSubscription = existingSubscriptions.find((sub) => + isActiveOrTrialing(sub), + ); + + if (activeOrTrialingSubscription) { + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.ALREADY_SUBSCRIBED.message, + }); + } + + // Find or create DB subscription record + const futureSubscription = existingSubscriptions.find( + (sub) => sub.status === "future", + ); + + let subscription: Subscription | undefined = futureSubscription; + + if (futureSubscription) { + const updated = await ctx.context.adapter.update({ + model: "subscription", + update: { + seats: ctx.body.seats || 1, + updatedAt: new Date(), + }, + where: [{ field: "id", value: futureSubscription.id }], + }); + subscription = (updated as Subscription) || futureSubscription; + } else { + subscription = await ctx.context.adapter.create({ + model: "subscription", + data: { + chargebeeCustomerId: customerId, + status: "future", + referenceId, + seats: ctx.body.seats || 1, + }, + }); + } + + if (!subscription) { + ctx.context.logger.error("Subscription ID not found"); + throw new APIError("NOT_FOUND", { + message: CHARGEBEE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND.message, + }); + } + + // Get custom params + const params = ctx.request + ? await subscriptionOptions.getHostedPageParams?.( + { user, session, plan, subscription }, + ctx.request, + ctx, + ) + : undefined; + + // Store pending subscription info in customer metadata + try { + await cb.customer.update(customerId, { + meta_data: { + pendingSubscriptionId: subscription.id, + pendingReferenceId: referenceId, + userId: user.id, + }, + }); + } catch (e) { + ctx.context.logger.warn("Failed to update customer metadata", e); + } + + // Apply trial if configured + let trialEnd = ctx.body.trialEnd; + if (!trialEnd && plan?.freeTrial?.days) { + let applyTrial = true; + + if (subscriptionOptions.preventDuplicateTrials) { + applyTrial = !existingSubscriptions.some( + (sub) => sub.trialStart != null, + ); + + if (!applyTrial) { + ctx.context.logger.info( + "User already had a trial, skipping duplicate trial", + ); + } + } + + if (applyTrial) { + const trialEndDate = new Date(); + trialEndDate.setDate(trialEndDate.getDate() + plan.freeTrial.days); + trialEnd = Math.floor(trialEndDate.getTime() / 1000); + ctx.context.logger.info( + `Applying ${plan.freeTrial.days}-day trial (ends: ${trialEndDate.toISOString()})`, + ); + } + } + + try { + const newSubParams: Record = { + subscription_items: itemPriceIds.map((id: string) => ({ + item_price_id: id, + quantity: ctx.body.seats || 1, + })), + customer: { id: customerId }, + ...(trialEnd && { + subscription: { + trial_end: trialEnd, + }, + }), + redirect_url: getUrl( + ctx, + `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent( + ctx.body.successUrl, + )}&subscriptionId=${encodeURIComponent(subscription.id)}`, + ), + cancel_url: getUrl(ctx, ctx.body.cancelUrl), + ...params, + }; + + const result = await cb.hostedPage.checkoutNewForItems(newSubParams); + + return ctx.json({ + url: result.hosted_page.url || "", + id: result.hosted_page.id || "", + redirect: !ctx.body.disableRedirect, + }); + } catch (e) { + const error = e as { message?: string; api_error_code?: string }; + throw ctx.error("BAD_REQUEST", { + message: error.message || "An error occurred", + code: error.api_error_code, + }); + } }, ); } -export function upgradeSubscription(options: ChargebeeOptions) { +/** + * Update (switch/upgrade) an existing subscription endpoint. + * Uses Chargebee checkoutExistingForItems to modify an active subscription. + */ +export function updateSubscription(options: ChargebeeOptions) { const cb = options.chargebeeClient; const subscriptionOptions = options.subscription as SubscriptionOptions; return createAuthEndpoint( - "/subscription/upgrade", + "/subscription/update", { method: "POST", body: z.object({ @@ -92,11 +466,10 @@ export function upgradeSubscription(options: ChargebeeOptions) { seats: z.number().optional(), metadata: z.record(z.string(), z.unknown()).optional(), disableRedirect: z.boolean().optional(), - trialEnd: z.number().optional(), }), metadata: { openapi: { - operationId: "upgradeSubscription", + operationId: "updateSubscription", }, }, use: [ @@ -132,13 +505,13 @@ export function upgradeSubscription(options: ChargebeeOptions) { }); } - // Get the plan for the first item price ID to check for trial const primaryItemPriceId = itemPriceIds[0]; if (!primaryItemPriceId) { throw new APIError("BAD_REQUEST", { message: "Invalid item price ID", }); } + const plan = await getPlanByItemPriceId(options, primaryItemPriceId); // If subscriptionId is provided, find that specific subscription @@ -170,143 +543,16 @@ export function upgradeSubscription(options: ChargebeeOptions) { }); } - // Determine customer ID - let customerId: string | null | undefined; - - if (customerType === "organization") { - // Organization subscription - customerId = subscriptionToUpdate?.chargebeeCustomerId; - - if (!customerId) { - const org = await ctx.context.adapter.findOne< - Organization & WithChargebeeCustomerId - >({ - model: "organization", - where: [{ field: "id", value: referenceId }], - }); - - if (!org) { - throw new APIError("BAD_REQUEST", { - message: CHARGEBEE_ERROR_CODES.ORGANIZATION_NOT_FOUND.message, - }); - } - - customerId = org.chargebeeCustomerId ?? undefined; - - // Create customer if doesn't exist - if (!customerId) { - try { - // Search for existing customer - using metadata filter - const customerList = await cb.customer.list({ - limit: 1, - }); - - // Filter by organizationId in metadata - let chargebeeCustomer = customerList?.list?.find( - (item) => item.customer.meta_data?.organizationId === org.id, - )?.customer; - - if (!chargebeeCustomer) { - // Get custom params - let extraCreateParams: Record = {}; - if (options.organization?.getCustomerCreateParams) { - extraCreateParams = - await options.organization.getCustomerCreateParams( - org, - ctx, - ); - } - - // Create customer - const customerResult = await cb.customer.create({ - first_name: org.name, - meta_data: { - organizationId: org.id, - customerType: "organization", - ...ctx.body.metadata, - }, - ...extraCreateParams, - }); - - chargebeeCustomer = customerResult.customer; - - // Call onCreate callback - await options.organization?.onCustomerCreate?.( - { - chargebeeCustomer, - organization: { - ...org, - chargebeeCustomerId: chargebeeCustomer.id, - }, - }, - ctx, - ); - } - - // Update org with customer ID - await ctx.context.adapter.update({ - model: "organization", - update: { chargebeeCustomerId: chargebeeCustomer.id }, - where: [{ field: "id", value: org.id }], - }); - - customerId = chargebeeCustomer.id; - } catch (e) { - ctx.context.logger.error("Error creating customer", e); - throw new APIError("BAD_REQUEST", { - message: - CHARGEBEE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER.message, - }); - } - } - } - } else { - // User subscription - customerId = - subscriptionToUpdate?.chargebeeCustomerId || user.chargebeeCustomerId; - - if (!customerId) { - try { - // Search for existing customer by email - const customerList = await cb.customer.list({ - limit: 1, - }); - - let chargebeeCustomer = customerList?.list?.find( - (item) => - item.customer.email === user.email && - item.customer.meta_data?.customerType !== "organization", - )?.customer; - - if (!chargebeeCustomer) { - const customerResult = await cb.customer.create({ - email: user.email, - first_name: user.name, - meta_data: { - userId: user.id, - customerType: "user", - ...ctx.body.metadata, - }, - }); - chargebeeCustomer = customerResult.customer; - } - - // Update user with customer ID - await ctx.context.adapter.update({ - model: "user", - update: { chargebeeCustomerId: chargebeeCustomer.id }, - where: [{ field: "id", value: user.id }], - }); - - customerId = chargebeeCustomer.id; - } catch (e) { - ctx.context.logger.error("Error creating customer", e); - throw new APIError("BAD_REQUEST", { - message: CHARGEBEE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER.message, - }); - } - } - } + // Find or create customer + const customerId = await getOrCreateCustomerId( + ctx, + options, + customerType, + referenceId, + ctx.body.metadata, + subscriptionToUpdate?.chargebeeCustomerId || + (customerType === "user" ? user.chargebeeCustomerId : undefined), + ); // Get subscriptions from DB const subscriptions = subscriptionToUpdate @@ -320,24 +566,23 @@ export function upgradeSubscription(options: ChargebeeOptions) { isActiveOrTrialing(sub), ); - // Get active Chargebee subscriptions + // Get active Chargebee subscriptions for this customer const chargebeeSubsList = await cb.subscription.list({ + customer_id: { is: customerId }, limit: 100, }); - // Filter subscriptions by customer ID and status const activeSubscriptions = chargebeeSubsList?.list ?.filter( (item) => - item.subscription.customer_id === customerId && - (item.subscription.status === "active" || - item.subscription.status === "in_trial"), + item.subscription.status === "active" || + item.subscription.status === "in_trial" || + item.subscription.status === "non_renewing", ) .map((item) => item.subscription) || []; const activeSubscription = activeSubscriptions.find((sub) => { - // Match specific subscription if provided if ( subscriptionToUpdate?.chargebeeSubscriptionId || ctx.body.subscriptionId @@ -347,7 +592,6 @@ export function upgradeSubscription(options: ChargebeeOptions) { sub.id === ctx.body.subscriptionId ); } - // Match by referenceId if (activeOrTrialingSubscription?.chargebeeSubscriptionId) { return ( sub.id === activeOrTrialingSubscription.chargebeeSubscriptionId @@ -356,10 +600,11 @@ export function upgradeSubscription(options: ChargebeeOptions) { return false; }); - // Find future subscription for reuse - const futureSubscription = subscriptions.find( - (sub) => sub.status === "future", - ); + if (!activeSubscription) { + throw new APIError("BAD_REQUEST", { + message: CHARGEBEE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND.message, + }); + } // Check if already subscribed to same item prices const currentItemPriceIds = @@ -388,65 +633,31 @@ export function upgradeSubscription(options: ChargebeeOptions) { }); } - // Handle upgrade of existing subscription - if (activeSubscription && customerId) { - // Find or create DB subscription record - let dbSubscription = await ctx.context.adapter.findOne({ - model: "subscription", - where: [ - { - field: "chargebeeSubscriptionId", - value: activeSubscription.id, - }, - ], - }); - - // Update existing DB record if needed - if (!dbSubscription && activeOrTrialingSubscription) { - await ctx.context.adapter.update({ - model: "subscription", - update: { - chargebeeSubscriptionId: activeSubscription.id, - updatedAt: new Date(), - }, - where: [{ field: "id", value: activeOrTrialingSubscription.id }], - }); - dbSubscription = activeOrTrialingSubscription; - } - - // Continue to hosted page checkout for upgrades - // (removed portal session redirect to use checkoutExistingForItems) - } - - // Create new subscription - let subscription: Subscription | undefined = - activeOrTrialingSubscription || futureSubscription; + // Find or sync DB subscription record + let dbSubscription = await ctx.context.adapter.findOne({ + model: "subscription", + where: [ + { + field: "chargebeeSubscriptionId", + value: activeSubscription.id, + }, + ], + }); - // Update future subscription - if (futureSubscription && !activeOrTrialingSubscription) { - const updated = await ctx.context.adapter.update({ + if (!dbSubscription && activeOrTrialingSubscription) { + await ctx.context.adapter.update({ model: "subscription", update: { - seats: ctx.body.seats || 1, + chargebeeSubscriptionId: activeSubscription.id, updatedAt: new Date(), }, - where: [{ field: "id", value: futureSubscription.id }], + where: [{ field: "id", value: activeOrTrialingSubscription.id }], }); - subscription = (updated as Subscription) || futureSubscription; + dbSubscription = activeOrTrialingSubscription; } - // Create new subscription record - if (!subscription) { - subscription = await ctx.context.adapter.create({ - model: "subscription", - data: { - chargebeeCustomerId: customerId, - status: "future", - referenceId, - seats: ctx.body.seats || 1, - }, - }); - } + const subscription: Subscription = + dbSubscription || activeOrTrialingSubscription || subscriptionToUpdate!; if (!subscription) { ctx.context.logger.error("Subscription ID not found"); @@ -465,7 +676,6 @@ export function upgradeSubscription(options: ChargebeeOptions) { : undefined; // Store pending subscription info in customer metadata - // Hosted pages don't support subscription metadata, so we use customer metadata instead try { await cb.customer.update(customerId, { meta_data: { @@ -478,108 +688,27 @@ export function upgradeSubscription(options: ChargebeeOptions) { ctx.context.logger.warn("Failed to update customer metadata", e); } - // Check if upgrading existing subscription or creating new one - const hasActiveSubscription = activeSubscription?.id; - try { - let result: { hosted_page: { url?: string; id?: string } }; - - if (hasActiveSubscription) { - // Upgrade existing subscription using checkoutExistingForItems - ctx.context.logger.info( - `Upgrading existing subscription ${activeSubscription.id}`, - ); - - const existingSubParams: Record = { - subscription: { - id: activeSubscription.id, - // Note: Trials cannot be set on existing subscriptions during upgrades - }, - subscription_items: itemPriceIds.map((id: string) => ({ - item_price_id: id, - quantity: ctx.body.seats || 1, - })), - redirect_url: getUrl( - ctx, - `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent( - ctx.body.successUrl, - )}&subscriptionId=${encodeURIComponent(subscription.id)}`, - ), - cancel_url: getUrl(ctx, ctx.body.cancelUrl), - ...params, - }; - - result = - await cb.hostedPage.checkoutExistingForItems(existingSubParams); - } else { - // Create new subscription using checkoutNewForItems - ctx.context.logger.info("Creating new subscription via hosted page"); - - // Calculate trial end date from plan configuration - let trialEnd = ctx.body.trialEnd; - if (!trialEnd && plan?.freeTrial?.days) { - // Check if user already had a trial to prevent duplicate trials - if (subscriptionOptions.preventDuplicateTrails) { - const previousSubscriptions = - await ctx.context.adapter.findMany({ - model: "subscription", - where: [{ field: "referenceId", value: referenceId }], - }); - - const hadTrial = previousSubscriptions.some( - (sub) => sub.trialStart != null, - ); - - if (!hadTrial) { - // Calculate trial end: current time + trial days - const trialEndDate = new Date(); - trialEndDate.setDate( - trialEndDate.getDate() + plan.freeTrial.days, - ); - trialEnd = Math.floor(trialEndDate.getTime() / 1000); - ctx.context.logger.info( - `Applying ${plan.freeTrial.days}-day trial (ends: ${trialEndDate.toISOString()})`, - ); - } else { - ctx.context.logger.info( - "User already had a trial, skipping duplicate trial", - ); - } - } else { - // Calculate trial end: current time + trial days - const trialEndDate = new Date(); - trialEndDate.setDate( - trialEndDate.getDate() + plan.freeTrial.days, - ); - trialEnd = Math.floor(trialEndDate.getTime() / 1000); - ctx.context.logger.info( - `Applying ${plan.freeTrial.days}-day trial (ends: ${trialEndDate.toISOString()})`, - ); - } - } + const existingSubParams: Record = { + subscription: { + id: activeSubscription.id, + }, + subscription_items: itemPriceIds.map((id: string) => ({ + item_price_id: id, + quantity: ctx.body.seats || 1, + })), + redirect_url: getUrl( + ctx, + `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent( + ctx.body.successUrl, + )}&subscriptionId=${encodeURIComponent(subscription.id)}`, + ), + cancel_url: getUrl(ctx, ctx.body.cancelUrl), + ...params, + }; - const newSubParams: Record = { - subscription_items: itemPriceIds.map((id: string) => ({ - item_price_id: id, - quantity: ctx.body.seats || 1, - })), - customer: { id: customerId }, - ...(trialEnd && { - subscription: { - trial_end: trialEnd, - }, - }), - redirect_url: getUrl( - ctx, - `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent( - ctx.body.successUrl, - )}&subscriptionId=${encodeURIComponent(subscription.id)}`, - ), - cancel_url: getUrl(ctx, ctx.body.cancelUrl), - ...params, - }; - result = await cb.hostedPage.checkoutNewForItems(newSubParams); - } + const result = + await cb.hostedPage.checkoutExistingForItems(existingSubParams); return ctx.json({ url: result.hosted_page.url || "", @@ -597,6 +726,85 @@ export function upgradeSubscription(options: ChargebeeOptions) { ); } +/** + * List active subscriptions endpoint. + * Returns the active/trialing subscriptions for the current user or organization, + * enriched with plan limits and itemPriceId from subscription items. + */ +export function listActiveSubscriptions(options: ChargebeeOptions) { + const subscriptionOptions = options.subscription as SubscriptionOptions; + + return createAuthEndpoint( + "/subscription/list", + { + method: "GET", + query: z + .object({ + referenceId: z.string().optional(), + customerType: z.enum(["user", "organization"]).optional(), + }) + .optional(), + metadata: { + openapi: { + operationId: "listActiveSubscriptions", + }, + }, + use: [ + sessionMiddleware, + referenceMiddleware(subscriptionOptions, "list-subscription"), + ], + }, + async (ctx) => { + const customerType = ctx.query?.customerType || "user"; + const referenceId = + ctx.query?.referenceId || + getReferenceId(ctx.context.session, customerType, options); + + const subscriptions = await ctx.context.adapter.findMany({ + model: "subscription", + where: [{ field: "referenceId", value: referenceId }], + }); + + if (!subscriptions.length) { + return ctx.json([]); + } + + const plans = await getPlans(options.subscription); + + const activeSubs = subscriptions.filter((sub) => isActiveOrTrialing(sub)); + + const enrichedSubs = await Promise.all( + activeSubs.map(async (sub) => { + // Look up the subscription items to find the primary item price ID + const items = await ctx.context.adapter.findMany<{ + id: string; + subscriptionId: string; + itemPriceId: string; + itemType: string; + }>({ + model: "subscriptionItem", + where: [{ field: "subscriptionId", value: sub.id }], + }); + + const primaryItem = + items.find((i) => i.itemType === "plan") || items[0]; + const plan = primaryItem + ? plans.find((p) => p.itemPriceId === primaryItem.itemPriceId) + : undefined; + + return { + ...sub, + limits: plan?.limits, + itemPriceId: primaryItem?.itemPriceId, + }; + }), + ); + + return ctx.json(enrichedSubs); + }, + ); +} + /** * Callback endpoint after subscription cancellation * Checks if cancellation was successful and updates the database @@ -690,8 +898,7 @@ export function cancelSubscriptionCallback(options: ChargebeeOptions) { ], }); - // Call onSubscriptionCancel callback - await subscriptionOptions.onSubscriptionDeleted?.({ + await subscriptionOptions.onSubscriptionCancel?.({ subscription: { ...subscription, status: chargebeeSub.status, @@ -891,8 +1098,9 @@ export function cancelSubscription(options: ChargebeeOptions) { }); } - // Get active subscriptions from Chargebee + // Get active subscriptions from Chargebee for this customer const chargebeeSubsList = await cb.subscription.list({ + customer_id: { is: subscription.chargebeeCustomerId }, limit: 100, }); @@ -900,10 +1108,9 @@ export function cancelSubscription(options: ChargebeeOptions) { chargebeeSubsList?.list ?.filter( (item) => - item.subscription.customer_id === - subscription.chargebeeCustomerId && - (item.subscription.status === "active" || - item.subscription.status === "in_trial"), + item.subscription.status === "active" || + item.subscription.status === "in_trial" || + item.subscription.status === "non_renewing", ) .map((item) => item.subscription) || []; diff --git a/packages/better-auth/src/types.ts b/packages/better-auth/src/types.ts index 7d7930d..872a446 100644 --- a/packages/better-auth/src/types.ts +++ b/packages/better-auth/src/types.ts @@ -5,6 +5,7 @@ import type { Event as ChargebeeEvent, Subscription as ChargebeeSubscription, Customer, + WebhookHandler, } from "chargebee"; export interface ChargebeePlan { @@ -13,8 +14,6 @@ export interface ChargebeePlan { itemId?: string; itemFamilyId?: string; type: "plan" | "addon" | "charges"; - trialPeriod?: number; - trialPeriodUnit?: "day" | "month"; billingCycles?: number; /** * Free trial configuration @@ -36,6 +35,7 @@ export type SubscriptionStatus = ChargebeeSubscription["status"]; export type CustomerType = "user" | "organization"; export type AuthorizeReferenceAction = + | "create-subscription" | "upgrade-subscription" | "list-subscription" | "cancel-subscription" @@ -77,7 +77,7 @@ export interface OrganizationCustomerCreateParams { export type SubscriptionOptions = { enabled: boolean; plans: ChargebeePlan[] | (() => Promise); - preventDuplicateTrails?: boolean; + preventDuplicateTrials?: boolean; requireEmailVerification?: boolean; // subscription lifecycle @@ -147,7 +147,7 @@ export interface ChargebeeOptions { webhookPassword?: string; createCustomerOnSignUp?: boolean; onCustomerCreate?: (params: CustomerCreateParams) => Promise | void; - onEvent?: (event: WebhookEvent) => Promise | void; + webhookHandler?: (handler: WebhookHandler) => void; subscription?: SubscriptionOptions; organization?: { enabled: boolean; diff --git a/packages/better-auth/src/utils.ts b/packages/better-auth/src/utils.ts index 8e69a33..3a1c153 100644 --- a/packages/better-auth/src/utils.ts +++ b/packages/better-auth/src/utils.ts @@ -53,7 +53,11 @@ export async function getPlanByItemPriceId( * Check if a subscription is active or trialing */ export function isActiveOrTrialing(subscription: Subscription): boolean { - return subscription.status === "active" || subscription.status === "in_trial"; + return ( + subscription.status === "active" || + subscription.status === "in_trial" || + subscription.status === "non_renewing" + ); } /** diff --git a/packages/better-auth/src/version.ts b/packages/better-auth/src/version.ts new file mode 100644 index 0000000..e5821fa --- /dev/null +++ b/packages/better-auth/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "1.0.0-beta.4"; diff --git a/packages/better-auth/test/hooks.test.ts b/packages/better-auth/test/hooks.test.ts index 06b03b3..f1b43bb 100644 --- a/packages/better-auth/test/hooks.test.ts +++ b/packages/better-auth/test/hooks.test.ts @@ -345,6 +345,8 @@ describe("database hooks", () => { "cust_123", { email: "newemail@example.com", + first_name: "Test", + last_name: "User", }, ); }); @@ -407,7 +409,7 @@ describe("database hooks", () => { .mockResolvedValue({ subscription: { id: "sub_cb_123" } }); const initResult = plugin.init(ctx as never); - const hook = initResult.options.databaseHooks?.delete?.before; + const hook = initResult.options.databaseHooks?.user?.delete?.before; const user: User & WithChargebeeCustomerId = { id: "user_123", @@ -488,7 +490,7 @@ describe("database hooks", () => { .mockRejectedValue(new Error("Already cancelled")); const initResult = plugin.init(ctx as never); - const hook = initResult.options.databaseHooks?.delete?.before; + const hook = initResult.options.databaseHooks?.user?.delete?.before; const user: User & WithChargebeeCustomerId = { id: "user_123", @@ -531,7 +533,7 @@ describe("database hooks", () => { mockAdapter.deleteMany = vi.fn().mockResolvedValue(undefined); const initResult = plugin.init(ctx as never); - const hook = initResult.options.databaseHooks?.delete?.before; + const hook = initResult.options.databaseHooks?.user?.delete?.before; const user: User & WithChargebeeCustomerId = { id: "user_123", @@ -565,7 +567,7 @@ describe("database hooks", () => { .mockRejectedValue(new Error("Database error")); const initResult = plugin.init(ctx as never); - const hook = initResult.options.databaseHooks?.delete?.before; + const hook = initResult.options.databaseHooks?.user?.delete?.before; const user: User & WithChargebeeCustomerId = { id: "user_123", diff --git a/packages/better-auth/test/metadata.test.ts b/packages/better-auth/test/metadata.test.ts index 41acf9f..06d5f7e 100644 --- a/packages/better-auth/test/metadata.test.ts +++ b/packages/better-auth/test/metadata.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { customerMetadata, subscriptionMetadata } from "../src/metadata"; +import { customerMetadata } from "../src/metadata"; describe("metadata - customerMetadata", () => { describe("set", () => { @@ -114,90 +114,3 @@ describe("metadata - customerMetadata", () => { }); }); }); - -describe("metadata - subscriptionMetadata", () => { - describe("set", () => { - it("should set subscription values", () => { - const result = subscriptionMetadata.set(undefined, { - referenceId: "user_123", - subscriptionId: "sub_456", - plan: "pro-plan", - }); - - expect(result.referenceId).toBe("user_123"); - expect(result.subscriptionId).toBe("sub_456"); - expect(result.plan).toBe("pro-plan"); - }); - - it("should merge user metadata with subscription values", () => { - const userMetadata = { - customField: "custom_value", - anotherField: "another_value", - }; - - const result = subscriptionMetadata.set(userMetadata, { - referenceId: "user_123", - subscriptionId: "sub_456", - plan: "pro-plan", - }); - - expect(result.referenceId).toBe("user_123"); - expect(result.subscriptionId).toBe("sub_456"); - expect(result.plan).toBe("pro-plan"); - expect(result.customField).toBe("custom_value"); - expect(result.anotherField).toBe("another_value"); - }); - - it("should override subscription fields if present in user metadata", () => { - const userMetadata = { - referenceId: "old_user", - subscriptionId: "old_sub", - plan: "old_plan", - }; - - const result = subscriptionMetadata.set(userMetadata, { - referenceId: "new_user_123", - subscriptionId: "new_sub_456", - plan: "new-pro-plan", - }); - - expect(result.referenceId).toBe("new_user_123"); - expect(result.subscriptionId).toBe("new_sub_456"); - expect(result.plan).toBe("new-pro-plan"); - }); - }); - - describe("get", () => { - it("should extract subscription fields from metadata", () => { - const metadata = { - referenceId: "user_123", - subscriptionId: "sub_456", - plan: "pro-plan", - extra: "ignored", - }; - - const result = subscriptionMetadata.get(metadata); - - expect(result.referenceId).toBe("user_123"); - expect(result.subscriptionId).toBe("sub_456"); - expect(result.plan).toBe("pro-plan"); - expect(result).not.toHaveProperty("extra"); - }); - - it("should handle undefined metadata", () => { - const result = subscriptionMetadata.get(undefined); - - expect(result.referenceId).toBeUndefined(); - expect(result.subscriptionId).toBeUndefined(); - expect(result.plan).toBeUndefined(); - }); - - it("should handle empty metadata object", () => { - const result = subscriptionMetadata.get({}); - - expect(result.referenceId).toBeUndefined(); - expect(result.subscriptionId).toBeUndefined(); - expect(result.plan).toBeUndefined(); - }); - }); -}); diff --git a/packages/better-auth/test/routes.test.ts b/packages/better-auth/test/routes.test.ts index 5822d49..b413a92 100644 --- a/packages/better-auth/test/routes.test.ts +++ b/packages/better-auth/test/routes.test.ts @@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cancelSubscription, cancelSubscriptionCallback, + createSubscription, getWebhookEndpoint, - upgradeSubscription, + listActiveSubscriptions, + updateSubscription, } from "../src/routes"; import type { ChargebeeOptions } from "../src/types"; @@ -33,14 +35,14 @@ describe("routes - getWebhookEndpoint", () => { expect(endpoint.path).toBe("/chargebee/webhook"); }); - it("should support onEvent handler option", () => { - const onEvent = vi.fn(); - const optionsWithEvent: ChargebeeOptions = { + it("should support webhookHandler option", () => { + const webhookHandler = vi.fn(); + const optionsWithHandler: ChargebeeOptions = { ...mockOptions, - onEvent, + webhookHandler, }; - const endpoint = getWebhookEndpoint(optionsWithEvent); + const endpoint = getWebhookEndpoint(optionsWithHandler); expect(endpoint).toBeDefined(); }); @@ -62,7 +64,7 @@ describe("routes - getWebhookEndpoint", () => { }); }); -describe("routes - upgradeSubscription", () => { +describe("routes - createSubscription", () => { const mockChargebee = { __clientIdentifier: vi.fn(), customer: { @@ -94,11 +96,11 @@ describe("routes - upgradeSubscription", () => { vi.clearAllMocks(); }); - it("should create upgrade subscription endpoint", () => { - const endpoint = upgradeSubscription(mockOptions); + it("should create subscription endpoint", () => { + const endpoint = createSubscription(mockOptions); expect(endpoint).toBeDefined(); - expect(endpoint.path).toBe("/subscription/upgrade"); + expect(endpoint.path).toBe("/subscription/create"); }); it("should have email verification enabled option", () => { @@ -111,19 +113,19 @@ describe("routes - upgradeSubscription", () => { }, }; - const endpoint = upgradeSubscription(optionsWithVerification); + const endpoint = createSubscription(optionsWithVerification); expect(endpoint).toBeDefined(); }); it("should accept single itemPriceId", () => { - const endpoint = upgradeSubscription(mockOptions); + const endpoint = createSubscription(mockOptions); expect(endpoint).toBeDefined(); }); it("should accept array of itemPriceIds", () => { - const endpoint = upgradeSubscription(mockOptions); + const endpoint = createSubscription(mockOptions); expect(endpoint).toBeDefined(); }); @@ -136,31 +138,134 @@ describe("routes - upgradeSubscription", () => { }, }; - const endpoint = upgradeSubscription(optionsWithOrg); + const endpoint = createSubscription(optionsWithOrg); expect(endpoint).toBeDefined(); }); it("should support metadata option", () => { - const endpoint = upgradeSubscription(mockOptions); + const endpoint = createSubscription(mockOptions); expect(endpoint).toBeDefined(); }); it("should support seats option", () => { - const endpoint = upgradeSubscription(mockOptions); + const endpoint = createSubscription(mockOptions); expect(endpoint).toBeDefined(); }); it("should support trial end option", () => { - const endpoint = upgradeSubscription(mockOptions); + const endpoint = createSubscription(mockOptions); expect(endpoint).toBeDefined(); }); it("should support disable redirect option", () => { - const endpoint = upgradeSubscription(mockOptions); + const endpoint = createSubscription(mockOptions); + + expect(endpoint).toBeDefined(); + }); +}); + +describe("routes - updateSubscription", () => { + const mockChargebee = { + __clientIdentifier: vi.fn(), + customer: { + list: vi.fn(), + create: vi.fn(), + }, + subscription: { + list: vi.fn(), + update: vi.fn(), + }, + } as unknown as Chargebee; + + const mockPlans = [ + { name: "Basic", itemPriceId: "basic-usd-monthly" }, + { name: "Pro", itemPriceId: "pro-usd-monthly" }, + ]; + + const mockOptions: ChargebeeOptions = { + chargebeeClient: mockChargebee, + subscription: { + enabled: true, + plans: mockPlans, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create update subscription endpoint", () => { + const endpoint = updateSubscription(mockOptions); + + expect(endpoint).toBeDefined(); + expect(endpoint.path).toBe("/subscription/update"); + }); + + it("should support organization subscriptions", () => { + const optionsWithOrg: ChargebeeOptions = { + ...mockOptions, + organization: { + enabled: true, + }, + }; + + const endpoint = updateSubscription(optionsWithOrg); + + expect(endpoint).toBeDefined(); + }); + + it("should support seats option", () => { + const endpoint = updateSubscription(mockOptions); + + expect(endpoint).toBeDefined(); + }); + + it("should support disable redirect option", () => { + const endpoint = updateSubscription(mockOptions); + + expect(endpoint).toBeDefined(); + }); +}); + +describe("routes - listActiveSubscriptions", () => { + const mockChargebee = { + __clientIdentifier: vi.fn(), + } as unknown as Chargebee; + + const mockPlans = [ + { name: "Basic", itemPriceId: "basic-usd-monthly", type: "plan" as const }, + { name: "Pro", itemPriceId: "pro-usd-monthly", type: "plan" as const }, + ]; + + const mockOptions: ChargebeeOptions = { + chargebeeClient: mockChargebee, + subscription: { + enabled: true, + plans: mockPlans, + }, + }; + + it("should create list active subscriptions endpoint", () => { + const endpoint = listActiveSubscriptions(mockOptions); + + expect(endpoint).toBeDefined(); + expect(endpoint.path).toBe("/subscription/list"); + }); + + it("should support async plans function", () => { + const optionsWithAsyncPlans: ChargebeeOptions = { + ...mockOptions, + subscription: { + enabled: true, + plans: async () => mockPlans, + }, + }; + + const endpoint = listActiveSubscriptions(optionsWithAsyncPlans); expect(endpoint).toBeDefined(); }); diff --git a/packages/better-auth/test/utils.test.ts b/packages/better-auth/test/utils.test.ts index 93b9fef..bf1a963 100644 --- a/packages/better-auth/test/utils.test.ts +++ b/packages/better-auth/test/utils.test.ts @@ -126,6 +126,11 @@ describe("utils - isActiveOrTrialing", () => { expect(isActiveOrTrialing(subscription)).toBe(true); }); + it("should return true for non_renewing subscription", () => { + const subscription = { status: "non_renewing" } as Subscription; + expect(isActiveOrTrialing(subscription)).toBe(true); + }); + it("should return false for cancelled subscription", () => { const subscription = { status: "cancelled" } as Subscription; expect(isActiveOrTrialing(subscription)).toBe(false); diff --git a/packages/better-auth/test/webhook.test.ts b/packages/better-auth/test/webhook.test.ts index 1d9c319..f591df5 100644 --- a/packages/better-auth/test/webhook.test.ts +++ b/packages/better-auth/test/webhook.test.ts @@ -1,3 +1,4 @@ +import type { GenericEndpointContext } from "@better-auth/core"; import type Chargebee from "chargebee"; import type { WebhookEvent, WebhookEventType } from "chargebee"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -51,12 +52,19 @@ describe("webhook handler", () => { webhookPassword: "test_pass", }; + const mockEndpointCtx = { + context: { + adapter: mockContext.adapter, + logger: mockContext.logger, + }, + } as GenericEndpointContext; + beforeEach(() => { vi.clearAllMocks(); }); it("should create webhook handler with basic auth", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -67,7 +75,7 @@ describe("webhook handler", () => { webhookPassword: undefined, }; - const _handler = createWebhookHandler(optionsWithoutAuth, mockContext); + const _handler = createWebhookHandler(optionsWithoutAuth, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalledWith({ requestValidator: undefined, }); @@ -124,7 +132,7 @@ describe("webhook handler", () => { // Simulate the handler processing the event // Note: This is a simplified test - in reality, the handler would be called via handle() - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); // Verify handler setup expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); @@ -165,7 +173,7 @@ describe("webhook handler", () => { mockContext.adapter.update = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -203,13 +211,13 @@ describe("webhook handler", () => { mockContext.adapter.deleteMany = vi.fn().mockResolvedValue({}); mockContext.adapter.update = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); it("should log authentication errors", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); // Verify handler was created expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); @@ -223,7 +231,7 @@ describe("webhook handler", () => { }); it("should handle unhandled events gracefully", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); @@ -242,7 +250,7 @@ describe("webhook handler", () => { mockContext.adapter.create = vi.fn().mockResolvedValue({}); mockContext.adapter.update = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -250,7 +258,7 @@ describe("webhook handler", () => { it("should handle missing subscription in webhook", () => { mockContext.adapter.findOne = vi.fn().mockResolvedValue(null); - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -265,7 +273,7 @@ describe("webhook handler", () => { mockContext.adapter.deleteMany = vi.fn().mockResolvedValue({}); mockContext.adapter.create = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -289,7 +297,7 @@ describe("webhook handler", () => { mockContext.adapter.update = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(optionsWithCallback, mockContext); + const _handler = createWebhookHandler(optionsWithCallback, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -305,13 +313,13 @@ describe("webhook handler", () => { mockContext.adapter.findMany = vi.fn().mockResolvedValue([]); mockContext.adapter.update = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(optionsWithOrg, mockContext); + const _handler = createWebhookHandler(optionsWithOrg, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); it("should handle subscription_activated event", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); @@ -321,38 +329,38 @@ describe("webhook handler", () => { }); it("should handle subscription_changed event", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); it("should handle subscription_renewed event", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); it("should handle subscription_started event", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); it("should handle subscription_scheduled_cancellation_removed event", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); it("should handle error event with WebhookAuthenticationError", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); expect(mockContext.logger.warn).toBeDefined(); }); it("should handle error event with generic error", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); expect(mockContext.logger.error).toBeDefined(); @@ -361,7 +369,7 @@ describe("webhook handler", () => { it("should handle subscription without metadata", () => { mockContext.adapter.findOne = vi.fn().mockResolvedValue(null); - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -377,7 +385,7 @@ describe("webhook handler", () => { mockContext.adapter.findMany = vi.fn().mockResolvedValue([]); mockContext.adapter.update = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(optionsWithOrg, mockContext); + const _handler = createWebhookHandler(optionsWithOrg, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); @@ -395,13 +403,13 @@ describe("webhook handler", () => { }); mockContext.adapter.update = vi.fn().mockResolvedValue({}); - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); it("should handle webhook with response undefined", () => { - const _handler = createWebhookHandler(mockOptions, mockContext); + const _handler = createWebhookHandler(mockOptions, mockContext, mockEndpointCtx); expect(mockChargebee.webhooks.createHandler).toHaveBeenCalled(); }); From 16b6a6eea7cd6ec5d6e529a761e2a5e491f6a001 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 17 Mar 2026 11:34:25 +0530 Subject: [PATCH 2/5] Updated the README.md --- packages/better-auth/README.md | 788 ++++++++++++++++----------------- 1 file changed, 390 insertions(+), 398 deletions(-) diff --git a/packages/better-auth/README.md b/packages/better-auth/README.md index 17347b0..527cbd2 100644 --- a/packages/better-auth/README.md +++ b/packages/better-auth/README.md @@ -2,19 +2,21 @@ Chargebee plugin for Better Auth to manage subscriptions and payments. -The Chargebee plugin integrates Chargebee's subscription management and billing functionality with Better Auth. Since payment and authentication are often tightly coupled, this plugin simplifies the integration of Chargebee into your application, handling customer creation, subscription management, and webhook processing. +The Chargebee plugin integrates [Chargebee's](https://www.chargebee.com) subscription management and billing functionality with Better Auth. Since payment and authentication are often tightly coupled, this plugin simplifies the integration of Chargebee into your application, handling customer creation, subscription management, and webhook processing. ## Features - Create Chargebee customers automatically when users sign up - Manage subscription plans and pricing (item-based: plans, addons, charges) -- Process subscription lifecycle events (creation, updates, cancellations). +- Process subscription lifecycle events (creation, updates, cancellations) - Handle Chargebee webhooks securely with Basic Auth verification - Expose subscription data to your application - Support for trial periods and multi-item subscriptions +- Automatic trial abuse prevention - Users can only get one trial per account across all plans - Flexible reference system to associate subscriptions with users or organizations - Team subscription support with seats management - Hosted checkout and portal via Chargebee Hosted Pages +- Self-service billing portal for managing payment methods, invoices, and subscriptions ## Installation @@ -62,8 +64,8 @@ export const auth = betterAuth({ chargebee({ chargebeeClient, createCustomerOnSignUp: true, - webhookPassword: process.env.CHARGEBEE_WEBHOOK_PASSWORD!, - webhookUsername: process.env.CHARGEBEE_WEBHOOK_USERNAME!, + webhookUsername: process.env.CHARGEBEE_WEBHOOK_USERNAME, + webhookPassword: process.env.CHARGEBEE_WEBHOOK_PASSWORD, }) ] }) @@ -92,19 +94,17 @@ Run the migration or generate the schema to add the necessary tables to the data **Option A – migrate:** ```bash -npx @better-auth/cli migrate +npx auth migrate ``` **Option B – generate:** ```bash -npx @better-auth/cli generate +npx auth generate ``` See the [Schema](#schema) section to add the tables manually. -> **Note:** The plugin works with any Better Auth adapter (Prisma, Drizzle, Kysely, etc.). The `npx @better-auth/cli generate` command will create the correct schema for your adapter. Ensure your database column names match the generated schema or follow your adapter's documentation for field name mapping. - ### Step 6: Set up Chargebee webhooks Create a webhook endpoint in your Chargebee dashboard pointing to: @@ -130,59 +130,6 @@ If you set `webhookUsername` and `webhookPassword`, configure the same Basic Aut ## Usage -### Complete Setup Example - -Here's a complete example showing how to set up the plugin with plans fetched from Chargebee: - -```ts -import { betterAuth } from "better-auth" -import { chargebee } from "@chargebee/better-auth" -import Chargebee from "chargebee" - -// Initialize Chargebee client -const chargebeeClient = new Chargebee({ - apiKey: process.env.CHARGEBEE_API_KEY!, - site: process.env.CHARGEBEE_SITE!, -}) - -// Fetch plans from Chargebee -const plansResponse = await chargebeeClient.itemPrice.list({ - item_type: { is: 'plan' }, - status: { is: 'active' }, -}) - -const plans = plansResponse.list.map((item) => ({ - name: item.item_price.name, - itemPriceId: item.item_price.id, - type: 'plan' as const, - limits: { - // Map from Chargebee metadata or hardcode - projects: item.item_price.metadata?.projects || 10, - storage: item.item_price.metadata?.storage || 50, - } -})) - -export const auth = betterAuth({ - database: /* your adapter */, - secret: process.env.BETTER_AUTH_SECRET!, - plugins: [ - chargebee({ - chargebeeClient, - createCustomerOnSignUp: true, - webhookUsername: process.env.CHARGEBEE_WEBHOOK_USERNAME, - webhookPassword: process.env.CHARGEBEE_WEBHOOK_PASSWORD, - subscription: { - enabled: true, - plans, - }, - onCustomerCreate: async ({ chargebeeCustomer, user }) => { - console.log(`Customer ${chargebeeCustomer.id} created for user ${user.id}`) - }, - }) - ] -}) -``` - ### Customer Management You can use this plugin solely for customer management without enabling subscriptions. This is useful if you just want to link Chargebee customers to your users. @@ -204,17 +151,17 @@ chargebee({ #### Defining Plans -Chargebee uses an item-based billing model. You can define your subscription plans in three ways: statically, dynamically from your database, or by fetching directly from the Chargebee API. +Chargebee uses an item-based billing model. You can define your subscription plans either statically or dynamically: -**Option 1: Static plans** +**Static plans:** ```ts subscription: { enabled: true, plans: [ { - name: "starter", - itemPriceId: "starter-USD-Monthly", + name: "starter", // automatically lowercased when stored in the database + itemPriceId: "starter-USD-Monthly", // the item price ID from Chargebee type: "plan", limits: { projects: 5, @@ -237,87 +184,9 @@ subscription: { } ``` -**Option 2: Fetch from Chargebee API (Recommended)** - -Fetch plans directly from Chargebee to keep them in sync with your Chargebee configuration: - -```ts -// Fetch plans directly from Chargebee -const chargebeeClient = new Chargebee() -chargebeeClient.configure({ - site: process.env.CHARGEBEE_SITE!, - api_key: process.env.CHARGEBEE_API_KEY!, -}) - -// Fetch all plans -const plans = await chargebeeClient.itemPrice.list({ - item_type: { is: 'plan' }, -}) - -const confPlans = plans.list.map((plan) => ({ - name: plan.item_price.name, - itemPriceId: plan.item_price.id, - type: 'plan' as const, -})) - -export const auth = betterAuth({ - // ... other config - plugins: [ - chargebee({ - chargebeeClient, - subscription: { - enabled: true, - plans: confPlans, - } - }) - ] -}) -``` - -You can also filter or customize the plans: - -```ts -// Fetch only active plans with a specific status -const plans = await chargebeeClient.itemPrice.list({ - item_type: { is: 'plan' }, - status: { is: 'active' }, -}) - -const confPlans = plans.list.map((plan) => ({ - name: plan.item_price.name, - itemPriceId: plan.item_price.id, - type: 'plan' as const, - // Add custom limits based on plan metadata - limits: { - projects: plan.item_price.metadata?.projects || 10, - storage: plan.item_price.metadata?.storage || 50, - }, - // Add free trial if configured in Chargebee - freeTrial: plan.item_price.trial_period - ? { days: plan.item_price.trial_period } - : undefined, -})) -``` - -You can also fetch addons and charges: - -```ts -// Fetch addons -const addons = await chargebeeClient.itemPrice.list({ - item_type: { is: 'addon' }, -}) - -const confAddons = addons.list.map((addon) => ({ - name: addon.item_price.name, - itemPriceId: addon.item_price.id, - type: 'addon' as const, -})) - -// Combine plans and addons -const allProducts = [...confPlans, ...confAddons] -``` +**Dynamic plans from database (Recommended):** -**Option 3: Dynamic plans from database** +Fetching plans from your own database is the recommended approach. It gives you full control over plan data, lets you enrich plans with custom metadata (limits, features, display info), and avoids hard-coding Chargebee configuration into your auth setup: ```ts subscription: { @@ -328,52 +197,30 @@ subscription: { name: plan.name, itemPriceId: plan.chargebee_item_price_id, type: "plan" as const, - limits: plan.limits + limits: JSON.parse(plan.limits) })); } } ``` -**Which option should you use?** - -| Approach | Best for | Pros | Cons | -|----------|----------|------|------| -| **Static plans** | Small, unchanging catalogs | Simple, fast startup | Requires code changes to update | -| **Chargebee API** | Dynamic catalogs, multiple environments | Always in sync, no code changes needed | Adds startup time, requires API call | -| **Database** | Custom pricing logic, cached plans | Flexible, can cache API results | Requires custom sync logic | - -> **Recommended:** Use the Chargebee API approach (Option 2) for most applications. It ensures your plans are always in sync with your Chargebee configuration without manual updates. - See [Plan configuration](#plan-configuration) for more details on plan options. #### Creating a Subscription -To create a subscription, use the `subscription.upgrade` method: +To create a new subscription, use the `subscription.create` method: -**Endpoint:** `POST /subscription/upgrade` (requires session) from the frontend. +**Endpoint:** `POST /subscription/create` (requires session) ```ts -type upgradeSubscription = { +type createSubscription = { /** * The item price ID(s) from Chargebee. Single string or array for multi-item subscriptions. */ itemPriceId: string | string[] - /** - * The URL to which the user is sent when payment or setup is complete. - */ - successUrl: string - /** - * If set, customers are directed here if they cancel. - */ - cancelUrl: string /** * Reference id of the subscription. Defaults based on customerType. */ referenceId?: string - /** - * The id of the subscription to upgrade. - */ - subscriptionId?: string /** * Additional metadata to store with the subscription. */ @@ -387,9 +234,13 @@ type upgradeSubscription = { */ seats?: number /** - * The URL to return to from the portal (used when upgrading). + * The URL to which the user is sent when payment or setup is complete. */ - returnUrl?: string + successUrl: string + /** + * If set, customers are directed here if they cancel. + */ + cancelUrl: string /** * Disable redirect after successful subscription. */ @@ -404,7 +255,7 @@ type upgradeSubscription = { **Simple example:** ```ts -await authClient.subscription.upgrade({ +await authClient.subscription.create({ itemPriceId: "pro-USD-Monthly", successUrl: "/dashboard", cancelUrl: "/pricing", @@ -415,14 +266,8 @@ await authClient.subscription.upgrade({ This creates a Chargebee Hosted Page and redirects the user to the Chargebee checkout page. -> **Note:** The plugin supports one active or trialing subscription per reference ID (user or organization) at a time. Multiple concurrent subscriptions for the same reference ID are not supported. -> -> If the user already has an active subscription, you **must** provide the `subscriptionId` parameter when upgrading. Otherwise, a new subscription may be created alongside the existing one, resulting in duplicate billing. - -> **Important:** The `successUrl` parameter is internally modified to handle race conditions between checkout completion and webhook processing. The plugin uses an intermediate redirect so subscription status is updated before redirecting to your success page. - ```ts -const { error } = await authClient.subscription.upgrade({ +const { error } = await authClient.subscription.create({ itemPriceId: "pro-USD-Monthly", successUrl: "/dashboard", cancelUrl: "/pricing", @@ -432,165 +277,263 @@ if (error) { } ``` +> **Important:** The `successUrl` parameter is internally modified to handle race conditions between checkout completion and webhook processing. The plugin uses an intermediate redirect so subscription status is updated before redirecting to your success page. + #### Switching Plans -To switch a subscription to a different plan, use `subscription.upgrade` with the current new itemPriceId. +To switch an existing subscription to a different plan, use the `subscription.update` method. This ensures the user only pays for the new plan: + +**Endpoint:** `POST /subscription/update` (requires session) ```ts -await authClient.subscription.upgrade({ - itemPriceId: "enterprise-USD-Monthly", // this has to be a new item price id for upgrade. +type updateSubscription = { + /** + * The item price ID(s) from Chargebee. Single string or array for multi-item subscriptions. + */ + itemPriceId: string | string[] + /** + * Reference id of the subscription. Defaults based on customerType. + */ + referenceId?: string + /** + * The id of the subscription to update. + */ + subscriptionId?: string + /** + * Additional metadata to store with the subscription. + */ + metadata?: Record + /** + * The type of customer for billing. (Default: "user") + */ + customerType?: "user" | "organization" + /** + * Number of seats to update to (if applicable). + */ + seats?: number + /** + * The URL to which the user is sent when payment or setup is complete. + */ + successUrl: string + /** + * If set, customers are directed here if they cancel. + */ + cancelUrl: string + /** + * The URL to return to from the portal. + */ + returnUrl?: string + /** + * Disable redirect after successful update. + */ + disableRedirect?: boolean +} +``` + +```ts +await authClient.subscription.update({ + itemPriceId: "enterprise-USD-Monthly", // new item price id successUrl: "/dashboard", cancelUrl: "/pricing", }); ``` -This ensures the user is charged only for the new plan. +> **Note:** The plugin only supports one active or trialing subscription per reference ID (user or organization) at a time. Use `subscription.update` when the user already has an active subscription and wants to switch plans. Use `subscription.create` when the user has no active subscription. +> +> If the user already has an active subscription, you **must** use `subscription.update`. Attempting to create a new subscription via `subscription.create` will fail with an `ALREADY_SUBSCRIBED` error. -#### Accessing the Customer Portal +#### Listing Active Subscriptions -To give users access to the Chargebee customer portal where they can manage their subscription, payment methods, and view invoices: +To retrieve the active subscriptions for the current user or organization, use the `subscription.list` method: -**Endpoint:** `POST /subscription/portal` (requires session) +**Endpoint:** `GET /subscription/list` (requires session) ```ts -await authClient.subscription.portal({ - returnUrl: `${window.location.origin}/dashboard`, -}) +type listActiveSubscriptions = { + /** + * Reference id of the subscription. Defaults based on customerType. + */ + referenceId?: string + /** + * The type of customer for billing. (Default: "user") + */ + customerType?: "user" | "organization" +} ``` -This creates a Chargebee portal session and redirects the user to the Chargebee customer portal. From there, users can: -- View and manage their subscriptions -- Update payment methods -- View billing history and invoices -- Update billing address - -You can also create a portal session for an organization: - ```ts -await authClient.subscription.portal({ - referenceId: "org_123456", - customerType: "organization", - returnUrl: `${window.location.origin}/organizations/org_123456`, -}) +const { data } = await authClient.subscription.list(); +// data → array of active/trialing subscriptions enriched with plan limits and itemPriceId + +// For an organization: +const { data: orgSubscriptions } = await authClient.subscription.list({ + query: { + referenceId: "org_123", + customerType: "organization" + } +}); ``` #### Canceling a Subscription -To cancel a subscription: +To cancel a subscription, use the `subscription.cancel` method. This redirects the user to the Chargebee Portal where they can cancel their subscription. When a subscription is canceled at the end of the current billing period, Chargebee marks it as `non_renewing`. The status changes to `cancelled` only when the period ends. **Endpoint:** `POST /subscription/cancel` (requires session) +```ts +type cancelSubscription = { + /** + * Reference id of the subscription to cancel. Defaults based on customerType. + */ + referenceId?: string + /** + * The type of customer for billing. (Default: "user") + */ + customerType?: "user" | "organization" + /** + * The id of the subscription to cancel. + */ + subscriptionId?: string + /** + * URL to take customers to when they click the billing portal's link to return to your website. + */ + returnUrl: string +} +``` + ```ts await authClient.subscription.cancel({ returnUrl: `${window.location.origin}/pricing?cancelled=true`, }) ``` -This redirects the user to the Chargebee Portal where they can cancel their subscription. - > **Note:** Chargebee supports different cancellation behaviors; the plugin tracks them: > > | Field | Description | > | ------------ | ----------------------------------------------------------------- | -> | `canceledAt` | When the subscription was canceled. | -> | `status` | Becomes `"cancelled"` when the subscription has ended. | - - - -### Reference System & Organization Subscriptions +> | `canceledAt` | The time when the subscription was canceled. | +> | `status` | Changes to `"cancelled"` when the subscription has ended. | -By default, subscriptions are tied to the user ID. You can use a custom reference ID to tie them to other entities (e.g. organizations). +#### Billing Portal Session -### Setup +For a complete self-service billing experience, you can open the Chargebee customer portal where users can manage all aspects of their billing: -### 1. Enable Organization Plugin & Configure Authorization +**Endpoint:** `POST /subscription/portal` (requires session) ```ts -// src/lib/auth.ts -import { organization } from "better-auth/plugins"; -import { chargebee } from "@chargebee/better-auth"; +type createPortalSession = { + /** + * Reference id of the customer. Defaults based on customerType. + */ + referenceId?: string + /** + * The type of customer for billing. (Default: "user") + */ + customerType?: "user" | "organization" + /** + * URL to redirect customers to after they complete their portal session. + */ + returnUrl: string + /** + * Disable redirect after opening portal. + */ + disableRedirect?: boolean +} +``` -export const auth = betterAuth({ - plugins: [ - organization(), // Required for organization subscriptions - chargebee({ - chargebeeClient, - subscription: { - enabled: true, - plans, - // Must be inside subscription config - authorizeReference: async ({ referenceId, user }) => { - const membership = await prisma.organizationMember.findFirst({ - where: { - organizationId: referenceId, - userId: user.id, - role: { in: ["owner", "admin"] } - } - }); - return !!membership; +```ts +await authClient.subscription.portal({ + returnUrl: "/account/billing", + fetchOptions: { + onSuccess: (ctx) => { + window.location.href = ctx.data.url; } - } - }) - ] + } }); ``` -### Usage - -### Personal Subscription (Default) +For organization billing: ```ts -await authClient.subscription.upgrade({ - itemPriceId: "personal-plan-USD-Monthly", - successUrl: "/dashboard", - cancelUrl: "/pricing" +await authClient.subscription.portal({ + referenceId: "org_123456", + customerType: "organization", + returnUrl: "/org/billing" }); ``` -### Organization Subscription +The portal allows users to: +- Update payment methods (credit cards, bank accounts) +- View and download invoices +- Manage subscriptions (upgrade, downgrade, cancel) +- Update billing address and contact information +- View subscription history +- Apply promotional codes + +> **Note:** The portal session provides a complete self-service experience and is recommended over individual operations like cancellation when you want to give users full control over their billing. + +### Reference System + +By default, subscriptions are associated with the user ID. However, you can use a custom reference ID to associate subscriptions with other entities, such as organizations: ```ts // Create a subscription for an organization -await authClient.subscription.upgrade({ - itemPriceId: "team-plan-USD-Monthly", +await authClient.subscription.create({ + itemPriceId: "team-USD-Monthly", referenceId: "org_123456", customerType: "organization", - seats: 10, - successUrl: "/organizations/org_123456", - cancelUrl: "/organizations/org_123456/pricing" + successUrl: "/dashboard", + cancelUrl: "/pricing", + seats: 10 // Number of seats for team plans +}); + +// List subscriptions for an organization +const { data: subscriptions } = await authClient.subscription.list({ + query: { + referenceId: "org_123456", + customerType: "organization" + } }); ``` -## Team Subscriptions with Seats +#### Team Subscriptions with Seats -For team or organization plans, you can set the number of seats: +For team or organization plans, you can specify the number of seats: ```ts -await authClient.subscription.upgrade({ +await authClient.subscription.create({ itemPriceId: "team-USD-Monthly", referenceId: "org_123456", customerType: "organization", - seats: 10, + seats: 10, // 10 team members successUrl: "/org/billing/success", cancelUrl: "/org/billing" }); ``` -The `seats` value is sent to Chargebee as the quantity for the subscription item. Use it in your app to limit team or organization size. +The `seats` parameter is passed to Chargebee as the quantity for the subscription item. You can use this value in your application logic to limit the number of members in a team or organization. -### Enforce Seat Limits +To authorize reference IDs, implement the `authorizeReference` function: ```ts -const seatLimit = subscription?.seats || 0; -const seatsUsed = memberCount; -const seatsAvailable = seatLimit - seatsUsed; - -if (seatsAvailable <= 0) { - alert("No available seats. Please upgrade your plan."); +subscription: { + // ... other options + authorizeReference: async ({ user, session, referenceId, action }) => { + if (action === "create-subscription" || action === "update-subscription" || action === "cancel-subscription" || action === "billing-portal") { + const org = await db.member.findFirst({ + where: { + organizationId: referenceId, + userId: user.id + } + }); + return org?.role === "owner" + } + return true; + } } ``` -## Cancel Organization Subscription +#### Cancel Organization Subscription ```ts await authClient.subscription.cancel({ @@ -600,71 +543,67 @@ await authClient.subscription.cancel({ }); ``` -## Key Points - -- `authorizeReference` must be **inside** `subscription` config -- Enable `organization()` plugin before `chargebee()` plugin -- Only users with "owner" or "admin" roles can manage organization subscriptions -- `seats` value controls team size and billing quantity - - ### Webhook Handling -The plugin handles these webhook events: +The plugin automatically processes common webhook events from Chargebee: -- `subscription_created`: Creates a subscription when created -- `subscription_activated`: Updates subscription when activated -- `subscription_changed`: Updates subscription when changed -- `subscription_renewed`: Updates on renewal -- `subscription_started`: Updates when trial ends and subscription starts -- `subscription_cancelled`: Marks subscription as canceled -- `subscription_cancellation_scheduled`: Updates with scheduled cancellation -- `customer_deleted`: Cleans up customer and related subscriptions +- **`subscription_created`** – Creates a subscription when it is created in Chargebee. +- **`subscription_activated`** – Updates the subscription when it becomes active. +- **`subscription_changed`** – Updates the subscription when changes are made. +- **`subscription_renewed`** – Updates the subscription upon renewal. +- **`subscription_started`** – Updates the subscription when the trial ends and the subscription starts. +- **`subscription_cancelled`** – Marks the subscription as canceled. +- **`subscription_cancellation_scheduled`** – Updates the subscription with the scheduled cancellation details. +- **`customer_deleted`** – Removes the customer and any associated subscriptions. -You can also handle custom events: +You can also handle custom events using `webhookHandler`, which gives you direct access to the typed handler instance: ```ts -import Chargebee, { WebhookEventType } from "chargebee"; // ensure you import WebhookEventType from chargebee +import { WebhookEventType, WebhookHandler } from "chargebee" + chargebee({ - // ... other options - onEvent: async (event) => { - switch (event.event_type) { - case WebhookEventType.PaymentFailed: - // Handle successful payment - break; - case WebhookEventType.InvoiceGenerated: - // Handle generated invoice - break; - } + chargebeeClient, + createCustomerOnSignUp: true, + webhookHandler: (handler: WebhookHandler) => { + handler.on(WebhookEventType.PaymentFailed, async ({ event }) => { + // Handle failed payment + }); + handler.on(WebhookEventType.InvoiceGenerated, async ({ event }) => { + // Handle generated invoice + }); } }) ``` ### Subscription Lifecycle Hooks -You can hook into subscription lifecycle events: +You can hook into various subscription lifecycle events: ```ts subscription: { // ... other options - onSubscriptionComplete: async ({ subscription, chargebeeSubscription }) => { - // When a subscription is completed via hosted page - await sendWelcomeEmail(subscription.referenceId); + onSubscriptionComplete: async ({ subscription, chargebeeSubscription, plan }) => { + // Called when a subscription is successfully created via hosted page + await sendWelcomeEmail(subscription.referenceId, plan.name); }, - onSubscriptionCreated: async ({ subscription, chargebeeSubscription }) => { - // When a subscription is created - await sendSubscriptionCreatedEmail(subscription.referenceId); + onSubscriptionCreated: async ({ subscription, chargebeeSubscription, plan }) => { + // Called when a subscription is created + await sendSubscriptionCreatedEmail(subscription.referenceId, plan.name); }, onSubscriptionUpdate: async ({ subscription }) => { + // Called when a subscription is updated console.log(`Subscription ${subscription.id} updated`); }, onSubscriptionDeleted: async ({ subscription, chargebeeSubscription }) => { + // Called when a subscription is deleted await sendCancellationEmail(subscription.referenceId); }, onTrialStart: async ({ subscription }) => { + // Called when a trial starts await sendTrialStartEmail(subscription.referenceId); }, onTrialEnd: async ({ subscription }) => { + // Called when a trial ends await sendTrialEndEmail(subscription.referenceId); } } @@ -689,11 +628,11 @@ Configure trial periods on your plans: } ``` -When a user subscribes to this plan, **the trial is automatically applied** - no need to pass `trialEnd` manually: +When a user subscribes to this plan, **the trial is automatically applied** — no need to pass `trialEnd` manually: ```ts // Trial is automatically calculated and applied based on plan config -await authClient.subscription.upgrade({ +await authClient.subscription.create({ itemPriceId: "pro-USD-Monthly", // Plan with 14-day trial successUrl: "/dashboard", cancelUrl: "/pricing", @@ -703,24 +642,24 @@ await authClient.subscription.upgrade({ The plugin calculates the trial end date as: **current date + trial days**. -### Prevent Duplicate Trials +#### Prevent Duplicate Trials -To prevent users from getting multiple trials, enable `preventDuplicateTrails`: +To prevent users from getting multiple trials, enable `preventDuplicateTrials`: ```ts subscription: { enabled: true, plans, - preventDuplicateTrails: true, // Users can only get one trial + preventDuplicateTrials: true, // Users can only get one trial } ``` -### Override Trial End Date (Optional) +#### Override Trial End Date (Optional) To set a custom trial end date, pass `trialEnd` (Unix timestamp): ```ts -await authClient.subscription.upgrade({ +await authClient.subscription.create({ itemPriceId: "pro-USD-Monthly", successUrl: "/dashboard", cancelUrl: "/pricing", @@ -728,7 +667,7 @@ await authClient.subscription.upgrade({ }); ``` -**Note:** Trials only work for **new subscriptions**. Upgrades to existing subscriptions cannot have trials (Chargebee limitation). +> **Note:** Trials only work for **new subscriptions**. Updates to existing subscriptions cannot have trials (Chargebee limitation). ## Schema @@ -740,7 +679,7 @@ The Chargebee plugin adds the following tables to your database. | Field | Type | Description | Optional | | --------------------- | -------- | -------------------------- | -------- | -| `chargebeeCustomerId` | `string` | The Chargebee customer ID | Yes | +| `chargebeeCustomerId` | `string` | The Chargebee customer ID | Yes | ### Organization @@ -754,21 +693,20 @@ The Chargebee plugin adds the following tables to your database. **Table name:** `subscription` -| Field | Type | Description | Optional | Default | -| ------------------------ | -------- | --------------------------------------------------------------------------- | -------- | --------- | -| `id` | `string` | Unique identifier for each subscription | No | - | -| `plan` | `string` | Plan name (if single plan) or derived from items | Yes | - | -| `referenceId` | `string` | ID this subscription is associated with (user ID by default). Not unique. | No | - | -| `chargebeeCustomerId` | `string` | The Chargebee customer ID | Yes | - | -| `chargebeeSubscriptionId`| `string` | The Chargebee subscription ID | Yes | - | -| `status` | `string` | Subscription status (future, in_trial, active, non_renewing, paused, cancelled, transferred) | Yes | "future" | -| `periodStart` | `Date` | Start of the current billing period | Yes | - | -| `periodEnd` | `Date` | End of the current billing period | Yes | - | -| `trialStart` | `Date` | Trial start | Yes | - | -| `trialEnd` | `Date` | Trial end | Yes | - | -| `canceledAt` | `Date` | When the subscription was canceled | Yes | - | -| `seats` | `number` | Number of seats for team plans | Yes | - | -| `metadata` | `string` | JSON string of additional metadata | Yes | - | +| Field | Type | Description | Optional | Default | +| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- | --------- | +| `id` | `string` | Unique identifier for each subscription | No | - | +| `referenceId` | `string` | The ID this subscription is associated with (user ID by default). Should NOT be unique — allows users to resubscribe after cancellation. | No | - | +| `chargebeeCustomerId` | `string` | The Chargebee customer ID | Yes | - | +| `chargebeeSubscriptionId` | `string` | The Chargebee subscription ID | Yes | - | +| `status` | `string` | Subscription status (future, in_trial, active, non_renewing, paused, cancelled, transferred) | Yes | "future" | +| `periodStart` | `Date` | Start date of the current billing period | Yes | - | +| `periodEnd` | `Date` | End date of the current billing period | Yes | - | +| `trialStart` | `Date` | Start date of the trial period | Yes | - | +| `trialEnd` | `Date` | End date of the trial period | Yes | - | +| `canceledAt` | `Date` | If the subscription has been canceled, this is the time when it was canceled | Yes | - | +| `seats` | `number` | Number of seats for team plans | Yes | - | +| `metadata` | `string` | JSON string of additional metadata | Yes | - | ### Subscription Item @@ -786,82 +724,96 @@ The Chargebee plugin adds the following tables to your database. ### Customizing the Schema -To change table or field names, pass a `schema` option to the Chargebee plugin (if supported by the plugin API). Otherwise, rely on the default schema and use your adapter’s mapping. +To change the schema table names or fields, pass a `schema` option to the Chargebee plugin: + +```ts +chargebee({ + // ... other options + schema: { + subscription: { + modelName: "chargebeeSubscriptions", // map the subscription table to chargebeeSubscriptions + fields: { + referenceId: "userId" // map the referenceId field to userId + } + } + } +}) +``` ## Options -| Option | Type | Description | -| ------------------------ | ---------- | --------------------------------------------------------------------------- | -| `chargebeeClient` | `Chargebee`| The Chargebee client instance. **Required.** | -| `webhookUsername` | `string` | Username for Basic Auth on the webhook endpoint. Recommended in production. | -| `webhookPassword` | `string` | Password for Basic Auth on the webhook endpoint. Recommended in production. | -| `createCustomerOnSignUp` | `boolean` | Create a Chargebee customer when a user signs up. Default: `false`. | -| `onCustomerCreate` | `function` | Callback after a customer is created. Receives `{ chargebeeCustomer, user }`. | -| `onEvent` | `function` | Callback for any Chargebee webhook event. Receives the event object. | -| `configureWebhookHandler`| `function` | Configure custom webhook handlers with `.on()` method for specific events. | -| `subscription` | `object` | Subscription configuration. See [Subscription options](#subscription-options). | -| `organization` | `object` | Organization customer support. See [Organization options](#organization-options). | +| Option | Type | Description | +| ------------------------ | ---------- | --------------------------------------------------------------------------------------------- | +| `chargebeeClient` | `Chargebee`| The Chargebee client instance. **Required.** | +| `webhookUsername` | `string` | Username for Basic Auth on the webhook endpoint. Recommended in production. | +| `webhookPassword` | `string` | Password for Basic Auth on the webhook endpoint. Recommended in production. | +| `createCustomerOnSignUp` | `boolean` | Whether to automatically create a Chargebee customer when a user signs up. Default: `false`. | +| `onCustomerCreate` | `function` | Callback called after a customer is created. Receives `{ chargebeeCustomer, user }`. | +| `webhookHandler` | `function` | Callback receiving the webhook handler instance. Call `handler.on(EventType, fn)` to register typed event listeners. | +| `subscription` | `object` | Subscription configuration. See [Subscription options](#subscription-options). | +| `organization` | `object` | Enable Organization Customer support. See [Organization options](#organization-options). | ### Subscription Options -| Option | Type | Description | -| ------------------------- | ---------------------------- | --------------------------------------------------------------------------- | -| `enabled` | `boolean` | Enable subscription functionality. **Required.** | -| `plans` | `ChargebeePlan[]` or `function` | Array of plans or async function returning plans. **Required** if enabled. | -| `requireEmailVerification`| `boolean` | Require verified email before upgrade. Default: `false`. | -| `authorizeReference` | `function` | Authorize reference IDs. Receives `{ user, session, referenceId, action }`. | -| `getHostedPageParams` | `function` | Customize Hosted Page params. Receives `{ user, session, plan, subscription }`, request, context. | -| `onSubscriptionComplete` | `function` | When subscription is completed via hosted page. Receives `{ subscription, chargebeeSubscription }`. | -| `onSubscriptionCreated` | `function` | When subscription is created. Receives `{ subscription, chargebeeSubscription }`. | -| `onSubscriptionUpdate` | `function` | When subscription is updated. Receives `{ subscription }`. | -| `onSubscriptionDeleted` | `function` | When subscription is deleted. Receives `{ subscription, chargebeeSubscription }`. | -| `onTrialStart` | `function` | When a trial starts. Receives `{ subscription }`. | -| `onTrialEnd` | `function` | When a trial ends. Receives `{ subscription }`. | +| Option | Type | Description | +| ------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `boolean` | Whether to enable subscription functionality. **Required.** | +| `plans` | `ChargebeePlan[]` or `function` | An array of subscription plans or an async function that returns plans. **Required** if enabled. | +| `requireEmailVerification`| `boolean` | Whether to require email verification before allowing subscription creation. Default: `false`. | +| `preventDuplicateTrials` | `boolean` | Prevent users from getting multiple trials. Default: `false`. | +| `authorizeReference` | `function` | Authorize reference IDs. Receives `{ user, session, referenceId, action }` and context. | +| `getHostedPageParams` | `function` | Customize Chargebee Hosted Page parameters. Receives `{ user, session, plan, subscription }`, request, and context. | +| `onSubscriptionComplete` | `function` | Called when a subscription is created via hosted page. Receives `{ subscription, chargebeeSubscription, plan }`. | +| `onSubscriptionCreated` | `function` | Called when a subscription is created. Receives `{ subscription, chargebeeSubscription, plan }`. | +| `onSubscriptionUpdate` | `function` | Called when a subscription is updated. Receives `{ subscription }`. | +| `onSubscriptionDeleted` | `function` | Called when a subscription is deleted. Receives `{ subscription, chargebeeSubscription }`. | +| `onTrialStart` | `function` | Called when a trial starts. Receives `{ subscription }`. | +| `onTrialEnd` | `function` | Called when a trial ends. Receives `{ subscription }`. | #### Plan configuration -| Option | Type | Description | -| --------------- | -------- | ----------------------------------------------------- | -| `name` | `string` | Plan name. **Required.** | -| `itemPriceId` | `string` | Chargebee item price ID. **Required.** | -| `itemId` | `string` | Chargebee item ID. Optional. | -| `itemFamilyId` | `string` | Chargebee item family ID. Optional. | -| `type` | `string` | `"plan"` \| `"addon"` \| `"charges"`. **Required.** | -| `limits` | `object` | Limits (e.g. `{ projects: 10, storage: 5 }`). | -| `freeTrial` | `object` | Free trial config: `{ days: number }`. | -| `trialPeriod` | `number` | Trial period length. Optional. | -| `trialPeriodUnit` | `string` | `"day"` \| `"month"`. Optional. | -| `billingCycles` | `number` | Number of billing cycles. Optional. | +| Option | Type | Description | +| ----------------- | -------- | ----------------------------------------------------- | +| `name` | `string` | Plan name. **Required.** | +| `itemPriceId` | `string` | Chargebee item price ID. **Required.** | +| `itemId` | `string` | Chargebee item ID. Optional. | +| `itemFamilyId` | `string` | Chargebee item family ID. Optional. | +| `type` | `string` | `"plan"` \| `"addon"` \| `"charges"`. **Required.** | +| `limits` | `object` | Limits (e.g. `{ projects: 10, storage: 5 }`). | +| `freeTrial` | `object` | Free trial config. See [below](#free-trial-configuration). | +| `trialPeriod` | `number` | Trial period length. Optional. | +| `trialPeriodUnit` | `string` | `"day"` \| `"month"`. Optional. | +| `billingCycles` | `number` | Number of billing cycles. Optional. | #### Free trial configuration -| Option | Type | Description | -| -------- | ---------- | ---------------------------------------------- | -| `days` | `number` | Number of trial days. **Required.** | +| Option | Type | Description | +| ------ | -------- | ----------------------------------- | +| `days` | `number` | Number of trial days. **Required.** | ### Organization Options -| Option | Type | Description | -| ------------------------- | ---------- | --------------------------------------------------------------------------- | -| `enabled` | `boolean` | Enable organization as customer. **Required.** | -| `getCustomerCreateParams` | `function` | Customize customer creation for organizations. Receives `organization`, context. | -| `onCustomerCreate` | `function` | After organization customer is created. Receives `{ chargebeeCustomer, organization }`, context. | +| Option | Type | Description | +| ------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | `boolean` | Enable Organization Customer support. **Required.** | +| `getCustomerCreateParams` | `function` | Customize Chargebee customer creation parameters for organizations. Receives `organization` and context. | +| `onCustomerCreate` | `function` | Called after an organization customer is created. Receives `{ chargebeeCustomer, organization }` and context. | ## Advanced Usage ### Using with Organizations -The Chargebee plugin works with the [organization plugin](https://www.better-auth.com/docs/plugins/organization) so organizations can be the billing entity. Subscriptions are then tied to the organization instead of individual users. +The Chargebee plugin integrates with the [organization plugin](https://www.better-auth.com/docs/plugins/organization) to enable organizations as Chargebee Customers. Instead of individual users, organizations become the billing entity for subscriptions. This is useful for B2B services where billing is tied to the organization rather than individual users. > **When Organization Customer is enabled:** > -> - A Chargebee customer is created when an organization first subscribes -> - Organization name changes can be synced to the Chargebee customer -> - Organizations with active subscriptions typically cannot be deleted (enforce in your app if needed) +> - A Chargebee Customer is automatically created when an organization first subscribes +> - Organization name changes are synced to the Chargebee Customer +> - Organizations with active subscriptions cannot be deleted #### Enabling Organization Customer -Set `organization.enabled` to `true` and ensure the organization plugin is installed: +To enable Organization Customer, set `organization.enabled` to `true` and ensure the organization plugin is installed: ```ts plugins: [ @@ -881,10 +833,10 @@ plugins: [ #### Creating Organization Subscriptions -With Organization Customer enabled, pass `customerType: "organization"` and the organization ID as `referenceId`: +Even with Organization Customer enabled, user subscriptions remain available and are the default. To use the organization as the billing entity, pass `customerType: "organization"`: ```ts -await authClient.subscription.upgrade({ +await authClient.subscription.create({ itemPriceId: "team-USD-Monthly", referenceId: activeOrg.id, customerType: "organization", @@ -896,26 +848,37 @@ await authClient.subscription.upgrade({ #### Authorization -Implement `authorizeReference` so only allowed users can manage organization subscriptions: +Implement `authorizeReference` to verify that the user has permission to manage subscriptions for the organization: ```ts subscription: { - // ... other options + // ... other subscription options authorizeReference: async ({ user, referenceId, action }) => { - const member = await db.member.findFirst({ + const member = await db.members.findFirst({ where: { userId: user.id, organizationId: referenceId } }); + return member?.role === "owner" || member?.role === "admin"; } } ``` +#### Organization Billing Email + +Unlike users, organization billing email is not automatically synced because organizations don't have a unique email. Organizations often use a dedicated billing email separate from user accounts. To change the billing email after checkout, update it through the Chargebee Dashboard or implement custom logic using `chargebeeClient`: + +```ts +await chargebeeClient.customer.update(organization.chargebeeCustomerId, { + email: "billing@company.com" +}); +``` + ### Custom Hosted Page Parameters -You can customize the Chargebee Hosted Page: +You can customize the Chargebee Hosted Page with additional parameters: ```ts getHostedPageParams: async ({ user, session, plan, subscription }, request, ctx) => { @@ -932,9 +895,27 @@ getHostedPageParams: async ({ user, session, plan, subscription }, request, ctx) } ``` +### Trial Period Management + +The Chargebee plugin automatically prevents users from getting multiple free trials. Once a user has used a trial period (regardless of which plan), they will not be eligible for additional trials on any plan. + +**How it works:** +- The system tracks trial usage across all plans for each user +- When a user subscribes to a plan with a trial, the system checks their subscription history +- If the user has ever had a trial (indicated by `trialStart`/`trialEnd` fields or `in_trial` status), no new trial will be offered +- This prevents abuse where users cancel subscriptions and resubscribe to get multiple free trials + +**Example scenario:** +1. User subscribes to "Starter" plan with 7-day trial +2. User cancels the subscription after the trial +3. User tries to subscribe to "Premium" plan — no trial will be offered +4. User will be charged immediately for the Premium plan + +This behavior is automatic and requires no additional configuration when `preventDuplicateTrials` is enabled. + ## Error Handling -The plugin can expose typed error codes for handling failures: +The plugin exposes typed error codes for handling failures: ```ts import { CHARGEBEE_ERROR_CODES } from "@better-auth/chargebee"; @@ -961,32 +942,43 @@ If you see errors like `no such column: "chargebee_customer_id"` or `no such col **Solution:** -1. Run `npx @better-auth/cli generate` to regenerate your schema with the Chargebee plugin fields +1. Run `npx better-auth generate` to regenerate your schema with the Chargebee plugin fields 2. Apply the migration to your database 3. If manually migrating from another adapter, ensure your column names match your database adapter's conventions 4. Refer to the [Better Auth adapter documentation](https://www.better-auth.com/docs/concepts/database) for field name mapping specific to your adapter (Prisma, Drizzle, Kysely, etc.) -### Webhook issues +### Webhook Issues -If webhooks are not processed correctly: +If webhooks aren't being processed correctly: -1. Confirm the webhook URL in the Chargebee dashboard matches your auth base path (e.g. `https://your-domain.com/api/auth/chargebee/webhook`). -2. Ensure `webhookUsername` and `webhookPassword` match the Basic Auth settings in Chargebee. -3. Confirm all required events are selected in Chargebee. -4. Check server logs for errors during webhook handling and for 401s if Basic Auth is used. +1. Check that your webhook URL is correctly configured in the Chargebee dashboard (e.g. `https://your-domain.com/api/auth/chargebee/webhook`) +2. Verify that the Basic Auth credentials (`webhookUsername` and `webhookPassword`) are correct +3. Ensure you've selected all the necessary events in the Chargebee dashboard +4. Check your server logs for any errors during webhook processing -### Subscription status issues +### Subscription Status Issues -If subscription status does not update: +If subscription statuses aren't updating correctly: -1. Verify webhook events are received and processed (logs). -2. Check that `chargebeeCustomerId` and `chargebeeSubscriptionId` are set on the subscription record. -3. Ensure `referenceId` in your DB matches what you use in the app and in Chargebee. -4. Confirm your database schema matches the plugin’s expected tables and columns. +1. Make sure the webhook events are being received and processed +2. Check that the `chargebeeCustomerId` and `chargebeeSubscriptionId` fields are correctly populated +3. Verify that the reference IDs match between your application and Chargebee -### Testing webhooks locally +### Testing Webhooks Locally + +For local development, you can use a tunnel (e.g. ngrok) to forward webhooks to your local environment: + +```bash +ngrok http 3000 +``` + +Then configure your Chargebee webhook to point to: + +``` +https://your-ngrok-url/api/auth/chargebee/webhook +``` -Use a tunnel (e.g. ngrok) to expose your local server and register a webhook in Chargebee pointing to `https://your-ngrok-url/api/auth/chargebee/webhook`. Use the same Basic Auth credentials in Chargebee and in your local env. Prefer a non-primary webhook for local testing. +Make sure to use the same Basic Auth credentials in Chargebee and in your local environment variables. ## Resources From 441291cb45f83f43590fc677bcedb919d7bcd144 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Wed, 18 Mar 2026 10:54:15 +0530 Subject: [PATCH 3/5] feat!: add getCustomerCreateParams and remove implicit name splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better Auth uses a single user.name field, so automatically splitting it into first_name/last_name when creating or updating a Chargebee customer was the wrong default. This removes that behaviour and introduces getCustomerCreateParams — a typed callback on ChargebeeOptions that lets callers explicitly pass first_name, last_name, or any other Customer.CreateInputParam fields when creating user customers. The callback is supported in both createCustomerOnSignUp (sign-up hook) and on-demand customer creation (subscription routes). For organisations, the existing organization.getCustomerCreateParams already handles this. The user update hook now only syncs email to Chargebee. Made-with: Cursor --- packages/better-auth/CHANGELOG.md | 6 +++- packages/better-auth/README.md | 22 +++++++++++++ packages/better-auth/src/index.ts | 16 ++++++--- packages/better-auth/src/routes.ts | 11 ++++--- packages/better-auth/src/types.ts | 12 +++++++ packages/better-auth/test/hooks.test.ts | 43 +++++++++++++------------ 6 files changed, 79 insertions(+), 31 deletions(-) diff --git a/packages/better-auth/CHANGELOG.md b/packages/better-auth/CHANGELOG.md index 89e1de3..c181d33 100644 --- a/packages/better-auth/CHANGELOG.md +++ b/packages/better-auth/CHANGELOG.md @@ -2,6 +2,7 @@ * * * ### Feature: +- Added `getCustomerCreateParams` option to `ChargebeeOptions`, allowing you to return additional Chargebee customer creation params (e.g. `first_name`, `last_name`, phone) for user customers. The callback receives the `user` object and an optional request `ctx` (available when the customer is created on-demand, not during sign-up). - Added `createSubscription` route to initiate a new Chargebee hosted checkout session. - Added `updateSubscription` route to update an existing subscription via hosted page. - Added `listActiveSubscriptions` route (`GET /subscription/list`) to retrieve the caller's active/trialing subscriptions enriched with plan `limits` and `itemPriceId`. @@ -14,10 +15,13 @@ ### Improvement: - Added a startup warning when `webhookUsername` / `webhookPassword` are not configured, alerting that the webhook endpoint is unauthenticated. -- User's name is now split into `first_name` / `last_name` when creating a Chargebee customer. - Extracted `isActiveOrTrialing` helper and used it consistently across hooks and routes. - Renamed `onEvent` to `webhookHandler`, which now receives the typed `WebhookHandler` instance — use `handler.on(WebhookEventType.X, fn)` for per-event listeners with full type safety. +### Breaking Change: +- `cb.customer.create` no longer automatically splits `user.name` into `first_name` and `last_name`. Better Auth uses a single `user.name` field; pass name fields explicitly via `getCustomerCreateParams` if needed. +- The user update hook no longer syncs `first_name` / `last_name` to Chargebee — only `email` is synced. + ### v1.0.0-beta.2 (2026-02-05) * * * diff --git a/packages/better-auth/README.md b/packages/better-auth/README.md index 527cbd2..03caee1 100644 --- a/packages/better-auth/README.md +++ b/packages/better-auth/README.md @@ -147,6 +147,27 @@ chargebee({ }) ``` +#### Passing Additional Customer Params + +Better Auth stores names in a single `user.name` field. If you want to pass `first_name`, `last_name`, or any other Chargebee customer field, use `getCustomerCreateParams`: + +```ts +chargebee({ + // ... other options + createCustomerOnSignUp: true, + getCustomerCreateParams: (user) => { + const [firstName, ...rest] = (user.name ?? "").split(" "); + return { + first_name: firstName, + last_name: rest.join(" ") || undefined, + // any other Chargebee Customer.CreateInputParam fields + }; + }, +}) +``` + +The callback receives the `user` object and an optional `ctx` (request context, available when the customer is created on-demand at subscription time rather than during sign-up). + ### Subscription Management #### Defining Plans @@ -748,6 +769,7 @@ chargebee({ | `webhookUsername` | `string` | Username for Basic Auth on the webhook endpoint. Recommended in production. | | `webhookPassword` | `string` | Password for Basic Auth on the webhook endpoint. Recommended in production. | | `createCustomerOnSignUp` | `boolean` | Whether to automatically create a Chargebee customer when a user signs up. Default: `false`. | +| `getCustomerCreateParams`| `function` | Return additional params for `cb.customer.create` (e.g. `first_name`, `last_name`). Receives `user` and optional `ctx`. | | `onCustomerCreate` | `function` | Callback called after a customer is created. Receives `{ chargebeeCustomer, user }`. | | `webhookHandler` | `function` | Callback receiving the webhook handler instance. Call `handler.on(EventType, fn)` to register typed event listeners. | | `subscription` | `object` | Subscription configuration. See [Subscription options](#subscription-options). | diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index 8444e6d..64fd06a 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -12,7 +12,11 @@ import { updateSubscription, } from "./routes"; import { getSchema } from "./schema"; -import type { ChargebeeOptions, WithChargebeeCustomerId } from "./types"; +import type { + ChargebeeCustomerCreateParams, + ChargebeeOptions, + WithChargebeeCustomerId, +} from "./types"; import { VERSION } from "./version"; declare module "@better-auth/core" { @@ -73,14 +77,18 @@ export const chargebee = (options: O) => { ) { chargebeeCustomer = existing.list[0].customer; } else { + let extraCreateParams: ChargebeeCustomerCreateParams = {}; + if (options.getCustomerCreateParams) { + extraCreateParams = + await options.getCustomerCreateParams(user); + } const result = await cb.customer.create({ email: user.email, - first_name: user.name?.split(" ")[0], - last_name: user.name?.split(" ").slice(1).join(" "), meta_data: customerMetadata.set(undefined, { userId: user.id, customerType: "user", }), + ...extraCreateParams, }); chargebeeCustomer = result.customer; } @@ -107,8 +115,6 @@ export const chargebee = (options: O) => { try { await cb.customer.update(user.chargebeeCustomerId, { email: user.email, - first_name: user.name?.split(" ")[0], - last_name: user.name?.split(" ").slice(1).join(" "), }); } catch { // Silently fail — don't break auth for billing sync issues diff --git a/packages/better-auth/src/routes.ts b/packages/better-auth/src/routes.ts index d4fd1d2..91a5c77 100644 --- a/packages/better-auth/src/routes.ts +++ b/packages/better-auth/src/routes.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { CHARGEBEE_ERROR_CODES } from "./error-codes"; import { referenceMiddleware, sessionMiddleware } from "./middleware"; import type { + ChargebeeCustomerCreateParams, ChargebeeOptions, Subscription, SubscriptionOptions, @@ -99,7 +100,7 @@ async function getOrCreateCustomerId( if (org.chargebeeCustomerId) return org.chargebeeCustomerId; try { - let extraCreateParams: Record = {}; + let extraCreateParams: ChargebeeCustomerCreateParams = {}; if (options.organization?.getCustomerCreateParams) { extraCreateParams = await options.organization.getCustomerCreateParams( org, @@ -108,7 +109,6 @@ async function getOrCreateCustomerId( } const customerResult = await cb.customer.create({ - first_name: org.name, meta_data: { organizationId: org.id, customerType: "organization", @@ -175,15 +175,18 @@ async function getOrCreateCustomerId( )?.customer; if (!chargebeeCustomer) { + let extraCreateParams: ChargebeeCustomerCreateParams = {}; + if (options.getCustomerCreateParams) { + extraCreateParams = await options.getCustomerCreateParams(user, ctx); + } const customerResult = await cb.customer.create({ email: user.email, - first_name: user.name?.split(" ")[0], - last_name: user.name?.split(" ").slice(1).join(" "), meta_data: { userId: user.id, customerType: "user", ...metadata, }, + ...extraCreateParams, }); chargebeeCustomer = customerResult.customer; } diff --git a/packages/better-auth/src/types.ts b/packages/better-auth/src/types.ts index 872a446..a9680ab 100644 --- a/packages/better-auth/src/types.ts +++ b/packages/better-auth/src/types.ts @@ -146,6 +146,18 @@ export interface ChargebeeOptions { webhookUsername?: string; webhookPassword?: string; createCustomerOnSignUp?: boolean; + /** + * Return additional params to pass to `cb.customer.create` for user customers. + * Use this to pass fields like `first_name`, `last_name`, or any other + * Chargebee customer params. The `ctx` argument is only available when the + * customer is created on-demand (e.g. at subscription time), not during sign-up. + */ + getCustomerCreateParams?: ( + user: User, + ctx?: Record, + ) => + | Promise> + | Partial; onCustomerCreate?: (params: CustomerCreateParams) => Promise | void; webhookHandler?: (handler: WebhookHandler) => void; subscription?: SubscriptionOptions; diff --git a/packages/better-auth/test/hooks.test.ts b/packages/better-auth/test/hooks.test.ts index f1b43bb..5ef2f18 100644 --- a/packages/better-auth/test/hooks.test.ts +++ b/packages/better-auth/test/hooks.test.ts @@ -148,22 +148,24 @@ describe("database hooks", () => { await hook?.(user); - expect(mockChargebeeClient.customer.create).toHaveBeenCalledWith( - expect.objectContaining({ - email: "test@example.com", - first_name: "John", - last_name: "Doe", - }), - ); - expect(mockAdapter.updateUser).toHaveBeenCalledWith("user_123", { - chargebeeCustomerId: "cust_new", - }); + expect(mockChargebeeClient.customer.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: "test@example.com", + }), + ); + expect(mockAdapter.updateUser).toHaveBeenCalledWith("user_123", { + chargebeeCustomerId: "cust_new", + }); }); - it("should handle single name without space", async () => { + it("should merge getCustomerCreateParams into customer create payload", async () => { const plugin = chargebee({ chargebeeClient: mockChargebeeClient, createCustomerOnSignUp: true, + getCustomerCreateParams: (user) => ({ + first_name: user.name?.split(" ")[0], + last_name: user.name?.split(" ").slice(1).join(" ") || undefined, + }), }); const ctx = { @@ -185,7 +187,7 @@ describe("database hooks", () => { const user: User = { id: "user_123", email: "test@example.com", - name: "John", + name: "John Doe", emailVerified: false, createdAt: new Date(), updatedAt: new Date(), @@ -195,8 +197,9 @@ describe("database hooks", () => { expect(mockChargebeeClient.customer.create).toHaveBeenCalledWith( expect.objectContaining({ + email: "test@example.com", first_name: "John", - last_name: "", + last_name: "Doe", }), ); }); @@ -341,14 +344,12 @@ describe("database hooks", () => { await hook?.(user); - expect(mockChargebeeClient.customer.update).toHaveBeenCalledWith( - "cust_123", - { - email: "newemail@example.com", - first_name: "Test", - last_name: "User", - }, - ); + expect(mockChargebeeClient.customer.update).toHaveBeenCalledWith( + "cust_123", + { + email: "newemail@example.com", + }, + ); }); it("should silently fail if update throws error", async () => { From 8baf4b1a8fc47d633d74d56a2cf3d30334565255 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Wed, 18 Mar 2026 11:33:40 +0530 Subject: [PATCH 4/5] fix: register missing GET /subscription/success endpoint Chargebee's redirect_url after hosted-page checkout was pointing to /subscription/success, but the endpoint was never registered, causing a 404 for every user after a successful checkout. Added the subscriptionSuccess endpoint that receives Chargebee's post-checkout redirect and forwards the user to their original successUrl via the callbackURL query param. Updated docs and changelog to clearly explain the intermediate redirect flow. Made-with: Cursor --- packages/better-auth/CHANGELOG.md | 3 ++- packages/better-auth/README.md | 2 +- packages/better-auth/src/index.ts | 2 ++ packages/better-auth/src/routes.ts | 34 ++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/better-auth/CHANGELOG.md b/packages/better-auth/CHANGELOG.md index c181d33..c8f356a 100644 --- a/packages/better-auth/CHANGELOG.md +++ b/packages/better-auth/CHANGELOG.md @@ -1,8 +1,9 @@ -### v1.0.0-beta.4 (2026-03-16) +### v1.0.0-beta.4 (2026-03-18) * * * ### Feature: - Added `getCustomerCreateParams` option to `ChargebeeOptions`, allowing you to return additional Chargebee customer creation params (e.g. `first_name`, `last_name`, phone) for user customers. The callback receives the `user` object and an optional request `ctx` (available when the customer is created on-demand, not during sign-up). +- Registered the missing `GET /subscription/success` endpoint. Chargebee's `redirect_url` after hosted-page checkout points to this endpoint, which forwards the user to their original `successUrl`. Without this endpoint the post-checkout redirect resulted in a 404. - Added `createSubscription` route to initiate a new Chargebee hosted checkout session. - Added `updateSubscription` route to update an existing subscription via hosted page. - Added `listActiveSubscriptions` route (`GET /subscription/list`) to retrieve the caller's active/trialing subscriptions enriched with plan `limits` and `itemPriceId`. diff --git a/packages/better-auth/README.md b/packages/better-auth/README.md index 03caee1..e045b46 100644 --- a/packages/better-auth/README.md +++ b/packages/better-auth/README.md @@ -298,7 +298,7 @@ if (error) { } ``` -> **Important:** The `successUrl` parameter is internally modified to handle race conditions between checkout completion and webhook processing. The plugin uses an intermediate redirect so subscription status is updated before redirecting to your success page. +> **How the checkout redirect works:** The plugin does not redirect straight to your `successUrl`. Instead, Chargebee's `redirect_url` is set to the plugin's internally registered `GET /subscription/success` endpoint, which immediately forwards the user to your original `successUrl`. This gives the plugin a hook point between Chargebee's hosted-page redirect and your application. #### Switching Plans diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index 64fd06a..ae8729a 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -9,6 +9,7 @@ import { createSubscription, getWebhookEndpoint, listActiveSubscriptions, + subscriptionSuccess, updateSubscription, } from "./routes"; import { getSchema } from "./schema"; @@ -38,6 +39,7 @@ export const chargebee = (options: O) => { chargebeeWebhook: getWebhookEndpoint(options), createSubscription: createSubscription(options), updateSubscription: updateSubscription(options), + subscriptionSuccess: subscriptionSuccess(options), cancelSubscription: cancelSubscription(options), cancelSubscriptionCallback: cancelSubscriptionCallback(options), createPortalSession: createPortalSession(options), diff --git a/packages/better-auth/src/routes.ts b/packages/better-auth/src/routes.ts index 91a5c77..973d064 100644 --- a/packages/better-auth/src/routes.ts +++ b/packages/better-auth/src/routes.ts @@ -808,6 +808,40 @@ export function listActiveSubscriptions(options: ChargebeeOptions) { ); } +/** + * Intermediate redirect endpoint after a Chargebee hosted-page checkout completes. + * Chargebee lands here via redirect_url; this endpoint simply forwards the user + * to their original successUrl (passed as callbackURL). + * + * This acts as the bridge between Chargebee's hosted-page redirect and the + * application's success page, giving the plugin a hook point between the two. + */ +export function subscriptionSuccess(options: ChargebeeOptions) { + return createAuthEndpoint( + "/subscription/success", + { + method: "GET", + query: z + .object({ + callbackURL: z.string(), + subscriptionId: z.string(), + }) + .partial(), + metadata: { + openapi: { + operationId: "subscriptionSuccess", + }, + isAction: false, + }, + use: [originCheck((ctx) => ctx.query?.callbackURL)], + }, + async (ctx) => { + const callbackURL = ctx.query?.callbackURL || "/"; + throw ctx.redirect(getUrl(ctx, callbackURL)); + }, + ); +} + /** * Callback endpoint after subscription cancellation * Checks if cancellation was successful and updates the database From ebbf2dc97fec869c1c9dfc9389eaaeb9615328a4 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Wed, 18 Mar 2026 12:45:01 +0530 Subject: [PATCH 5/5] fix: conditionally include schemas and guard org-mode billing hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - userSchema is now omitted when organization.enabled is true — no chargebeeCustomerId column is added to the user table in org-only billing mode, preventing adapter crashes on missing columns - subscriptionSchema and subscriptionItemSchema are now omitted when subscription.enabled is not set — only tables that are actually in use get created - user.create and user.update hooks now return early when organization.enabled is true, preventing any attempt to write chargebeeCustomerId to the user table in org mode - handleCustomerDeletion fallback user-table query is now guarded by !organization.enabled, preventing a guaranteed crash on every customer_deleted webhook event in org-only mode Made-with: Cursor --- packages/better-auth/CHANGELOG.md | 1 + packages/better-auth/README.md | 2 + packages/better-auth/src/index.ts | 2 + packages/better-auth/src/schema.ts | 6 +- packages/better-auth/src/webhook-handler.ts | 47 ++++++++------- packages/better-auth/test/schema.test.ts | 65 +++++++++++++-------- 6 files changed, 73 insertions(+), 50 deletions(-) diff --git a/packages/better-auth/CHANGELOG.md b/packages/better-auth/CHANGELOG.md index c8f356a..d3502be 100644 --- a/packages/better-auth/CHANGELOG.md +++ b/packages/better-auth/CHANGELOG.md @@ -4,6 +4,7 @@ ### Feature: - Added `getCustomerCreateParams` option to `ChargebeeOptions`, allowing you to return additional Chargebee customer creation params (e.g. `first_name`, `last_name`, phone) for user customers. The callback receives the `user` object and an optional request `ctx` (available when the customer is created on-demand, not during sign-up). - Registered the missing `GET /subscription/success` endpoint. Chargebee's `redirect_url` after hosted-page checkout points to this endpoint, which forwards the user to their original `successUrl`. Without this endpoint the post-checkout redirect resulted in a 404. +- When `organization.enabled: true`, the `chargebeeCustomerId` field is no longer added to the `user` table and user-level billing hooks are disabled. Previously the user schema was always included regardless of billing mode, causing adapter crashes if the column was missing from the database. - Added `createSubscription` route to initiate a new Chargebee hosted checkout session. - Added `updateSubscription` route to update an existing subscription via hosted page. - Added `listActiveSubscriptions` route (`GET /subscription/list`) to retrieve the caller's active/trialing subscriptions enriched with plan `limits` and `itemPriceId`. diff --git a/packages/better-auth/README.md b/packages/better-auth/README.md index e045b46..6fc66a7 100644 --- a/packages/better-auth/README.md +++ b/packages/better-auth/README.md @@ -853,6 +853,8 @@ plugins: [ ] ``` +When `organization.enabled: true`, the plugin automatically omits `chargebeeCustomerId` from the `user` table and disables user-level billing hooks — no extra column or migration needed for the user table. + #### Creating Organization Subscriptions Even with Organization Customer enabled, user subscriptions remain available and are the default. To use the organization as the billing entity, pass `customerType: "organization"`: diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index ae8729a..2d24bda 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -63,6 +63,7 @@ export const chargebee = (options: O) => { user: { create: { async after(user) { + if (options.organization?.enabled) return; if (!options.createCustomerOnSignUp) return; try { const existing = await cb.customer.list({ @@ -113,6 +114,7 @@ export const chargebee = (options: O) => { }, update: { async after(user: User & WithChargebeeCustomerId) { + if (options.organization?.enabled) return; if (!user.chargebeeCustomerId) return; try { await cb.customer.update(user.chargebeeCustomerId, { diff --git a/packages/better-auth/src/schema.ts b/packages/better-auth/src/schema.ts index ee584a2..e4c20b5 100644 --- a/packages/better-auth/src/schema.ts +++ b/packages/better-auth/src/schema.ts @@ -118,9 +118,9 @@ const subscriptionItemSchema = { export function getSchema(options: ChargebeeOptions) { return { - ...userSchema, - ...subscriptionSchema, - ...subscriptionItemSchema, + ...(!options.organization?.enabled ? userSchema : {}), + ...(options.subscription?.enabled ? subscriptionSchema : {}), + ...(options.subscription?.enabled ? subscriptionItemSchema : {}), ...(options.organization?.enabled ? orgSchema : {}), }; } diff --git a/packages/better-auth/src/webhook-handler.ts b/packages/better-auth/src/webhook-handler.ts index 437f484..dab27e4 100644 --- a/packages/better-auth/src/webhook-handler.ts +++ b/packages/better-auth/src/webhook-handler.ts @@ -282,31 +282,34 @@ async function handleCustomerDeletion( } // Fallback: Find user/org by chargebeeCustomerId directly - // This handles cases where metadata is missing or incorrect - try { - // Try to find and clear user - const users = await ctx.adapter.findMany<{ id: string }>({ - model: "user", - where: [ - { - field: "chargebeeCustomerId", - value: customer.id, - }, - ], - }); - - for (const user of users) { - await ctx.adapter.update({ + // This handles cases where metadata is missing or incorrect. + // Skip user fallback in org mode — the chargebeeCustomerId column does not + // exist on the user table when organization.enabled is true. + if (!_options.organization?.enabled) { + try { + const users = await ctx.adapter.findMany<{ id: string }>({ model: "user", - update: { chargebeeCustomerId: null }, - where: [{ field: "id", value: user.id }], + where: [ + { + field: "chargebeeCustomerId", + value: customer.id, + }, + ], }); - ctx.logger.info( - `Cleared chargebeeCustomerId from user ${user.id} (fallback)`, - ); + + for (const user of users) { + await ctx.adapter.update({ + model: "user", + update: { chargebeeCustomerId: null }, + where: [{ field: "id", value: user.id }], + }); + ctx.logger.info( + `Cleared chargebeeCustomerId from user ${user.id} (fallback)`, + ); + } + } catch (e) { + ctx.logger.error("Error clearing chargebeeCustomerId from users:", e); } - } catch (e) { - ctx.logger.error("Error clearing chargebeeCustomerId from users:", e); } // Try to clear organizations (if enabled) diff --git a/packages/better-auth/test/schema.test.ts b/packages/better-auth/test/schema.test.ts index a9bcdbc..6f8fc88 100644 --- a/packages/better-auth/test/schema.test.ts +++ b/packages/better-auth/test/schema.test.ts @@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest"; import { getSchema } from "../src/schema"; import type { ChargebeeOptions } from "../src/types"; +const basePlans = [ + { name: "pro", itemPriceId: "pro-USD-Monthly", type: "plan" as const }, +]; + describe("schema - getSchema", () => { - it("should return user and subscription schemas without organization", () => { + it("should include user schema and omit subscription schemas when subscription is not enabled", () => { const options: ChargebeeOptions = { chargebeeClient: {} as Chargebee, }; @@ -19,12 +23,25 @@ describe("schema - getSchema", () => { fieldName: "chargebeeCustomerId", }); + expect(schema).not.toHaveProperty("subscription"); + expect(schema).not.toHaveProperty("subscriptionItem"); + expect(schema).not.toHaveProperty("organization"); + }); + + it("should include subscription schemas when subscription is enabled", () => { + const options: ChargebeeOptions = { + chargebeeClient: {} as Chargebee, + subscription: { enabled: true, plans: basePlans }, + }; + + const schema = getSchema(options); + + expect(schema.user).toBeDefined(); expect(schema.subscription).toBeDefined(); expect(schema.subscription.fields.referenceId).toEqual({ type: "string", required: true, }); - expect(schema.subscriptionItem).toBeDefined(); expect(schema.subscriptionItem.fields.subscriptionId).toEqual({ type: "string", @@ -35,25 +52,22 @@ describe("schema - getSchema", () => { onDelete: "cascade", }, }); - expect(schema).not.toHaveProperty("organization"); }); - it("should include organization schema when organization is enabled", () => { + it("should include organization schema and omit user schema when organization is enabled", () => { const options: ChargebeeOptions = { chargebeeClient: {} as Chargebee, - organization: { - enabled: true, - }, + subscription: { enabled: true, plans: basePlans }, + organization: { enabled: true }, }; const schema = getSchema(options); - expect(schema.user).toBeDefined(); + expect(schema).not.toHaveProperty("user"); expect(schema.subscription).toBeDefined(); expect(schema.subscriptionItem).toBeDefined(); expect(schema.organization).toBeDefined(); - expect(schema.organization.fields.chargebeeCustomerId).toEqual({ type: "string", required: false, @@ -65,6 +79,7 @@ describe("schema - getSchema", () => { it("should have correct subscription field types", () => { const options: ChargebeeOptions = { chargebeeClient: {} as Chargebee, + subscription: { enabled: true, plans: basePlans }, }; const schema = getSchema(options); @@ -73,49 +88,40 @@ describe("schema - getSchema", () => { type: "string", required: false, }); - expect(schema.subscription.fields.chargebeeSubscriptionId).toEqual({ type: "string", required: false, unique: true, }); - expect(schema.subscription.fields.status).toEqual({ type: "string", required: false, defaultValue: "future", }); - expect(schema.subscription.fields.periodStart).toEqual({ type: "date", required: false, }); - expect(schema.subscription.fields.periodEnd).toEqual({ type: "date", required: false, }); - expect(schema.subscription.fields.trialStart).toEqual({ type: "date", required: false, }); - expect(schema.subscription.fields.trialEnd).toEqual({ type: "date", required: false, }); - expect(schema.subscription.fields.canceledAt).toEqual({ type: "date", required: false, }); - expect(schema.subscription.fields.seats).toEqual({ type: "number", required: false, }); - expect(schema.subscription.fields.metadata).toEqual({ type: "string", required: false, @@ -125,6 +131,7 @@ describe("schema - getSchema", () => { it("should have correct subscription item field types", () => { const options: ChargebeeOptions = { chargebeeClient: {} as Chargebee, + subscription: { enabled: true, plans: basePlans }, }; const schema = getSchema(options); @@ -133,22 +140,18 @@ describe("schema - getSchema", () => { type: "string", required: true, }); - expect(schema.subscriptionItem.fields.itemType).toEqual({ type: "string", required: true, }); - expect(schema.subscriptionItem.fields.quantity).toEqual({ type: "number", required: true, }); - expect(schema.subscriptionItem.fields.unitPrice).toEqual({ type: "number", required: false, }); - expect(schema.subscriptionItem.fields.amount).toEqual({ type: "number", required: false, @@ -169,13 +172,25 @@ describe("schema - getSchema", () => { it("should not include organization schema when organization is explicitly disabled", () => { const options: ChargebeeOptions = { chargebeeClient: {} as Chargebee, - organization: { - enabled: false, - }, + organization: { enabled: false }, }; const schema = getSchema(options); expect(schema).not.toHaveProperty("organization"); }); + + it("should omit user schema when organization is enabled", () => { + const options: ChargebeeOptions = { + chargebeeClient: {} as Chargebee, + organization: { enabled: true }, + }; + + const schema = getSchema(options); + + expect(schema).not.toHaveProperty("user"); + expect(schema).not.toHaveProperty("subscription"); + expect(schema).not.toHaveProperty("subscriptionItem"); + expect(schema.organization).toBeDefined(); + }); });