@yosr-ai/template-next-fullstack 0.0.0-stage → 0.1.0-alpha.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +15 -2
  3. package/features/api-trpc/files/src/app/api/trpc/[trpc]/route.ts +15 -0
  4. package/features/api-trpc/files/src/app/trpc-provider.tsx +27 -0
  5. package/features/api-trpc/files/src/features/notes/components/note-form.tsx +48 -0
  6. package/features/api-trpc/files/src/server/api/context.ts +10 -0
  7. package/features/api-trpc/files/src/server/api/notes.test.ts +30 -0
  8. package/features/api-trpc/files/src/server/api/notes.ts +42 -0
  9. package/features/api-trpc/files/src/server/api/root.ts +8 -0
  10. package/features/api-trpc/files/src/server/api/trpc.ts +8 -0
  11. package/features/api-trpc/snippets/providers-imports.tsx +2 -0
  12. package/features/api-trpc/snippets/providers.tsx +1 -0
  13. package/features/auth-authjs/files/e2e/sign-in.spec.ts +10 -0
  14. package/features/auth-authjs/files/src/app/api/auth/[...nextauth]/route.ts +12 -0
  15. package/features/auth-authjs/files/src/app/auth-session-provider.tsx +8 -0
  16. package/features/auth-authjs/files/src/app/sign-in/page.tsx +19 -0
  17. package/features/auth-authjs/files/src/server/auth/config.ts +28 -0
  18. package/features/auth-authjs/files/src/server/auth/index.ts +15 -0
  19. package/features/auth-authjs/snippets/providers-imports.tsx +2 -0
  20. package/features/auth-authjs/snippets/providers.tsx +1 -0
  21. package/features/auth-authjs-drizzle/files/src/server/db/auth-tables.ts +86 -0
  22. package/features/auth-authjs-drizzle/snippets/adapter-db-imports.ts +8 -0
  23. package/features/auth-authjs-drizzle/snippets/adapter-imports.ts +1 -0
  24. package/features/auth-authjs-drizzle/snippets/adapter.ts +7 -0
  25. package/features/auth-authjs-drizzle/snippets/auth-tables.ts +7 -0
  26. package/features/auth-authjs-prisma/snippets/adapter-db-imports.ts +1 -0
  27. package/features/auth-authjs-prisma/snippets/adapter-imports.ts +1 -0
  28. package/features/auth-authjs-prisma/snippets/adapter.ts +1 -0
  29. package/features/auth-authjs-prisma/snippets/auth-models.prisma +58 -0
  30. package/features/auth-clerk/files/e2e/sign-in.spec.ts +15 -0
  31. package/features/auth-clerk/files/src/app/clerk-app-provider.tsx +13 -0
  32. package/features/auth-clerk/files/src/app/sign-in/[[...sign-in]]/page.tsx +18 -0
  33. package/features/auth-clerk/files/src/app/sign-up/[[...sign-up]]/page.tsx +18 -0
  34. package/features/auth-clerk/files/src/proxy.ts +24 -0
  35. package/features/auth-clerk/files/src/server/auth/index.ts +11 -0
  36. package/features/auth-clerk/snippets/providers-imports.tsx +2 -0
  37. package/features/auth-clerk/snippets/providers.tsx +1 -0
  38. package/features/deploy-docker/files/.dockerignore +6 -0
  39. package/features/deploy-docker/files/Dockerfile +24 -0
  40. package/features/deploy-docker/files/compose.yaml +6 -0
  41. package/features/deploy-docker-npm/files/Dockerfile +24 -0
  42. package/features/deploy-docker-postgres/snippets/postgres-service.yaml +4 -0
  43. package/features/deploy-vercel/snippets/readme-deploy.md +3 -0
  44. package/features/orm-drizzle/files/drizzle.config.ts +8 -0
  45. package/features/orm-drizzle/files/src/features/notes/notes-store.ts +21 -0
  46. package/features/orm-drizzle/files/src/server/db/index.ts +20 -0
  47. package/features/orm-drizzle/files/src/server/db/schema.ts +11 -0
  48. package/features/orm-drizzle/snippets/ci-e2e-setup.yaml +1 -0
  49. package/features/orm-drizzle/snippets/ci-job.yaml +11 -0
  50. package/features/orm-drizzle/snippets/readme-db.md +4 -0
  51. package/features/orm-prisma/files/prisma/schema.prisma +18 -0
  52. package/features/orm-prisma/files/prisma.config.ts +13 -0
  53. package/features/orm-prisma/files/src/features/notes/notes-store.ts +13 -0
  54. package/features/orm-prisma/files/src/server/db/index.ts +20 -0
  55. package/features/orm-prisma/snippets/ci-e2e-setup.yaml +1 -0
  56. package/features/orm-prisma/snippets/ci-job.yaml +11 -0
  57. package/features/orm-prisma/snippets/readme-db.md +4 -0
  58. package/features/payments-stripe/files/src/app/api/checkout/route.ts +27 -0
  59. package/features/payments-stripe/files/src/app/api/webhooks/stripe/route.ts +18 -0
  60. package/features/payments-stripe/files/src/server/billing/billing.test.ts +113 -0
  61. package/features/payments-stripe/files/src/server/billing/index.ts +102 -0
  62. package/features/payments-stripe/files/src/server/billing/stripe.ts +10 -0
  63. package/features/payments-stripe-drizzle/files/src/server/billing/store.ts +39 -0
  64. package/features/payments-stripe-drizzle/snippets/billing-tables.ts +13 -0
  65. package/features/payments-stripe-prisma/files/src/server/billing/store.ts +30 -0
  66. package/features/payments-stripe-prisma/snippets/billing-models.prisma +17 -0
  67. package/files/.env.example +1 -0
  68. package/files/.github/workflows/ci.yml +25 -0
  69. package/files/.prettierignore +7 -0
  70. package/files/.prettierrc.json +1 -0
  71. package/files/README.md +18 -0
  72. package/files/components.json +20 -0
  73. package/files/e2e/smoke.spec.ts +16 -0
  74. package/files/eslint.config.mjs +21 -0
  75. package/files/gitignore +25 -0
  76. package/files/next.config.ts +7 -0
  77. package/files/package.json +42 -0
  78. package/files/playwright.config.ts +20 -0
  79. package/files/pnpm-workspace.yaml +16 -0
  80. package/files/postcss.config.mjs +7 -0
  81. package/files/src/app/globals.css +68 -0
  82. package/files/src/app/layout.tsx +22 -0
  83. package/files/src/app/page.tsx +21 -0
  84. package/files/src/app/providers.tsx +15 -0
  85. package/files/src/components/ui/button.tsx +59 -0
  86. package/files/src/components/ui/input.tsx +21 -0
  87. package/files/src/features/notes/actions.ts +22 -0
  88. package/files/src/features/notes/components/note-form.tsx +32 -0
  89. package/files/src/features/notes/components/note-list.tsx +23 -0
  90. package/files/src/features/notes/create-note.test.ts +27 -0
  91. package/files/src/features/notes/create-note.ts +23 -0
  92. package/files/src/features/notes/note-schema.ts +13 -0
  93. package/files/src/features/notes/notes-store.ts +17 -0
  94. package/files/src/shared/utils.test.ts +13 -0
  95. package/files/src/shared/utils.ts +6 -0
  96. package/files/tsconfig.json +35 -0
  97. package/files/vitest.config.mts +9 -0
  98. package/package.json +26 -4
  99. package/rules/api-server-actions.md +1 -0
  100. package/rules/api-trpc.md +1 -0
  101. package/rules/architecture.md +11 -0
  102. package/rules/auth-authjs.md +1 -0
  103. package/rules/auth-clerk.md +1 -0
  104. package/rules/commands.md +5 -0
  105. package/rules/data-drizzle.md +1 -0
  106. package/rules/data-none.md +1 -0
  107. package/rules/data-prisma.md +1 -0
  108. package/rules/data-server-first.md +1 -0
  109. package/rules/errors-result.md +1 -0
  110. package/rules/errors-throw.md +1 -0
  111. package/rules/forms-zod.md +1 -0
  112. package/rules/layout-feature.md +5 -0
  113. package/rules/layout-flat.md +5 -0
  114. package/rules/layout-layer.md +6 -0
  115. package/rules/payments-stripe.md +1 -0
  116. package/rules/project-overview.md +5 -0
  117. package/rules/tests-colocated.md +1 -0
  118. package/rules/tests-folder.md +1 -0
  119. package/rules/tests-separate.md +1 -0
  120. package/rules/ui-shadcn.md +1 -0
  121. package/seed.template.json +1714 -0
  122. package/style/errors-result/files/src/features/notes/actions.ts +18 -0
  123. package/style/errors-result/files/src/features/notes/create-note.test.ts +30 -0
  124. package/style/errors-result/files/src/features/notes/create-note.ts +16 -0
  125. package/style/errors-result/files/src/shared/result.ts +12 -0
@@ -0,0 +1,24 @@
1
+ # Next.js standalone image.
2
+ # https://nextjs.org/docs/app/api-reference/config/next-config-js/output
3
+ FROM node:24 AS build
4
+ WORKDIR /app
5
+ ENV NEXT_TELEMETRY_DISABLED=1
6
+ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
7
+
8
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
9
+ RUN corepack enable && __seed_pmInstallCi__
10
+ COPY . .
11
+ RUN __seed_pmRun__ build
12
+
13
+ FROM node:24 AS runner
14
+ WORKDIR /app
15
+ ENV NEXT_TELEMETRY_DISABLED=1
16
+ ENV NODE_ENV=production
17
+ ENV PORT=3000
18
+ ENV HOSTNAME=0.0.0.0
19
+
20
+ COPY --from=build /app/.next/standalone ./
21
+ COPY --from=build /app/.next/static ./.next/static
22
+
23
+ EXPOSE 3000
24
+ CMD ["node", "server.js"]
@@ -0,0 +1,6 @@
1
+ services:
2
+ app:
3
+ build: .
4
+ ports:
5
+ - "3000:3000"
6
+ # @seed:anchor services
@@ -0,0 +1,24 @@
1
+ # Next.js standalone image.
2
+ # https://nextjs.org/docs/app/api-reference/config/next-config-js/output
3
+ FROM node:24 AS build
4
+ WORKDIR /app
5
+ ENV NEXT_TELEMETRY_DISABLED=1
6
+ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
7
+
8
+ COPY package.json package-lock.json ./
9
+ RUN npm ci
10
+ COPY . .
11
+ RUN __seed_pmRun__ build
12
+
13
+ FROM node:24 AS runner
14
+ WORKDIR /app
15
+ ENV NEXT_TELEMETRY_DISABLED=1
16
+ ENV NODE_ENV=production
17
+ ENV PORT=3000
18
+ ENV HOSTNAME=0.0.0.0
19
+
20
+ COPY --from=build /app/.next/standalone ./
21
+ COPY --from=build /app/.next/static ./.next/static
22
+
23
+ EXPOSE 3000
24
+ CMD ["node", "server.js"]
@@ -0,0 +1,4 @@
1
+ postgres:
2
+ image: postgres:18.6-alpine
3
+ environment:
4
+ POSTGRES_PASSWORD: postgres
@@ -0,0 +1,3 @@
1
+ ## Deploy (Vercel)
2
+
3
+ Import the repository in Vercel; it detects Next.js automatically. Set the variables from `.env.example` in the Vercel project settings.
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ dialect: "postgresql",
5
+ schema: "./__seed_serverDir__/db/schema.ts",
6
+ out: "./drizzle",
7
+ dbCredentials: { url: process.env.DATABASE_URL ?? "" },
8
+ });
@@ -0,0 +1,21 @@
1
+ import { desc } from "drizzle-orm";
2
+
3
+ import { db } from "@server/db";
4
+ import { notes } from "@server/db/schema";
5
+
6
+ import type { Note, NoteInput } from "./note-schema";
7
+
8
+ export async function insertNote(input: NoteInput): Promise<Note> {
9
+ const [note] = await db()
10
+ .insert(notes)
11
+ .values({ title: input.title })
12
+ .returning();
13
+ if (!note) {
14
+ throw new Error("Insert returned no row");
15
+ }
16
+ return note;
17
+ }
18
+
19
+ export async function listNotes(): Promise<Note[]> {
20
+ return db().select().from(notes).orderBy(desc(notes.createdAt));
21
+ }
@@ -0,0 +1,20 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+
4
+ import * as schema from "./schema";
5
+
6
+ function createDb() {
7
+ const url = process.env.DATABASE_URL;
8
+ if (!url) {
9
+ throw new Error("DATABASE_URL is not set. Copy .env.example to .env.local.");
10
+ }
11
+ return drizzle(postgres(url), { schema });
12
+ }
13
+
14
+ let instance: ReturnType<typeof createDb> | undefined;
15
+
16
+ /** Lazily creates the Drizzle client so builds and tests don't need a database. */
17
+ export function db() {
18
+ instance ??= createDb();
19
+ return instance;
20
+ }
@@ -0,0 +1,11 @@
1
+ import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
2
+
3
+ export const notes = pgTable("notes", {
4
+ id: uuid("id").primaryKey().defaultRandom(),
5
+ title: text("title").notNull(),
6
+ createdAt: timestamp("created_at", { withTimezone: true })
7
+ .notNull()
8
+ .defaultNow(),
9
+ });
10
+
11
+ // @seed:anchor tables
@@ -0,0 +1 @@
1
+ - run: __seed_pmRun__ db:push
@@ -0,0 +1,11 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:18.6-alpine
4
+ env:
5
+ POSTGRES_PASSWORD: postgres
6
+ ports: ["5432:5432"]
7
+ options: >-
8
+ --health-cmd pg_isready --health-interval 5s --health-timeout 5s
9
+ --health-retries 10
10
+ env:
11
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
@@ -0,0 +1,4 @@
1
+ ## Database (Postgres + Drizzle)
2
+
3
+ 1. Start Postgres and set `DATABASE_URL` in `.env.local` (see `.env.example`).
4
+ 2. Create the tables: `__seed_pmExec__ drizzle-kit push` (or `__seed_pmRun__ db:generate` then `__seed_pmRun__ db:migrate` for migration files).
@@ -0,0 +1,18 @@
1
+ generator client {
2
+ provider = "prisma-client"
3
+ output = "../src/generated/prisma"
4
+ }
5
+
6
+ datasource db {
7
+ provider = "postgresql"
8
+ }
9
+
10
+ model Note {
11
+ id String @id @default(uuid()) @db.Uuid
12
+ title String @db.Text
13
+ createdAt DateTime @default(now()) @db.Timestamptz(6)
14
+
15
+ @@map("notes")
16
+ }
17
+
18
+ // @seed:anchor models
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "prisma/config";
2
+
3
+ // process.env only: prisma/config's env() throws when DATABASE_URL is unset,
4
+ // and `prisma generate` runs at install before any database is configured.
5
+ export default defineConfig({
6
+ schema: "prisma/schema.prisma",
7
+ migrations: {
8
+ path: "prisma/migrations",
9
+ },
10
+ datasource: {
11
+ url: process.env.DATABASE_URL ?? "",
12
+ },
13
+ });
@@ -0,0 +1,13 @@
1
+ import { db } from "@server/db";
2
+
3
+ import type { Note, NoteInput } from "./note-schema";
4
+
5
+ export async function insertNote(input: NoteInput): Promise<Note> {
6
+ return db().note.create({
7
+ data: { title: input.title },
8
+ });
9
+ }
10
+
11
+ export async function listNotes(): Promise<Note[]> {
12
+ return db().note.findMany({ orderBy: { createdAt: "desc" } });
13
+ }
@@ -0,0 +1,20 @@
1
+ import { PrismaPg } from "@prisma/adapter-pg";
2
+
3
+ import { PrismaClient } from "@/generated/prisma/client";
4
+
5
+ function createDb() {
6
+ const url = process.env.DATABASE_URL;
7
+ if (!url) {
8
+ throw new Error("DATABASE_URL is not set. Copy .env.example to .env.local.");
9
+ }
10
+ const adapter = new PrismaPg({ connectionString: url });
11
+ return new PrismaClient({ adapter });
12
+ }
13
+
14
+ let instance: ReturnType<typeof createDb> | undefined;
15
+
16
+ /** Lazily creates the Prisma client so builds and tests don't need a database. */
17
+ export function db() {
18
+ instance ??= createDb();
19
+ return instance;
20
+ }
@@ -0,0 +1 @@
1
+ - run: __seed_pmRun__ db:push
@@ -0,0 +1,11 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:18.6-alpine
4
+ env:
5
+ POSTGRES_PASSWORD: postgres
6
+ ports: ["5432:5432"]
7
+ options: >-
8
+ --health-cmd pg_isready --health-interval 5s --health-timeout 5s
9
+ --health-retries 10
10
+ env:
11
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
@@ -0,0 +1,4 @@
1
+ ## Database (Postgres + Prisma)
2
+
3
+ 1. Start Postgres and set `DATABASE_URL` in `.env.local` (see `.env.example`).
4
+ 2. Create the tables: `__seed_pmRun__ db:push` (or `__seed_pmRun__ db:generate` then `__seed_pmRun__ db:migrate` for migration files).
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+
3
+ import { sessionEmail } from "@server/auth";
4
+ import { createCheckoutSession } from "@server/billing";
5
+
6
+ const bodySchema = z.object({
7
+ priceId: z.string().min(1),
8
+ });
9
+
10
+ export async function POST(req: Request) {
11
+ const email = await sessionEmail();
12
+ if (email === null) {
13
+ return Response.json({ error: "Sign in required" }, { status: 401 });
14
+ }
15
+ const parsed = bodySchema.safeParse(await req.json().catch(() => null));
16
+ if (!parsed.success) {
17
+ return Response.json({ error: "Price is required" }, { status: 400 });
18
+ }
19
+ const origin = new URL(req.url).origin;
20
+ const url = await createCheckoutSession({
21
+ userEmail: email,
22
+ priceId: parsed.data.priceId,
23
+ successUrl: `${origin}/`,
24
+ cancelUrl: `${origin}/`,
25
+ });
26
+ return Response.json({ url });
27
+ }
@@ -0,0 +1,18 @@
1
+ import { applyBillingEvent } from "@server/billing";
2
+ import { stripeClient } from "@server/billing/stripe";
3
+
4
+ export async function POST(req: Request) {
5
+ const signature = req.headers.get("stripe-signature");
6
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
7
+ if (typeof signature !== "string" || signature.length === 0 || typeof secret !== "string" || secret.length === 0) {
8
+ return new Response("Missing Stripe signature", { status: 400 });
9
+ }
10
+ const payload = await req.text();
11
+ try {
12
+ const event = stripeClient().webhooks.constructEvent(payload, signature, secret);
13
+ await applyBillingEvent(event);
14
+ } catch {
15
+ return new Response("Invalid Stripe signature", { status: 400 });
16
+ }
17
+ return new Response("ok", { status: 200 });
18
+ }
@@ -0,0 +1,113 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { POST as checkout } from "@/app/api/checkout/route";
4
+ import { POST as webhook } from "@/app/api/webhooks/stripe/route";
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ constructed: 0,
8
+ constructEvent: vi.fn(),
9
+ sessionsCreate: vi.fn(),
10
+ customersCreate: vi.fn(),
11
+ findCustomerId: vi.fn(),
12
+ saveCustomer: vi.fn(),
13
+ saveSubscription: vi.fn(),
14
+ sessionEmail: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("stripe", () => ({
18
+ default: class Stripe {
19
+ constructor() {
20
+ mocks.constructed += 1;
21
+ }
22
+ webhooks = { constructEvent: mocks.constructEvent };
23
+ checkout = { sessions: { create: mocks.sessionsCreate } };
24
+ customers = { create: mocks.customersCreate };
25
+ },
26
+ }));
27
+
28
+ vi.mock("@server/auth", () => ({
29
+ sessionEmail: mocks.sessionEmail,
30
+ }));
31
+
32
+ vi.mock("./store", () => ({
33
+ findCustomerId: mocks.findCustomerId,
34
+ saveCustomer: mocks.saveCustomer,
35
+ saveSubscription: mocks.saveSubscription,
36
+ }));
37
+
38
+ afterEach(() => {
39
+ delete process.env.STRIPE_SECRET_KEY;
40
+ delete process.env.STRIPE_WEBHOOK_SECRET;
41
+ mocks.constructed = 0;
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ describe("stripe webhook", () => {
46
+ it("rejects a missing signature without constructing Stripe", async () => {
47
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
48
+ const response = await webhook(
49
+ new Request("http://localhost/api/webhooks/stripe", {
50
+ method: "POST",
51
+ body: "{}",
52
+ }),
53
+ );
54
+ expect(response.status).toBe(400);
55
+ expect(mocks.constructed).toBe(0);
56
+ expect(mocks.constructEvent).not.toHaveBeenCalled();
57
+ expect(fetchSpy).not.toHaveBeenCalled();
58
+ fetchSpy.mockRestore();
59
+ });
60
+
61
+ it("rejects a bad signature without saving a subscription", async () => {
62
+ process.env.STRIPE_SECRET_KEY = "sk_test_mock";
63
+ process.env.STRIPE_WEBHOOK_SECRET = "whsec_mock";
64
+ mocks.constructEvent.mockImplementation(() => {
65
+ throw new Error("No signatures found matching the expected signature");
66
+ });
67
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
68
+ const response = await webhook(
69
+ new Request("http://localhost/api/webhooks/stripe", {
70
+ method: "POST",
71
+ body: "{}",
72
+ headers: { "stripe-signature": "t=1,v1=bad" },
73
+ }),
74
+ );
75
+ expect(response.status).toBe(400);
76
+ expect(mocks.constructEvent).toHaveBeenCalledOnce();
77
+ expect(mocks.saveSubscription).not.toHaveBeenCalled();
78
+ expect(fetchSpy).not.toHaveBeenCalled();
79
+ fetchSpy.mockRestore();
80
+ });
81
+ });
82
+
83
+ describe("checkout", () => {
84
+ it("opens a subscription session for the signed-in email", async () => {
85
+ process.env.STRIPE_SECRET_KEY = "sk_test_mock";
86
+ mocks.sessionEmail.mockResolvedValue("ada@example.com");
87
+ mocks.findCustomerId.mockResolvedValue("cus_123");
88
+ mocks.sessionsCreate.mockResolvedValue({
89
+ url: "https://checkout.stripe.test/c/pay",
90
+ });
91
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
92
+ const response = await checkout(
93
+ new Request("http://localhost/api/checkout", {
94
+ method: "POST",
95
+ body: JSON.stringify({ priceId: "price_123" }),
96
+ }),
97
+ );
98
+ expect(response.status).toBe(200);
99
+ await expect(response.json()).resolves.toEqual({
100
+ url: "https://checkout.stripe.test/c/pay",
101
+ });
102
+ expect(mocks.customersCreate).not.toHaveBeenCalled();
103
+ expect(mocks.sessionsCreate).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ mode: "subscription",
106
+ customer: "cus_123",
107
+ line_items: [{ price: "price_123", quantity: 1 }],
108
+ }),
109
+ );
110
+ expect(fetchSpy).not.toHaveBeenCalled();
111
+ fetchSpy.mockRestore();
112
+ });
113
+ });
@@ -0,0 +1,102 @@
1
+ import type Stripe from "stripe";
2
+
3
+ import {
4
+ findCustomerId,
5
+ saveCustomer,
6
+ saveSubscription,
7
+ } from "./store";
8
+ import { stripeClient } from "./stripe";
9
+
10
+ export type BillingUpdate = {
11
+ stripeCustomerId: string;
12
+ stripeSubscriptionId: string;
13
+ status: string;
14
+ priceId: string | null;
15
+ };
16
+
17
+ function asRecord(value: unknown): Record<string, unknown> | null {
18
+ if (typeof value !== "object" || value === null) return null;
19
+ return value as Record<string, unknown>;
20
+ }
21
+
22
+ function stringField(value: unknown, key: string): string | null {
23
+ const record = asRecord(value);
24
+ if (record === null) return null;
25
+ const field = record[key];
26
+ return typeof field === "string" ? field : null;
27
+ }
28
+
29
+ /** Maps a verified subscription or checkout event onto the billing tables. */
30
+ export function billingUpdateFromEvent(event: Stripe.Event): BillingUpdate | null {
31
+ const object: unknown = event.data.object;
32
+ if (event.type === "checkout.session.completed") {
33
+ const stripeCustomerId = stringField(object, "customer");
34
+ const stripeSubscriptionId = stringField(object, "subscription");
35
+ if (stripeCustomerId === null || stripeSubscriptionId === null) return null;
36
+ return {
37
+ stripeCustomerId,
38
+ stripeSubscriptionId,
39
+ status: "active",
40
+ priceId: null,
41
+ };
42
+ }
43
+ if (
44
+ event.type !== "customer.subscription.created" &&
45
+ event.type !== "customer.subscription.updated"
46
+ ) {
47
+ return null;
48
+ }
49
+ const stripeSubscriptionId = stringField(object, "id");
50
+ const stripeCustomerId = stringField(object, "customer");
51
+ const status = stringField(object, "status");
52
+ if (
53
+ stripeSubscriptionId === null ||
54
+ stripeCustomerId === null ||
55
+ status === null
56
+ ) {
57
+ return null;
58
+ }
59
+ let priceId: string | null = null;
60
+ const items = asRecord(asRecord(object)?.items);
61
+ const data = items?.data;
62
+ if (Array.isArray(data)) {
63
+ const first = asRecord(data[0]);
64
+ priceId = stringField(first?.price, "id");
65
+ }
66
+ return { stripeCustomerId, stripeSubscriptionId, status, priceId };
67
+ }
68
+
69
+ export async function createCheckoutSession(input: {
70
+ userEmail: string;
71
+ priceId: string;
72
+ successUrl: string;
73
+ cancelUrl: string;
74
+ }): Promise<string> {
75
+ const stripe = stripeClient();
76
+ let customerId = await findCustomerId(input.userEmail);
77
+ if (customerId === null) {
78
+ const customer = await stripe.customers.create({ email: input.userEmail });
79
+ customerId = customer.id;
80
+ await saveCustomer({
81
+ userEmail: input.userEmail,
82
+ stripeCustomerId: customerId,
83
+ });
84
+ }
85
+ const session = await stripe.checkout.sessions.create({
86
+ mode: "subscription",
87
+ customer: customerId,
88
+ line_items: [{ price: input.priceId, quantity: 1 }],
89
+ success_url: input.successUrl,
90
+ cancel_url: input.cancelUrl,
91
+ });
92
+ if (typeof session.url !== "string") {
93
+ throw new Error("Checkout session has no URL");
94
+ }
95
+ return session.url;
96
+ }
97
+
98
+ export async function applyBillingEvent(event: Stripe.Event): Promise<void> {
99
+ const update = billingUpdateFromEvent(event);
100
+ if (update === null) return;
101
+ await saveSubscription(update);
102
+ }
@@ -0,0 +1,10 @@
1
+ import Stripe from "stripe";
2
+
3
+ /** Stripe client for one call. Nothing constructs it at import. */
4
+ export function stripeClient(): Stripe {
5
+ const key = process.env.STRIPE_SECRET_KEY;
6
+ if (typeof key !== "string" || key.length === 0) {
7
+ throw new Error("STRIPE_SECRET_KEY is not set");
8
+ }
9
+ return new Stripe(key);
10
+ }
@@ -0,0 +1,39 @@
1
+ import { eq } from "drizzle-orm";
2
+
3
+ import { db } from "@server/db";
4
+ import { billingCustomers, billingSubscriptions } from "@server/db/schema";
5
+
6
+ export async function findCustomerId(userEmail: string): Promise<string | null> {
7
+ const rows = await db()
8
+ .select({ stripeCustomerId: billingCustomers.stripeCustomerId })
9
+ .from(billingCustomers)
10
+ .where(eq(billingCustomers.userEmail, userEmail))
11
+ .limit(1);
12
+ return rows[0]?.stripeCustomerId ?? null;
13
+ }
14
+
15
+ export async function saveCustomer(input: {
16
+ userEmail: string;
17
+ stripeCustomerId: string;
18
+ }): Promise<void> {
19
+ await db().insert(billingCustomers).values(input);
20
+ }
21
+
22
+ export async function saveSubscription(input: {
23
+ stripeCustomerId: string;
24
+ stripeSubscriptionId: string;
25
+ status: string;
26
+ priceId: string | null;
27
+ }): Promise<void> {
28
+ await db()
29
+ .insert(billingSubscriptions)
30
+ .values(input)
31
+ .onConflictDoUpdate({
32
+ target: billingSubscriptions.stripeSubscriptionId,
33
+ set: {
34
+ stripeCustomerId: input.stripeCustomerId,
35
+ status: input.status,
36
+ priceId: input.priceId,
37
+ },
38
+ });
39
+ }
@@ -0,0 +1,13 @@
1
+ export const billingCustomers = pgTable("billing_customer", {
2
+ id: uuid("id").primaryKey().defaultRandom(),
3
+ userEmail: text("user_email").notNull().unique(),
4
+ stripeCustomerId: text("stripe_customer_id").notNull().unique(),
5
+ });
6
+
7
+ export const billingSubscriptions = pgTable("billing_subscription", {
8
+ id: uuid("id").primaryKey().defaultRandom(),
9
+ stripeCustomerId: text("stripe_customer_id").notNull(),
10
+ stripeSubscriptionId: text("stripe_subscription_id").notNull().unique(),
11
+ status: text("status").notNull(),
12
+ priceId: text("price_id"),
13
+ });
@@ -0,0 +1,30 @@
1
+ import { db } from "@server/db";
2
+
3
+ export async function findCustomerId(userEmail: string): Promise<string | null> {
4
+ const row = await db().billingCustomer.findUnique({ where: { userEmail } });
5
+ return row?.stripeCustomerId ?? null;
6
+ }
7
+
8
+ export async function saveCustomer(input: {
9
+ userEmail: string;
10
+ stripeCustomerId: string;
11
+ }): Promise<void> {
12
+ await db().billingCustomer.create({ data: input });
13
+ }
14
+
15
+ export async function saveSubscription(input: {
16
+ stripeCustomerId: string;
17
+ stripeSubscriptionId: string;
18
+ status: string;
19
+ priceId: string | null;
20
+ }): Promise<void> {
21
+ await db().billingSubscription.upsert({
22
+ where: { stripeSubscriptionId: input.stripeSubscriptionId },
23
+ create: input,
24
+ update: {
25
+ stripeCustomerId: input.stripeCustomerId,
26
+ status: input.status,
27
+ priceId: input.priceId,
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,17 @@
1
+ model BillingCustomer {
2
+ id String @id @default(uuid()) @db.Uuid
3
+ userEmail String @unique @db.Text
4
+ stripeCustomerId String @unique @db.Text
5
+
6
+ @@map("billing_customer")
7
+ }
8
+
9
+ model BillingSubscription {
10
+ id String @id @default(uuid()) @db.Uuid
11
+ stripeCustomerId String @db.Text
12
+ stripeSubscriptionId String @unique @db.Text
13
+ status String @db.Text
14
+ priceId String? @db.Text
15
+
16
+ @@map("billing_subscription")
17
+ }
@@ -0,0 +1 @@
1
+ # Copy to .env.local and fill in. Never commit real secrets.
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ check:
10
+ runs-on: ubuntu-latest
11
+ # @seed:anchor ci-job
12
+ steps:
13
+ - uses: actions/checkout@v7
14
+ - uses: actions/setup-node@v7
15
+ with:
16
+ node-version: 24
17
+ - run: corepack enable
18
+ - run: __seed_pmInstallCi__
19
+ - run: __seed_pmRun__ lint
20
+ - run: __seed_pmRun__ typecheck
21
+ - run: __seed_pmRun__ build
22
+ - run: __seed_pmRun__ test
23
+ - run: __seed_pmExec__ playwright install --with-deps chromium
24
+ # @seed:anchor ci-e2e-setup
25
+ - run: __seed_pmRun__ test:e2e
@@ -0,0 +1,7 @@
1
+ .next
2
+ node_modules
3
+ pnpm-lock.yaml
4
+ package-lock.json
5
+ playwright-report
6
+ test-results
7
+ drizzle
@@ -0,0 +1 @@
1
+ {}