From 1136a4b08100ea0402aa1919f7cdae629940e2b2 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Fri, 26 Jun 2026 17:24:57 +1000 Subject: [PATCH 1/2] add webhook event bus for async processing --- packages/better-auth/src/index.ts | 7 + packages/better-auth/src/routes.ts | 45 +-- packages/better-auth/src/types.ts | 89 +++--- packages/better-auth/src/webhook-handler.ts | 256 +++++++++++++----- packages/better-auth/src/webhook-processor.ts | 88 ++++++ .../better-auth/test/webhook-queue.test.ts | 240 ++++++++++++++++ 6 files changed, 607 insertions(+), 118 deletions(-) create mode 100644 packages/better-auth/src/webhook-processor.ts create mode 100644 packages/better-auth/test/webhook-queue.test.ts diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index 2d24bda..5a6454e 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -212,8 +212,15 @@ export { CHARGEBEE_ERROR_CODES } from "./error-codes"; export type { ChargebeeOptions, ChargebeePlan, + ChargebeeWebhookEventBus, Subscription, SubscriptionOptions, SubscriptionStatus, + WebhookEvent, WithChargebeeCustomerId, } from "./types"; +export { + type ChargebeeWebhookProcessor, + type ChargebeeWebhookProcessorSource, + createChargebeeWebhookProcessor, +} from "./webhook-processor"; diff --git a/packages/better-auth/src/routes.ts b/packages/better-auth/src/routes.ts index 973d064..315a8f9 100644 --- a/packages/better-auth/src/routes.ts +++ b/packages/better-auth/src/routes.ts @@ -23,7 +23,10 @@ import { isActiveOrTrialing, isPendingCancel, } from "./utils"; -import { createWebhookHandler } from "./webhook-handler"; +import { + createWebhookHandler, + createWebhookPublishHandler, +} from "./webhook-handler"; export function getWebhookEndpoint(options: ChargebeeOptions) { return createAuthEndpoint( @@ -33,22 +36,30 @@ export function getWebhookEndpoint(options: ChargebeeOptions) { metadata: { isAction: false }, }, async (ctx) => { - // Create webhook handler with better-auth context - const handler = createWebhookHandler( - options, - { - context: ctx.context as Record, - adapter: ctx.context.adapter as unknown as { - findOne: (params: unknown) => Promise; - findMany: (params: unknown) => Promise; - update: (params: unknown) => Promise; - deleteMany: (params: unknown) => Promise; - create: (params: unknown) => Promise; - }, - logger: ctx.context.logger, - }, - ctx as any, - ); + // When an event bus is configured, validate + parse the event and + // forward it to the bus (e.g. an application queue) instead of running + // the DB-sync hooks inline. Otherwise process the event synchronously. + const handler = options.webhookEventBus + ? createWebhookPublishHandler( + options, + options.webhookEventBus, + ctx.context.logger, + ) + : createWebhookHandler( + options, + { + context: ctx.context as Record, + adapter: ctx.context.adapter as unknown as { + findOne: (params: unknown) => Promise; + findMany: (params: unknown) => Promise; + update: (params: unknown) => Promise; + deleteMany: (params: unknown) => Promise; + create: (params: unknown) => Promise; + }, + logger: ctx.context.logger, + }, + ctx as any, + ); // Let user register custom event listeners on the handler options.webhookHandler?.(handler); diff --git a/packages/better-auth/src/types.ts b/packages/better-auth/src/types.ts index 9bf6b2c..cbaa1cd 100644 --- a/packages/better-auth/src/types.ts +++ b/packages/better-auth/src/types.ts @@ -2,8 +2,8 @@ import type { Session, User } from "better-auth"; import type { Organization } from "better-auth/plugins/organization"; import type Chargebee from "chargebee"; import type { - Event as ChargebeeEvent, Subscription as ChargebeeSubscription, + WebhookEvent as ChargebeeWebhookEvent, Customer, WebhookHandler, } from "chargebee"; @@ -136,43 +136,68 @@ export type SubscriptionOptions = { ) => Promise; }; -export type WebhookEvent = ChargebeeEvent; +export type WebhookEvent = ChargebeeWebhookEvent; + +/** + * Event bus seam used to decouple webhook ingestion from processing. + * + * When provided via {@link ChargebeeOptions.webhookEventBus}, the webhook + * endpoint validates and parses each incoming Chargebee event and then calls + * `publish` instead of running the DB-sync hooks inline. The application is + * expected to push the event onto its own queue and later process it from a + * consumer using `createChargebeeWebhookProcessor`. + */ +export interface ChargebeeWebhookEventBus { + /** Called at the HTTP endpoint for every validated, parsed event. */ + publish(event: WebhookEvent): Promise | void; +} // Use native Chargebee customer creation params export type ChargebeeCustomerCreateParams = Partial; export interface ChargebeeOptions { - chargebeeClient: InstanceType; - 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; - organization?: { - enabled: boolean; + chargebeeClient: InstanceType; + 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?: ( - organization: Organization & WithChargebeeCustomerId, - ctx: Record, - ) => Promise>; - onCustomerCreate?: ( - params: OrganizationCustomerCreateParams, - ctx: Record, - ) => Promise | void; - }; -} + user: User, + ctx?: Record, + ) => + | Promise> + | Partial; + onCustomerCreate?: (params: CustomerCreateParams) => Promise | void; + webhookHandler?: (handler: WebhookHandler) => void; + /** + * Optional event bus used to decouple webhook ingestion from processing. + * + * When set, the webhook endpoint in the app is exptected to validate and + * parses each event and calls `webhookEventBus.publish(event)` (typically pushing it onto an application + * queue) instead of running the DB-sync hooks inline. Process queued events + * later with `createChargebeeWebhookProcessor`. + * + * When not set, events are processed synchronously within the request. + */ + webhookEventBus?: ChargebeeWebhookEventBus; + subscription?: SubscriptionOptions; + organization?: { + enabled: boolean; + getCustomerCreateParams?: ( + organization: Organization & WithChargebeeCustomerId, + ctx: Record, + ) => Promise>; + onCustomerCreate?: ( + params: OrganizationCustomerCreateParams, + ctx: Record, + ) => Promise | void; + }; + } export interface Subscription { id: string; diff --git a/packages/better-auth/src/webhook-handler.ts b/packages/better-auth/src/webhook-handler.ts index dab27e4..4ff0ab5 100644 --- a/packages/better-auth/src/webhook-handler.ts +++ b/packages/better-auth/src/webhook-handler.ts @@ -1,6 +1,8 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { basicAuthValidator, + type Subscription as ChargebeeSubscription, + type Customer, WebhookAuthenticationError, type WebhookEvent, WebhookEventType, @@ -11,7 +13,12 @@ import { onSubscriptionDeleted, onSubscriptionUpdated, } from "./hooks"; -import type { ChargebeeOptions, Logger, Subscription } from "./types"; +import type { + ChargebeeOptions, + ChargebeeWebhookEventBus, + Logger, + Subscription, +} from "./types"; /** * Context object that wraps better-auth context for webhook handlers @@ -33,6 +40,120 @@ interface WebhookResponse { send(body: string): void; } +/** + * Builds the Basic Auth request validator for the Chargebee webhook handler, + * or `undefined` when no credentials are configured. + */ +function buildRequestValidator(options: ChargebeeOptions) { + return options.webhookUsername && options.webhookPassword + ? basicAuthValidator((username, password) => { + return ( + username === options.webhookUsername && + password === options.webhookPassword + ); + }) + : undefined; +} + +/** + * Chargebee event types the plugin processes. These are registered on the + * publish handler so that every relevant event is forwarded to the event bus. + */ +const HANDLED_EVENT_TYPES = [ + WebhookEventType.SubscriptionCreated, + WebhookEventType.SubscriptionActivated, + WebhookEventType.SubscriptionChanged, + WebhookEventType.SubscriptionRenewed, + WebhookEventType.SubscriptionStarted, + WebhookEventType.SubscriptionCancelled, + WebhookEventType.SubscriptionScheduledCancellationRemoved, + WebhookEventType.CustomerDeleted, +] as const; + +/** + * Dispatches a single validated Chargebee webhook event to the appropriate + * subscription/customer hook. + * + * This contains the event-type to hook mapping and is shared by both the + * synchronous webhook handler ({@link createWebhookHandler}) and the + * asynchronous queue consumer ({@link createChargebeeWebhookProcessor}). + * + * @param event - The parsed Chargebee webhook event + * @param endpointCtx - Better-auth endpoint context (provides adapter/logger to hooks) + * @param ctx - Better-auth webhook context wrapper (used by customer deletion) + * @param options - Chargebee plugin options + */ +export async function dispatchWebhookEvent( + event: WebhookEvent, + endpointCtx: GenericEndpointContext, + ctx: BetterAuthWebhookContext, + options: ChargebeeOptions, +): Promise { + // The event type is narrowed via the switch below, but `content` is a union + // across all event types, so we read the subscription/customer fields the + // subscription hooks need through a focused view. + const content = event.content as { + subscription?: ChargebeeSubscription; + customer?: Customer; + }; + + switch (event.event_type) { + case WebhookEventType.SubscriptionCreated: { + if (content.subscription && content.customer) { + await onSubscriptionCreated( + endpointCtx, + options, + content.subscription, + content.customer, + ); + } + return; + } + case WebhookEventType.SubscriptionActivated: + case WebhookEventType.SubscriptionStarted: { + if (content.subscription && content.customer) { + await onSubscriptionComplete( + endpointCtx, + options, + content.subscription, + content.customer, + ); + } + return; + } + case WebhookEventType.SubscriptionChanged: + case WebhookEventType.SubscriptionRenewed: + case WebhookEventType.SubscriptionScheduledCancellationRemoved: { + if (content.subscription && content.customer) { + await onSubscriptionUpdated( + endpointCtx, + options, + content.subscription, + content.customer, + ); + } + return; + } + case WebhookEventType.SubscriptionCancelled: { + if (content.subscription) { + await onSubscriptionDeleted(endpointCtx, options, content.subscription); + } + return; + } + case WebhookEventType.CustomerDeleted: { + await handleCustomerDeletion( + event as unknown as WebhookEvent, + ctx, + options, + ); + return; + } + default: { + ctx.logger.info(`Unhandled Chargebee webhook event: ${event.event_type}`); + } + } +} + /** * Creates and configures a Chargebee webhook handler with typed event listeners * @param options - Chargebee plugin options @@ -47,15 +168,7 @@ export function createWebhookHandler( const cb = options.chargebeeClient; const handler = cb.webhooks.createHandler({ - requestValidator: - options.webhookUsername && options.webhookPassword - ? basicAuthValidator((username, password) => { - return ( - username === options.webhookUsername && - password === options.webhookPassword - ); - }) - : undefined, + requestValidator: buildRequestValidator(options), }); /** @@ -64,15 +177,7 @@ export function createWebhookHandler( handler.on( WebhookEventType.SubscriptionCreated, async ({ event, response }) => { - const content = event.content; - if (content.subscription && content.customer) { - await onSubscriptionCreated( - endpointCtx, - options, - content.subscription, - content.customer, - ); - } + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }, ); @@ -80,15 +185,7 @@ export function createWebhookHandler( handler.on( WebhookEventType.SubscriptionActivated, async ({ event, response }) => { - const content = event.content; - if (content.subscription && content.customer) { - await onSubscriptionComplete( - endpointCtx, - options, - content.subscription, - content.customer, - ); - } + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }, ); @@ -96,15 +193,7 @@ export function createWebhookHandler( handler.on( WebhookEventType.SubscriptionChanged, async ({ event, response }) => { - const content = event.content; - if (content.subscription && content.customer) { - await onSubscriptionUpdated( - endpointCtx, - options, - content.subscription, - content.customer, - ); - } + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }, ); @@ -112,15 +201,7 @@ export function createWebhookHandler( handler.on( WebhookEventType.SubscriptionRenewed, async ({ event, response }) => { - const content = event.content; - if (content.subscription && content.customer) { - await onSubscriptionUpdated( - endpointCtx, - options, - content.subscription, - content.customer, - ); - } + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }, ); @@ -128,15 +209,7 @@ export function createWebhookHandler( handler.on( WebhookEventType.SubscriptionStarted, async ({ event, response }) => { - const content = event.content; - if (content.subscription && content.customer) { - await onSubscriptionComplete( - endpointCtx, - options, - content.subscription, - content.customer, - ); - } + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }, ); @@ -147,10 +220,7 @@ export function createWebhookHandler( handler.on( WebhookEventType.SubscriptionCancelled, async ({ event, response }) => { - const content = event.content; - if (content.subscription) { - await onSubscriptionDeleted(endpointCtx, options, content.subscription); - } + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }, ); @@ -158,15 +228,7 @@ export function createWebhookHandler( handler.on( WebhookEventType.SubscriptionScheduledCancellationRemoved, async ({ event, response }) => { - const content = event.content; - if (content.subscription && content.customer) { - await onSubscriptionUpdated( - endpointCtx, - options, - content.subscription, - content.customer, - ); - } + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }, ); @@ -175,7 +237,7 @@ export function createWebhookHandler( * Handle customer deletion events */ handler.on(WebhookEventType.CustomerDeleted, async ({ event, response }) => { - await handleCustomerDeletion(event, ctx, options); + await dispatchWebhookEvent(event, endpointCtx, ctx, options); response?.status(200).send("OK"); }); @@ -208,6 +270,62 @@ export function createWebhookHandler( return handler; } +/** + * Creates a Chargebee webhook handler that validates and parses incoming events + * and forwards every event to the provided event bus instead of running the + * DB-sync hooks inline. + * + * Used by the webhook endpoint when `options.webhookEventBus` is configured, so + * events can be pushed onto an application queue and processed later via + * `createChargebeeWebhookProcessor`. + * + * @param options - Chargebee plugin options + * @param eventBus - Event bus that receives each validated, parsed event + * @param logger - Logger used for unhandled-event and error reporting + * @returns Configured webhook handler instance + */ +export function createWebhookPublishHandler( + options: ChargebeeOptions, + eventBus: ChargebeeWebhookEventBus, + logger: Logger, +) { + const cb = options.chargebeeClient; + + const handler = cb.webhooks.createHandler({ + requestValidator: buildRequestValidator(options), + }); + + for (const eventType of HANDLED_EVENT_TYPES) { + handler.on(eventType, async ({ event, response }) => { + await eventBus.publish(event); + response?.status(200).send("OK"); + }); + } + + // Forward all other events too, so the application receives every webhook. + handler.on("unhandled_event", async ({ event, response }) => { + await eventBus.publish(event); + response?.status(200).send("OK"); + }); + + handler.on("error", (error: Error, { response }) => { + const webhookResponse = response as WebhookResponse | undefined; + if (error instanceof WebhookAuthenticationError) { + logger.warn( + `Webhook rejected: ${error.message}. Please verify webhookUsername and webhookPassword are correctly configured in your plugin options and that the webhook in Chargebee dashboard has matching Basic Auth credentials.`, + ); + webhookResponse?.status(401).send("Unauthorized"); + return; + } + + // Log other errors and send 200 to prevent Chargebee retries + logger.error("Error processing webhook event:", error); + webhookResponse?.status(200).send("OK"); + }); + + return handler; +} + /** * Handle customer deletion events */ diff --git a/packages/better-auth/src/webhook-processor.ts b/packages/better-auth/src/webhook-processor.ts new file mode 100644 index 0000000..0ccdea9 --- /dev/null +++ b/packages/better-auth/src/webhook-processor.ts @@ -0,0 +1,88 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import type { ChargebeeOptions, Logger, WebhookEvent } from "./types"; +import { + type BetterAuthWebhookContext, + dispatchWebhookEvent, +} from "./webhook-handler"; + +/** + * Minimal shape of a better-auth context required to process webhook events. + * The DB-sync hooks only need an adapter and a logger. + */ +interface ChargebeeProcessorContext { + adapter: BetterAuthWebhookContext["adapter"]; + logger: Logger; +} + +/** + * Source used by the processor to obtain a better-auth context. + * + * - `auth`: an in-process better-auth instance. The context is resolved lazily + * via `auth.$context`, so the same processor can be reused across messages. + * - `context`: an explicit adapter + logger, for consumers that run in a + * separate process and construct their own context. + */ +export type ChargebeeWebhookProcessorSource = + | { auth: { $context: Promise } } + | { context: ChargebeeProcessorContext }; + +export interface ChargebeeWebhookProcessor { + /** + * Process a single queued Chargebee webhook event by running the matching + * DB-sync hook. Safe to call repeatedly for the same event - the hooks + * guard against duplicate records. + */ + process(event: WebhookEvent): Promise; +} + +/** + * Creates a processor for Chargebee webhook events consumed from a queue. + * + * Pair this with {@link ChargebeeOptions.webhookEventBus}: the webhook endpoint + * validates + parses each event and publishes it to your queue, then your + * consumer calls `processor.process(event)` to run the plugin's hooks. + * + * @example In-process consumer + * ```ts + * const processor = createChargebeeWebhookProcessor(options, { auth }); + * await processor.process(event); + * ``` + * + * @example Separate process consumer + * ```ts + * const processor = createChargebeeWebhookProcessor(options, { + * context: { adapter, logger }, + * }); + * await processor.process(event); + * ``` + */ +export function createChargebeeWebhookProcessor( + options: ChargebeeOptions, + source: ChargebeeWebhookProcessorSource, +): ChargebeeWebhookProcessor { + const resolveContext = async (): Promise => { + if ("auth" in source) { + const authCtx = await source.auth.$context; + return { adapter: authCtx.adapter, logger: authCtx.logger }; + } + return source.context; + }; + + return { + async process(event: WebhookEvent) { + const { adapter, logger } = await resolveContext(); + + const endpointCtx = { + context: { adapter, logger }, + } as unknown as GenericEndpointContext; + + const wrapperCtx: BetterAuthWebhookContext = { + context: { adapter, logger }, + adapter, + logger, + }; + + await dispatchWebhookEvent(event, endpointCtx, wrapperCtx, options); + }, + }; +} diff --git a/packages/better-auth/test/webhook-queue.test.ts b/packages/better-auth/test/webhook-queue.test.ts new file mode 100644 index 0000000..fe1e2a0 --- /dev/null +++ b/packages/better-auth/test/webhook-queue.test.ts @@ -0,0 +1,240 @@ +import type Chargebee from "chargebee"; +import { type WebhookEvent, WebhookEventType } from "chargebee"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChargebeeOptions, ChargebeeWebhookEventBus } from "../src/types"; +import { createWebhookPublishHandler } from "../src/webhook-handler"; +import { createChargebeeWebhookProcessor } from "../src/webhook-processor"; + +const createdEvent: WebhookEvent = { + id: "ev_123", + occurred_at: 1234567890, + source: "scheduled", + object: "event", + api_version: "v2", + event_type: "subscription_created" as WebhookEventType.SubscriptionCreated, + webhook_status: "scheduled", + content: { + subscription: { + id: "sub_123", + customer_id: "cust_123", + status: "active", + current_term_start: 1234567890, + current_term_end: 1267103890, + object: "subscription", + subscription_items: [ + { + item_price_id: "plan-USD-monthly", + item_type: "plan", + quantity: 1, + unit_price: 999, + amount: 999, + }, + ], + }, + customer: { + id: "cust_123", + email: "test@example.com", + object: "customer", + }, + }, +}; + +describe("webhook event bus / queue", () => { + let mockHandlerInstance: { + on: ReturnType; + handle: ReturnType; + }; + + const mockChargebee = { + webhooks: { + createHandler: vi.fn(), + }, + } as unknown as Chargebee; + + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const baseOptions: ChargebeeOptions = { + chargebeeClient: mockChargebee, + webhookUsername: "test_user", + webhookPassword: "test_pass", + subscription: { + enabled: true, + plans: [ + { + name: "Basic Plan", + itemPriceId: "plan-USD-monthly", + type: "plan" as const, + }, + ], + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockHandlerInstance = { + on: vi.fn().mockReturnThis(), + handle: vi.fn().mockResolvedValue({}), + }; + mockChargebee.webhooks.createHandler = vi + .fn() + .mockReturnValue(mockHandlerInstance); + }); + + describe("createWebhookPublishHandler", () => { + it("publishes parsed events to the bus instead of touching the adapter", async () => { + const eventBus: ChargebeeWebhookEventBus = { + publish: vi.fn().mockResolvedValue(undefined), + }; + + createWebhookPublishHandler(baseOptions, eventBus, logger); + + const onHandler = mockHandlerInstance.on.mock.calls.find( + (call) => call[0] === WebhookEventType.SubscriptionCreated, + ); + expect(onHandler).toBeDefined(); + + const response = { + status: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + await onHandler?.[1]({ event: createdEvent, response }); + + expect(eventBus.publish).toHaveBeenCalledWith(createdEvent); + expect(response.status).toHaveBeenCalledWith(200); + }); + + it("forwards unhandled events to the bus too", async () => { + const eventBus: ChargebeeWebhookEventBus = { + publish: vi.fn().mockResolvedValue(undefined), + }; + + createWebhookPublishHandler(baseOptions, eventBus, logger); + + const onHandler = mockHandlerInstance.on.mock.calls.find( + (call) => call[0] === "unhandled_event", + ); + expect(onHandler).toBeDefined(); + + const unknownEvent = { + ...createdEvent, + event_type: "payment_succeeded", + } as unknown as WebhookEvent; + + const response = { + status: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + await onHandler?.[1]({ event: unknownEvent, response }); + + expect(eventBus.publish).toHaveBeenCalledWith(unknownEvent); + }); + }); + + describe("createChargebeeWebhookProcessor", () => { + it("runs the matching hook using an explicit context", async () => { + const adapter = { + findOne: vi + .fn() + .mockResolvedValueOnce(null) // subscription doesn't exist + .mockResolvedValueOnce({ + id: "user_123", + email: "test@example.com", + chargebeeCustomerId: "cust_123", + }), + findMany: vi.fn().mockResolvedValue([]), + create: vi + .fn() + .mockResolvedValueOnce({ id: "local_sub_123" }) + .mockResolvedValue({ id: "item_123" }), + update: vi.fn().mockResolvedValue({}), + deleteMany: vi.fn().mockResolvedValue(undefined), + }; + + const processor = createChargebeeWebhookProcessor(baseOptions, { + context: { adapter, logger }, + }); + + await processor.process(createdEvent); + + expect(adapter.findOne).toHaveBeenCalledWith({ + model: "subscription", + where: [{ field: "chargebeeSubscriptionId", value: "sub_123" }], + }); + expect(adapter.create).toHaveBeenCalled(); + }); + + it("resolves context lazily from an in-process auth instance", async () => { + const adapter = { + findOne: vi.fn().mockResolvedValue({ + id: "local_sub_123", + referenceId: "user_123", + chargebeeSubscriptionId: "sub_123", + }), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + deleteMany: vi.fn().mockResolvedValue(undefined), + }; + + const auth = { + $context: Promise.resolve({ adapter, logger }), + }; + + const cancelEvent: WebhookEvent = + { + ...createdEvent, + event_type: + "subscription_cancelled" as WebhookEventType.SubscriptionCancelled, + content: { + subscription: { + id: "sub_123", + customer_id: "cust_123", + status: "cancelled", + cancelled_at: 1234567890, + object: "subscription", + }, + }, + } as WebhookEvent; + + const processor = createChargebeeWebhookProcessor(baseOptions, { auth }); + await processor.process(cancelEvent); + + expect(adapter.update).toHaveBeenCalledWith( + expect.objectContaining({ + model: "subscription", + update: expect.objectContaining({ status: "cancelled" }), + }), + ); + }); + + it("logs and ignores unknown event types", async () => { + const adapter = { + findOne: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + deleteMany: vi.fn(), + }; + + const processor = createChargebeeWebhookProcessor(baseOptions, { + context: { adapter, logger }, + }); + + const unknownEvent = { + ...createdEvent, + event_type: "payment_succeeded", + } as unknown as WebhookEvent; + + await processor.process(unknownEvent); + + expect(adapter.findOne).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("Unhandled Chargebee webhook event"), + ); + }); + }); +}); From 2bfcfa8b7d605eb0ad18b6262968e9687cdd9b89 Mon Sep 17 00:00:00 2001 From: Srinath Sankar Date: Wed, 1 Jul 2026 12:47:53 +1000 Subject: [PATCH 2/2] formatting fix --- packages/better-auth/src/types.ts | 82 +++++++++++++++---------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/better-auth/src/types.ts b/packages/better-auth/src/types.ts index cbaa1cd..2b2e1cd 100644 --- a/packages/better-auth/src/types.ts +++ b/packages/better-auth/src/types.ts @@ -156,48 +156,48 @@ export interface ChargebeeWebhookEventBus { export type ChargebeeCustomerCreateParams = Partial; export interface ChargebeeOptions { - chargebeeClient: InstanceType; - 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. - */ + chargebeeClient: InstanceType; + 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; + /** + * Optional event bus used to decouple webhook ingestion from processing. + * + * When set, the webhook endpoint in the app is exptected to validate and + * parses each event and calls `webhookEventBus.publish(event)` (typically pushing it onto an application + * queue) instead of running the DB-sync hooks inline. Process queued events + * later with `createChargebeeWebhookProcessor`. + * + * When not set, events are processed synchronously within the request. + */ + webhookEventBus?: ChargebeeWebhookEventBus; + subscription?: SubscriptionOptions; + organization?: { + enabled: boolean; getCustomerCreateParams?: ( - user: User, - ctx?: Record, - ) => - | Promise> - | Partial; - onCustomerCreate?: (params: CustomerCreateParams) => Promise | void; - webhookHandler?: (handler: WebhookHandler) => void; - /** - * Optional event bus used to decouple webhook ingestion from processing. - * - * When set, the webhook endpoint in the app is exptected to validate and - * parses each event and calls `webhookEventBus.publish(event)` (typically pushing it onto an application - * queue) instead of running the DB-sync hooks inline. Process queued events - * later with `createChargebeeWebhookProcessor`. - * - * When not set, events are processed synchronously within the request. - */ - webhookEventBus?: ChargebeeWebhookEventBus; - subscription?: SubscriptionOptions; - organization?: { - enabled: boolean; - getCustomerCreateParams?: ( - organization: Organization & WithChargebeeCustomerId, - ctx: Record, - ) => Promise>; - onCustomerCreate?: ( - params: OrganizationCustomerCreateParams, - ctx: Record, - ) => Promise | void; - }; - } + organization: Organization & WithChargebeeCustomerId, + ctx: Record, + ) => Promise>; + onCustomerCreate?: ( + params: OrganizationCustomerCreateParams, + ctx: Record, + ) => Promise | void; + }; +} export interface Subscription { id: string;