Skip to content

Commit 40dbcde

Browse files
authored
feat(billing): integrate Stripe subscriptions via Better Auth (#2160)
1 parent e2118ac commit 40dbcde

34 files changed

Lines changed: 1063 additions & 439 deletions

.env

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ GA_MEASUREMENT_ID=G-XXXXXXXX
5757
RESEND_API_KEY=xxxxx
5858
RESEND_EMAIL_FROM=onboarding@resend.dev
5959

60+
# Stripe Billing (optional — app works without these, billing features disabled)
61+
# https://dashboard.stripe.com/apikeys
62+
# STRIPE_SECRET_KEY=sk_test_xxxxx
63+
# STRIPE_WEBHOOK_SECRET=whsec_xxxxx
64+
# STRIPE_STARTER_PRICE_ID=price_xxxxx
65+
# STRIPE_PRO_PRICE_ID=price_xxxxx
66+
# STRIPE_PRO_ANNUAL_PRICE_ID=price_xxxxx
67+
6068
# Algolia Search
6169
# https://dashboard.algolia.com/account/api-keys/all
6270
ALGOLIA_APP_ID=xxxxx

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ A full-stack monorepo template for building SaaS applications with React 19, tRP
1717

1818
- **Type-safe full stack** — TypeScript, tRPC, and Drizzle ORM create a single type contract from database to UI
1919
- **Edge-native** — Three Cloudflare Workers (web, app, api) connected via service bindings
20-
- **Auth included** — Better Auth with email OTP, passkey, Google OAuth, and organizations
20+
- **Auth + billing included** — Better Auth with email OTP, passkey, Google OAuth, organizations, and Stripe subscriptions
2121
- **Modern React** — React 19, TanStack Router (file-based), TanStack Query, Jotai, Tailwind CSS v4, shadcn/ui
2222
- **Database ready** — Drizzle ORM with Neon PostgreSQL, migrations, and seed data
2323
- **Fast DX** — Bun runtime, Vite, Vitest, ESLint, Prettier, and pre-configured VS Code settings
@@ -33,7 +33,7 @@ React Starter Kit is proudly supported by these amazing sponsors:
3333
| **Runtime** | [Bun](https://bun.sh/), [Cloudflare Workers](https://workers.cloudflare.com/), [TypeScript](https://www.typescriptlang.org/) 5.9 |
3434
| **Frontend** | [React 19](https://react.dev/), [TanStack Router](https://tanstack.com/router), [Tailwind CSS v4](https://tailwindcss.com/), [shadcn/ui](https://ui.shadcn.com/), [Jotai](https://jotai.org/) |
3535
| **Marketing** | [Astro](https://astro.build/) |
36-
| **Backend** | [Hono](https://hono.dev/), [tRPC](https://trpc.io/), [Better Auth](https://www.better-auth.com/) |
36+
| **Backend** | [Hono](https://hono.dev/), [tRPC](https://trpc.io/), [Better Auth](https://www.better-auth.com/), [Stripe](https://stripe.com/) |
3737
| **Database** | [Drizzle ORM](https://orm.drizzle.team/), [Neon PostgreSQL](https://get.neon.com/HD157BR) |
3838
| **Tooling** | [Vite](https://vitejs.dev/), [Vitest](https://vitest.dev/), [ESLint](https://eslint.org/), [Prettier](https://prettier.io/) |
3939

@@ -125,6 +125,13 @@ Configure your production secrets in Cloudflare Workers:
125125
# Required secrets
126126
bun wrangler secret put BETTER_AUTH_SECRET
127127

128+
# Stripe billing (optional — first 4 required to enable, annual is optional)
129+
bun wrangler secret put STRIPE_SECRET_KEY
130+
bun wrangler secret put STRIPE_WEBHOOK_SECRET
131+
bun wrangler secret put STRIPE_STARTER_PRICE_ID
132+
bun wrangler secret put STRIPE_PRO_PRICE_ID
133+
bun wrangler secret put STRIPE_PRO_ANNUAL_PRICE_ID # optional
134+
128135
# OAuth providers (as needed)
129136
bun wrangler secret put GOOGLE_CLIENT_ID
130137
bun wrangler secret put GOOGLE_CLIENT_SECRET

apps/api/dev.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,17 @@ app.use(async (c, next) => {
6262
"OPENAI_API_KEY",
6363
"RESEND_API_KEY",
6464
"RESEND_EMAIL_FROM",
65+
"STRIPE_SECRET_KEY",
66+
"STRIPE_WEBHOOK_SECRET",
67+
"STRIPE_STARTER_PRICE_ID",
68+
"STRIPE_PRO_PRICE_ID",
69+
"STRIPE_PRO_ANNUAL_PRICE_ID",
6570
] as const;
6671

6772
const env = {
6873
...cf.env,
6974
...Object.fromEntries(
70-
secretKeys.map((key) => [key, (process.env[key] || cf.env[key]) ?? ""]),
75+
secretKeys.map((key) => [key, process.env[key] || cf.env[key]]),
7176
),
7277
APP_NAME: process.env.APP_NAME || cf.env.APP_NAME || "Example",
7378
APP_ORIGIN:

apps/api/lib/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
88
import { Hono } from "hono";
99
import type { AppContext } from "./context.js";
1010
import { router } from "./trpc.js";
11+
import { billingRouter } from "../routers/billing.js";
1112
import { organizationRouter } from "../routers/organization.js";
1213
import { userRouter } from "../routers/user.js";
1314

1415
// tRPC API router
1516
const appRouter = router({
17+
billing: billingRouter,
1618
user: userRouter,
1719
organization: organizationRouter,
1820
});

apps/api/lib/auth.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { passkey } from "@better-auth/passkey";
2+
import { stripe } from "@better-auth/stripe";
23
import { schema as Db } from "@repo/db";
34
import { betterAuth } from "better-auth";
45
import type { DB } from "better-auth/adapters/drizzle";
56
import { drizzleAdapter } from "better-auth/adapters/drizzle";
67
import { createAuthMiddleware } from "better-auth/api";
78
import { anonymous, organization } from "better-auth/plugins";
89
import { emailOTP } from "better-auth/plugins/email-otp";
10+
import { and, eq } from "drizzle-orm";
911
import { sendOTP, sendPasswordReset, sendVerificationEmail } from "./email";
1012
import 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
187254
type SessionResponse = Auth["$Infer"]["Session"];
188255
export 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+
};

apps/api/lib/env.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export const envSchema = z.object({
1717
OPENAI_API_KEY: z.string(),
1818
RESEND_API_KEY: z.string(),
1919
RESEND_EMAIL_FROM: z.email(),
20+
// Stripe billing (optional — app works without these, billing features disabled)
21+
STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(),
22+
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_").optional(),
23+
STRIPE_STARTER_PRICE_ID: z.string().startsWith("price_").optional(),
24+
STRIPE_PRO_PRICE_ID: z.string().startsWith("price_").optional(),
25+
STRIPE_PRO_ANNUAL_PRICE_ID: z.string().startsWith("price_").optional(),
2026
});
2127

2228
/**

apps/api/lib/plans.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Single source of truth for plan limits.
2+
// Referenced by auth plugin config (plan definitions) and tRPC router (query responses).
3+
4+
export const planLimits = {
5+
free: { members: 1 },
6+
starter: { members: 5 },
7+
pro: { members: 50 },
8+
} as const;
9+
10+
export type PlanName = keyof typeof planLimits;

apps/api/lib/stripe.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Stripe from "stripe";
2+
import type { Env } from "./env";
3+
4+
// Only called when STRIPE_SECRET_KEY is verified present (see auth.ts conditional)
5+
export function createStripeClient(env: Pick<Env, "STRIPE_SECRET_KEY">) {
6+
return new Stripe(env.STRIPE_SECRET_KEY!, {
7+
appInfo: { name: "React Starter Kit" },
8+
});
9+
}

apps/api/lib/trpc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const t = initTRPC.context<TRPCContext>().create({
1717

1818
export const router = t.router;
1919
export const publicProcedure = t.procedure;
20+
export const createCallerFactory = t.createCallerFactory;
2021

2122
// Derive type from publicProcedure to stay in sync with initTRPC config.
2223
// Explicit annotation required to avoid TS2742 (non-portable inferred type).

apps/api/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"dependencies": {
2121
"@ai-sdk/openai": "^3.0.29",
2222
"@better-auth/passkey": "^1.4.18",
23+
"@better-auth/stripe": "^1.4.18",
2324
"@repo/core": "workspace:*",
2425
"@repo/db": "workspace:*",
2526
"@repo/email": "workspace:*",
@@ -29,7 +30,8 @@
2930
"dataloader": "^2.2.3",
3031
"drizzle-orm": "^0.45.1",
3132
"postgres": "^3.4.8",
32-
"resend": "^6.9.2"
33+
"resend": "^6.9.2",
34+
"stripe": "^20.3.1"
3335
},
3436
"peerDependencies": {
3537
"hono": "^4.11.9",

0 commit comments

Comments
 (0)