create-softeneers-app 0.2.2 → 0.2.3

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.
Files changed (51) hide show
  1. package/README.html +4 -4
  2. package/README.md +6 -6
  3. package/dist/args.js +23 -2
  4. package/dist/args.js.map +1 -1
  5. package/dist/fragments.js +1 -1
  6. package/dist/fragments.js.map +1 -1
  7. package/dist/index.js +10 -7
  8. package/dist/index.js.map +1 -1
  9. package/dist/prompts.js +12 -2
  10. package/dist/prompts.js.map +1 -1
  11. package/package.json +1 -1
  12. package/templates/express-api/.env.example +23 -0
  13. package/templates/express-api/README.md +68 -1
  14. package/templates/express-api/package.json +3 -0
  15. package/templates/express-api/softeneers.template.json +20 -1
  16. package/templates/express-api/src/email/mailer.ts +6 -0
  17. package/templates/express-api/src/email/routes.ts +26 -0
  18. package/templates/express-api/src/env.ts +18 -0
  19. package/templates/express-api/src/index.ts +23 -0
  20. package/templates/express-api/src/payments/routes.ts +44 -0
  21. package/templates/express-api/src/payments/stripe.ts +6 -0
  22. package/templates/express-api/src/payments/webhook.ts +43 -0
  23. package/templates/express-api/src/storage/routes.ts +28 -0
  24. package/templates/express-api/src/storage/store.ts +13 -0
  25. package/templates/hono-api/.env.example +23 -0
  26. package/templates/hono-api/README.md +69 -2
  27. package/templates/hono-api/package.json +3 -0
  28. package/templates/hono-api/softeneers.template.json +20 -1
  29. package/templates/hono-api/src/email/mailer.ts +6 -0
  30. package/templates/hono-api/src/email/routes.ts +24 -0
  31. package/templates/hono-api/src/env.ts +18 -0
  32. package/templates/hono-api/src/index.ts +20 -0
  33. package/templates/hono-api/src/payments/routes.ts +41 -0
  34. package/templates/hono-api/src/payments/stripe.ts +6 -0
  35. package/templates/hono-api/src/payments/webhook.ts +36 -0
  36. package/templates/hono-api/src/storage/routes.ts +29 -0
  37. package/templates/hono-api/src/storage/store.ts +13 -0
  38. package/templates/tanstack-start/.env.example +23 -0
  39. package/templates/tanstack-start/README.md +58 -3
  40. package/templates/tanstack-start/package.json +4 -0
  41. package/templates/tanstack-start/softeneers.template.json +28 -3
  42. package/templates/tanstack-start/src/lib/auth-client.ts +4 -0
  43. package/templates/tanstack-start/src/routes/account.tsx +47 -0
  44. package/templates/tanstack-start/src/routes/api/webhooks/stripe.ts +43 -0
  45. package/templates/tanstack-start/src/routes/billing.tsx +59 -0
  46. package/templates/tanstack-start/src/routes/index.tsx +15 -6
  47. package/templates/tanstack-start/src/routes/login.tsx +77 -0
  48. package/templates/tanstack-start/src/server/email.ts +27 -0
  49. package/templates/tanstack-start/src/server/env.ts +18 -0
  50. package/templates/tanstack-start/src/server/payments.ts +40 -0
  51. package/templates/tanstack-start/src/server/storage.ts +36 -0
@@ -0,0 +1,13 @@
1
+ import { createStorage } from "@softeneers/storage";
2
+
3
+ import { env } from "../env.js";
4
+
5
+ // An S3-compatible storage handle (AWS S3 / Cloudflare R2 / MinIO). No network
6
+ // call until you upload or sign a URL.
7
+ export const storage = createStorage({
8
+ accessKeyId: env.S3_ACCESS_KEY_ID,
9
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
10
+ bucket: env.S3_BUCKET,
11
+ region: env.S3_REGION,
12
+ endpoint: env.S3_ENDPOINT || undefined,
13
+ });
@@ -11,3 +11,26 @@ DB_PASSWORD=
11
11
  AUTH_SECRET=dev-secret-change-me-to-a-long-random-string
12
12
  AUTH_BASE_URL=http://localhost:4000
13
13
  # #endif
14
+ # #if email
15
+ # Get a key at https://resend.com — the app boots without it; sending needs it.
16
+ RESEND_API_KEY=re_set_me
17
+ EMAIL_FROM=onboarding@resend.dev
18
+ # #endif
19
+ # #if storage
20
+ # S3-compatible storage (AWS S3 / Cloudflare R2 / MinIO).
21
+ S3_ACCESS_KEY_ID=
22
+ S3_SECRET_ACCESS_KEY=
23
+ S3_BUCKET=uploads
24
+ S3_REGION=auto
25
+ S3_ENDPOINT=
26
+ # #endif
27
+ # #if payments
28
+ # Stripe — the ONLY thing you must set for payments to work. Test keys from
29
+ # https://dashboard.stripe.com/test/apikeys ; webhook secret from `stripe listen`
30
+ # or the dashboard. Create two Prices and paste their ids below.
31
+ APP_URL=http://localhost:4000
32
+ STRIPE_SECRET_KEY=sk_test_set_me
33
+ STRIPE_WEBHOOK_SECRET=whsec_set_me
34
+ STRIPE_PRICE_ID=price_set_me
35
+ STRIPE_SUBSCRIPTION_PRICE_ID=price_set_me
36
+ # #endif
@@ -30,7 +30,21 @@ npm run db:reset # drop, recreate, reseed
30
30
  | PUT | `/api/cars/:id` | Update a car |
31
31
  | DELETE | `/api/cars/:id` | Delete a car |
32
32
  # #if auth
33
- | GET/POST | `/api/auth/*` | better-auth routes |
33
+ | ALL | `/api/auth/*` | better-auth routes |
34
+ # #endif
35
+ # #if email
36
+ | POST | `/api/email/welcome` | Send a welcome email |
37
+ # #endif
38
+ # #if storage
39
+ | POST | `/api/files/:key` | Upload an object |
40
+ | GET | `/api/files/:key/url`| Signed download URL |
41
+ | DELETE | `/api/files/:key` | Delete an object |
42
+ # #endif
43
+ # #if payments
44
+ | POST | `/api/payments/checkout` | One-time Stripe checkout |
45
+ | POST | `/api/payments/subscribe` | Subscription checkout |
46
+ | POST | `/api/payments/portal` | Billing portal |
47
+ | POST | `/api/webhooks/stripe` | Stripe webhook (verified) |
34
48
  # #endif
35
49
 
36
50
  ```bash
@@ -61,7 +75,60 @@ npm run db:migrate && npm run db:seed
61
75
  ## Authentication
62
76
 
63
77
  Email + password auth via better-auth ([`@softeneers/auth`](https://www.npmjs.com/package/@softeneers/auth)),
64
- mounted at `/api/auth/*`. Set a strong `AUTH_SECRET` in `.env` before deploying.
78
+ mounted at `/api/auth/*` (`sign-up/email`, `sign-in/email`, `get-session`, …).
79
+ Set a strong `AUTH_SECRET` in `.env` before deploying.
80
+ # #endif
81
+ # #if email
82
+
83
+ ## Email
84
+
85
+ Transactional email via Resend ([`@softeneers/email`](https://www.npmjs.com/package/@softeneers/email)).
86
+ Set `RESEND_API_KEY` (and `EMAIL_FROM`) in `.env`, then:
87
+
88
+ ```bash
89
+ curl -X POST localhost:4000/api/email/welcome -H 'content-type: application/json' \
90
+ -d '{"to":"you@example.com","name":"Ada"}'
91
+ ```
92
+ # #endif
93
+ # #if storage
94
+
95
+ ## Storage
96
+
97
+ S3-compatible uploads ([`@softeneers/storage`](https://www.npmjs.com/package/@softeneers/storage))
98
+ — works with AWS S3, Cloudflare R2, and MinIO. Set the `S3_*` keys in `.env`, then:
99
+
100
+ ```bash
101
+ curl -X POST --data-binary @photo.png localhost:4000/api/files/photo.png
102
+ curl localhost:4000/api/files/photo.png/url # → a signed download URL
103
+ ```
104
+ # #endif
105
+ # #if payments
106
+
107
+ ## Payments (Stripe)
108
+
109
+ Stripe checkout, subscriptions, the billing portal, and a verified webhook
110
+ ([`@softeneers/payments`](https://www.npmjs.com/package/@softeneers/payments)) —
111
+ **all pre-wired**. The only thing you do is paste your keys into `.env`:
112
+
113
+ ```
114
+ STRIPE_SECRET_KEY=sk_test_…
115
+ STRIPE_WEBHOOK_SECRET=whsec_…
116
+ STRIPE_PRICE_ID=price_… # one-time price
117
+ STRIPE_SUBSCRIPTION_PRICE_ID=price_… # recurring price
118
+ ```
119
+
120
+ Get test keys at <https://dashboard.stripe.com/test/apikeys> and create two
121
+ Prices. Locally, forward webhooks with the Stripe CLI (this also prints the
122
+ `whsec_…` secret):
123
+
124
+ ```bash
125
+ stripe listen --forward-to localhost:4000/api/webhooks/stripe
126
+ ```
127
+
128
+ Then `POST /api/payments/checkout` (or `/subscribe`) returns a Stripe URL to
129
+ redirect the customer to. The webhook handles `checkout.session.completed` and
130
+ the `customer.subscription.*` events — add your fulfilment logic in
131
+ `src/payments/webhook.ts`.
65
132
  # #endif
66
133
 
67
134
  ## Getting started
@@ -21,7 +21,10 @@
21
21
  "@hono/node-server": "^1.13.0",
22
22
  "@softeneers/auth": "^0.1.0",
23
23
  "@softeneers/db": "^0.1.0",
24
+ "@softeneers/email": "^0.1.0",
24
25
  "@softeneers/env": "^0.1.0",
26
+ "@softeneers/payments": "^0.1.0",
27
+ "@softeneers/storage": "^0.1.0",
25
28
  "dotenv": "^16.4.5",
26
29
  "hono": "^4.6.0",
27
30
  "mysql2": "^3.11.0"
@@ -1,5 +1,12 @@
1
1
  {
2
- "toggles": { "db": false, "auth": false, "docker": false },
2
+ "toggles": {
3
+ "db": false,
4
+ "auth": false,
5
+ "docker": false,
6
+ "email": false,
7
+ "storage": false,
8
+ "payments": false
9
+ },
3
10
  "fragments": {
4
11
  "db": {
5
12
  "removePaths": ["src/db.ts", "src/scripts", "docker-compose.yml"],
@@ -12,6 +19,18 @@
12
19
  },
13
20
  "docker": {
14
21
  "removePaths": ["docker-compose.yml"]
22
+ },
23
+ "email": {
24
+ "removePaths": ["src/email"],
25
+ "removeDeps": ["@softeneers/email"]
26
+ },
27
+ "storage": {
28
+ "removePaths": ["src/storage"],
29
+ "removeDeps": ["@softeneers/storage"]
30
+ },
31
+ "payments": {
32
+ "removePaths": ["src/payments"],
33
+ "removeDeps": ["@softeneers/payments"]
15
34
  }
16
35
  }
17
36
  }
@@ -0,0 +1,6 @@
1
+ import { createEmailClient } from "@softeneers/email";
2
+
3
+ import { env } from "../env.js";
4
+
5
+ // A Resend client. No network call until you send.
6
+ export const mailer = createEmailClient(env.RESEND_API_KEY);
@@ -0,0 +1,24 @@
1
+ import { Hono } from "hono";
2
+
3
+ import { sendEmail } from "@softeneers/email";
4
+
5
+ import { env } from "../env.js";
6
+ import { mailer } from "./mailer.js";
7
+
8
+ export const email = new Hono();
9
+
10
+ // Demo: send a welcome email. POST { "to": "a@b.com", "name": "Ada" }
11
+ email.post("/welcome", async (c) => {
12
+ const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
13
+ const to = typeof body.to === "string" ? body.to : "";
14
+ const name = typeof body.name === "string" ? body.name : "there";
15
+ if (!to) return c.json({ message: "to (email address) is required." }, 400);
16
+ await sendEmail(mailer, {
17
+ from: env.EMAIL_FROM,
18
+ to,
19
+ subject: "Welcome!",
20
+ html: `<h1>Welcome, ${name}!</h1><p>Thanks for joining.</p>`,
21
+ text: `Welcome, ${name}! Thanks for joining.`,
22
+ });
23
+ return c.json({ sent: true });
24
+ });
@@ -19,5 +19,23 @@ export const env = createEnv({
19
19
  AUTH_SECRET: z.string().min(16).default("dev-secret-change-me-to-a-long-random-string"),
20
20
  AUTH_BASE_URL: z.string().default("http://localhost:4000"),
21
21
  // #endif
22
+ // #if email
23
+ RESEND_API_KEY: z.string().default("re_set_me_in_dotenv"),
24
+ EMAIL_FROM: z.string().default("onboarding@resend.dev"),
25
+ // #endif
26
+ // #if storage
27
+ S3_ACCESS_KEY_ID: z.string().default(""),
28
+ S3_SECRET_ACCESS_KEY: z.string().default(""),
29
+ S3_BUCKET: z.string().default("uploads"),
30
+ S3_REGION: z.string().default("auto"),
31
+ S3_ENDPOINT: z.string().default(""),
32
+ // #endif
33
+ // #if payments
34
+ APP_URL: z.string().default("http://localhost:4000"),
35
+ STRIPE_SECRET_KEY: z.string().default("sk_test_set_me_in_dotenv"),
36
+ STRIPE_WEBHOOK_SECRET: z.string().default("whsec_set_me_in_dotenv"),
37
+ STRIPE_PRICE_ID: z.string().default("price_set_me_in_dotenv"),
38
+ STRIPE_SUBSCRIPTION_PRICE_ID: z.string().default("price_set_me_in_dotenv"),
39
+ // #endif
22
40
  },
23
41
  });
@@ -7,6 +7,16 @@ import { env } from "./env.js";
7
7
  // #if auth
8
8
  import { auth } from "./auth/auth.js";
9
9
  // #endif
10
+ // #if email
11
+ import { email } from "./email/routes.js";
12
+ // #endif
13
+ // #if storage
14
+ import { files } from "./storage/routes.js";
15
+ // #endif
16
+ // #if payments
17
+ import { payments } from "./payments/routes.js";
18
+ import { stripeWebhook } from "./payments/webhook.js";
19
+ // #endif
10
20
 
11
21
  const app = new Hono();
12
22
 
@@ -20,6 +30,16 @@ app.route("/api/cars", cars);
20
30
  // better-auth speaks the Fetch API, so hand it the raw Request and return its Response.
21
31
  app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
22
32
  // #endif
33
+ // #if email
34
+ app.route("/api/email", email);
35
+ // #endif
36
+ // #if storage
37
+ app.route("/api/files", files);
38
+ // #endif
39
+ // #if payments
40
+ app.post("/api/webhooks/stripe", stripeWebhook);
41
+ app.route("/api/payments", payments);
42
+ // #endif
23
43
 
24
44
  serve({ fetch: app.fetch, port: env.PORT }, (info) => {
25
45
  console.log(`API running on http://localhost:${info.port}`);
@@ -0,0 +1,41 @@
1
+ import { Hono } from "hono";
2
+
3
+ import { createBillingPortalSession, createCheckoutSession } from "@softeneers/payments";
4
+
5
+ import { env } from "../env.js";
6
+ import { stripe } from "./stripe.js";
7
+
8
+ export const payments = new Hono();
9
+
10
+ // One-time purchase → returns a Stripe Checkout URL.
11
+ payments.post("/checkout", async (c) => {
12
+ const session = await createCheckoutSession(stripe, {
13
+ mode: "payment",
14
+ priceId: env.STRIPE_PRICE_ID,
15
+ successUrl: `${env.APP_URL}/?paid=1`,
16
+ cancelUrl: `${env.APP_URL}/?canceled=1`,
17
+ });
18
+ return c.json({ url: session.url });
19
+ });
20
+
21
+ // Subscription checkout.
22
+ payments.post("/subscribe", async (c) => {
23
+ const session = await createCheckoutSession(stripe, {
24
+ mode: "subscription",
25
+ priceId: env.STRIPE_SUBSCRIPTION_PRICE_ID,
26
+ successUrl: `${env.APP_URL}/?subscribed=1`,
27
+ cancelUrl: `${env.APP_URL}/?canceled=1`,
28
+ });
29
+ return c.json({ url: session.url });
30
+ });
31
+
32
+ // Billing portal — pass ?customer=cus_…
33
+ payments.post("/portal", async (c) => {
34
+ const customer = c.req.query("customer") ?? "";
35
+ if (!customer) return c.json({ message: "A Stripe customer id (?customer=cus_…) is required." }, 400);
36
+ const session = await createBillingPortalSession(stripe, {
37
+ customer,
38
+ returnUrl: `${env.APP_URL}/`,
39
+ });
40
+ return c.json({ url: session.url });
41
+ });
@@ -0,0 +1,6 @@
1
+ import { createStripe } from "@softeneers/payments";
2
+
3
+ import { env } from "../env.js";
4
+
5
+ // A Stripe client. No network call until you actually create a session.
6
+ export const stripe = createStripe(env.STRIPE_SECRET_KEY);
@@ -0,0 +1,36 @@
1
+ import type { Context } from "hono";
2
+
3
+ import { constructWebhookEvent } from "@softeneers/payments";
4
+
5
+ import { env } from "../env.js";
6
+ import { stripe } from "./stripe.js";
7
+
8
+ // Stripe webhook handler. Reads the raw body via c.req.text() (needed for
9
+ // signature verification) and handles both payment and subscription events.
10
+ export async function stripeWebhook(c: Context) {
11
+ const signature = c.req.header("stripe-signature");
12
+ if (!signature) return c.text("Missing stripe-signature header.", 400);
13
+
14
+ let event;
15
+ try {
16
+ event = constructWebhookEvent(stripe, await c.req.text(), signature, env.STRIPE_WEBHOOK_SECRET);
17
+ } catch (error) {
18
+ console.error("Stripe webhook signature verification failed:", error);
19
+ return c.text("Invalid signature.", 400);
20
+ }
21
+
22
+ switch (event.type) {
23
+ case "checkout.session.completed":
24
+ console.log("✓ checkout.session.completed", event.data.object.id);
25
+ break;
26
+ case "customer.subscription.created":
27
+ case "customer.subscription.updated":
28
+ case "customer.subscription.deleted":
29
+ console.log(`✓ ${event.type}`, event.data.object.id);
30
+ break;
31
+ default:
32
+ console.log("Unhandled Stripe event:", event.type);
33
+ }
34
+
35
+ return c.json({ received: true });
36
+ }
@@ -0,0 +1,29 @@
1
+ import { Hono } from "hono";
2
+
3
+ import { deleteFile, getSignedDownloadUrl, uploadFile } from "@softeneers/storage";
4
+
5
+ import { storage } from "./store.js";
6
+
7
+ export const files = new Hono();
8
+
9
+ // Upload raw bytes: POST the file body to /api/files/:key
10
+ files.post("/:key", async (c) => {
11
+ const key = c.req.param("key");
12
+ await uploadFile(storage, {
13
+ key,
14
+ body: Buffer.from(await c.req.arrayBuffer()),
15
+ contentType: c.req.header("content-type"),
16
+ });
17
+ return c.json({ key }, 201);
18
+ });
19
+
20
+ // Get a time-limited download URL for an object.
21
+ files.get("/:key/url", async (c) => {
22
+ return c.json({ url: await getSignedDownloadUrl(storage, c.req.param("key")) });
23
+ });
24
+
25
+ // Delete an object.
26
+ files.delete("/:key", async (c) => {
27
+ await deleteFile(storage, c.req.param("key"));
28
+ return c.body(null, 204);
29
+ });
@@ -0,0 +1,13 @@
1
+ import { createStorage } from "@softeneers/storage";
2
+
3
+ import { env } from "../env.js";
4
+
5
+ // An S3-compatible storage handle (AWS S3 / Cloudflare R2 / MinIO). No network
6
+ // call until you upload or sign a URL.
7
+ export const storage = createStorage({
8
+ accessKeyId: env.S3_ACCESS_KEY_ID,
9
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
10
+ bucket: env.S3_BUCKET,
11
+ region: env.S3_REGION,
12
+ endpoint: env.S3_ENDPOINT || undefined,
13
+ });
@@ -9,3 +9,26 @@ DB_PASSWORD=
9
9
  AUTH_SECRET=dev-secret-change-me-to-a-long-random-string
10
10
  AUTH_BASE_URL=http://localhost:3000
11
11
  # #endif
12
+ # #if email
13
+ # Get a key at https://resend.com — the app boots without it; sending needs it.
14
+ RESEND_API_KEY=re_set_me
15
+ EMAIL_FROM=onboarding@resend.dev
16
+ # #endif
17
+ # #if storage
18
+ # S3-compatible storage (AWS S3 / Cloudflare R2 / MinIO).
19
+ S3_ACCESS_KEY_ID=
20
+ S3_SECRET_ACCESS_KEY=
21
+ S3_BUCKET=uploads
22
+ S3_REGION=auto
23
+ S3_ENDPOINT=
24
+ # #endif
25
+ # #if payments
26
+ # Stripe — the ONLY thing you must set for payments to work. Test keys from
27
+ # https://dashboard.stripe.com/test/apikeys ; webhook secret from `stripe listen`
28
+ # or the dashboard. Create two Prices and paste their ids below.
29
+ APP_URL=http://localhost:3000
30
+ STRIPE_SECRET_KEY=sk_test_set_me
31
+ STRIPE_WEBHOOK_SECRET=whsec_set_me
32
+ STRIPE_PRICE_ID=price_set_me
33
+ STRIPE_SUBSCRIPTION_PRICE_ID=price_set_me
34
+ # #endif
@@ -27,9 +27,23 @@ src/routes/cars.tsx cars CRUD UI (loader + server functions)
27
27
  src/server/cars.ts createServerFn RPCs (list / create / remove)
28
28
  src/server/store.ts data layer (MySQL or in-memory)
29
29
  # #if auth
30
+ src/routes/login.tsx sign-in / sign-up UI
31
+ src/routes/account.tsx session + sign-out UI
32
+ src/lib/auth-client.ts better-auth/react client
30
33
  src/routes/api/auth/$.ts better-auth catch-all route (/api/auth/*)
31
34
  src/server/auth.ts better-auth instance
32
35
  # #endif
36
+ # #if payments
37
+ src/routes/billing.tsx Buy / Subscribe UI
38
+ src/server/payments.ts Stripe server functions
39
+ src/routes/api/webhooks/stripe.ts verified Stripe webhook
40
+ # #endif
41
+ # #if email
42
+ src/server/email.ts sendWelcomeEmail server function
43
+ # #endif
44
+ # #if storage
45
+ src/server/storage.ts upload / signed-URL / delete server functions
46
+ # #endif
33
47
  ```
34
48
 
35
49
  The `/cars` page loads data with a route `loader` and mutates via server
@@ -54,11 +68,52 @@ npm run db:migrate && npm run db:seed
54
68
  # #endif
55
69
  # #if auth
56
70
 
57
- ## Authentication
71
+ ## Authentication (with UI)
58
72
 
59
73
  Email + password auth via better-auth ([`@softeneers/auth`](https://www.npmjs.com/package/@softeneers/auth)),
60
- served at `/api/auth/*`. Set a strong `AUTH_SECRET` in `.env`, and add the
61
- `better-auth/react` client in your components to drive sign-in/up flows.
74
+ served at `/api/auth/*`, **with the UI included**: visit `/login` to sign up or
75
+ in, and `/account` to see the session and sign out (driven by the
76
+ `better-auth/react` client in `src/lib/auth-client.ts`). Set a strong
77
+ `AUTH_SECRET` in `.env`.
78
+ # #endif
79
+ # #if email
80
+
81
+ ## Email
82
+
83
+ Transactional email via Resend ([`@softeneers/email`](https://www.npmjs.com/package/@softeneers/email)).
84
+ Set `RESEND_API_KEY` + `EMAIL_FROM` in `.env`, then call the `sendWelcomeEmail`
85
+ server function from any component.
86
+ # #endif
87
+ # #if storage
88
+
89
+ ## Storage
90
+
91
+ S3-compatible uploads ([`@softeneers/storage`](https://www.npmjs.com/package/@softeneers/storage),
92
+ AWS S3 / Cloudflare R2 / MinIO) via the `uploadText` / `getFileUrl` / `removeFile`
93
+ server functions. Set the `S3_*` keys in `.env`.
94
+ # #endif
95
+ # #if payments
96
+
97
+ ## Payments (Stripe)
98
+
99
+ Stripe checkout, subscriptions, the billing portal, and a verified webhook
100
+ ([`@softeneers/payments`](https://www.npmjs.com/package/@softeneers/payments)) —
101
+ **all pre-wired, with a `/billing` page**. The only thing you do is paste your
102
+ keys into `.env`:
103
+
104
+ ```
105
+ STRIPE_SECRET_KEY=sk_test_…
106
+ STRIPE_WEBHOOK_SECRET=whsec_…
107
+ STRIPE_PRICE_ID=price_… # one-time price
108
+ STRIPE_SUBSCRIPTION_PRICE_ID=price_… # recurring price
109
+ ```
110
+
111
+ Get test keys at <https://dashboard.stripe.com/test/apikeys>, create two Prices,
112
+ and forward webhooks locally with the Stripe CLI:
113
+
114
+ ```bash
115
+ stripe listen --forward-to localhost:3000/api/webhooks/stripe
116
+ ```
62
117
  # #endif
63
118
 
64
119
  ## Getting started
@@ -19,7 +19,11 @@
19
19
  "dependencies": {
20
20
  "@softeneers/auth": "^0.1.0",
21
21
  "@softeneers/db": "^0.1.0",
22
+ "@softeneers/email": "^0.1.0",
22
23
  "@softeneers/env": "^0.1.0",
24
+ "@softeneers/payments": "^0.1.0",
25
+ "@softeneers/storage": "^0.1.0",
26
+ "better-auth": "^1.6.0",
23
27
  "@tailwindcss/vite": "^4.1.18",
24
28
  "@tanstack/react-devtools": "latest",
25
29
  "@tanstack/react-router": "latest",
@@ -1,5 +1,12 @@
1
1
  {
2
- "toggles": { "db": false, "auth": false, "docker": false },
2
+ "toggles": {
3
+ "db": false,
4
+ "auth": false,
5
+ "docker": false,
6
+ "email": false,
7
+ "storage": false,
8
+ "payments": false
9
+ },
3
10
  "fragments": {
4
11
  "db": {
5
12
  "removePaths": ["src/server/db.ts", "src/server/scripts", "docker-compose.yml"],
@@ -7,11 +14,29 @@
7
14
  "removeScripts": ["db:migrate", "db:seed", "db:reset"]
8
15
  },
9
16
  "auth": {
10
- "removePaths": ["src/server/auth.ts", "src/routes/api"],
11
- "removeDeps": ["@softeneers/auth"]
17
+ "removePaths": [
18
+ "src/server/auth.ts",
19
+ "src/routes/api/auth",
20
+ "src/lib/auth-client.ts",
21
+ "src/routes/login.tsx",
22
+ "src/routes/account.tsx"
23
+ ],
24
+ "removeDeps": ["@softeneers/auth", "better-auth"]
12
25
  },
13
26
  "docker": {
14
27
  "removePaths": ["docker-compose.yml"]
28
+ },
29
+ "email": {
30
+ "removePaths": ["src/server/email.ts"],
31
+ "removeDeps": ["@softeneers/email"]
32
+ },
33
+ "storage": {
34
+ "removePaths": ["src/server/storage.ts"],
35
+ "removeDeps": ["@softeneers/storage"]
36
+ },
37
+ "payments": {
38
+ "removePaths": ["src/server/payments.ts", "src/routes/api/webhooks", "src/routes/billing.tsx"],
39
+ "removeDeps": ["@softeneers/payments"]
15
40
  }
16
41
  }
17
42
  }
@@ -0,0 +1,4 @@
1
+ import { createAuthClient } from 'better-auth/react'
2
+
3
+ // Talks to the better-auth routes mounted at /api/auth/* (same origin).
4
+ export const authClient = createAuthClient()
@@ -0,0 +1,47 @@
1
+ import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
2
+
3
+ import { authClient } from '../lib/auth-client'
4
+
5
+ export const Route = createFileRoute('/account')({ component: Account })
6
+
7
+ function Account() {
8
+ const router = useRouter()
9
+ const { data: session, isPending } = authClient.useSession()
10
+
11
+ if (isPending) {
12
+ return <div className="mx-auto max-w-sm p-8 text-gray-500">Loading…</div>
13
+ }
14
+
15
+ if (!session) {
16
+ return (
17
+ <div className="mx-auto max-w-sm p-8">
18
+ <p>You're not signed in.</p>
19
+ <Link to="/login" className="mt-3 inline-block text-blue-600 hover:underline">
20
+ Go to sign in →
21
+ </Link>
22
+ </div>
23
+ )
24
+ }
25
+
26
+ async function signOut() {
27
+ await authClient.signOut()
28
+ router.navigate({ to: '/login' })
29
+ }
30
+
31
+ return (
32
+ <div className="mx-auto max-w-sm p-8">
33
+ <h1 className="text-3xl font-bold">Account</h1>
34
+ <p className="mt-3">
35
+ Signed in as <strong>{session.user.email}</strong>
36
+ {session.user.name ? ` (${session.user.name})` : ''}.
37
+ </p>
38
+ <button
39
+ className="mt-6 rounded border border-gray-300 px-4 py-2 font-medium"
40
+ type="button"
41
+ onClick={signOut}
42
+ >
43
+ Sign out
44
+ </button>
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,43 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+
3
+ import { constructWebhookEvent, createStripe } from '@softeneers/payments'
4
+
5
+ import { env } from '../../../server/env'
6
+
7
+ const stripe = createStripe(env.STRIPE_SECRET_KEY)
8
+
9
+ // Catch-all Stripe webhook server route. Reads the raw body for signature
10
+ // verification and handles both payment and subscription events.
11
+ export const Route = createFileRoute('/api/webhooks/stripe')({
12
+ server: {
13
+ handlers: {
14
+ POST: async ({ request }) => {
15
+ const signature = request.headers.get('stripe-signature')
16
+ if (!signature) return new Response('Missing stripe-signature header.', { status: 400 })
17
+
18
+ let event
19
+ try {
20
+ event = constructWebhookEvent(stripe, await request.text(), signature, env.STRIPE_WEBHOOK_SECRET)
21
+ } catch (error) {
22
+ console.error('Stripe webhook signature verification failed:', error)
23
+ return new Response('Invalid signature.', { status: 400 })
24
+ }
25
+
26
+ switch (event.type) {
27
+ case 'checkout.session.completed':
28
+ console.log('✓ checkout.session.completed', event.data.object.id)
29
+ break
30
+ case 'customer.subscription.created':
31
+ case 'customer.subscription.updated':
32
+ case 'customer.subscription.deleted':
33
+ console.log(`✓ ${event.type}`, event.data.object.id)
34
+ break
35
+ default:
36
+ console.log('Unhandled Stripe event:', event.type)
37
+ }
38
+
39
+ return Response.json({ received: true })
40
+ },
41
+ },
42
+ },
43
+ })