@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.
- package/CHANGELOG.md +7 -0
- package/README.md +15 -2
- package/features/api-trpc/files/src/app/api/trpc/[trpc]/route.ts +15 -0
- package/features/api-trpc/files/src/app/trpc-provider.tsx +27 -0
- package/features/api-trpc/files/src/features/notes/components/note-form.tsx +48 -0
- package/features/api-trpc/files/src/server/api/context.ts +10 -0
- package/features/api-trpc/files/src/server/api/notes.test.ts +30 -0
- package/features/api-trpc/files/src/server/api/notes.ts +42 -0
- package/features/api-trpc/files/src/server/api/root.ts +8 -0
- package/features/api-trpc/files/src/server/api/trpc.ts +8 -0
- package/features/api-trpc/snippets/providers-imports.tsx +2 -0
- package/features/api-trpc/snippets/providers.tsx +1 -0
- package/features/auth-authjs/files/e2e/sign-in.spec.ts +10 -0
- package/features/auth-authjs/files/src/app/api/auth/[...nextauth]/route.ts +12 -0
- package/features/auth-authjs/files/src/app/auth-session-provider.tsx +8 -0
- package/features/auth-authjs/files/src/app/sign-in/page.tsx +19 -0
- package/features/auth-authjs/files/src/server/auth/config.ts +28 -0
- package/features/auth-authjs/files/src/server/auth/index.ts +15 -0
- package/features/auth-authjs/snippets/providers-imports.tsx +2 -0
- package/features/auth-authjs/snippets/providers.tsx +1 -0
- package/features/auth-authjs-drizzle/files/src/server/db/auth-tables.ts +86 -0
- package/features/auth-authjs-drizzle/snippets/adapter-db-imports.ts +8 -0
- package/features/auth-authjs-drizzle/snippets/adapter-imports.ts +1 -0
- package/features/auth-authjs-drizzle/snippets/adapter.ts +7 -0
- package/features/auth-authjs-drizzle/snippets/auth-tables.ts +7 -0
- package/features/auth-authjs-prisma/snippets/adapter-db-imports.ts +1 -0
- package/features/auth-authjs-prisma/snippets/adapter-imports.ts +1 -0
- package/features/auth-authjs-prisma/snippets/adapter.ts +1 -0
- package/features/auth-authjs-prisma/snippets/auth-models.prisma +58 -0
- package/features/auth-clerk/files/e2e/sign-in.spec.ts +15 -0
- package/features/auth-clerk/files/src/app/clerk-app-provider.tsx +13 -0
- package/features/auth-clerk/files/src/app/sign-in/[[...sign-in]]/page.tsx +18 -0
- package/features/auth-clerk/files/src/app/sign-up/[[...sign-up]]/page.tsx +18 -0
- package/features/auth-clerk/files/src/proxy.ts +24 -0
- package/features/auth-clerk/files/src/server/auth/index.ts +11 -0
- package/features/auth-clerk/snippets/providers-imports.tsx +2 -0
- package/features/auth-clerk/snippets/providers.tsx +1 -0
- package/features/deploy-docker/files/.dockerignore +6 -0
- package/features/deploy-docker/files/Dockerfile +24 -0
- package/features/deploy-docker/files/compose.yaml +6 -0
- package/features/deploy-docker-npm/files/Dockerfile +24 -0
- package/features/deploy-docker-postgres/snippets/postgres-service.yaml +4 -0
- package/features/deploy-vercel/snippets/readme-deploy.md +3 -0
- package/features/orm-drizzle/files/drizzle.config.ts +8 -0
- package/features/orm-drizzle/files/src/features/notes/notes-store.ts +21 -0
- package/features/orm-drizzle/files/src/server/db/index.ts +20 -0
- package/features/orm-drizzle/files/src/server/db/schema.ts +11 -0
- package/features/orm-drizzle/snippets/ci-e2e-setup.yaml +1 -0
- package/features/orm-drizzle/snippets/ci-job.yaml +11 -0
- package/features/orm-drizzle/snippets/readme-db.md +4 -0
- package/features/orm-prisma/files/prisma/schema.prisma +18 -0
- package/features/orm-prisma/files/prisma.config.ts +13 -0
- package/features/orm-prisma/files/src/features/notes/notes-store.ts +13 -0
- package/features/orm-prisma/files/src/server/db/index.ts +20 -0
- package/features/orm-prisma/snippets/ci-e2e-setup.yaml +1 -0
- package/features/orm-prisma/snippets/ci-job.yaml +11 -0
- package/features/orm-prisma/snippets/readme-db.md +4 -0
- package/features/payments-stripe/files/src/app/api/checkout/route.ts +27 -0
- package/features/payments-stripe/files/src/app/api/webhooks/stripe/route.ts +18 -0
- package/features/payments-stripe/files/src/server/billing/billing.test.ts +113 -0
- package/features/payments-stripe/files/src/server/billing/index.ts +102 -0
- package/features/payments-stripe/files/src/server/billing/stripe.ts +10 -0
- package/features/payments-stripe-drizzle/files/src/server/billing/store.ts +39 -0
- package/features/payments-stripe-drizzle/snippets/billing-tables.ts +13 -0
- package/features/payments-stripe-prisma/files/src/server/billing/store.ts +30 -0
- package/features/payments-stripe-prisma/snippets/billing-models.prisma +17 -0
- package/files/.env.example +1 -0
- package/files/.github/workflows/ci.yml +25 -0
- package/files/.prettierignore +7 -0
- package/files/.prettierrc.json +1 -0
- package/files/README.md +18 -0
- package/files/components.json +20 -0
- package/files/e2e/smoke.spec.ts +16 -0
- package/files/eslint.config.mjs +21 -0
- package/files/gitignore +25 -0
- package/files/next.config.ts +7 -0
- package/files/package.json +42 -0
- package/files/playwright.config.ts +20 -0
- package/files/pnpm-workspace.yaml +16 -0
- package/files/postcss.config.mjs +7 -0
- package/files/src/app/globals.css +68 -0
- package/files/src/app/layout.tsx +22 -0
- package/files/src/app/page.tsx +21 -0
- package/files/src/app/providers.tsx +15 -0
- package/files/src/components/ui/button.tsx +59 -0
- package/files/src/components/ui/input.tsx +21 -0
- package/files/src/features/notes/actions.ts +22 -0
- package/files/src/features/notes/components/note-form.tsx +32 -0
- package/files/src/features/notes/components/note-list.tsx +23 -0
- package/files/src/features/notes/create-note.test.ts +27 -0
- package/files/src/features/notes/create-note.ts +23 -0
- package/files/src/features/notes/note-schema.ts +13 -0
- package/files/src/features/notes/notes-store.ts +17 -0
- package/files/src/shared/utils.test.ts +13 -0
- package/files/src/shared/utils.ts +6 -0
- package/files/tsconfig.json +35 -0
- package/files/vitest.config.mts +9 -0
- package/package.json +26 -4
- package/rules/api-server-actions.md +1 -0
- package/rules/api-trpc.md +1 -0
- package/rules/architecture.md +11 -0
- package/rules/auth-authjs.md +1 -0
- package/rules/auth-clerk.md +1 -0
- package/rules/commands.md +5 -0
- package/rules/data-drizzle.md +1 -0
- package/rules/data-none.md +1 -0
- package/rules/data-prisma.md +1 -0
- package/rules/data-server-first.md +1 -0
- package/rules/errors-result.md +1 -0
- package/rules/errors-throw.md +1 -0
- package/rules/forms-zod.md +1 -0
- package/rules/layout-feature.md +5 -0
- package/rules/layout-flat.md +5 -0
- package/rules/layout-layer.md +6 -0
- package/rules/payments-stripe.md +1 -0
- package/rules/project-overview.md +5 -0
- package/rules/tests-colocated.md +1 -0
- package/rules/tests-folder.md +1 -0
- package/rules/tests-separate.md +1 -0
- package/rules/ui-shadcn.md +1 -0
- package/seed.template.json +1714 -0
- package/style/errors-result/files/src/features/notes/actions.ts +18 -0
- package/style/errors-result/files/src/features/notes/create-note.test.ts +30 -0
- package/style/errors-result/files/src/features/notes/create-note.ts +16 -0
- 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,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,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,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,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 @@
|
|
|
1
|
+
{}
|