From 0705a5f74dc479e03cfd21e8693d8603f5f491c4 Mon Sep 17 00:00:00 2001 From: Yudi Xiao <463708580@qq.com> Date: Tue, 14 Oct 2025 10:42:41 +0800 Subject: [PATCH] v1.3.27-c: Add adjustment/summary action for meter event --- package.json | 2 +- src/index.ts | 2784 ++++++++++++++++++++++++++------------------------ src/types.ts | 641 ++++++------ src/utils.ts | 42 +- 4 files changed, 1792 insertions(+), 1677 deletions(-) diff --git a/package.json b/package.json index c025731..f4656fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@bowong/better-auth-stripe", "author": "Bowong", - "version": "1.3.27-a", + "version": "1.3.27-c", "main": "dist/index.cjs", "license": "MIT", "keywords": [ diff --git a/src/index.ts b/src/index.ts index 6bc4c94..67eb14b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,1227 +1,1221 @@ import { - type GenericEndpointContext, - type BetterAuthPlugin, - logger, + type GenericEndpointContext, + type BetterAuthPlugin, + logger, } from "better-auth"; -import { createAuthEndpoint, createAuthMiddleware } from "better-auth/plugins"; +import {createAuthEndpoint, createAuthMiddleware} from "better-auth/plugins"; import Stripe from "stripe"; -import { type Stripe as StripeType } from "stripe"; +import {type Stripe as StripeType} from "stripe"; import * as z from "zod/v4"; import { - sessionMiddleware, - APIError, - originCheck, - getSessionFromCtx, + sessionMiddleware, + APIError, + originCheck, + getSessionFromCtx, } from "better-auth/api"; import { - onCheckoutSessionCompleted, - onSubscriptionDeleted, - onSubscriptionUpdated, + onCheckoutSessionCompleted, + onSubscriptionDeleted, + onSubscriptionUpdated, } from "./hooks"; import type { - InputSubscription, - StripeOptions, - StripePlan, - Subscription, + actionType, + InputSubscription, + StripeOptions, + StripePlan, + Subscription, } from "./types"; -import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils"; -import { getSchema } from "./schema"; -import { defu } from "defu"; +import {getPlanByName, getPlanByPriceInfo, getPlans} from "./utils"; +import {getSchema} from "./schema"; +import {defu} from "defu"; const STRIPE_ERROR_CODES = { - SUBSCRIPTION_NOT_FOUND: "Subscription not found", - SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found", - ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan", - UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer", - FAILED_TO_FETCH_PLANS: "Failed to fetch plans", - EMAIL_VERIFICATION_REQUIRED: - "Email verification is required before you can subscribe to a plan", - SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active", - SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION: - "Subscription is not scheduled for cancellation", + SUBSCRIPTION_NOT_FOUND: "Subscription not found", + SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found", + ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan", + UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer", + FAILED_TO_FETCH_PLANS: "Failed to fetch plans", + EMAIL_VERIFICATION_REQUIRED: + "Email verification is required before you can subscribe to a plan", + SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active", + SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION: + "Subscription is not scheduled for cancellation", } as const; const getUrl = (ctx: GenericEndpointContext, url: string) => { - if (url.startsWith("http")) { - return url; - } - return `${ctx.context.options.baseURL}${ - url.startsWith("/") ? url : `/${url}` - }`; + if (url.startsWith("http")) { + return url; + } + return `${ctx.context.options.baseURL}${ + url.startsWith("/") ? url : `/${url}` + }`; }; async function resolvePriceIdFromLookupKey( - stripeClient: Stripe, - lookupKey: string, + stripeClient: Stripe, + lookupKey: string, ): Promise { - if (!lookupKey) return undefined; - const prices = await stripeClient.prices.list({ - lookup_keys: [lookupKey], - active: true, - limit: 1, - }); - return prices.data[0]?.id; + if (!lookupKey) return undefined; + const prices = await stripeClient.prices.list({ + lookup_keys: [lookupKey], + active: true, + limit: 1, + }); + return prices.data[0]?.id; } export const stripe = (options: O) => { - const client = options.stripeClient; + const client = options.stripeClient; - const referenceMiddleware = ( - action: - | "upgrade-subscription" - | "list-subscription" - | "cancel-subscription" - | "restore-subscription" - | "meter-event" - | "billing-portal", - ) => - createAuthMiddleware(async (ctx) => { - const session = ctx.context.session; - if (!session) { - throw new APIError("UNAUTHORIZED"); - } - const referenceId = - ctx.body?.referenceId || ctx.query?.referenceId || session.user.id; + const referenceMiddleware = ( + action: actionType, + ) => + createAuthMiddleware(async (ctx) => { + const session = ctx.context.session; + if (!session) { + throw new APIError("UNAUTHORIZED"); + } + const referenceId = + ctx.body?.referenceId || ctx.query?.referenceId || session.user.id; - if (ctx.body?.referenceId && !options.subscription?.authorizeReference) { - logger.error( - `Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`, - ); - throw new APIError("BAD_REQUEST", { - message: - "Reference id is not allowed. Read server logs for more details.", - }); - } - const isAuthorized = ctx.body?.referenceId - ? await options.subscription?.authorizeReference?.( - { - user: session.user, - session: session.session, - referenceId, - action, - }, - ctx, - ) - : true; - if (!isAuthorized) { - throw new APIError("UNAUTHORIZED", { - message: "Unauthorized", - }); - } - }); + if (ctx.body?.referenceId && !options.subscription?.authorizeReference) { + logger.error( + `Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`, + ); + throw new APIError("BAD_REQUEST", { + message: + "Reference id is not allowed. Read server logs for more details.", + }); + } + const isAuthorized = ctx.body?.referenceId + ? await options.subscription?.authorizeReference?.( + { + user: session.user, + session: session.session, + referenceId, + action, + }, + ctx, + ) + : true; + if (!isAuthorized) { + throw new APIError("UNAUTHORIZED", { + message: "Unauthorized", + }); + } + }); - const subscriptionEndpoints = { - /** - * ### Endpoint - * - * POST `/subscription/upgrade` - * - * ### API Methods - * - * **server:** - * `auth.api.upgradeSubscription` - * - * **client:** - * `authClient.subscription.upgrade` - * - * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-upgrade) - */ - upgradeSubscription: createAuthEndpoint( - "/subscription/upgrade", - { - method: "POST", - body: z.object({ - /** - * The name of the plan to subscribe - */ - plan: z.string().meta({ - description: 'The name of the plan to upgrade to. Eg: "pro"', - }), - /** - * If annual plan should be applied. - */ - annual: z - .boolean() - .meta({ - description: "Whether to upgrade to an annual plan. Eg: true", - }) - .optional(), - /** - * Reference id of the subscription to upgrade - * This is used to identify the subscription to upgrade - * If not provided, the user's id will be used - */ - referenceId: z - .string() - .meta({ - description: - 'Reference id of the subscription to upgrade. Eg: "123"', - }) - .optional(), - /** - * This is to allow a specific subscription to be upgrade. - * If subscription id is provided, and subscription isn't found, - * it'll throw an error. - */ - subscriptionId: z - .string() - .meta({ - description: - 'The id of the subscription to upgrade. Eg: "sub_123"', - }) - .optional(), - /** - * Any additional data you want to store in your database - * subscriptions - */ - metadata: z.record(z.string(), z.any()).optional(), - /** - * If a subscription - */ - seats: z - .number() - .meta({ - description: - "Number of seats to upgrade to (if applicable). Eg: 1", - }) - .optional(), - /** - * Success URL to redirect back after successful subscription - */ - successUrl: z - .string() - .meta({ - description: - 'Callback URL to redirect back after successful subscription. Eg: "https://example.com/success"', - }) - .default("/"), - /** - * Cancel URL - */ - cancelUrl: z - .string() - .meta({ - description: - 'If set, checkout shows a back button and customers will be directed here if they cancel payment. Eg: "https://example.com/pricing"', - }) - .default("/"), - /** - * Return URL - */ - returnUrl: z - .string() - .meta({ - description: - 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', - }) - .optional(), - /** - * Disable Redirect - */ - disableRedirect: z - .boolean() - .meta({ - description: - "Disable redirect after successful subscription. Eg: true", - }) - .default(false), - }), - use: [ - sessionMiddleware, - originCheck((c) => { - return [c.body.successURL as string, c.body.cancelURL as string]; - }), - referenceMiddleware("upgrade-subscription"), - ], - }, - async (ctx) => { - const { user, session } = ctx.context.session; - if ( - !user.emailVerified && - options.subscription?.requireEmailVerification - ) { - throw new APIError("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED, - }); - } - const referenceId = ctx.body.referenceId || user.id; - const plan = await getPlanByName(options, ctx.body.plan); - if (!plan) { - throw new APIError("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND, - }); - } - const subscriptionToUpdate = ctx.body.subscriptionId - ? await ctx.context.adapter.findOne({ - model: "subscription", - where: [ - { - field: "id", - value: ctx.body.subscriptionId, - connector: "OR", - }, - { - field: "stripeSubscriptionId", - value: ctx.body.subscriptionId, - connector: "OR", - }, - ], - }) - : referenceId - ? await ctx.context.adapter.findOne({ - model: "subscription", - where: [{ field: "referenceId", value: referenceId }], - }) - : null; + const subscriptionEndpoints = { + /** + * ### Endpoint + * + * POST `/subscription/upgrade` + * + * ### API Methods + * + * **server:** + * `auth.api.upgradeSubscription` + * + * **client:** + * `authClient.subscription.upgrade` + * + * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-upgrade) + */ + upgradeSubscription: createAuthEndpoint( + "/subscription/upgrade", + { + method: "POST", + body: z.object({ + /** + * The name of the plan to subscribe + */ + plan: z.string().meta({ + description: 'The name of the plan to upgrade to. Eg: "pro"', + }), + /** + * If annual plan should be applied. + */ + annual: z + .boolean() + .meta({ + description: "Whether to upgrade to an annual plan. Eg: true", + }) + .optional(), + /** + * Reference id of the subscription to upgrade + * This is used to identify the subscription to upgrade + * If not provided, the user's id will be used + */ + referenceId: z + .string() + .meta({ + description: + 'Reference id of the subscription to upgrade. Eg: "123"', + }) + .optional(), + /** + * This is to allow a specific subscription to be upgrade. + * If subscription id is provided, and subscription isn't found, + * it'll throw an error. + */ + subscriptionId: z + .string() + .meta({ + description: + 'The id of the subscription to upgrade. Eg: "sub_123"', + }) + .optional(), + /** + * Any additional data you want to store in your database + * subscriptions + */ + metadata: z.record(z.string(), z.any()).optional(), + /** + * If a subscription + */ + seats: z + .number() + .meta({ + description: + "Number of seats to upgrade to (if applicable). Eg: 1", + }) + .optional(), + /** + * Success URL to redirect back after successful subscription + */ + successUrl: z + .string() + .meta({ + description: + 'Callback URL to redirect back after successful subscription. Eg: "https://example.com/success"', + }) + .default("/"), + /** + * Cancel URL + */ + cancelUrl: z + .string() + .meta({ + description: + 'If set, checkout shows a back button and customers will be directed here if they cancel payment. Eg: "https://example.com/pricing"', + }) + .default("/"), + /** + * Return URL + */ + returnUrl: z + .string() + .meta({ + description: + 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', + }) + .optional(), + /** + * Disable Redirect + */ + disableRedirect: z + .boolean() + .meta({ + description: + "Disable redirect after successful subscription. Eg: true", + }) + .default(false), + }), + use: [ + sessionMiddleware, + originCheck((c) => { + return [c.body.successURL as string, c.body.cancelURL as string]; + }), + referenceMiddleware("upgrade-subscription"), + ], + }, + async (ctx) => { + const {user, session} = ctx.context.session; + if ( + !user.emailVerified && + options.subscription?.requireEmailVerification + ) { + throw new APIError("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED, + }); + } + const referenceId = ctx.body.referenceId || user.id; + const plan = await getPlanByName(options, ctx.body.plan); + if (!plan) { + throw new APIError("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND, + }); + } + const subscriptionToUpdate = ctx.body.subscriptionId + ? await ctx.context.adapter.findOne({ + model: "subscription", + where: [ + { + field: "id", + value: ctx.body.subscriptionId, + connector: "OR", + }, + { + field: "stripeSubscriptionId", + value: ctx.body.subscriptionId, + connector: "OR", + }, + ], + }) + : referenceId + ? await ctx.context.adapter.findOne({ + model: "subscription", + where: [{field: "referenceId", value: referenceId}], + }) + : null; - if (ctx.body.subscriptionId && !subscriptionToUpdate) { - throw new APIError("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, - }); - } + if (ctx.body.subscriptionId && !subscriptionToUpdate) { + throw new APIError("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, + }); + } - let customerId = - subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId; + let customerId = + subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId; - if (!customerId) { - try { - // Try to find existing Stripe customer by email - const existingCustomers = await client.customers.list({ - email: user.email, - limit: 1, - }); + if (!customerId) { + try { + // Try to find existing Stripe customer by email + const existingCustomers = await client.customers.list({ + email: user.email, + limit: 1, + }); - let stripeCustomer = existingCustomers.data[0]; + let stripeCustomer = existingCustomers.data[0]; - if (!stripeCustomer) { - stripeCustomer = await client.customers.create({ - email: user.email, - name: user.name, - metadata: { - ...ctx.body.metadata, - userId: user.id, - }, - }); - } + if (!stripeCustomer) { + stripeCustomer = await client.customers.create({ + email: user.email, + name: user.name, + metadata: { + ...ctx.body.metadata, + userId: user.id, + }, + }); + } - // Update local DB with Stripe customer ID - await ctx.context.adapter.update({ - model: "user", - update: { - stripeCustomerId: stripeCustomer.id, - }, - where: [ - { - field: "id", - value: user.id, - }, - ], - }); + // Update local DB with Stripe customer ID + await ctx.context.adapter.update({ + model: "user", + update: { + stripeCustomerId: stripeCustomer.id, + }, + where: [ + { + field: "id", + value: user.id, + }, + ], + }); - customerId = stripeCustomer.id; - } catch (e: any) { - ctx.context.logger.error(e); - throw new APIError("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, - }); - } - } + customerId = stripeCustomer.id; + } catch (e: any) { + ctx.context.logger.error(e); + throw new APIError("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, + }); + } + } - const subscriptions = subscriptionToUpdate - ? [subscriptionToUpdate] - : await ctx.context.adapter.findMany({ - model: "subscription", - where: [ - { - field: "referenceId", - value: ctx.body.referenceId || user.id, - }, - ], - }); + const subscriptions = subscriptionToUpdate + ? [subscriptionToUpdate] + : await ctx.context.adapter.findMany({ + model: "subscription", + where: [ + { + field: "referenceId", + value: ctx.body.referenceId || user.id, + }, + ], + }); - const activeOrTrialingSubscription = subscriptions.find( - (sub) => sub.status === "active" || sub.status === "trialing", - ); + const activeOrTrialingSubscription = subscriptions.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ); - const activeSubscriptions = await client.subscriptions - .list({ - customer: customerId, - }) - .then((res) => - res.data.filter( - (sub) => sub.status === "active" || sub.status === "trialing", - ), - ); + const activeSubscriptions = await client.subscriptions + .list({ + customer: customerId, + }) + .then((res) => + res.data.filter( + (sub) => sub.status === "active" || sub.status === "trialing", + ), + ); - const activeSubscription = activeSubscriptions.find((sub) => { - // If we have a specific subscription to update, match by ID - if ( - subscriptionToUpdate?.stripeSubscriptionId || - ctx.body.subscriptionId - ) { - return ( - sub.id === subscriptionToUpdate?.stripeSubscriptionId || - sub.id === ctx.body.subscriptionId - ); - } - // Only find subscription for the same referenceId to avoid mixing personal and org subscriptions - if (activeOrTrialingSubscription?.stripeSubscriptionId) { - return sub.id === activeOrTrialingSubscription.stripeSubscriptionId; - } - return false; - }); + const activeSubscription = activeSubscriptions.find((sub) => { + // If we have a specific subscription to update, match by ID + if ( + subscriptionToUpdate?.stripeSubscriptionId || + ctx.body.subscriptionId + ) { + return ( + sub.id === subscriptionToUpdate?.stripeSubscriptionId || + sub.id === ctx.body.subscriptionId + ); + } + // Only find subscription for the same referenceId to avoid mixing personal and org subscriptions + if (activeOrTrialingSubscription?.stripeSubscriptionId) { + return sub.id === activeOrTrialingSubscription.stripeSubscriptionId; + } + return false; + }); - // Also find any incomplete subscription that we can reuse - const incompleteSubscription = subscriptions.find( - (sub) => sub.status === "incomplete", - ); + // Also find any incomplete subscription that we can reuse + const incompleteSubscription = subscriptions.find( + (sub) => sub.status === "incomplete", + ); - if ( - activeOrTrialingSubscription && - activeOrTrialingSubscription.status === "active" && - activeOrTrialingSubscription.plan === ctx.body.plan && - activeOrTrialingSubscription.seats === (ctx.body.seats || 1) - ) { - throw new APIError("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN, - }); - } + if ( + activeOrTrialingSubscription && + activeOrTrialingSubscription.status === "active" && + activeOrTrialingSubscription.plan === ctx.body.plan && + activeOrTrialingSubscription.seats === (ctx.body.seats || 1) + ) { + throw new APIError("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN, + }); + } - if (activeSubscription && customerId) { - // Find the corresponding database subscription for this Stripe subscription - let dbSubscription = await ctx.context.adapter.findOne({ - model: "subscription", - where: [ - { - field: "stripeSubscriptionId", - value: activeSubscription.id, - }, - ], - }); + if (activeSubscription && customerId) { + // Find the corresponding database subscription for this Stripe subscription + let dbSubscription = await ctx.context.adapter.findOne({ + model: "subscription", + where: [ + { + field: "stripeSubscriptionId", + value: activeSubscription.id, + }, + ], + }); - // If no database record exists for this Stripe subscription, update the existing one - if (!dbSubscription && activeOrTrialingSubscription) { - await ctx.context.adapter.update({ - model: "subscription", - update: { - stripeSubscriptionId: activeSubscription.id, - updatedAt: new Date(), - }, - where: [ - { - field: "id", - value: activeOrTrialingSubscription.id, - }, - ], - }); - dbSubscription = activeOrTrialingSubscription; - } + // If no database record exists for this Stripe subscription, update the existing one + if (!dbSubscription && activeOrTrialingSubscription) { + await ctx.context.adapter.update({ + model: "subscription", + update: { + stripeSubscriptionId: activeSubscription.id, + updatedAt: new Date(), + }, + where: [ + { + field: "id", + value: activeOrTrialingSubscription.id, + }, + ], + }); + dbSubscription = activeOrTrialingSubscription; + } - // Resolve price ID if using lookup keys - let priceIdToUse: string | undefined = undefined; - if (ctx.body.annual) { - priceIdToUse = plan.annualDiscountPriceId; - if (!priceIdToUse && plan.annualDiscountLookupKey) { - priceIdToUse = await resolvePriceIdFromLookupKey( - client, - plan.annualDiscountLookupKey, - ); - } - } else { - priceIdToUse = plan.priceId; - if (!priceIdToUse && plan.lookupKey) { - priceIdToUse = await resolvePriceIdFromLookupKey( - client, - plan.lookupKey, - ); - } - } + // Resolve price ID if using lookup keys + let priceIdToUse: string | undefined = undefined; + if (ctx.body.annual) { + priceIdToUse = plan.annualDiscountPriceId; + if (!priceIdToUse && plan.annualDiscountLookupKey) { + priceIdToUse = await resolvePriceIdFromLookupKey( + client, + plan.annualDiscountLookupKey, + ); + } + } else { + priceIdToUse = plan.priceId; + if (!priceIdToUse && plan.lookupKey) { + priceIdToUse = await resolvePriceIdFromLookupKey( + client, + plan.lookupKey, + ); + } + } - if (!priceIdToUse) { - throw ctx.error("BAD_REQUEST", { - message: "Price ID not found for the selected plan", - }); - } + if (!priceIdToUse) { + throw ctx.error("BAD_REQUEST", { + message: "Price ID not found for the selected plan", + }); + } - const { url } = await client.billingPortal.sessions - .create({ - customer: customerId, - return_url: getUrl(ctx, ctx.body.returnUrl || "/"), - flow_data: { - type: "subscription_update_confirm", - after_completion: { - type: "redirect", - redirect: { - return_url: getUrl(ctx, ctx.body.returnUrl || "/"), - }, - }, - subscription_update_confirm: { - subscription: activeSubscription.id, - items: [ - { - id: activeSubscription.items.data[0]?.id as string, - quantity: ctx.body.seats || 1, - price: priceIdToUse, - }, - ], - }, - }, - }) - .catch(async (e) => { - throw ctx.error("BAD_REQUEST", { - message: e.message, - code: e.code, - }); - }); - return ctx.json({ - url, - redirect: true, - }); - } + const {url} = await client.billingPortal.sessions + .create({ + customer: customerId, + return_url: getUrl(ctx, ctx.body.returnUrl || "/"), + flow_data: { + type: "subscription_update_confirm", + after_completion: { + type: "redirect", + redirect: { + return_url: getUrl(ctx, ctx.body.returnUrl || "/"), + }, + }, + subscription_update_confirm: { + subscription: activeSubscription.id, + items: [ + { + id: activeSubscription.items.data[0]?.id as string, + quantity: ctx.body.seats || 1, + price: priceIdToUse, + }, + ], + }, + }, + }) + .catch(async (e) => { + throw ctx.error("BAD_REQUEST", { + message: e.message, + code: e.code, + }); + }); + return ctx.json({ + url, + redirect: true, + }); + } - let subscription: Subscription | undefined = - activeOrTrialingSubscription || incompleteSubscription; + let subscription: Subscription | undefined = + activeOrTrialingSubscription || incompleteSubscription; - if (incompleteSubscription && !activeOrTrialingSubscription) { - const updated = await ctx.context.adapter.update({ - model: "subscription", - update: { - plan: plan.name.toLowerCase(), - seats: ctx.body.seats || 1, - updatedAt: new Date(), - }, - where: [ - { - field: "id", - value: incompleteSubscription.id, - }, - ], - }); - subscription = (updated as Subscription) || incompleteSubscription; - } + if (incompleteSubscription && !activeOrTrialingSubscription) { + const updated = await ctx.context.adapter.update({ + model: "subscription", + update: { + plan: plan.name.toLowerCase(), + seats: ctx.body.seats || 1, + updatedAt: new Date(), + }, + where: [ + { + field: "id", + value: incompleteSubscription.id, + }, + ], + }); + subscription = (updated as Subscription) || incompleteSubscription; + } - if (!subscription) { - subscription = await ctx.context.adapter.create< - InputSubscription, - Subscription - >({ - model: "subscription", - data: { - plan: plan.name.toLowerCase(), - stripeCustomerId: customerId, - status: "incomplete", - referenceId, - seats: ctx.body.seats || 1, - }, - }); - } + if (!subscription) { + subscription = await ctx.context.adapter.create< + InputSubscription, + Subscription + >({ + model: "subscription", + data: { + plan: plan.name.toLowerCase(), + stripeCustomerId: customerId, + status: "incomplete", + referenceId, + seats: ctx.body.seats || 1, + }, + }); + } - if (!subscription) { - ctx.context.logger.error("Subscription ID not found"); - throw new APIError("INTERNAL_SERVER_ERROR"); - } + if (!subscription) { + ctx.context.logger.error("Subscription ID not found"); + throw new APIError("INTERNAL_SERVER_ERROR"); + } - const params = await options.subscription?.getCheckoutSessionParams?.( - { - user, - session, - plan, - subscription, - }, - ctx.request, - //@ts-expect-error - ctx, - ); + const params = await options.subscription?.getCheckoutSessionParams?.( + { + user, + session, + plan, + subscription, + }, + ctx.request, + //@ts-expect-error + ctx, + ); - const hasEverTrialed = subscriptions.some((s) => { - // Check if user has ever had a trial for any plan (not just the same plan) - // This prevents users from getting multiple trials by switching plans - const hadTrial = - !!(s.trialStart || s.trialEnd) || s.status === "trialing"; - return hadTrial; - }); + const hasEverTrialed = subscriptions.some((s) => { + // Check if user has ever had a trial for any plan (not just the same plan) + // This prevents users from getting multiple trials by switching plans + const hadTrial = + !!(s.trialStart || s.trialEnd) || s.status === "trialing"; + return hadTrial; + }); - const freeTrial = - !hasEverTrialed && plan.freeTrial - ? { trial_period_days: plan.freeTrial.days } - : undefined; + const freeTrial = + !hasEverTrialed && plan.freeTrial + ? {trial_period_days: plan.freeTrial.days} + : undefined; - let priceIdToUse: string | undefined = undefined; - if (ctx.body.annual) { - priceIdToUse = plan.annualDiscountPriceId; - if (!priceIdToUse && plan.annualDiscountLookupKey) { - priceIdToUse = await resolvePriceIdFromLookupKey( - client, - plan.annualDiscountLookupKey, - ); - } - } else { - priceIdToUse = plan.priceId; - if (!priceIdToUse && plan.lookupKey) { - priceIdToUse = await resolvePriceIdFromLookupKey( - client, - plan.lookupKey, - ); - } - } - const checkoutSession = await client.checkout.sessions - .create( - { - ...(customerId - ? { - customer: customerId, - customer_update: { - name: "auto", - address: "auto", - }, - } - : { - customer_email: session.user.email, - }), - success_url: getUrl( - ctx, - `${ - ctx.context.baseURL - }/subscription/success?callbackURL=${encodeURIComponent( - ctx.body.successUrl, - )}&subscriptionId=${encodeURIComponent(subscription.id)}`, - ), - cancel_url: getUrl(ctx, ctx.body.cancelUrl), - line_items: [ - { - price: priceIdToUse, - quantity: ctx.body.seats || 1, - }, - ], - subscription_data: { - ...freeTrial, - }, - mode: "subscription", - client_reference_id: referenceId, - ...params?.params, - metadata: { - userId: user.id, - subscriptionId: subscription.id, - referenceId, - ...params?.params?.metadata, - }, - }, - params?.options, - ) - .catch(async (e) => { - throw ctx.error("BAD_REQUEST", { - message: e.message, - code: e.code, - }); - }); - return ctx.json({ - ...checkoutSession, - redirect: !ctx.body.disableRedirect, - }); - }, - ), - cancelSubscriptionCallback: createAuthEndpoint( - "/subscription/cancel/callback", - { - method: "GET", - query: z.record(z.string(), z.any()).optional(), - use: [originCheck((ctx) => ctx.query.callbackURL)], - }, - async (ctx) => { - if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { - throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); - } - const session = await getSessionFromCtx<{ stripeCustomerId: string }>( - ctx, - ); - if (!session) { - throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); - } - const { user } = session; - const { callbackURL, subscriptionId } = ctx.query; + let priceIdToUse: string | undefined = undefined; + if (ctx.body.annual) { + priceIdToUse = plan.annualDiscountPriceId; + if (!priceIdToUse && plan.annualDiscountLookupKey) { + priceIdToUse = await resolvePriceIdFromLookupKey( + client, + plan.annualDiscountLookupKey, + ); + } + } else { + priceIdToUse = plan.priceId; + if (!priceIdToUse && plan.lookupKey) { + priceIdToUse = await resolvePriceIdFromLookupKey( + client, + plan.lookupKey, + ); + } + } + const checkoutSession = await client.checkout.sessions + .create( + { + ...(customerId + ? { + customer: customerId, + customer_update: { + name: "auto", + address: "auto", + }, + } + : { + customer_email: session.user.email, + }), + success_url: getUrl( + ctx, + `${ + ctx.context.baseURL + }/subscription/success?callbackURL=${encodeURIComponent( + ctx.body.successUrl, + )}&subscriptionId=${encodeURIComponent(subscription.id)}`, + ), + cancel_url: getUrl(ctx, ctx.body.cancelUrl), + line_items: [ + { + price: priceIdToUse, + quantity: ctx.body.seats || 1, + }, + ], + subscription_data: { + ...freeTrial, + }, + mode: "subscription", + client_reference_id: referenceId, + ...params?.params, + metadata: { + userId: user.id, + subscriptionId: subscription.id, + referenceId, + ...params?.params?.metadata, + }, + }, + params?.options, + ) + .catch(async (e) => { + throw ctx.error("BAD_REQUEST", { + message: e.message, + code: e.code, + }); + }); + return ctx.json({ + ...checkoutSession, + redirect: !ctx.body.disableRedirect, + }); + }, + ), + cancelSubscriptionCallback: createAuthEndpoint( + "/subscription/cancel/callback", + { + method: "GET", + query: z.record(z.string(), z.any()).optional(), + use: [originCheck((ctx) => ctx.query.callbackURL)], + }, + async (ctx) => { + if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { + throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); + } + const session = await getSessionFromCtx<{ stripeCustomerId: string }>( + ctx, + ); + if (!session) { + throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); + } + const {user} = session; + const {callbackURL, subscriptionId} = ctx.query; - if (user?.stripeCustomerId) { - try { - const subscription = - await ctx.context.adapter.findOne({ - model: "subscription", - where: [ - { - field: "id", - value: subscriptionId, - }, - ], - }); - if ( - !subscription || - subscription.cancelAtPeriodEnd || - subscription.status === "canceled" - ) { - throw ctx.redirect(getUrl(ctx, callbackURL)); - } + if (user?.stripeCustomerId) { + try { + const subscription = + await ctx.context.adapter.findOne({ + model: "subscription", + where: [ + { + field: "id", + value: subscriptionId, + }, + ], + }); + if ( + !subscription || + subscription.cancelAtPeriodEnd || + subscription.status === "canceled" + ) { + throw ctx.redirect(getUrl(ctx, callbackURL)); + } - const stripeSubscription = await client.subscriptions.list({ - customer: user.stripeCustomerId, - status: "active", - }); - const currentSubscription = stripeSubscription.data.find( - (sub) => sub.id === subscription.stripeSubscriptionId, - ); - if (currentSubscription?.cancel_at_period_end === true) { - await ctx.context.adapter.update({ - model: "subscription", - update: { - status: currentSubscription?.status, - cancelAtPeriodEnd: true, - }, - where: [ - { - field: "id", - value: subscription.id, - }, - ], - }); - await options.subscription?.onSubscriptionCancel?.({ - subscription, - cancellationDetails: currentSubscription.cancellation_details, - stripeSubscription: currentSubscription, - event: undefined, - }); - } - } catch (error) { - ctx.context.logger.error( - "Error checking subscription status from Stripe", - error, - ); - } - } - throw ctx.redirect(getUrl(ctx, callbackURL)); - }, - ), - /** - * ### Endpoint - * - * POST `/subscription/cancel` - * - * ### API Methods - * - * **server:** - * `auth.api.cancelSubscription` - * - * **client:** - * `authClient.subscription.cancel` - * - * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-cancel) - */ - cancelSubscription: createAuthEndpoint( - "/subscription/cancel", - { - method: "POST", - body: z.object({ - referenceId: z - .string() - .meta({ - description: - "Reference id of the subscription to cancel. Eg: '123'", - }) - .optional(), - subscriptionId: z - .string() - .meta({ - description: - "The id of the subscription to cancel. Eg: 'sub_123'", - }) - .optional(), - returnUrl: z.string().meta({ - description: - 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', - }), - }), - use: [ - sessionMiddleware, - originCheck((ctx) => ctx.body.returnUrl), - referenceMiddleware("cancel-subscription"), - ], - }, - async (ctx) => { - const referenceId = - ctx.body?.referenceId || ctx.context.session.user.id; - const subscription = ctx.body.subscriptionId - ? await ctx.context.adapter.findOne({ - model: "subscription", - where: [ - { - field: "id", - value: ctx.body.subscriptionId, - }, - ], - }) - : await ctx.context.adapter - .findMany({ - model: "subscription", - where: [{ field: "referenceId", value: referenceId }], - }) - .then((subs) => - subs.find( - (sub) => sub.status === "active" || sub.status === "trialing", - ), - ); + const stripeSubscription = await client.subscriptions.list({ + customer: user.stripeCustomerId, + status: "active", + }); + const currentSubscription = stripeSubscription.data.find( + (sub) => sub.id === subscription.stripeSubscriptionId, + ); + if (currentSubscription?.cancel_at_period_end === true) { + await ctx.context.adapter.update({ + model: "subscription", + update: { + status: currentSubscription?.status, + cancelAtPeriodEnd: true, + }, + where: [ + { + field: "id", + value: subscription.id, + }, + ], + }); + await options.subscription?.onSubscriptionCancel?.({ + subscription, + cancellationDetails: currentSubscription.cancellation_details, + stripeSubscription: currentSubscription, + event: undefined, + }); + } + } catch (error) { + ctx.context.logger.error( + "Error checking subscription status from Stripe", + error, + ); + } + } + throw ctx.redirect(getUrl(ctx, callbackURL)); + }, + ), + /** + * ### Endpoint + * + * POST `/subscription/cancel` + * + * ### API Methods + * + * **server:** + * `auth.api.cancelSubscription` + * + * **client:** + * `authClient.subscription.cancel` + * + * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-cancel) + */ + cancelSubscription: createAuthEndpoint( + "/subscription/cancel", + { + method: "POST", + body: z.object({ + referenceId: z + .string() + .meta({ + description: + "Reference id of the subscription to cancel. Eg: '123'", + }) + .optional(), + subscriptionId: z + .string() + .meta({ + description: + "The id of the subscription to cancel. Eg: 'sub_123'", + }) + .optional(), + returnUrl: z.string().meta({ + description: + 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"', + }), + }), + use: [ + sessionMiddleware, + originCheck((ctx) => ctx.body.returnUrl), + referenceMiddleware("cancel-subscription"), + ], + }, + async (ctx) => { + const referenceId = + ctx.body?.referenceId || ctx.context.session.user.id; + const subscription = ctx.body.subscriptionId + ? await ctx.context.adapter.findOne({ + model: "subscription", + where: [ + { + field: "id", + value: ctx.body.subscriptionId, + }, + ], + }) + : await ctx.context.adapter + .findMany({ + model: "subscription", + where: [{field: "referenceId", value: referenceId}], + }) + .then((subs) => + subs.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ), + ); - if (!subscription || !subscription.stripeCustomerId) { - throw ctx.error("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, - }); - } - const activeSubscriptions = await client.subscriptions - .list({ - customer: subscription.stripeCustomerId, - }) - .then((res) => - res.data.filter( - (sub) => sub.status === "active" || sub.status === "trialing", - ), - ); - if (!activeSubscriptions.length) { - /** - * If the subscription is not found, we need to delete the subscription - * from the database. This is a rare case and should not happen. - */ - await ctx.context.adapter.deleteMany({ - model: "subscription", - where: [ - { - field: "referenceId", - value: referenceId, - }, - ], - }); - throw ctx.error("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, - }); - } - const activeSubscription = activeSubscriptions.find( - (sub) => sub.id === subscription.stripeSubscriptionId, - ); - if (!activeSubscription) { - throw ctx.error("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, - }); - } - const { url } = await client.billingPortal.sessions - .create({ - customer: subscription.stripeCustomerId, - return_url: getUrl( - ctx, - `${ - ctx.context.baseURL - }/subscription/cancel/callback?callbackURL=${encodeURIComponent( - ctx.body?.returnUrl || "/", - )}&subscriptionId=${encodeURIComponent(subscription.id)}`, - ), - flow_data: { - type: "subscription_cancel", - subscription_cancel: { - subscription: activeSubscription.id, - }, - }, - }) - .catch(async (e) => { - if (e.message.includes("already set to be cancel")) { - /** - * incase we missed the event from stripe, we set it manually - * this is a rare case and should not happen - */ - if (!subscription.cancelAtPeriodEnd) { - await ctx.context.adapter.update({ - model: "subscription", - update: { - cancelAtPeriodEnd: true, - }, - where: [ - { - field: "referenceId", - value: referenceId, - }, - ], - }); - } - } - throw ctx.error("BAD_REQUEST", { - message: e.message, - code: e.code, - }); - }); - return { - url, - redirect: true, - }; - }, - ), - restoreSubscription: createAuthEndpoint( - "/subscription/restore", - { - method: "POST", - body: z.object({ - referenceId: z - .string() - .meta({ - description: - "Reference id of the subscription to restore. Eg: '123'", - }) - .optional(), - subscriptionId: z - .string() - .meta({ - description: - "The id of the subscription to restore. Eg: 'sub_123'", - }) - .optional(), - }), - use: [sessionMiddleware, referenceMiddleware("restore-subscription")], - }, - async (ctx) => { - const referenceId = - ctx.body?.referenceId || ctx.context.session.user.id; + if (!subscription || !subscription.stripeCustomerId) { + throw ctx.error("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, + }); + } + const activeSubscriptions = await client.subscriptions + .list({ + customer: subscription.stripeCustomerId, + }) + .then((res) => + res.data.filter( + (sub) => sub.status === "active" || sub.status === "trialing", + ), + ); + if (!activeSubscriptions.length) { + /** + * If the subscription is not found, we need to delete the subscription + * from the database. This is a rare case and should not happen. + */ + await ctx.context.adapter.deleteMany({ + model: "subscription", + where: [ + { + field: "referenceId", + value: referenceId, + }, + ], + }); + throw ctx.error("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, + }); + } + const activeSubscription = activeSubscriptions.find( + (sub) => sub.id === subscription.stripeSubscriptionId, + ); + if (!activeSubscription) { + throw ctx.error("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, + }); + } + const {url} = await client.billingPortal.sessions + .create({ + customer: subscription.stripeCustomerId, + return_url: getUrl( + ctx, + `${ + ctx.context.baseURL + }/subscription/cancel/callback?callbackURL=${encodeURIComponent( + ctx.body?.returnUrl || "/", + )}&subscriptionId=${encodeURIComponent(subscription.id)}`, + ), + flow_data: { + type: "subscription_cancel", + subscription_cancel: { + subscription: activeSubscription.id, + }, + }, + }) + .catch(async (e) => { + if (e.message.includes("already set to be cancel")) { + /** + * incase we missed the event from stripe, we set it manually + * this is a rare case and should not happen + */ + if (!subscription.cancelAtPeriodEnd) { + await ctx.context.adapter.update({ + model: "subscription", + update: { + cancelAtPeriodEnd: true, + }, + where: [ + { + field: "referenceId", + value: referenceId, + }, + ], + }); + } + } + throw ctx.error("BAD_REQUEST", { + message: e.message, + code: e.code, + }); + }); + return { + url, + redirect: true, + }; + }, + ), + restoreSubscription: createAuthEndpoint( + "/subscription/restore", + { + method: "POST", + body: z.object({ + referenceId: z + .string() + .meta({ + description: + "Reference id of the subscription to restore. Eg: '123'", + }) + .optional(), + subscriptionId: z + .string() + .meta({ + description: + "The id of the subscription to restore. Eg: 'sub_123'", + }) + .optional(), + }), + use: [sessionMiddleware, referenceMiddleware("restore-subscription")], + }, + async (ctx) => { + const referenceId = + ctx.body?.referenceId || ctx.context.session.user.id; - const subscription = ctx.body.subscriptionId - ? await ctx.context.adapter.findOne({ - model: "subscription", - where: [ - { - field: "id", - value: ctx.body.subscriptionId, - }, - ], - }) - : await ctx.context.adapter - .findMany({ - model: "subscription", - where: [ - { - field: "referenceId", - value: referenceId, - }, - ], - }) - .then((subs) => - subs.find( - (sub) => sub.status === "active" || sub.status === "trialing", - ), - ); - if (!subscription || !subscription.stripeCustomerId) { - throw ctx.error("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, - }); - } - if ( - subscription.status != "active" && - subscription.status != "trialing" - ) { - throw ctx.error("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE, - }); - } - if (!subscription.cancelAtPeriodEnd) { - throw ctx.error("BAD_REQUEST", { - message: - STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION, - }); - } + const subscription = ctx.body.subscriptionId + ? await ctx.context.adapter.findOne({ + model: "subscription", + where: [ + { + field: "id", + value: ctx.body.subscriptionId, + }, + ], + }) + : await ctx.context.adapter + .findMany({ + model: "subscription", + where: [ + { + field: "referenceId", + value: referenceId, + }, + ], + }) + .then((subs) => + subs.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ), + ); + if (!subscription || !subscription.stripeCustomerId) { + throw ctx.error("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, + }); + } + if ( + subscription.status != "active" && + subscription.status != "trialing" + ) { + throw ctx.error("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE, + }); + } + if (!subscription.cancelAtPeriodEnd) { + throw ctx.error("BAD_REQUEST", { + message: + STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION, + }); + } - const activeSubscription = await client.subscriptions - .list({ - customer: subscription.stripeCustomerId, - }) - .then( - (res) => - res.data.filter( - (sub) => sub.status === "active" || sub.status === "trialing", - )[0], - ); - if (!activeSubscription) { - throw ctx.error("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, - }); - } + const activeSubscription = await client.subscriptions + .list({ + customer: subscription.stripeCustomerId, + }) + .then( + (res) => + res.data.filter( + (sub) => sub.status === "active" || sub.status === "trialing", + )[0], + ); + if (!activeSubscription) { + throw ctx.error("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, + }); + } - try { - const newSub = await client.subscriptions.update( - activeSubscription.id, - { - cancel_at_period_end: false, - }, - ); + try { + const newSub = await client.subscriptions.update( + activeSubscription.id, + { + cancel_at_period_end: false, + }, + ); - await ctx.context.adapter.update({ - model: "subscription", - update: { - cancelAtPeriodEnd: false, - updatedAt: new Date(), - }, - where: [ - { - field: "id", - value: subscription.id, - }, - ], - }); + await ctx.context.adapter.update({ + model: "subscription", + update: { + cancelAtPeriodEnd: false, + updatedAt: new Date(), + }, + where: [ + { + field: "id", + value: subscription.id, + }, + ], + }); - return ctx.json(newSub); - } catch (error) { - ctx.context.logger.error("Error restoring subscription", error); - throw new APIError("BAD_REQUEST", { - message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, - }); - } - }, - ), - /** - * ### Endpoint - * - * GET `/subscription/list` - * - * ### API Methods - * - * **server:** - * `auth.api.listActiveSubscriptions` - * - * **client:** - * `authClient.subscription.list` - * - * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-list) - */ - listActiveSubscriptions: createAuthEndpoint( - "/subscription/list", - { - method: "GET", - query: z.optional( - z.object({ - referenceId: z - .string() - .meta({ - description: - "Reference id of the subscription to list. Eg: '123'", - }) - .optional(), - }), - ), - use: [sessionMiddleware, referenceMiddleware("list-subscription")], - }, - async (ctx) => { - const subscriptions = await ctx.context.adapter.findMany({ - model: "subscription", - where: [ - { - field: "referenceId", - value: ctx.query?.referenceId || ctx.context.session.user.id, - }, - ], - }); - if (!subscriptions.length) { - return []; - } - const plans = await getPlans(options); - if (!plans) { - return []; - } - const subs = subscriptions - .map((sub) => { - const plan = plans.find( - (p) => p.name.toLowerCase() === sub.plan.toLowerCase(), - ); - return { - ...sub, - limits: plan?.limits, - priceId: plan?.priceId, - }; - }) - .filter((sub) => { - return sub.status === "active" || sub.status === "trialing"; - }); - return ctx.json(subs); - }, - ), - subscriptionSuccess: createAuthEndpoint( - "/subscription/success", - { - method: "GET", - query: z.record(z.string(), z.any()).optional(), - use: [originCheck((ctx) => ctx.query.callbackURL)], - }, - async (ctx) => { - if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { - throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); - } - const session = await getSessionFromCtx<{ stripeCustomerId: string }>( - ctx, - ); - if (!session) { - throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); - } - const { user } = session; - const { callbackURL, subscriptionId } = ctx.query; + return ctx.json(newSub); + } catch (error) { + ctx.context.logger.error("Error restoring subscription", error); + throw new APIError("BAD_REQUEST", { + message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER, + }); + } + }, + ), + /** + * ### Endpoint + * + * GET `/subscription/list` + * + * ### API Methods + * + * **server:** + * `auth.api.listActiveSubscriptions` + * + * **client:** + * `authClient.subscription.list` + * + * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-list) + */ + listActiveSubscriptions: createAuthEndpoint( + "/subscription/list", + { + method: "GET", + query: z.optional( + z.object({ + referenceId: z + .string() + .meta({ + description: + "Reference id of the subscription to list. Eg: '123'", + }) + .optional(), + }), + ), + use: [sessionMiddleware, referenceMiddleware("list-subscription")], + }, + async (ctx) => { + const subscriptions = await ctx.context.adapter.findMany({ + model: "subscription", + where: [ + { + field: "referenceId", + value: ctx.query?.referenceId || ctx.context.session.user.id, + }, + ], + }); + if (!subscriptions.length) { + return []; + } + const plans = await getPlans(options); + if (!plans) { + return []; + } + const subs = subscriptions + .map((sub) => { + const plan = plans.find( + (p) => p.name.toLowerCase() === sub.plan.toLowerCase(), + ); + return { + ...sub, + limits: plan?.limits, + priceId: plan?.priceId, + }; + }) + .filter((sub) => { + return sub.status === "active" || sub.status === "trialing"; + }); + return ctx.json(subs); + }, + ), + subscriptionSuccess: createAuthEndpoint( + "/subscription/success", + { + method: "GET", + query: z.record(z.string(), z.any()).optional(), + use: [originCheck((ctx) => ctx.query.callbackURL)], + }, + async (ctx) => { + if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) { + throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); + } + const session = await getSessionFromCtx<{ stripeCustomerId: string }>( + ctx, + ); + if (!session) { + throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); + } + const {user} = session; + const {callbackURL, subscriptionId} = ctx.query; - const subscription = await ctx.context.adapter.findOne({ - model: "subscription", - where: [ - { - field: "id", - value: subscriptionId, - }, - ], - }); + const subscription = await ctx.context.adapter.findOne({ + model: "subscription", + where: [ + { + field: "id", + value: subscriptionId, + }, + ], + }); - if ( - subscription?.status === "active" || - subscription?.status === "trialing" - ) { - return ctx.redirect(getUrl(ctx, callbackURL)); - } - const customerId = - subscription?.stripeCustomerId || user.stripeCustomerId; + if ( + subscription?.status === "active" || + subscription?.status === "trialing" + ) { + return ctx.redirect(getUrl(ctx, callbackURL)); + } + const customerId = + subscription?.stripeCustomerId || user.stripeCustomerId; - if (customerId) { - try { - const stripeSubscription = await client.subscriptions - .list({ - customer: customerId, - status: "active", - }) - .then((res) => res.data[0]); + if (customerId) { + try { + const stripeSubscription = await client.subscriptions + .list({ + customer: customerId, + status: "active", + }) + .then((res) => res.data[0]); - if (stripeSubscription) { - const plan = await getPlanByPriceInfo( - options, - stripeSubscription.items.data[0]?.price.id!, - stripeSubscription.items.data[0]?.price.lookup_key!, - ); + if (stripeSubscription) { + const plan = await getPlanByPriceInfo( + options, + stripeSubscription.items.data[0]?.price.id!, + stripeSubscription.items.data[0]?.price.lookup_key!, + ); - if (plan && subscription) { - await ctx.context.adapter.update({ - model: "subscription", - update: { - status: stripeSubscription.status, - seats: stripeSubscription.items.data[0]?.quantity || 1, - plan: plan.name.toLowerCase(), - periodEnd: new Date( - stripeSubscription.items.data[0]?.current_period_end! * - 1000, - ), - periodStart: new Date( - stripeSubscription.items.data[0]?.current_period_start! * - 1000, - ), - stripeSubscriptionId: stripeSubscription.id, - ...(stripeSubscription.trial_start && - stripeSubscription.trial_end - ? { - trialStart: new Date( - stripeSubscription.trial_start * 1000, - ), - trialEnd: new Date( - stripeSubscription.trial_end * 1000, - ), - } - : {}), - }, - where: [ - { - field: "id", - value: subscription.id, - }, - ], - }); - } - } - } catch (error) { - ctx.context.logger.error( - "Error fetching subscription from Stripe", - error, - ); - } - } - throw ctx.redirect(getUrl(ctx, callbackURL)); - }, - ), - createBillingPortal: createAuthEndpoint( - "/subscription/billing-portal", - { - method: "POST", - body: z.object({ - locale: z - .custom((localization) => { - return typeof localization === "string"; - }) - .optional(), - referenceId: z.string().optional(), - returnUrl: z.string().default("/"), - }), - use: [ - sessionMiddleware, - originCheck((ctx) => ctx.body.returnUrl), - referenceMiddleware("billing-portal"), - ], - }, - async (ctx) => { - const { user } = ctx.context.session; - const referenceId = ctx.body.referenceId || user.id; + if (plan && subscription) { + await ctx.context.adapter.update({ + model: "subscription", + update: { + status: stripeSubscription.status, + seats: stripeSubscription.items.data[0]?.quantity || 1, + plan: plan.name.toLowerCase(), + periodEnd: new Date( + stripeSubscription.items.data[0]?.current_period_end! * + 1000, + ), + periodStart: new Date( + stripeSubscription.items.data[0]?.current_period_start! * + 1000, + ), + stripeSubscriptionId: stripeSubscription.id, + ...(stripeSubscription.trial_start && + stripeSubscription.trial_end + ? { + trialStart: new Date( + stripeSubscription.trial_start * 1000, + ), + trialEnd: new Date( + stripeSubscription.trial_end * 1000, + ), + } + : {}), + }, + where: [ + { + field: "id", + value: subscription.id, + }, + ], + }); + } + } + } catch (error) { + ctx.context.logger.error( + "Error fetching subscription from Stripe", + error, + ); + } + } + throw ctx.redirect(getUrl(ctx, callbackURL)); + }, + ), + createBillingPortal: createAuthEndpoint( + "/subscription/billing-portal", + { + method: "POST", + body: z.object({ + locale: z + .custom((localization) => { + return typeof localization === "string"; + }) + .optional(), + referenceId: z.string().optional(), + returnUrl: z.string().default("/"), + }), + use: [ + sessionMiddleware, + originCheck((ctx) => ctx.body.returnUrl), + referenceMiddleware("billing-portal"), + ], + }, + async (ctx) => { + const {user} = ctx.context.session; + const referenceId = ctx.body.referenceId || user.id; - let customerId = user.stripeCustomerId; + let customerId = user.stripeCustomerId; - if (!customerId) { - const subscription = await ctx.context.adapter - .findMany({ - model: "subscription", - where: [ - { - field: "referenceId", - value: referenceId, - }, - ], - }) - .then((subs) => - subs.find( - (sub) => sub.status === "active" || sub.status === "trialing", - ), - ); + if (!customerId) { + const subscription = await ctx.context.adapter + .findMany({ + model: "subscription", + where: [ + { + field: "referenceId", + value: referenceId, + }, + ], + }) + .then((subs) => + subs.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ), + ); - customerId = subscription?.stripeCustomerId; - } + customerId = subscription?.stripeCustomerId; + } - if (!customerId) { - throw new APIError("BAD_REQUEST", { - message: "No Stripe customer found for this user", - }); - } + if (!customerId) { + throw new APIError("BAD_REQUEST", { + message: "No Stripe customer found for this user", + }); + } - try { - const { url } = await client.billingPortal.sessions.create({ - locale: ctx.body.locale, - customer: customerId, - return_url: getUrl(ctx, ctx.body.returnUrl), - }); + try { + const {url} = await client.billingPortal.sessions.create({ + locale: ctx.body.locale, + customer: customerId, + return_url: getUrl(ctx, ctx.body.returnUrl), + }); - return ctx.json({ - url, - redirect: true, - }); - } catch (error: any) { - ctx.context.logger.error( - "Error creating billing portal session", - error, - ); - throw new APIError("BAD_REQUEST", { - message: error.message, - }); - } - }, - ), + return ctx.json({ + url, + redirect: true, + }); + } catch (error: any) { + ctx.context.logger.error( + "Error creating billing portal session", + error, + ); + throw new APIError("BAD_REQUEST", { + message: error.message, + }); + } + }, + ), reportBillingMeterEvent: createAuthEndpoint( "/subscription/meter-event", // https://docs.stripe.com/api/billing/meter-event/create { method: "POST", body: z.object({ - meter_id: z.string(), event_name: z.string(), payload: z.object({ value: z.string(), @@ -1235,7 +1229,7 @@ export const stripe = (options: O) => { ] }, async (ctx) => { - const { user } = ctx.context.session; + const {user} = ctx.context.session; let customerId = user.stripeCustomerId; const referenceId = ctx.body.referenceId || user.id; @@ -1265,17 +1259,17 @@ export const stripe = (options: O) => { }); } - const submitPayload = { - value: ctx.body.payload.value, - stripe_customer_id: customerId, - } - try { - const {identifier, payload, timestamp} = await client.billing.meterEvents.create({ + const bodyPayload = { event_name: ctx.body.event_name, - payload: submitPayload, + payload: { + value: ctx.body.payload.value, + stripe_customer_id: customerId, + }, identifier: ctx.body.identifier - }); + } + + const {identifier, payload, timestamp} = await client.v2.billing.meterEvents.create(bodyPayload); return ctx.json({ identifier, @@ -1293,190 +1287,306 @@ export const stripe = (options: O) => { } } ), - } as const; - return { - id: "stripe", - endpoints: { - stripeWebhook: createAuthEndpoint( - "/stripe/webhook", - { - method: "POST", - metadata: { - isAction: false, - }, - cloneRequest: true, - //don't parse the body - disableBody: true, - }, - async (ctx) => { - if (!ctx.request?.body) { - throw new APIError("INTERNAL_SERVER_ERROR"); - } - const buf = await ctx.request.text(); - const sig = ctx.request.headers.get("stripe-signature") as string; - const webhookSecret = options.stripeWebhookSecret; - let event: Stripe.Event; - try { - if (!sig || !webhookSecret) { - throw new APIError("BAD_REQUEST", { - message: "Stripe webhook secret not found", - }); - } - event = await client.webhooks.constructEventAsync( - buf, - sig, - webhookSecret, - ); - } catch (err: any) { - ctx.context.logger.error(`${err.message}`); - throw new APIError("BAD_REQUEST", { - message: `Webhook Error: ${err.message}`, - }); - } - if (!event) { - throw new APIError("BAD_REQUEST", { - message: "Failed to construct event", - }); - } - try { - switch (event.type) { - case "checkout.session.completed": - await onCheckoutSessionCompleted(ctx, options, event); - await options.onEvent?.(event); - break; - case "customer.subscription.updated": - await onSubscriptionUpdated(ctx, options, event); - await options.onEvent?.(event); - break; - case "customer.subscription.deleted": - await onSubscriptionDeleted(ctx, options, event); - await options.onEvent?.(event); - break; - default: - await options.onEvent?.(event); - break; - } - } catch (e: any) { - ctx.context.logger.error( - `Stripe webhook failed. Error: ${e.message}`, - ); - throw new APIError("BAD_REQUEST", { - message: "Webhook error: See server logs for more information.", - }); - } - return ctx.json({ success: true }); - }, - ), - ...((options.subscription?.enabled - ? subscriptionEndpoints - : {}) as O["subscription"] extends { - enabled: boolean; - } - ? typeof subscriptionEndpoints - : {}), - }, - init(ctx) { - return { - options: { - databaseHooks: { - user: { - create: { - async after(user, ctx) { - if (ctx && options.createCustomerOnSignUp) { - let extraCreateParams: Partial = - {}; - if (options.getCustomerCreateParams) { - extraCreateParams = await options.getCustomerCreateParams( - user, - ctx, - ); - } + adjustBillingMeterEvent: createAuthEndpoint( + "/subscription/meter-event/adjust", // https://docs.stripe.com/api/v2/billing-meter-adjustment + { + method: "POST", + body: z.object({ + event_name: z.string(), + type: z.enum(["cancel"]), + cancel: z.object({ + identifier: z.string(), + }), + }), + use: [sessionMiddleware, referenceMiddleware("adjust-meter-event")], + }, + async (ctx) => { + const bodyPayload = ctx.body + try { + const result = await client.v2.billing.meterEventAdjustments.create(bodyPayload); - const params: Stripe.CustomerCreateParams = defu( - { - email: user.email, - name: user.name, - metadata: { - userId: user.id, - }, - }, - extraCreateParams, - ); - const stripeCustomer = - await client.customers.create(params); - await ctx.context.internalAdapter.updateUser(user.id, { - stripeCustomerId: stripeCustomer.id, - }); - await options.onCustomerCreate?.( - { - stripeCustomer, - user: { - ...user, - stripeCustomerId: stripeCustomer.id, - }, - }, - ctx, - ); - } - }, - }, - update: { - async after(user, ctx) { - if (!ctx) return; + return ctx.json(result); + } catch (error: any) { + ctx.context.logger.error( + "Error submitting meter event", + error, + ); + throw new APIError("BAD_REQUEST", { + message: error.message, + }); + } + } + ), + summaryBillingMeterEvent: createAuthEndpoint( + "/subscription/meter-event/summary", // https://docs.stripe.com/api/billing/meter-event-summary + { + method: "GET", + query: z.object({ + id: z.string().meta({ + description: "The id of the meter event, ie: 'mtr_test_61Q8nQMqIFK9fRQmr41CMAXJrFdZ5MnA'" + }), + start_time: z.number().meta({ + description: "Unix timestamp in seconds" + }), + end_time: z.number().meta({ + description: "Unix timestamp in seconds" + }), + value_grouping_window: z.enum(['day', 'hour']), + ending_before: z.string().optional().meta({ + description: "A cursor for use in pagination" + }), + starting_after: z.string().optional().meta({ + description: "A cursor for use in pagination" + }), + limit: z.number().optional().meta({ + description: "A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10." + }) + }), + use: [ + sessionMiddleware, + referenceMiddleware("meter-event-summary"), + ] + }, + async (ctx) => { + const {user} = ctx.context.session; + let customerId = user.stripeCustomerId; + const referenceId = ctx.body.referenceId || user.id; - try { - // Cast user to include stripeCustomerId (added by the stripe plugin schema) - const userWithStripe = user as typeof user & { - stripeCustomerId?: string; - }; + if (!customerId) { + const subscription = await ctx.context.adapter + .findMany({ + model: "subscription", + where: [ + { + field: "referenceId", + value: referenceId, + }, + ], + }) + .then((subs) => + subs.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ), + ); - // Only proceed if user has a Stripe customer ID - if (!userWithStripe.stripeCustomerId) return; + customerId = subscription?.stripeCustomerId; + } - // Get the user from the database to check if email actually changed - // The 'user' parameter here is the freshly updated user - // We need to check if the Stripe customer's email matches - const stripeCustomer = await client.customers.retrieve( - userWithStripe.stripeCustomerId, - ); + if (!customerId) { + throw new APIError("BAD_REQUEST", { + message: "No Stripe customer found for this user", + }); + } - // Check if customer was deleted - if (stripeCustomer.deleted) { - ctx.context.logger.warn( - `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`, - ); - return; - } + try { + const result = await client.billing.meters.listEventSummaries( + ctx.query.id, + { + customer: customerId, + start_time: ctx.query.start_time, + end_time: ctx.query.end_time, + limit: ctx.query.limit, + starting_after: ctx.query.starting_after, + ending_before: ctx.query.ending_before, + } + ); - // If Stripe customer email doesn't match the user's current email, update it - if (stripeCustomer.email !== user.email) { - await client.customers.update( - userWithStripe.stripeCustomerId, - { - email: user.email, - }, - ); - ctx.context.logger.info( - `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`, - ); - } - } catch (e: any) { - // Ignore errors - this is a best-effort sync - // Email might have been deleted or Stripe customer might not exist - ctx.context.logger.error( - `Failed to sync email to Stripe customer: ${e.message}`, - e, - ); - } - }, - }, - }, - }, - }, - }; - }, - schema: getSchema(options), - } satisfies BetterAuthPlugin; + return ctx.json(result); + } catch (error: any) { + ctx.context.logger.error( + "Error submitting meter event", + error, + ); + throw new APIError("BAD_REQUEST", { + message: error.message, + }); + } + } + ) + } as const; + return { + id: "stripe", + endpoints: { + stripeWebhook: createAuthEndpoint( + "/stripe/webhook", + { + method: "POST", + metadata: { + isAction: false, + }, + cloneRequest: true, + //don't parse the body + disableBody: true, + }, + async (ctx) => { + if (!ctx.request?.body) { + throw new APIError("INTERNAL_SERVER_ERROR"); + } + const buf = await ctx.request.text(); + const sig = ctx.request.headers.get("stripe-signature") as string; + const webhookSecret = options.stripeWebhookSecret; + let event: Stripe.Event; + try { + if (!sig || !webhookSecret) { + throw new APIError("BAD_REQUEST", { + message: "Stripe webhook secret not found", + }); + } + event = await client.webhooks.constructEventAsync( + buf, + sig, + webhookSecret, + ); + } catch (err: any) { + ctx.context.logger.error(`${err.message}`); + throw new APIError("BAD_REQUEST", { + message: `Webhook Error: ${err.message}`, + }); + } + if (!event) { + throw new APIError("BAD_REQUEST", { + message: "Failed to construct event", + }); + } + try { + switch (event.type) { + case "checkout.session.completed": + await onCheckoutSessionCompleted(ctx, options, event); + await options.onEvent?.(event); + break; + case "customer.subscription.updated": + await onSubscriptionUpdated(ctx, options, event); + await options.onEvent?.(event); + break; + case "customer.subscription.deleted": + await onSubscriptionDeleted(ctx, options, event); + await options.onEvent?.(event); + break; + default: + await options.onEvent?.(event); + break; + } + } catch (e: any) { + ctx.context.logger.error( + `Stripe webhook failed. Error: ${e.message}`, + ); + throw new APIError("BAD_REQUEST", { + message: "Webhook error: See server logs for more information.", + }); + } + return ctx.json({success: true}); + }, + ), + ...((options.subscription?.enabled + ? subscriptionEndpoints + : {}) as O["subscription"] extends { + enabled: boolean; + } + ? typeof subscriptionEndpoints + : {}), + }, + init(ctx) { + return { + options: { + databaseHooks: { + user: { + create: { + async after(user, ctx) { + if (ctx && options.createCustomerOnSignUp) { + let extraCreateParams: Partial = + {}; + if (options.getCustomerCreateParams) { + extraCreateParams = await options.getCustomerCreateParams( + user, + ctx, + ); + } + + const params: Stripe.CustomerCreateParams = defu( + { + email: user.email, + name: user.name, + metadata: { + userId: user.id, + }, + }, + extraCreateParams, + ); + const stripeCustomer = + await client.customers.create(params); + await ctx.context.internalAdapter.updateUser(user.id, { + stripeCustomerId: stripeCustomer.id, + }); + await options.onCustomerCreate?.( + { + stripeCustomer, + user: { + ...user, + stripeCustomerId: stripeCustomer.id, + }, + }, + ctx, + ); + } + }, + }, + update: { + async after(user, ctx) { + if (!ctx) return; + + try { + // Cast user to include stripeCustomerId (added by the stripe plugin schema) + const userWithStripe = user as typeof user & { + stripeCustomerId?: string; + }; + + // Only proceed if user has a Stripe customer ID + if (!userWithStripe.stripeCustomerId) return; + + // Get the user from the database to check if email actually changed + // The 'user' parameter here is the freshly updated user + // We need to check if the Stripe customer's email matches + const stripeCustomer = await client.customers.retrieve( + userWithStripe.stripeCustomerId, + ); + + // Check if customer was deleted + if (stripeCustomer.deleted) { + ctx.context.logger.warn( + `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`, + ); + return; + } + + // If Stripe customer email doesn't match the user's current email, update it + if (stripeCustomer.email !== user.email) { + await client.customers.update( + userWithStripe.stripeCustomerId, + { + email: user.email, + }, + ); + ctx.context.logger.info( + `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`, + ); + } + } catch (e: any) { + // Ignore errors - this is a best-effort sync + // Email might have been deleted or Stripe customer might not exist + ctx.context.logger.error( + `Failed to sync email to Stripe customer: ${e.message}`, + e, + ); + } + }, + }, + }, + }, + }, + }; + }, + schema: getSchema(options), + } satisfies BetterAuthPlugin; }; -export type { Subscription, StripePlan }; +export type {Subscription, StripePlan}; diff --git a/src/types.ts b/src/types.ts index fe3da25..8081d56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,331 +1,336 @@ import type { - GenericEndpointContext, - InferOptionSchema, - Session, - User, + GenericEndpointContext, + InferOptionSchema, + Session, + User, } from "better-auth"; import type Stripe from "stripe"; -import type { subscriptions, user } from "./schema"; +import type {subscriptions, user} from "./schema"; export type StripePlan = { - /** - * Monthly price id - */ - priceId?: string; - /** - * To use lookup key instead of price id - * - * https://docs.stripe.com/products-prices/ - * manage-prices#lookup-keys - */ - lookupKey?: string; - /** - * A yearly discount price id - * - * useful when you want to offer a discount for - * yearly subscription - */ - annualDiscountPriceId?: string; - /** - * To use lookup key instead of price id - * - * https://docs.stripe.com/products-prices/ - * manage-prices#lookup-keys - */ - annualDiscountLookupKey?: string; - /** - * Plan name - */ - name: string; - /** - * Limits for the plan - */ - limits?: Record; - /** - * Plan group name - * - * useful when you want to group plans or - * when a user can subscribe to multiple plans. - */ - group?: string; - /** - * Free trial days - */ - freeTrial?: { - /** - * Number of days - */ - days: number; - /** - * A function that will be called when the trial - * starts. - * - * @param subscription - * @returns - */ - onTrialStart?: (subscription: Subscription) => Promise; - /** - * A function that will be called when the trial - * ends - * - * @param subscription - Subscription - * @returns - */ - onTrialEnd?: ( - data: { - subscription: Subscription; - }, - ctx: GenericEndpointContext, - ) => Promise; - /** - * A function that will be called when the trial - * expired. - * @param subscription - Subscription - * @returns - */ - onTrialExpired?: ( - subscription: Subscription, - ctx: GenericEndpointContext, - ) => Promise; - }; + /** + * Monthly price id + */ + priceId?: string; + /** + * To use lookup key instead of price id + * + * https://docs.stripe.com/products-prices/ + * manage-prices#lookup-keys + */ + lookupKey?: string; + /** + * A yearly discount price id + * + * useful when you want to offer a discount for + * yearly subscription + */ + annualDiscountPriceId?: string; + /** + * To use lookup key instead of price id + * + * https://docs.stripe.com/products-prices/ + * manage-prices#lookup-keys + */ + annualDiscountLookupKey?: string; + /** + * Plan name + */ + name: string; + /** + * Limits for the plan + */ + limits?: Record; + /** + * Plan group name + * + * useful when you want to group plans or + * when a user can subscribe to multiple plans. + */ + group?: string; + /** + * Free trial days + */ + freeTrial?: { + /** + * Number of days + */ + days: number; + /** + * A function that will be called when the trial + * starts. + * + * @param subscription + * @returns + */ + onTrialStart?: (subscription: Subscription) => Promise; + /** + * A function that will be called when the trial + * ends + * + * @param subscription - Subscription + * @returns + */ + onTrialEnd?: ( + data: { + subscription: Subscription; + }, + ctx: GenericEndpointContext, + ) => Promise; + /** + * A function that will be called when the trial + * expired. + * @param subscription - Subscription + * @returns + */ + onTrialExpired?: ( + subscription: Subscription, + ctx: GenericEndpointContext, + ) => Promise; + }; }; +export type actionType = + | "upgrade-subscription" + | "list-subscription" + | "cancel-subscription" + | "restore-subscription" + | "meter-event" + | "adjust-meter-event" + | "meter-event-summary" + | "billing-portal"; + export interface Subscription { - /** - * Database identifier - */ - id: string; - /** - * The plan name - */ - plan: string; - /** - * Stripe customer id - */ - stripeCustomerId?: string; - /** - * Stripe subscription id - */ - stripeSubscriptionId?: string; - /** - * Trial start date - */ - trialStart?: Date; - /** - * Trial end date - */ - trialEnd?: Date; - /** - * Price Id for the subscription - */ - priceId?: string; - /** - * To what reference id the subscription belongs to - * @example - * - userId for a user - * - workspace id for a saas platform - * - website id for a hosting platform - * - * @default - userId - */ - referenceId: string; - /** - * Subscription status - */ - status: - | "active" - | "canceled" - | "incomplete" - | "incomplete_expired" - | "past_due" - | "paused" - | "trialing" - | "unpaid"; - /** - * The billing cycle start date - */ - periodStart?: Date; - /** - * The billing cycle end date - */ - periodEnd?: Date; - /** - * Cancel at period end - */ - cancelAtPeriodEnd?: boolean; - /** - * A field to group subscriptions so you can have multiple subscriptions - * for one reference id - */ - groupId?: string; - /** - * Number of seats for the subscription (useful for team plans) - */ - seats?: number; + /** + * Database identifier + */ + id: string; + /** + * The plan name + */ + plan: string; + /** + * Stripe customer id + */ + stripeCustomerId?: string; + /** + * Stripe subscription id + */ + stripeSubscriptionId?: string; + /** + * Trial start date + */ + trialStart?: Date; + /** + * Trial end date + */ + trialEnd?: Date; + /** + * Price Id for the subscription + */ + priceId?: string; + /** + * To what reference id the subscription belongs to + * @example + * - userId for a user + * - workspace id for a saas platform + * - website id for a hosting platform + * + * @default - userId + */ + referenceId: string; + /** + * Subscription status + */ + status: + | "active" + | "canceled" + | "incomplete" + | "incomplete_expired" + | "past_due" + | "paused" + | "trialing" + | "unpaid"; + /** + * The billing cycle start date + */ + periodStart?: Date; + /** + * The billing cycle end date + */ + periodEnd?: Date; + /** + * Cancel at period end + */ + cancelAtPeriodEnd?: boolean; + /** + * A field to group subscriptions so you can have multiple subscriptions + * for one reference id + */ + groupId?: string; + /** + * Number of seats for the subscription (useful for team plans) + */ + seats?: number; } export interface StripeOptions { - /** - * Stripe Client - */ - stripeClient: Stripe; - /** - * Stripe Webhook Secret - * - * @description Stripe webhook secret key - */ - stripeWebhookSecret: string; - /** - * Enable customer creation when a user signs up - */ - createCustomerOnSignUp?: boolean; - /** - * A callback to run after a customer has been created - * @param customer - Customer Data - * @param stripeCustomer - Stripe Customer Data - * @returns - */ - onCustomerCreate?: ( - data: { - stripeCustomer: Stripe.Customer; - user: User & { stripeCustomerId: string }; - }, - ctx: GenericEndpointContext, - ) => Promise; - /** - * A custom function to get the customer create - * params - * @param data - data containing user and session - * @returns - */ - getCustomerCreateParams?: ( - user: User, - ctx: GenericEndpointContext, - ) => Promise>; - /** - * Subscriptions - */ - subscription?: { - enabled: boolean; - /** - * Subscription Configuration - */ - /** - * List of plan - */ - plans: StripePlan[] | (() => StripePlan[] | Promise); - /** - * Require email verification before a user is allowed to upgrade - * their subscriptions - * - * @default false - */ - requireEmailVerification?: boolean; - /** - * A callback to run after a user has subscribed to a package - * @param event - Stripe Event - * @param subscription - Subscription Data - * @returns - */ - onSubscriptionComplete?: ( - data: { - event: Stripe.Event; - stripeSubscription: Stripe.Subscription; - subscription: Subscription; - plan: StripePlan; - }, - ctx: GenericEndpointContext, - ) => Promise; - /** - * A callback to run after a user is about to cancel their subscription - * @returns - */ - onSubscriptionUpdate?: (data: { - event: Stripe.Event; - subscription: Subscription; - }) => Promise; - /** - * A callback to run after a user is about to cancel their subscription - * @returns - */ - onSubscriptionCancel?: (data: { - event?: Stripe.Event; - subscription: Subscription; - stripeSubscription: Stripe.Subscription; - cancellationDetails?: Stripe.Subscription.CancellationDetails | null; - }) => Promise; - /** - * A function to check if the reference id is valid - * and belongs to the user - * - * @param data - data containing user, session and referenceId - * @param ctx - the context object - * @returns - */ - authorizeReference?: ( - data: { - user: User & Record; - session: Session & Record; - referenceId: string; - action: - | "upgrade-subscription" - | "list-subscription" - | "cancel-subscription" - | "restore-subscription" - | "meter-event" - | "billing-portal"; - }, - ctx: GenericEndpointContext, - ) => Promise; - /** - * A callback to run after a user has deleted their subscription - * @returns - */ - onSubscriptionDeleted?: (data: { - event: Stripe.Event; - stripeSubscription: Stripe.Subscription; - subscription: Subscription; - }) => Promise; - /** - * parameters for session create params - * - * @param data - data containing user, session and plan - * @param ctx - the context object - */ - getCheckoutSessionParams?: ( - data: { - user: User & Record; - session: Session & Record; - plan: StripePlan; - subscription: Subscription; - }, - ctx: GenericEndpointContext, - ) => - | Promise<{ - params?: Stripe.Checkout.SessionCreateParams; - options?: Stripe.RequestOptions; - }> - | { - params?: Stripe.Checkout.SessionCreateParams; - options?: Stripe.RequestOptions; - }; - /** - * Enable organization subscription - */ - organization?: { - enabled: boolean; - }; - }; - /** - * A callback to run after a stripe event is received - * @param event - Stripe Event - * @returns - */ - onEvent?: (event: Stripe.Event) => Promise; - /** - * Schema for the stripe plugin - */ - schema?: InferOptionSchema; + /** + * Stripe Client + */ + stripeClient: Stripe; + /** + * Stripe Webhook Secret + * + * @description Stripe webhook secret key + */ + stripeWebhookSecret: string; + /** + * Enable customer creation when a user signs up + */ + createCustomerOnSignUp?: boolean; + /** + * A callback to run after a customer has been created + * @param customer - Customer Data + * @param stripeCustomer - Stripe Customer Data + * @returns + */ + onCustomerCreate?: ( + data: { + stripeCustomer: Stripe.Customer; + user: User & { stripeCustomerId: string }; + }, + ctx: GenericEndpointContext, + ) => Promise; + /** + * A custom function to get the customer create + * params + * @param data - data containing user and session + * @returns + */ + getCustomerCreateParams?: ( + user: User, + ctx: GenericEndpointContext, + ) => Promise>; + /** + * Subscriptions + */ + subscription?: { + enabled: boolean; + /** + * Subscription Configuration + */ + /** + * List of plan + */ + plans: StripePlan[] | (() => StripePlan[] | Promise); + /** + * Require email verification before a user is allowed to upgrade + * their subscriptions + * + * @default false + */ + requireEmailVerification?: boolean; + /** + * A callback to run after a user has subscribed to a package + * @param event - Stripe Event + * @param subscription - Subscription Data + * @returns + */ + onSubscriptionComplete?: ( + data: { + event: Stripe.Event; + stripeSubscription: Stripe.Subscription; + subscription: Subscription; + plan: StripePlan; + }, + ctx: GenericEndpointContext, + ) => Promise; + /** + * A callback to run after a user is about to cancel their subscription + * @returns + */ + onSubscriptionUpdate?: (data: { + event: Stripe.Event; + subscription: Subscription; + }) => Promise; + /** + * A callback to run after a user is about to cancel their subscription + * @returns + */ + onSubscriptionCancel?: (data: { + event?: Stripe.Event; + subscription: Subscription; + stripeSubscription: Stripe.Subscription; + cancellationDetails?: Stripe.Subscription.CancellationDetails | null; + }) => Promise; + /** + * A function to check if the reference id is valid + * and belongs to the user + * + * @param data - data containing user, session and referenceId + * @param ctx - the context object + * @returns + */ + authorizeReference?: ( + data: { + user: User & Record; + session: Session & Record; + referenceId: string; + action: actionType; + }, + ctx: GenericEndpointContext, + ) => Promise; + /** + * A callback to run after a user has deleted their subscription + * @returns + */ + onSubscriptionDeleted?: (data: { + event: Stripe.Event; + stripeSubscription: Stripe.Subscription; + subscription: Subscription; + }) => Promise; + /** + * parameters for session create params + * + * @param data - data containing user, session and plan + * @param ctx - the context object + */ + getCheckoutSessionParams?: ( + data: { + user: User & Record; + session: Session & Record; + plan: StripePlan; + subscription: Subscription; + }, + ctx: GenericEndpointContext, + ) => + | Promise<{ + params?: Stripe.Checkout.SessionCreateParams; + options?: Stripe.RequestOptions; + }> + | { + params?: Stripe.Checkout.SessionCreateParams; + options?: Stripe.RequestOptions; + }; + /** + * Enable organization subscription + */ + organization?: { + enabled: boolean; + }; + }; + /** + * A callback to run after a stripe event is received + * @param event - Stripe Event + * @returns + */ + onEvent?: (event: Stripe.Event) => Promise; + /** + * Schema for the stripe plugin + */ + schema?: InferOptionSchema; } -export interface InputSubscription extends Omit {} +export interface InputSubscription extends Omit { +} diff --git a/src/utils.ts b/src/utils.ts index 3f6907d..863de5c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,30 +1,30 @@ -import type { StripeOptions } from "./types"; +import type {StripeOptions} from "./types"; export async function getPlans(options: StripeOptions) { - return typeof options?.subscription?.plans === "function" - ? await options.subscription?.plans() - : options.subscription?.plans; + return typeof options?.subscription?.plans === "function" + ? await options.subscription?.plans() + : options.subscription?.plans; } export async function getPlanByPriceInfo( - options: StripeOptions, - priceId: string, - priceLookupKey: string | null, + options: StripeOptions, + priceId: string, + priceLookupKey: string | null, ) { - return await getPlans(options).then((res) => - res?.find( - (plan) => - plan.priceId === priceId || - plan.annualDiscountPriceId === priceId || - (priceLookupKey && - (plan.lookupKey === priceLookupKey || - plan.annualDiscountLookupKey === priceLookupKey)), - ), - ); + return await getPlans(options).then((res) => + res?.find( + (plan) => + plan.priceId === priceId || + plan.annualDiscountPriceId === priceId || + (priceLookupKey && + (plan.lookupKey === priceLookupKey || + plan.annualDiscountLookupKey === priceLookupKey)), + ), + ); } export async function getPlanByName(options: StripeOptions, name: string) { - return await getPlans(options).then((res) => - res?.find((plan) => plan.name.toLowerCase() === name.toLowerCase()), - ); -} + return await getPlans(options).then((res) => + res?.find((plan) => plan.name.toLowerCase() === name.toLowerCase()), + ); +} \ No newline at end of file