11import { passkey } from "@better-auth/passkey" ;
2+ import { stripe } from "@better-auth/stripe" ;
23import { schema as Db } from "@repo/db" ;
34import { betterAuth } from "better-auth" ;
45import type { DB } from "better-auth/adapters/drizzle" ;
56import { drizzleAdapter } from "better-auth/adapters/drizzle" ;
67import { createAuthMiddleware } from "better-auth/api" ;
78import { anonymous , organization } from "better-auth/plugins" ;
89import { emailOTP } from "better-auth/plugins/email-otp" ;
10+ import { and , eq } from "drizzle-orm" ;
911import { sendOTP , sendPasswordReset , sendVerificationEmail } from "./email" ;
1012import type { Env } from "./env" ;
13+ import { planLimits } from "./plans" ;
14+ import { createStripeClient } from "./stripe" ;
1115
1216// Auth hint cookie for edge routing (see docs/adr/001-auth-hint-cookie.md)
1317// NOT a security boundary - false positives are acceptable (causes one redirect)
@@ -28,8 +32,69 @@ type AuthEnv = Pick<
2832 | "GOOGLE_CLIENT_SECRET"
2933 | "RESEND_API_KEY"
3034 | "RESEND_EMAIL_FROM"
35+ | "STRIPE_SECRET_KEY"
36+ | "STRIPE_WEBHOOK_SECRET"
37+ | "STRIPE_STARTER_PRICE_ID"
38+ | "STRIPE_PRO_PRICE_ID"
39+ | "STRIPE_PRO_ANNUAL_PRICE_ID"
3140> ;
3241
42+ /**
43+ * Stripe billing plugin — only enabled when all required env vars are set.
44+ * Without Stripe config, the app works but billing endpoints return 404.
45+ */
46+ function stripePlugin ( db : DB , env : AuthEnv ) {
47+ if (
48+ ! env . STRIPE_SECRET_KEY ||
49+ ! env . STRIPE_WEBHOOK_SECRET ||
50+ ! env . STRIPE_STARTER_PRICE_ID ||
51+ ! env . STRIPE_PRO_PRICE_ID
52+ ) {
53+ return [ ] ;
54+ }
55+
56+ return [
57+ stripe ( {
58+ stripeClient : createStripeClient ( env ) ,
59+ stripeWebhookSecret : env . STRIPE_WEBHOOK_SECRET ,
60+ createCustomerOnSignUp : true ,
61+ subscription : {
62+ enabled : true ,
63+ plans : [
64+ {
65+ name : "starter" ,
66+ priceId : env . STRIPE_STARTER_PRICE_ID ,
67+ limits : planLimits . starter ,
68+ } ,
69+ {
70+ name : "pro" ,
71+ priceId : env . STRIPE_PRO_PRICE_ID ,
72+ annualDiscountPriceId : env . STRIPE_PRO_ANNUAL_PRICE_ID ,
73+ limits : planLimits . pro ,
74+ freeTrial : { days : 14 } ,
75+ } ,
76+ ] ,
77+ // Personal billing: user can manage their own subscription.
78+ // Organization billing: only owner/admin can manage.
79+ authorizeReference : async ( { user, referenceId } ) => {
80+ if ( referenceId === user . id ) return true ;
81+ const [ row ] = await db
82+ . select ( { role : Db . member . role } )
83+ . from ( Db . member )
84+ . where (
85+ and (
86+ eq ( Db . member . organizationId , referenceId ) ,
87+ eq ( Db . member . userId , user . id ) ,
88+ ) ,
89+ ) ;
90+ return row ?. role === "owner" || row ?. role === "admin" ;
91+ } ,
92+ } ,
93+ organization : { enabled : true } ,
94+ } ) ,
95+ ] ;
96+ }
97+
3398/**
3499 * Creates a Better Auth instance configured for multi-tenant SaaS with organization support.
35100 *
@@ -42,7 +107,7 @@ type AuthEnv = Pick<
42107 * @param db Drizzle database instance - must include all required auth tables (user, session, identity, organization, member, invitation, verification)
43108 * @param env Environment variables containing auth secrets and OAuth credentials
44109 * @returns Configured Better Auth instance with email/password and Google OAuth
45- * @throws Will fail silently if required database tables are missing from schema
110+ * @remarks Missing database tables will cause runtime errors when auth endpoints are called.
46111 *
47112 * @example
48113 * ```ts
@@ -75,6 +140,7 @@ export function createAuth(
75140 organization : Db . organization ,
76141 passkey : Db . passkey ,
77142 session : Db . session ,
143+ subscription : Db . subscription ,
78144 user : Db . user ,
79145 verification : Db . verification ,
80146 } ,
@@ -130,6 +196,7 @@ export function createAuth(
130196 expiresIn : 300 , // 5 minutes
131197 allowedAttempts : 3 ,
132198 } ) ,
199+ ...stripePlugin ( db , env ) ,
133200 ] ,
134201
135202 advanced : {
@@ -186,4 +253,7 @@ export type Auth = ReturnType<typeof betterAuth>;
186253// Base session types from Better Auth - plugin-specific fields added at runtime
187254type SessionResponse = Auth [ "$Infer" ] [ "Session" ] ;
188255export type AuthUser = SessionResponse [ "user" ] ;
189- export type AuthSession = SessionResponse [ "session" ] ;
256+ // Organization plugin adds activeOrganizationId at runtime
257+ export type AuthSession = SessionResponse [ "session" ] & {
258+ activeOrganizationId ?: string ;
259+ } ;
0 commit comments