@clubedge/create-clubedge-app 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +12 -11
- package/package.json +3 -3
- package/template/.env.example +9 -0
- package/template/README.md +3 -0
- package/template/SETUP.md +29 -0
- package/template/apps/start/.env.example +9 -0
- package/template/apps/start/package.json +3 -1
- package/template/apps/start/scripts/check-services.ts +20 -0
- package/template/apps/start/src/components/billing/billing-card.tsx +83 -0
- package/template/apps/start/src/config/plans.ts +16 -0
- package/template/apps/start/src/routes/api/webhooks/payments.ts +31 -0
- package/template/apps/start/src/routes/billing/checkout.ts +11 -0
- package/template/apps/start/src/routes/billing/portal.ts +11 -0
- package/template/apps/start/src/routes/dashboard.tsx +16 -2
- package/template/apps/start/src/server/billing-forms.ts +50 -0
- package/template/apps/start/src/server/billing.functions.ts +29 -0
- package/template/apps/start/src/server/payments.ts +60 -0
- package/template/apps/start/src/server/users.ts +6 -1
- package/template/apps/start/src/start.ts +3 -1
- package/template/apps/web/package.json +3 -1
- package/template/apps/web/scripts/check-services.ts +20 -0
- package/template/apps/web/src/app/api/webhooks/payments/route.ts +23 -0
- package/template/apps/web/src/app/dashboard/_components/billing-actions.ts +44 -0
- package/template/apps/web/src/app/dashboard/_components/billing-card.tsx +89 -0
- package/template/apps/web/src/app/dashboard/page.tsx +7 -0
- package/template/apps/web/src/config/plans.ts +16 -0
- package/template/apps/web/src/server/payments.ts +60 -0
- package/template/apps/web/src/server/users.ts +6 -1
- package/template/clubedge.template.json +35 -0
- package/template/e2e/payments.spec.ts +51 -0
- package/template/e2e/stripe-fake.ts +95 -0
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/db/src/index.ts +1 -1
- package/template/packages/db/src/users.ts +23 -4
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/payments/drizzle/0000_initial.sql +24 -0
- package/template/packages/payments/drizzle/meta/0000_snapshot.json +171 -0
- package/template/packages/payments/drizzle/meta/_journal.json +13 -0
- package/template/packages/payments/drizzle.config.ts +12 -0
- package/template/packages/payments/package.json +35 -0
- package/template/packages/payments/scripts/migrate.ts +14 -0
- package/template/packages/payments/src/billing.test.ts +151 -0
- package/template/packages/payments/src/billing.ts +125 -0
- package/template/packages/payments/src/index.ts +17 -0
- package/template/packages/payments/src/schema.ts +57 -0
- package/template/packages/payments/src/types.ts +67 -0
- package/template/packages/payments/tsconfig.json +4 -0
- package/template/packages/payments-stripe/package.json +22 -0
- package/template/packages/payments-stripe/src/index.test.ts +156 -0
- package/template/packages/payments-stripe/src/index.ts +141 -0
- package/template/packages/payments-stripe/tsconfig.json +4 -0
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +60 -0
- package/template/render.yaml +8 -0
- package/template.lock.json +33 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.12.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.11.0`, which adds payments (`--payments stripe`): Stripe Checkout, the customer portal, and verified webhooks behind a provider-neutral payments interface. `--payments` needs no CLI code: it comes from the Starter's manifest.
|
|
8
|
+
- Test a payments project in CI and in the release smoke test, and add the payments module in the change-modules job.
|
|
9
|
+
|
|
5
10
|
## 0.11.0
|
|
6
11
|
|
|
7
12
|
- Default to Starter `v0.10.0`, which adds the deploy module (`--deploy docker|fly|railway|render|vercel|netlify`), a migrator in the production image, a manual deploy workflow, and the `/api/ready` readiness endpoint. `--deploy` needs no CLI code: it comes from the Starter's manifest.
|
package/README.md
CHANGED
|
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
|
|
|
12
12
|
Each CLI release is tested against one specific Starter release:
|
|
13
13
|
|
|
14
14
|
```text
|
|
15
|
-
create-clubedge-app v0.
|
|
15
|
+
create-clubedge-app v0.12.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
18
|
+
Clubedge Starter v0.11.0
|
|
19
|
+
commit b5637afcf3748e03bb7ecb1f1941a4e6c17cfa65
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
The current relationship is:
|
|
23
23
|
|
|
24
24
|
| CLI release | Default Starter release | Meaning |
|
|
25
25
|
| --- | --- | --- |
|
|
26
|
-
| `create-clubedge-app@0.
|
|
26
|
+
| `create-clubedge-app@0.12.0` | `clubedge-starter@v0.11.0` | The CLI's required, tested default |
|
|
27
27
|
|
|
28
28
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
29
29
|
|
|
30
|
-
The `v0.
|
|
31
|
-
`
|
|
30
|
+
The `v0.11.0` tag is expected to resolve to commit
|
|
31
|
+
`b5637afcf3748e03bb7ecb1f1941a4e6c17cfa65`. The CLI verifies this before
|
|
32
32
|
scaffolding with the default reference and fails rather than silently using a
|
|
33
33
|
moved tag.
|
|
34
34
|
|
|
35
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.
|
|
35
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.11.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
36
36
|
|
|
37
37
|
## Requirements
|
|
38
38
|
|
|
@@ -85,7 +85,7 @@ Either way, the app lives in `apps/web` and uses the same shared packages, so th
|
|
|
85
85
|
|
|
86
86
|
### Choosing services
|
|
87
87
|
|
|
88
|
-
Authentication, file storage, caching, email, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
|
|
88
|
+
Authentication, file storage, caching, email, payments, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
|
|
89
89
|
|
|
90
90
|
| Flag | Options | Default |
|
|
91
91
|
| ----------- | ------------------------------------------ | ---------- |
|
|
@@ -93,6 +93,7 @@ Authentication, file storage, caching, email, local infrastructure, and deployme
|
|
|
93
93
|
| `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
|
|
94
94
|
| `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
|
|
95
95
|
| `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
|
|
96
|
+
| `--payments` | `stripe`, `none` (needs an auth module) | `none` |
|
|
96
97
|
| `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
|
|
97
98
|
| `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
|
|
98
99
|
|
|
@@ -138,7 +139,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
138
139
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
139
140
|
|
|
140
141
|
# Reproduce a specific Starter release or commit
|
|
141
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
142
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.11.0
|
|
142
143
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
143
144
|
|
|
144
145
|
# Test local Starter changes before releasing them
|
|
@@ -155,7 +156,7 @@ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add aut
|
|
|
155
156
|
pnpm dlx @clubedge/create-clubedge-app remove storage
|
|
156
157
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
157
158
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
158
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.
|
|
159
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.11.0 # or a specific tag, branch, or commit
|
|
159
160
|
```
|
|
160
161
|
|
|
161
162
|
The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
|
|
@@ -206,7 +207,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
|
|
|
206
207
|
1. Make and validate changes in `clubedge-starter`.
|
|
207
208
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
208
209
|
3. Update the Starter package versions.
|
|
209
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
210
|
+
4. Commit `main`, create a release tag such as `v0.11.0`, and publish the Starter release.
|
|
210
211
|
|
|
211
212
|
### CLI maintainers
|
|
212
213
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"clubedge": {
|
|
24
24
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
25
|
-
"starterRef": "v0.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.11.0",
|
|
26
|
+
"starterCommit": "b5637afcf3748e03bb7ecb1f1941a4e6c17cfa65"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
package/template/.env.example
CHANGED
|
@@ -51,6 +51,15 @@ RESEND_API_KEY=
|
|
|
51
51
|
EMAIL_FROM=App <no-reply@example.com>
|
|
52
52
|
# clubedge:end
|
|
53
53
|
|
|
54
|
+
# clubedge:if payments=stripe
|
|
55
|
+
# Stripe (https://dashboard.stripe.com/apikeys): use test-mode keys during development. The
|
|
56
|
+
# webhook secret comes from `stripe listen` locally, or from the endpoint's settings in production.
|
|
57
|
+
STRIPE_SECRET_KEY=
|
|
58
|
+
STRIPE_WEBHOOK_SECRET=
|
|
59
|
+
# The Stripe price for each plan in src/config/plans.ts (STRIPE_PRICE_<PLAN>).
|
|
60
|
+
STRIPE_PRICE_PRO=
|
|
61
|
+
# clubedge:end
|
|
62
|
+
|
|
54
63
|
# clubedge:if storage=s3
|
|
55
64
|
# S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
|
|
56
65
|
STORAGE_BUCKET= # clubedge:only infra!=docker
|
package/template/README.md
CHANGED
|
@@ -27,6 +27,7 @@ This repository contains the same application for two frameworks, Next.js (`apps
|
|
|
27
27
|
- Fixed-window rate limiter and cache in process memory, suited to a single server instance. <!-- clubedge:only cache=memory -->
|
|
28
28
|
- Email over SMTP, for any provider or a local inbox; development prints emails to the server log until SMTP is configured. <!-- clubedge:only email=smtp -->
|
|
29
29
|
- Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
|
|
30
|
+
- Subscriptions with Stripe Checkout and the customer portal, kept in sync by verified webhooks, behind a provider-neutral payments interface. <!-- clubedge:only payments=stripe -->
|
|
30
31
|
- Zod environment validation, security headers, structured errors, and `/api/health`.
|
|
31
32
|
- Docker support, GitHub Actions CI, Vitest, Playwright, ESLint, and Prettier.
|
|
32
33
|
- A production image that includes its own migrator (`node /app/migrate.mjs`), for running migrations before each release.
|
|
@@ -221,6 +222,8 @@ The TanStack Start app is `apps/start`, run with `pnpm --filter @clubedge/start
|
|
|
221
222
|
- `packages/email/`: `EmailProvider` interface with console and in-memory providers <!-- clubedge:only email!=none -->
|
|
222
223
|
- `packages/email-smtp/`: SMTP email provider <!-- clubedge:only email=smtp -->
|
|
223
224
|
- `packages/email-resend/`: Resend email provider <!-- clubedge:only email=resend -->
|
|
225
|
+
- `packages/payments/`: `PaymentProvider` interface, billing logic, and the subscription tables <!-- clubedge:only payments!=none -->
|
|
226
|
+
- `packages/payments-stripe/`: Stripe payment provider <!-- clubedge:only payments=stripe -->
|
|
224
227
|
- `packages/ui/`: shared shadcn/ui components, utilities, and global theme styles
|
|
225
228
|
- `.github/`: CI workflow, issue forms, and pull request template
|
|
226
229
|
- `SETUP.md`, `CONTRIBUTING.md`, `SECURITY.md`: setup, contribution, and security guides
|
package/template/SETUP.md
CHANGED
|
@@ -45,6 +45,7 @@ The variables are:
|
|
|
45
45
|
- `REDIS_URL` (optional): see [Redis](#redis). <!-- clubedge:only cache=redis -->
|
|
46
46
|
- `SMTP_URL` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=smtp -->
|
|
47
47
|
- `RESEND_API_KEY` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=resend -->
|
|
48
|
+
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `STRIPE_PRICE_<PLAN>` (for payments): see [Payments](#payments). <!-- clubedge:only payments=stripe -->
|
|
48
49
|
- `STORAGE_*` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=s3 -->
|
|
49
50
|
- `SUPABASE_STORAGE_BUCKET` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=supabase -->
|
|
50
51
|
|
|
@@ -208,6 +209,33 @@ Set `RESEND_API_KEY` to a [Resend](https://resend.com) API key and `EMAIL_FROM`
|
|
|
208
209
|
|
|
209
210
|
Without a provider configured, development prints each email to the server log, so you can open links from the terminal, and production refuses to send. `pnpm check:services` confirms that the provider accepts the settings without sending anything.
|
|
210
211
|
|
|
212
|
+
<!-- clubedge:end -->
|
|
213
|
+
<!-- clubedge:if payments!=none -->
|
|
214
|
+
|
|
215
|
+
## Payments
|
|
216
|
+
|
|
217
|
+
The plans users can buy are in `apps/web/src/config/plans.ts`, with the app's own ids. The dashboard's billing card offers checkout for each plan, then shows the subscription and opens billing management. Server code reads a user's plan with `getBilling().subscriptionFor(userId)` and `isEntitled` from `@/server/payments`, and the `subscriptions` table keeps it in sync through the provider's webhook at `/api/webhooks/payments`.
|
|
218
|
+
|
|
219
|
+
<!-- clubedge:end -->
|
|
220
|
+
<!-- clubedge:if payments=stripe -->
|
|
221
|
+
|
|
222
|
+
[Stripe](https://stripe.com) runs checkout and billing management. In test mode:
|
|
223
|
+
|
|
224
|
+
1. Copy the secret key from the dashboard's API keys into `STRIPE_SECRET_KEY`.
|
|
225
|
+
2. Create a recurring price for each plan and put its id in `STRIPE_PRICE_<PLAN>`, such as `STRIPE_PRICE_PRO`.
|
|
226
|
+
3. Forward webhooks to your machine with the [Stripe CLI](https://docs.stripe.com/stripe-cli), and put the signing secret it prints in `STRIPE_WEBHOOK_SECRET`:
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
stripe listen --forward-to localhost:3000/api/webhooks/payments
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Turn on the customer portal in the dashboard's billing settings, so "Manage billing" works. In production, add a webhook endpoint for `https://<your-domain>/api/webhooks/payments` with the events `checkout.session.completed` and `customer.subscription.created`, `.updated`, `.deleted`, `.paused`, and `.resumed`, and use its signing secret. Every webhook's signature is verified, each event is applied once, and subscription events read the subscription's current state from Stripe, so retried or out-of-order deliveries are safe.
|
|
233
|
+
|
|
234
|
+
<!-- clubedge:end -->
|
|
235
|
+
<!-- clubedge:if payments!=none -->
|
|
236
|
+
|
|
237
|
+
Payments go through the provider-neutral `PaymentProvider` interface in `packages/payments`. To use another provider, implement that interface in a new package and return it from `apps/web/src/server/payments.ts`; billing logic, the tables, the routes, and the billing card stay the same.
|
|
238
|
+
|
|
211
239
|
<!-- clubedge:end -->
|
|
212
240
|
<!-- clubedge:if storage!=none -->
|
|
213
241
|
|
|
@@ -341,6 +369,7 @@ Production needs these variables:
|
|
|
341
369
|
- `BETTER_AUTH_SECRET`, at least 32 random characters, different from development. <!-- clubedge:only auth=better-auth -->
|
|
342
370
|
- `SMTP_URL` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=smtp -->
|
|
343
371
|
- `RESEND_API_KEY` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=resend -->
|
|
372
|
+
- `STRIPE_SECRET_KEY` (live mode), `STRIPE_WEBHOOK_SECRET` from the production webhook endpoint, and `STRIPE_PRICE_<PLAN>` for each plan. <!-- clubedge:only payments=stripe -->
|
|
344
373
|
- `REDIS_URL`, when you run more than one instance. <!-- clubedge:only cache=redis -->
|
|
345
374
|
- The `STORAGE_*` variables for your S3-compatible bucket. <!-- clubedge:only storage=s3 -->
|
|
346
375
|
- `SUPABASE_STORAGE_BUCKET`. <!-- clubedge:only storage=supabase -->
|
|
@@ -53,6 +53,15 @@ RESEND_API_KEY=
|
|
|
53
53
|
EMAIL_FROM=App <no-reply@example.com>
|
|
54
54
|
# clubedge:end
|
|
55
55
|
|
|
56
|
+
# clubedge:if payments=stripe
|
|
57
|
+
# Stripe (https://dashboard.stripe.com/apikeys): use test-mode keys during development. The
|
|
58
|
+
# webhook secret comes from `stripe listen` locally, or from the endpoint's settings in production.
|
|
59
|
+
STRIPE_SECRET_KEY=
|
|
60
|
+
STRIPE_WEBHOOK_SECRET=
|
|
61
|
+
# The Stripe price for each plan in src/config/plans.ts (STRIPE_PRICE_<PLAN>).
|
|
62
|
+
STRIPE_PRICE_PRO=
|
|
63
|
+
# clubedge:end
|
|
64
|
+
|
|
56
65
|
# clubedge:if storage=s3
|
|
57
66
|
# S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
|
|
58
67
|
STORAGE_BUCKET= # clubedge:only infra!=docker
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"@clubedge/email": "workspace:*",
|
|
23
23
|
"@clubedge/email-resend": "workspace:*",
|
|
24
24
|
"@clubedge/email-smtp": "workspace:*",
|
|
25
|
+
"@clubedge/payments": "workspace:*",
|
|
26
|
+
"@clubedge/payments-stripe": "workspace:*",
|
|
25
27
|
"@clubedge/storage": "workspace:*",
|
|
26
28
|
"@clubedge/storage-s3": "workspace:*",
|
|
27
29
|
"@clubedge/storage-supabase": "workspace:*",
|
|
@@ -14,6 +14,9 @@ import { createSmtpEmail } from "@clubedge/email-smtp";
|
|
|
14
14
|
// clubedge:if email=resend
|
|
15
15
|
import { createResendEmail } from "@clubedge/email-resend";
|
|
16
16
|
// clubedge:end
|
|
17
|
+
// clubedge:if payments=stripe
|
|
18
|
+
import { createStripePayments } from "@clubedge/payments-stripe";
|
|
19
|
+
// clubedge:end
|
|
17
20
|
// clubedge:if storage=s3 && infra=local
|
|
18
21
|
import { createFileSystemStorage } from "@clubedge/storage/file-system";
|
|
19
22
|
// clubedge:end
|
|
@@ -100,6 +103,23 @@ async function main() {
|
|
|
100
103
|
},
|
|
101
104
|
},
|
|
102
105
|
// clubedge:end
|
|
106
|
+
// clubedge:if payments=stripe
|
|
107
|
+
{
|
|
108
|
+
name: "Payments (Stripe)",
|
|
109
|
+
skip: env.STRIPE_SECRET_KEY ? undefined : "STRIPE_SECRET_KEY is not set",
|
|
110
|
+
async run() {
|
|
111
|
+
// A read-only request that checks the key; nothing is charged or created.
|
|
112
|
+
const stripe = createStripePayments({
|
|
113
|
+
secretKey: env.STRIPE_SECRET_KEY!,
|
|
114
|
+
webhookSecret: env.STRIPE_WEBHOOK_SECRET || "unused",
|
|
115
|
+
prices: {},
|
|
116
|
+
apiBase: env.STRIPE_API_BASE || undefined,
|
|
117
|
+
});
|
|
118
|
+
await stripe.verify();
|
|
119
|
+
return "secret key accepted";
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
// clubedge:end
|
|
103
123
|
// clubedge:if storage=s3 && infra=local
|
|
104
124
|
{
|
|
105
125
|
name: "Local folder storage",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { CreditCard } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { plans } from "@/config/plans";
|
|
4
|
+
import type { BillingStatus } from "@/server/billing.functions";
|
|
5
|
+
import { Badge } from "@clubedge/ui/components/badge";
|
|
6
|
+
import { Button } from "@clubedge/ui/components/button";
|
|
7
|
+
import {
|
|
8
|
+
Card,
|
|
9
|
+
CardContent,
|
|
10
|
+
CardDescription,
|
|
11
|
+
CardHeader,
|
|
12
|
+
CardTitle,
|
|
13
|
+
} from "@clubedge/ui/components/card";
|
|
14
|
+
|
|
15
|
+
const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
|
|
16
|
+
|
|
17
|
+
/** The signed-in user's plan, with checkout or the billing portal. Forms post to /billing/*. */
|
|
18
|
+
export function BillingCard({ billing }: { billing: BillingStatus }) {
|
|
19
|
+
const { subscription } = billing;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Card className="gap-0 py-0" id="billing">
|
|
23
|
+
<CardHeader className="flex-row items-start justify-between gap-3 border-b px-5 py-5 sm:px-6">
|
|
24
|
+
<div className="grid gap-1">
|
|
25
|
+
<CardTitle className="text-base">Billing</CardTitle>
|
|
26
|
+
<CardDescription>Your plan and payment details.</CardDescription>
|
|
27
|
+
</div>
|
|
28
|
+
<span className="grid size-9 place-items-center rounded-lg bg-primary/10 text-primary">
|
|
29
|
+
<CreditCard aria-hidden="true" className="size-4" />
|
|
30
|
+
</span>
|
|
31
|
+
</CardHeader>
|
|
32
|
+
<CardContent className="grid gap-4 px-5 py-5 sm:px-6">
|
|
33
|
+
{!billing.configured ? (
|
|
34
|
+
<p className="text-sm text-muted-foreground">
|
|
35
|
+
Payments are not configured yet. Set the Stripe variables in{" "}
|
|
36
|
+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
|
|
37
|
+
apps/web/.env.local
|
|
38
|
+
</code>
|
|
39
|
+
.
|
|
40
|
+
</p>
|
|
41
|
+
) : !billing.signedIn ? (
|
|
42
|
+
<p className="text-sm text-muted-foreground">Sign in to choose a plan.</p>
|
|
43
|
+
) : subscription ? (
|
|
44
|
+
<>
|
|
45
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
46
|
+
<span className="font-medium">{subscription.planName}</span>
|
|
47
|
+
<Badge variant={subscription.entitled ? "default" : "secondary"}>
|
|
48
|
+
{subscription.status.replace(/_/g, " ")}
|
|
49
|
+
</Badge>
|
|
50
|
+
</div>
|
|
51
|
+
{subscription.currentPeriodEnd && (
|
|
52
|
+
<p className="text-sm text-muted-foreground">
|
|
53
|
+
{subscription.cancelAtPeriodEnd ? "Ends" : "Renews"} on{" "}
|
|
54
|
+
{dateFormat.format(new Date(subscription.currentPeriodEnd))}.
|
|
55
|
+
</p>
|
|
56
|
+
)}
|
|
57
|
+
<form action="/billing/portal" method="post">
|
|
58
|
+
<Button type="submit" variant="outline">
|
|
59
|
+
Manage billing
|
|
60
|
+
</Button>
|
|
61
|
+
</form>
|
|
62
|
+
</>
|
|
63
|
+
) : (
|
|
64
|
+
plans.map((plan) => (
|
|
65
|
+
<form
|
|
66
|
+
action="/billing/checkout"
|
|
67
|
+
className="flex flex-wrap items-center justify-between gap-3"
|
|
68
|
+
key={plan.id}
|
|
69
|
+
method="post"
|
|
70
|
+
>
|
|
71
|
+
<input name="planId" type="hidden" value={plan.id} />
|
|
72
|
+
<div className="grid gap-0.5">
|
|
73
|
+
<span className="font-medium">{plan.name}</span>
|
|
74
|
+
<span className="text-sm text-muted-foreground">{plan.description}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<Button type="submit">Upgrade to {plan.name}</Button>
|
|
77
|
+
</form>
|
|
78
|
+
))
|
|
79
|
+
)}
|
|
80
|
+
</CardContent>
|
|
81
|
+
</Card>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plans users can subscribe to. Ids are the app's own: each payment provider maps them to
|
|
3
|
+
* its prices through environment variables, such as STRIPE_PRICE_PRO for the "pro" plan.
|
|
4
|
+
*/
|
|
5
|
+
export const plans = [
|
|
6
|
+
{
|
|
7
|
+
id: "pro",
|
|
8
|
+
name: "Pro",
|
|
9
|
+
description: "Everything in the free plan, plus the paid features you build.",
|
|
10
|
+
},
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type PlanId = (typeof plans)[number]["id"];
|
|
14
|
+
|
|
15
|
+
export const planName = (id: string | null) =>
|
|
16
|
+
plans.find((plan) => plan.id === id)?.name ?? "Unknown plan";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { getBilling, isPaymentsConfigured, WebhookVerificationError } from "@/server/payments";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The payment provider's webhook. The body is read as raw text, because the signature covers
|
|
6
|
+
* the exact bytes. Each event is applied once; failures answer 500 so the provider retries.
|
|
7
|
+
*/
|
|
8
|
+
export const Route = createFileRoute("/api/webhooks/payments")({
|
|
9
|
+
server: {
|
|
10
|
+
handlers: {
|
|
11
|
+
POST: async ({ request }) => {
|
|
12
|
+
if (!isPaymentsConfigured()) {
|
|
13
|
+
return Response.json({ error: "Payments are not configured." }, { status: 503 });
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const result = await getBilling().handleWebhook({
|
|
17
|
+
body: await request.text(),
|
|
18
|
+
headers: request.headers,
|
|
19
|
+
});
|
|
20
|
+
return Response.json({ received: true, result });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error instanceof WebhookVerificationError) {
|
|
23
|
+
return Response.json({ error: "Invalid signature." }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
console.error("Payment webhook failed", error);
|
|
26
|
+
return Response.json({ error: "Webhook failed." }, { status: 500 });
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { handleCheckout } from "@/server/billing-forms";
|
|
3
|
+
|
|
4
|
+
export const Route = createFileRoute("/billing/checkout")({
|
|
5
|
+
server: {
|
|
6
|
+
handlers: {
|
|
7
|
+
GET: () => new Response(null, { status: 303, headers: { Location: "/dashboard" } }),
|
|
8
|
+
POST: ({ request }) => handleCheckout(request),
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { handlePortal } from "@/server/billing-forms";
|
|
3
|
+
|
|
4
|
+
export const Route = createFileRoute("/billing/portal")({
|
|
5
|
+
server: {
|
|
6
|
+
handlers: {
|
|
7
|
+
GET: () => new Response(null, { status: 303, headers: { Location: "/dashboard" } }),
|
|
8
|
+
POST: () => handlePortal(),
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@clubedge/ui/components/sidebar";
|
|
48
48
|
import { siteConfig } from "@/config/site";
|
|
49
49
|
import { getPageUser } from "@/server/auth.functions";
|
|
50
|
+
// clubedge:if payments!=none
|
|
51
|
+
import { BillingCard } from "@/components/billing/billing-card";
|
|
52
|
+
import { getBillingStatus } from "@/server/billing.functions";
|
|
53
|
+
// clubedge:end
|
|
50
54
|
import { AccountButton } from "@/components/dashboard/account-button";
|
|
51
55
|
import { AppSidebar } from "@/components/dashboard/app-sidebar";
|
|
52
56
|
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
|
@@ -107,12 +111,18 @@ const setupRows = [
|
|
|
107
111
|
];
|
|
108
112
|
|
|
109
113
|
export const Route = createFileRoute("/dashboard")({
|
|
110
|
-
loader: () =>
|
|
114
|
+
loader: async () => ({
|
|
115
|
+
user: await getPageUser({ data: { pathname: "/dashboard" } }),
|
|
116
|
+
// clubedge:if payments!=none
|
|
117
|
+
billing: await getBillingStatus(),
|
|
118
|
+
// clubedge:end
|
|
119
|
+
}),
|
|
111
120
|
component: DashboardPage,
|
|
112
121
|
});
|
|
113
122
|
|
|
114
123
|
function DashboardPage() {
|
|
115
|
-
const
|
|
124
|
+
const data = Route.useLoaderData();
|
|
125
|
+
const user = data.user;
|
|
116
126
|
|
|
117
127
|
return (
|
|
118
128
|
<SidebarProvider defaultOpen>
|
|
@@ -243,6 +253,10 @@ function DashboardPage() {
|
|
|
243
253
|
</Card>
|
|
244
254
|
</section>
|
|
245
255
|
|
|
256
|
+
{/* clubedge:if payments!=none */}
|
|
257
|
+
<BillingCard billing={data.billing} />
|
|
258
|
+
{/* clubedge:end */}
|
|
259
|
+
|
|
246
260
|
<section className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]" id="modules">
|
|
247
261
|
<Card className="gap-0 py-0">
|
|
248
262
|
<CardHeader className="flex-row items-start justify-between gap-3 border-b px-5 py-5 sm:px-6">
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import { plans } from "@/config/plans";
|
|
3
|
+
import { getAppUrl } from "@/env/server";
|
|
4
|
+
import { loginUrl } from "@/lib/login-url";
|
|
5
|
+
import { getCurrentUser, isAuthConfigured } from "./auth";
|
|
6
|
+
import { getBilling, isPaymentsConfigured, NoBillingAccountError } from "./payments";
|
|
7
|
+
import { getUserProfileId } from "./users";
|
|
8
|
+
|
|
9
|
+
// Form endpoints for the billing card. They answer with 303 redirects to the provider's hosted
|
|
10
|
+
// pages, the same way the Next.js server actions do.
|
|
11
|
+
|
|
12
|
+
function seeOther(location: string): Response {
|
|
13
|
+
return new Response(null, { status: 303, headers: { Location: location } });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const appUrl = (path: string) => new URL(path, getAppUrl()).toString();
|
|
17
|
+
|
|
18
|
+
async function signedInProfile() {
|
|
19
|
+
const user = isAuthConfigured() ? await getCurrentUser() : null;
|
|
20
|
+
if (!user?.email) return null;
|
|
21
|
+
return { id: await getUserProfileId({ ...user, email: user.email }), email: user.email };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function handleCheckout(request: Request): Promise<Response> {
|
|
25
|
+
const formData = await request.formData();
|
|
26
|
+
const plan = plans.find((candidate) => candidate.id === formData.get("planId"));
|
|
27
|
+
if (!plan || !isPaymentsConfigured()) return seeOther("/dashboard");
|
|
28
|
+
const user = await signedInProfile();
|
|
29
|
+
if (!user) return seeOther(loginUrl({ next: "/dashboard" }));
|
|
30
|
+
const { url } = await getBilling().checkout({
|
|
31
|
+
user,
|
|
32
|
+
planId: plan.id,
|
|
33
|
+
successUrl: appUrl("/dashboard?billing=success"),
|
|
34
|
+
cancelUrl: appUrl("/dashboard"),
|
|
35
|
+
});
|
|
36
|
+
return seeOther(url);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handlePortal(): Promise<Response> {
|
|
40
|
+
if (!isPaymentsConfigured()) return seeOther("/dashboard");
|
|
41
|
+
const user = await signedInProfile();
|
|
42
|
+
if (!user) return seeOther(loginUrl({ next: "/dashboard" }));
|
|
43
|
+
try {
|
|
44
|
+
const { url } = await getBilling().portal({ userId: user.id, returnUrl: appUrl("/dashboard") });
|
|
45
|
+
return seeOther(url);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof NoBillingAccountError) return seeOther("/dashboard");
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { planName } from "@/config/plans";
|
|
3
|
+
import { getCurrentUser, isAuthConfigured } from "./auth";
|
|
4
|
+
import { getBilling, isEntitled, isPaymentsConfigured } from "./payments";
|
|
5
|
+
import { getUserProfileId } from "./users";
|
|
6
|
+
|
|
7
|
+
/** What the dashboard's billing card shows; serializable, so it can cross to the browser. */
|
|
8
|
+
export const getBillingStatus = createServerFn({ method: "GET" }).handler(async () => {
|
|
9
|
+
const configured = isPaymentsConfigured();
|
|
10
|
+
const user = isAuthConfigured() ? await getCurrentUser() : null;
|
|
11
|
+
if (!configured || !user?.email)
|
|
12
|
+
return { configured, signedIn: Boolean(user), subscription: null };
|
|
13
|
+
const subscription = await getBilling().subscriptionFor(
|
|
14
|
+
await getUserProfileId({ ...user, email: user.email }),
|
|
15
|
+
);
|
|
16
|
+
return {
|
|
17
|
+
configured,
|
|
18
|
+
signedIn: true,
|
|
19
|
+
subscription: subscription && {
|
|
20
|
+
planName: planName(subscription.planId),
|
|
21
|
+
status: subscription.status,
|
|
22
|
+
entitled: isEntitled(subscription),
|
|
23
|
+
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
|
|
24
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type BillingStatus = Awaited<ReturnType<typeof getBillingStatus>>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createBilling, type Billing } from "@clubedge/payments";
|
|
4
|
+
import { createStripePayments } from "@clubedge/payments-stripe";
|
|
5
|
+
import { plans } from "@/config/plans";
|
|
6
|
+
import { optional, parseEnv } from "@/env/server";
|
|
7
|
+
import { getDb } from "@/server/db";
|
|
8
|
+
|
|
9
|
+
export { isEntitled, NoBillingAccountError, WebhookVerificationError } from "@clubedge/payments";
|
|
10
|
+
|
|
11
|
+
// Stripe behind the provider-neutral PaymentProvider; the rest of the app only uses Billing.
|
|
12
|
+
const paymentsEnvSchema = z.object({
|
|
13
|
+
STRIPE_SECRET_KEY: optional(z.string()),
|
|
14
|
+
STRIPE_WEBHOOK_SECRET: optional(z.string()),
|
|
15
|
+
// For tests and stripe-mock: send API requests to another server.
|
|
16
|
+
STRIPE_API_BASE: optional(z.url()),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Each plan's Stripe price, from STRIPE_PRICE_<PLAN>, such as STRIPE_PRICE_PRO. */
|
|
20
|
+
function prices(): Record<string, string> {
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
plans.flatMap((plan) => {
|
|
23
|
+
const price = process.env[`STRIPE_PRICE_${plan.id.toUpperCase().replace(/-/g, "_")}`];
|
|
24
|
+
return price ? [[plan.id, price]] : [];
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function paymentsEnv() {
|
|
30
|
+
const env = parseEnv(paymentsEnvSchema, "Payments");
|
|
31
|
+
if (!env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET) return null;
|
|
32
|
+
return { ...env, secretKey: env.STRIPE_SECRET_KEY, webhookSecret: env.STRIPE_WEBHOOK_SECRET };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether checkout and webhooks can work: the Stripe keys are set. */
|
|
36
|
+
export function isPaymentsConfigured(): boolean {
|
|
37
|
+
return paymentsEnv() !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let billing: Billing | undefined;
|
|
41
|
+
|
|
42
|
+
/** One Billing per process. Throws while payments are not configured. */
|
|
43
|
+
export function getBilling(): Billing {
|
|
44
|
+
if (billing) return billing;
|
|
45
|
+
const env = paymentsEnv();
|
|
46
|
+
if (!env)
|
|
47
|
+
throw new Error(
|
|
48
|
+
"Payments are not configured: set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.",
|
|
49
|
+
);
|
|
50
|
+
billing = createBilling({
|
|
51
|
+
db: getDb(),
|
|
52
|
+
provider: createStripePayments({
|
|
53
|
+
secretKey: env.secretKey,
|
|
54
|
+
webhookSecret: env.webhookSecret,
|
|
55
|
+
prices: prices(),
|
|
56
|
+
apiBase: env.STRIPE_API_BASE,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
return billing;
|
|
60
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "@tanstack/react-start/server-only";
|
|
2
2
|
import type { AuthUser } from "@clubedge/auth";
|
|
3
|
-
import { upsertUserProfile } from "@clubedge/db";
|
|
3
|
+
import { ensureUserId, upsertUserProfile } from "@clubedge/db";
|
|
4
4
|
import { getDb } from "./db";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -15,3 +15,8 @@ export async function ensureUserProfile(user: AuthUser | null): Promise<void> {
|
|
|
15
15
|
console.error("Could not sync the user profile", error);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
/** The app-owned users row id for a signed-in user, creating the row if needed. */
|
|
20
|
+
export async function getUserProfileId(user: AuthUser & { email: string }): Promise<string> {
|
|
21
|
+
return ensureUserId(getDb(), { authUserId: user.id, email: user.email });
|
|
22
|
+
}
|
|
@@ -12,8 +12,10 @@ const securityHeaders: Record<string, string> = {
|
|
|
12
12
|
const safeMethods = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
13
13
|
|
|
14
14
|
// Rejects cross-site form posts and server function calls (Next.js does this for actions).
|
|
15
|
+
// Webhooks are cross-site by design; their handlers verify the sender's signature instead.
|
|
15
16
|
const csrfProtection = createCsrfMiddleware({
|
|
16
|
-
filter: ({ request }) =>
|
|
17
|
+
filter: ({ request }) =>
|
|
18
|
+
!safeMethods.has(request.method) && !new URL(request.url).pathname.startsWith("/api/webhooks/"),
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
// Refreshed cookies reach both later handlers in this request and the browser response.
|