UNPKG

@better-auth-ui/core

Version:

Authentication components and data utilities for [Better Auth](https://better-auth.com), available for React and Solid.

809 lines (748 loc) 24.9 kB
import type { stripeClient } from "@better-auth/stripe/client" import type { commetClient } from "@commet/better-auth/client" import type { creemClient } from "@creem_io/better-auth/client" import type { dodopaymentsClient } from "@dodopayments/better-auth/client" import type { polarClient } from "@polar-sh/better-auth/client" import type { IAutumnClient } from "autumn-js/react" import type { AuthClient } from "../../lib/auth-client" import type { BillingActionResult, BillingAdapter, BillingPlan, BillingScope, BillingState, BillingSubscription, BillingSubscriptionStatus, BillingUsage } from "./billing-adapter" export type StripeBillingClient = AuthClient<{ plugins: [ReturnType<typeof stripeClient<{ subscription: true }>>] }> export type PolarBillingClient = AuthClient<{ plugins: [ReturnType<typeof polarClient>] }> export type AutumnBillingClient = Pick< IAutumnClient, "getOrCreateCustomer" | "attach" | "updateSubscription" | "openCustomerPortal" > export type CreemBillingClient = AuthClient<{ plugins: [ReturnType<typeof creemClient>] }> export type DodoPaymentsBillingClient = AuthClient<{ plugins: [ReturnType<typeof dodopaymentsClient>] }> export type CommetBillingClient = AuthClient<{ plugins: [ReturnType<typeof commetClient>] }> export type VendorBillingAdapterOptions = { plans: BillingPlan[] successUrl: string cancelUrl: string returnUrl: string } export type PolarBillingAdapterOptions = VendorBillingAdapterOptions & { /** Polar checkout product or configured slug for each BAUI plan ID. */ products?: Record< string, { type: "slug"; value: string } | { type: "product"; value: string } > } export type ProductBillingAdapterOptions = VendorBillingAdapterOptions & { /** Provider product ID for each BAUI plan ID. */ products?: Record<string, string> } export type DodoPaymentsBillingAdapterOptions = VendorBillingAdapterOptions & { /** Dodo product or configured slug for each BAUI plan ID. */ products?: Record< string, { type: "slug"; value: string } | { type: "product"; value: string } > } export type AutumnBillingAdapterOptions = VendorBillingAdapterOptions & { /** Autumn license plan used for seat quantity on each BAUI plan. */ seatLicensePlans?: Record<string, string> } export type CommetBillingAdapterOptions = VendorBillingAdapterOptions & { /** Commet plan ID for each BAUI plan ID. */ planIds?: Record<string, string> /** Commet seat feature updated by BAUI's seat editor. */ seatFeatureCode?: string /** Read metered feature access into BAUI usage rows. @default false */ usage?: boolean } type RecordValue = Record<string, unknown> const record = (value: unknown): RecordValue | undefined => value && typeof value === "object" ? (value as RecordValue) : undefined const unwrap = <T>(result: unknown): T => { const value = record(result) const error = value?.error if (error) { const errorRecord = record(error) throw new Error(String(errorRecord?.message ?? error)) } return (value && "data" in value ? value.data : result) as T } const items = (result: unknown): unknown[] => { const value = unwrap<unknown>(result) if (Array.isArray(value)) return value const resultRecord = record(value) const nested = resultRecord?.items ?? record(resultRecord?.result)?.items return Array.isArray(nested) ? nested : [] } const stringValue = (value: unknown) => typeof value === "string" ? value : undefined const numberValue = (value: unknown) => typeof value === "number" && Number.isFinite(value) ? value : undefined const dateValue = (value: unknown) => { if (!value) return undefined const date = new Date(value as string | number | Date) return Number.isNaN(date.getTime()) ? undefined : date } const statusValue = (value: unknown): BillingSubscriptionStatus => { const status = stringValue(value) if (status === "cancelled") return "canceled" if (status === "scheduled_cancel") return "active" return status === "active" || status === "trialing" || status === "past_due" || status === "paused" || status === "canceled" || status === "incomplete" ? status : "unknown" } const intervalValue = (value: unknown) => { const interval = stringValue(value) if (interval === "month" || interval === "monthly") return "month" as const if (interval === "year" || interval === "yearly" || interval === "annual") return "year" as const if (interval === "one-time" || interval === "one_time") return "one-time" as const return undefined } const mapSubscription = (value: unknown): BillingSubscription | undefined => { const subscription = record(value) const id = stringValue(subscription?.id) ?? stringValue(subscription?.subscriptionId) ?? stringValue(subscription?.subscription_id) ?? stringValue(subscription?.creemSubscriptionId) if (!subscription || !id) return undefined const product = record(subscription.product) const plan = record(subscription.plan) const price = record(subscription.price) ?? record(plan?.price) const currentPeriod = record(subscription.currentPeriod) const canceledAt = subscription.canceledAt ?? subscription.canceled_at ?? subscription.cancelledAt ?? subscription.cancelled_at return { id, planId: stringValue(subscription.planId) ?? stringValue(subscription.plan) ?? stringValue(subscription.productId) ?? stringValue(subscription.product_id) ?? stringValue(product?.id) ?? stringValue(plan?.id) ?? "unknown", planName: stringValue(subscription.planName) ?? stringValue(product?.name) ?? stringValue(plan?.name) ?? stringValue(subscription.name), priceId: stringValue(subscription.priceId) ?? stringValue(subscription.price_id) ?? stringValue(price?.id), interval: intervalValue( subscription.interval ?? subscription.billingInterval ?? subscription.recurringInterval ?? subscription.recurring_interval ?? price?.interval ) ?? (typeof subscription.annual === "boolean" ? subscription.annual ? "year" : "month" : undefined), status: statusValue(subscription.status), currentPeriodEnd: dateValue( subscription.currentPeriodEnd ?? subscription.current_period_end ?? subscription.currentPeriodEndDate ?? subscription.current_period_end_date ?? subscription.periodEnd ?? subscription.period_end ?? subscription.nextBillingDate ?? subscription.next_billing_date ?? currentPeriod?.end ), cancelAtPeriodEnd: Boolean( subscription.cancelAtPeriodEnd ?? subscription.cancel_at_period_end ?? subscription.cancelAtNextBillingDate ?? subscription.cancel_at_next_billing_date ?? subscription.status === "scheduled_cancel" ), canceledAt: dateValue(canceledAt), seats: numberValue(subscription.seats) ?? numberValue(subscription.quantity) ?? numberValue(subscription.units) } } const mapUsage = (value: unknown): BillingUsage | undefined => { const usage = record(value) const meter = record(usage?.meter) const id = stringValue(usage?.id) ?? stringValue(meter?.id) if (!usage || !id) return undefined const used = numberValue(usage.consumedUnits) ?? numberValue(usage.consumed_units) ?? numberValue(usage.used) ?? 0 const credited = numberValue(usage.creditedUnits) ?? numberValue(usage.credited_units) ?? numberValue(usage.limit) return { id, label: stringValue(usage.label) ?? stringValue(meter?.name) ?? stringValue(meter?.slug) ?? id, used, limit: credited, unit: stringValue(usage.unit) ?? stringValue(meter?.unit) } } const actionResult = (result: unknown): BillingActionResult => { const value = record(unwrap(result)) return { url: stringValue( value?.url ?? value?.paymentUrl ?? value?.redirectUrl ?? value?.redirect_url ?? value?.customerPortalUrl ) } } const userOnlyScope = (scope: BillingScope, provider: string) => { if (scope.type === "organization") { throw new Error( `${provider} does not accept an explicit organization ID in its Better Auth client API.` ) } } const resolvePlanId = ( providerPlanId: string, products: Record<string, string> | undefined ) => Object.entries(products ?? {}).find( ([, productId]) => productId === providerPlanId )?.[0] ?? providerPlanId const mapAutumnUsage = (customer: RecordValue): BillingUsage[] => Object.entries(record(customer.balances) ?? {}).map(([id, value]) => { const balance = record(value) const feature = record(balance?.feature) const unlimited = balance?.unlimited === true return { id, label: stringValue(feature?.name) ?? id, used: numberValue(balance?.usage) ?? 0, limit: unlimited ? undefined : numberValue(balance?.granted), unit: stringValue(feature?.displayName) ?? stringValue(feature?.name) } }) const mapCommetUsage = (value: unknown): BillingUsage | undefined => { const feature = record(value) const consumption = record(feature?.consumption) const code = stringValue(feature?.code) const used = numberValue(consumption?.unitsUsed) if (!code || used === undefined) return undefined return { id: code, label: stringValue(feature?.name) ?? code, used, limit: consumption?.unlimited === true ? undefined : numberValue(consumption?.includedUnits), unit: stringValue(feature?.unitName) } } const scopeParams = (scope: BillingScope) => ({ referenceId: scope.type === "organization" ? scope.organizationId : scope.userId, customerType: scope.type }) export function createStripeBillingAdapter( client: StripeBillingClient, options: VendorBillingAdapterOptions ): BillingAdapter { const findPlan = (planId: string) => options.plans.find((plan) => plan.id === planId) const checkout = async ( scope: BillingScope, input: { planId: string; priceId: string; seats?: number }, subscriptionId?: string ) => { const price = findPlan(input.planId)?.prices.find( (entry) => entry.id === input.priceId ) return actionResult( await client.subscription.upgrade({ plan: input.planId, annual: price?.interval === "year", ...scopeParams(scope), ...(subscriptionId ? { subscriptionId } : {}), ...(input.seats ? { seats: input.seats } : {}), successUrl: options.successUrl, cancelUrl: options.cancelUrl, returnUrl: options.returnUrl, disableRedirect: true }) ) } const getState = async ( scope: BillingScope, signal?: AbortSignal ): Promise<BillingState> => { const subscriptions = items( await client.subscription.list({ query: scopeParams(scope), fetchOptions: { signal, throw: true } }) ) .map(mapSubscription) .filter((value): value is BillingSubscription => Boolean(value)) return { subscription: subscriptions[0], usage: [] } } return { id: "stripe", supports: { cancel: true, restore: true, seats: true }, async listPlans() { return options.plans }, getState, checkout: (scope, input) => checkout(scope, input), async openPortal(scope) { return actionResult( await client.subscription.billingPortal({ ...scopeParams(scope), returnUrl: options.returnUrl, disableRedirect: true }) ) }, async cancel(scope, subscriptionId) { return actionResult( await client.subscription.cancel({ ...scopeParams(scope), subscriptionId, returnUrl: options.returnUrl, disableRedirect: true }) ) }, async restore(scope, subscriptionId) { return actionResult( await client.subscription.restore({ ...scopeParams(scope), subscriptionId }) ) }, async updateSeats(scope, subscriptionId, seats) { const state = await getState(scope) const subscription = state.subscription const planId = subscription?.planId const plan = planId ? findPlan(planId) : undefined const price = subscription?.priceId ? plan?.prices.find((entry) => entry.id === subscription.priceId) : subscription?.interval ? plan?.prices.find( (entry) => entry.interval === subscription.interval ) : plan?.prices.length === 1 ? plan.prices[0] : undefined if (!plan || !price) throw new Error("The current billing price is unavailable.") return checkout( scope, { planId: plan.id, priceId: price.id, seats }, subscriptionId ) } } } export function createPolarBillingAdapter( client: PolarBillingClient, options: PolarBillingAdapterOptions ): BillingAdapter { const resolvePlanId = (providerPlanId: string) => Object.entries(options.products ?? {}).find( ([, product]) => product.value === providerPlanId )?.[0] ?? providerPlanId const portal = async (_scope: BillingScope) => actionResult( await client.customer.portal({ fetchOptions: { throw: true } }) ) return { id: "polar", supports: { cancel: false, restore: false, seats: false }, async listPlans() { return options.plans }, async getState(scope, signal): Promise<BillingState> { const subscriptionQuery = { page: 1, limit: 100, active: true, ...(scope.type === "organization" ? { referenceId: scope.organizationId } : {}) } const [subscriptionsResult, usageResult] = await Promise.all([ client.customer.subscriptions.list({ query: subscriptionQuery, fetchOptions: { signal, throw: true } }), client.usage.meters.list({ query: { page: 1, limit: 100 }, fetchOptions: { signal, throw: true } }) ]) const subscription = items(subscriptionsResult) .map(mapSubscription) .find(Boolean) const normalizedSubscription = subscription ? { ...subscription, planId: resolvePlanId(subscription.planId) } : undefined const usage = items(usageResult) .map(mapUsage) .filter((value): value is BillingUsage => Boolean(value)) return { subscription: normalizedSubscription, usage } }, async checkout(scope, input) { const product = options.products?.[input.planId] return actionResult( await client.checkout({ ...(product?.type === "product" ? { products: [product.value] } : { slug: product?.value ?? input.planId }), ...(scope.type === "organization" ? { referenceId: scope.organizationId } : {}), ...(input.seats ? { seats: input.seats } : {}), successUrl: options.successUrl, redirect: false }) ) }, openPortal: portal, /** Polar completes cancellation in its customer portal. */ async cancel(scope) { return portal(scope) }, /** Polar completes restoration in its customer portal. */ async restore(scope) { return portal(scope) }, /** Polar completes seat changes in its customer portal. */ async updateSeats(scope) { return portal(scope) } } } export function createAutumnBillingAdapter( client: AutumnBillingClient, options: AutumnBillingAdapterOptions ): BillingAdapter { const getState = async ( scope: BillingScope, _signal?: AbortSignal ): Promise<BillingState> => { userOnlyScope(scope, "Autumn") const customer = record( await client.getOrCreateCustomer({ expand: ["subscriptions.plan", "balances.feature"] }) ) const subscription = ( Array.isArray(customer?.subscriptions) ? customer.subscriptions : [] ) .map(mapSubscription) .find(Boolean) return { subscription, usage: customer ? mapAutumnUsage(customer) : [] } } const updateSubscription = async ( scope: BillingScope, input: Parameters<AutumnBillingClient["updateSubscription"]>[0] ) => { userOnlyScope(scope, "Autumn") return actionResult(await client.updateSubscription(input)) } return { id: "autumn", scopes: { user: true, organization: false }, supports: { cancel: true, restore: true, seats: Boolean(options.seatLicensePlans) }, async listPlans(scope) { userOnlyScope(scope, "Autumn") return options.plans }, getState, async checkout(scope, input) { userOnlyScope(scope, "Autumn") const seatLicensePlan = options.seatLicensePlans?.[input.planId] return actionResult( await client.attach({ planId: input.planId, successUrl: options.successUrl, redirectMode: "always", ...(seatLicensePlan && input.seats ? { licenseQuantities: [ { licensePlanId: seatLicensePlan, quantity: input.seats } ] } : {}) }) ) }, async openPortal(scope) { userOnlyScope(scope, "Autumn") return actionResult( await client.openCustomerPortal({ returnUrl: options.returnUrl }) ) }, cancel: (scope, subscriptionId) => updateSubscription(scope, { subscriptionId, cancelAction: "cancel_end_of_cycle" }), restore: (scope, subscriptionId) => updateSubscription(scope, { subscriptionId, cancelAction: "uncancel" }), async updateSeats(scope, subscriptionId, seats) { const state = await getState(scope) const planId = state.subscription?.planId const licensePlanId = planId ? options.seatLicensePlans?.[planId] : undefined if (!licensePlanId) { throw new Error("The Autumn seat license plan is unavailable.") } return updateSubscription(scope, { subscriptionId, licenseQuantities: [{ licensePlanId, quantity: seats }] }) } } } export function createCreemBillingAdapter( client: CreemBillingClient, options: ProductBillingAdapterOptions ): BillingAdapter { const portal = async (scope: BillingScope) => { userOnlyScope(scope, "Creem") return actionResult(await client.creem.createPortal()) } return { id: "creem", scopes: { user: true, organization: false }, supports: { cancel: true, restore: false, seats: false }, async listPlans(scope) { userOnlyScope(scope, "Creem") return options.plans }, async getState(scope): Promise<BillingState> { userOnlyScope(scope, "Creem") const access = record(unwrap(await client.creem.hasAccessGranted())) const subscription = mapSubscription(access?.subscription) const normalizedSubscription = subscription ? { ...subscription, planId: resolvePlanId(subscription.planId, options.products) } : undefined return { subscription: normalizedSubscription, usage: [] } }, async checkout(scope, input) { userOnlyScope(scope, "Creem") return actionResult( await client.creem.createCheckout({ productId: options.products?.[input.planId] ?? input.planId, ...(input.seats ? { units: input.seats } : {}), successUrl: options.successUrl }) ) }, openPortal: portal, async cancel(scope, subscriptionId) { userOnlyScope(scope, "Creem") return actionResult( await client.creem.cancelSubscription({ id: subscriptionId }) ) }, /** Creem completes restoration in its customer portal. */ restore: portal, /** Creem completes unit changes in its customer portal. */ updateSeats: portal } } export function createDodoPaymentsBillingAdapter( client: DodoPaymentsBillingClient, options: DodoPaymentsBillingAdapterOptions ): BillingAdapter { const portal = async (scope: BillingScope) => { userOnlyScope(scope, "Dodo Payments") return actionResult(await client.dodopayments.customer.portal()) } return { id: "dodo-payments", scopes: { user: true, organization: false }, supports: { cancel: false, restore: false, seats: false }, async listPlans(scope) { userOnlyScope(scope, "Dodo Payments") return options.plans }, async getState(scope, signal): Promise<BillingState> { userOnlyScope(scope, "Dodo Payments") const subscriptions = items( await client.dodopayments.customer.subscriptions.list({ query: { limit: 100, page: 1 }, fetchOptions: { signal, throw: true } }) ) const subscription = subscriptions.map(mapSubscription).find(Boolean) if (!subscription) return { usage: [] } const providerPlanId = subscription.planId const planId = Object.entries(options.products ?? {}).find( ([, product]) => product.value === providerPlanId )?.[0] ?? providerPlanId return { subscription: { ...subscription, planId }, usage: [] } }, async checkout(scope, input) { userOnlyScope(scope, "Dodo Payments") const product = options.products?.[input.planId] return actionResult( await client.dodopayments.checkoutSession({ ...(product?.type === "product" ? { product_cart: [ { product_id: product.value, quantity: input.seats ?? 1 } ] } : { slug: product?.value ?? input.planId }), referenceId: scope.type === "user" ? scope.userId : scope.organizationId }) ) }, openPortal: portal, /** Dodo Payments completes cancellation in its customer portal. */ cancel: portal, /** Dodo Payments completes restoration in its customer portal. */ restore: portal, /** Dodo Payments completes quantity changes in its customer portal. */ updateSeats: portal } } export function createCommetBillingAdapter( client: CommetBillingClient, options: CommetBillingAdapterOptions ): BillingAdapter { const portal = async (scope: BillingScope) => { userOnlyScope(scope, "Commet") return actionResult(await client.customer.portal()) } return { id: "commet", scopes: { user: true, organization: false }, supports: { cancel: true, restore: false, seats: Boolean(options.seatFeatureCode) }, async listPlans(scope) { userOnlyScope(scope, "Commet") return options.plans }, async getState(scope, signal): Promise<BillingState> { userOnlyScope(scope, "Commet") const [subscriptionResult, featuresResult, seatsResult] = await Promise.all([ client.subscription.get({ signal, throw: true }), options.usage ? client.features.list({ signal, throw: true }) : undefined, options.seatFeatureCode ? client.seats.list({ signal, throw: true }) : undefined ]) const subscription = mapSubscription(unwrap(subscriptionResult)) const seat = options.seatFeatureCode ? record(record(unwrap(seatsResult))?.[options.seatFeatureCode]) : undefined const normalizedSubscription = subscription ? { ...subscription, planId: resolvePlanId(subscription.planId, options.planIds), seats: numberValue(seat?.current) ?? subscription.seats } : undefined const usage = options.usage ? items(featuresResult) .map(mapCommetUsage) .filter((value): value is BillingUsage => Boolean(value)) : [] return { subscription: normalizedSubscription, usage } }, /** Commet starts plan changes in its customer portal. */ checkout: portal, openPortal: portal, async cancel(scope) { userOnlyScope(scope, "Commet") return actionResult( await client.subscription.cancel({ immediate: false }, { throw: true }) ) }, /** Commet completes restoration in its customer portal. */ restore: portal, async updateSeats(scope, _subscriptionId, seats) { userOnlyScope(scope, "Commet") if (!options.seatFeatureCode) return portal(scope) return actionResult( await client.seats.set( { featureCode: options.seatFeatureCode, count: seats }, { throw: true } ) ) } } }