Freelancer invoice builder with card payment links
Invoice app where freelancers build itemized invoices, share an unguessable link, and clients pay by card through hosted checkout.
nextjstypescriptprismapostgrestailwindstripe
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add freelancer-invoice-builder-with-card-payment-links-8e9441CLAUDE.md# Freelancer invoice builder
Freelancers create invoices in this app, publish them as a public link at /i/[token], and clients pay by card through Stripe Checkout. Freelancers sign in with an email link. Clients need no account. Each invoice shows its line items, tax, total and due date, and the client's Pay button creates a Stripe Checkout session for the exact stored total.
## Stack
- Next.js App Router with TypeScript in strict mode
- Tailwind CSS for styling
- Prisma with Postgres. The connection string comes from DATABASE_URL in the environment.
- Auth.js with email sign-in
- Stripe, in test mode until launch
- Zod for validating all input
- Vitest for unit tests
- Playwright for end-to-end tests
## Commands
- `npm run dev` starts the app at http://localhost:3000
- `npm run typecheck` runs the TypeScript compiler without emitting files
- `npm run lint` checks code style and common mistakes
- `npm test` runs the Vitest unit tests
- `npx playwright test` runs the end-to-end tests
- `npm run build` makes a production build
- `npx prisma migrate dev` creates and applies a migration in development
- `npx prisma studio` opens a browser view of the database
## Conventions
- Money is stored as integer cents. Each invoice stores its own currency code.
- Totals are always recomputed on the server. Never trust a total sent from the client.
- Server actions live in `app/actions/`.
- Stripe calls live only in `lib/stripe.ts`. Nothing else imports the Stripe SDK.
- Every database query that reads an invoice must filter by the signed-in owner or by the public token. A query without one of these filters is a bug.
- Invoice numbers are sequential per freelancer with no gaps. Assign them inside a database transaction.
- Keep components small. Put shared logic in `lib/`.
## Gotchas
- Stripe webhooks need the signature checked with STRIPE_WEBHOOK_SECRET against the raw request body. Parsed JSON will fail the check.
- Local webhook testing uses `stripe listen`. Follow the `stripe-test` skill.
- Never log card data, email addresses in bulk, or full secret values.
- Webhooks can arrive more than once. Payment creation must be idempotent, keyed on the Stripe session id.
- The success page does not mark an invoice paid. Only the verified webhook does.
- Never write real keys into any file. Use placeholders such as YOUR_STRIPE_SECRET_KEY in `.env.example`, and keep real values in `.env.local`, which is never committed.
## Working rules
- Explore the relevant code first. Then write a short numbered plan. Then code. Then run the `verify` skill.
- Run `/clear` between unrelated tasks.
- Ask before deleting files, running `prisma migrate reset`, running `prisma migrate deploy`, pushing to git, or changing anything in production.
- A task is not done while any check in `verify` fails.
- Review rules are in REVIEW.md, which is imported below so `/review` applies them.
@REVIEW.md
Detailed product scope is in SPEC.md. The phased build plan is in PLAN.md.
.claude/agents/test-runner.md---
name: test-runner
description: Use to run tests, lint or build and report only failures. Keeps long command output out of the main conversation.
model: haiku
tools: Bash, Read
maxTurns: 15
---
You run one check command for this project and report the result in a few lines.
## Steps
1. Run the command you were given, exactly as written. Typical commands: `npm run typecheck`, `npm run lint`, `npm test`, `npm run build`, `npx playwright test`.
2. Read the output. Use Read only to open a file named in an error, and only to find the exact line.
3. Report back using the format below.
## Report format
- Command: the exact command you ran
- Result: pass or fail
- Pass count: number of passing tests, if the tool prints one (otherwise write "n/a")
- Failures: for each failing test or error, give the test name (or error code), the first error message, and file and line
If everything passes, report only the command, the result and the pass count.
## Rules
- Never edit, create or delete any file.
- Never change a test to make it pass. Report the failure and stop.
- Never print environment variables, `.env` contents, or any secret, even if an error shows one. Replace it with `[redacted]`.
- Keep the whole reply under 40 lines. Cut stack traces down to the first project file in the trace.
- Do not guess causes. Report what the output says.
.claude/settings.json{
"model": "sonnet",
"permissions": {
"defaultMode": "default",
"allow": [
"Bash(npm run typecheck)",
"Bash(npm run lint)",
"Bash(npm test)",
"Bash(npm run build)",
"Bash(npx playwright test)",
"Bash(npx prisma generate)",
"Bash(npx prisma migrate status)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)"
],
"ask": [
"Bash(npx prisma migrate deploy)",
"Bash(npx prisma migrate reset)",
"Bash(git push *)",
"Bash(vercel *)",
"Bash(rm *)"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(./secrets/**)",
"Bash(rm -rf *)",
"Bash(curl * | sh)"
]
}
}
.claude/skills/stripe-test/SKILL.md---
name: stripe-test
description: Use when testing payments, Stripe Checkout or webhooks locally in Stripe test mode, including webhook signature, duplicate event and amount checks.
disable-model-invocation: false
---
# Stripe test
Follow these steps to test Checkout and webhooks locally. Everything runs in Stripe test mode. Never use live keys.
## 1. Start the app and the webhook forwarder
Use two terminals.
Terminal 1:
```
npm run dev
```
Terminal 2:
```
stripe listen --forward-to localhost:3000/api/stripe/webhook
```
The `stripe listen` command prints a signing secret that starts with `whsec_`. Copy it into `.env.local` as:
```
STRIPE_WEBHOOK_SECRET=YOUR_STRIPE_WEBHOOK_SECRET
```
Replace the placeholder with the value you copied. Restart `npm run dev` after editing `.env.local`.
## 2. Pay with the test card
1. Open the public invoice link (`http://localhost:3000/i/TOKEN`) and click Pay.
2. On Stripe Checkout, use:
- Card number: `+00 000 000 000`
- Expiry: any future date
- CVC: any three digits
- Postal code: any value
3. Never ask for, type or store a real card number.
## 3. Confirm the webhook marks the invoice paid once
After Checkout completes, the `stripe listen` terminal should show `checkout.session.completed` with a `200` response. Then check:
- The invoice status on the dashboard is `paid`.
- The database has exactly one Payment row for that `stripeSessionId`.
To send a sample event without going through Checkout:
```
stripe trigger checkout.session.completed
```
Note: trigger events use Stripe's sample data and do not carry your invoice id. Use them to check that the endpoint verifies and responds. Use a real Checkout run to check that the right invoice changes.
## 4. Check the Stripe dashboard
In the Stripe dashboard, switch to Test mode and open the payment. Confirm the amount equals the invoice total stored in the app, in cents.
## 5. Failure checks
Run each check and record the result.
- **Wrong amount:** change the invoice total after creating the Checkout session, pay, and confirm the app rejects or flags the mismatch. The paid amount must equal the stored total.
- **Duplicate webhook:** resend a completed event with the Stripe CLI, using the event id from the `stripe listen` output:
```
stripe events resend evt_YOUR_EVENT_ID
```
Confirm the invoice stays `paid` and there is still only one Payment row.
- **Bad signature:** send a request with a wrong signature. It must return `400`:
```
curl -i -X POST http://localhost:3000/api/stripe/webhook -H "Stripe-Signature: t=1,v1=bad" -d '{}'
```
## 6. Report
Report each step as pass or fail, with the status code, the invoice status and the Payment row count. If any step fails, stop and explain the failure before changing code. Never change a test to hide a failure.
.claude/skills/verify/SKILL.md---
name: verify
description: Use after every code change and before saying a task is done, to run the real typecheck, lint, test and build checks and report pass or fail.
allowed-tools: Bash, Read
---
# Verify
Use this skill as the proof step. Never say a task is done without running it and showing the result.
## Steps, in order
Run each command from the project root. Stop at the first failure.
1. `npm run typecheck`
2. `npm run lint`
3. `npm test`
4. `npm run build`
If the change touches invoices, totals, auth or payments, also run:
5. `npx playwright test`
For any payment change, confirm you followed every step of the stripe-test skill before reporting pass.
## Handling output
- Long test output: delegate the run to the `test-runner` subagent so the main conversation stays short. Ask it for failing names, the first error and a pass count.
- On failure, show the error lines that matter (the first error, with file and line). Do not paste the full log.
- Read the failing file before proposing a fix.
## Report format
Finish with a table like this:
| Check | Result | Most useful line |
|---|---|---|
| typecheck | pass | (none) |
| lint | fail | `app/actions/invoice.ts:42 'total' is assigned but never used` |
| test | not run | stopped after lint failed |
| build | not run | |
Use "not run" for checks that were skipped because an earlier check failed.
## Rules
- Never mark a task done while any check fails.
- Never edit tests just to make them pass. If a test fails, explain the failure first and ask before changing the test.
- Never print environment variables or secret values while reading logs.
- If a command needs approval, ask the user rather than working around it.
- If a check cannot run (missing dependency, missing `.env.local`), report that as a blocker with the exact message. Do not call it a pass.
INSTALL.md# Install and first run
This guide takes you from an empty folder to your first `/goal` run. No prior experience is needed. Read each section before you run its commands.
## What you are installing and why
- **Node.js 22 LTS** runs the app on your computer.
- **Git** records every change to the project, so you can always go back.
- **Claude Code** is the assistant that writes the code with you. You give it goals and approve what it does.
- **Stripe** handles card payments. We use test mode, so no real money moves and no real card is charged.
- **Postgres** is the database that stores invoices. We use a free hosted copy, so you do not install it.
## Step 1: Install the tools
1. Install Node.js 22 LTS from nodejs.org. Then check it in a terminal:
```
node --version
```
It should print a version starting with `v22`.
2. Install Git from git-scm.com. Then check it:
```
git --version
```
## Step 2: Create the project folder
1. Create a folder named `invoice-app`.
2. Open a terminal inside it.
3. Run:
```
git init
```
## Step 3: Set up accounts
1. Create a free Stripe account at stripe.com.
2. Switch the Stripe dashboard to **Test mode** (there is a toggle on the dashboard).
3. Copy your test secret key and publishable key into a password manager. You will paste them into `.env.local` later. Do not paste them into any other file.
4. Create a free Postgres database on Neon or Supabase.
5. Copy its connection URL into your password manager.
## Step 4: Install Claude Code
1. Use the official installer from the Claude Code documentation for your system.
2. Inside the `invoice-app` folder, run:
```
claude
```
3. Sign in when prompted.
## Step 5: Add the plugins
Plugins add extra skills and checks. Run these inside Claude Code:
1. Add the Anthropic skills marketplace:
```
claude plugin marketplace add anthropics/skills
```
2. Install the design skill:
```
claude plugin install frontend-design@anthropic-agent-skills
```
3. Install the three official plugins:
```
/plugin install code-review@claude-plugins-official
/plugin install commit-commands@claude-plugins-official
/plugin install security-guidance@claude-plugins-official
```
4. Quit Claude Code and start it again with `claude`, so the plugins load.
## Step 6: Copy the project files
Copy these files from the plan into the `invoice-app` folder, keeping the same folder layout:
- CLAUDE.md, SPEC.md, PLAN.md, REVIEW.md, INSTALL.md
- .claude/settings.json
- .claude/skills/verify/SKILL.md
- .claude/skills/stripe-test/SKILL.md
- .claude/agents/test-runner.md
The `.claude` folder holds Claude Code's project settings. Settings are shared through Git, so they stay the same for everyone on the project.
## Step 7: Create your environment files
Environment files hold values that the app reads at runtime, such as database addresses and keys.
1. Create `.env.example` with placeholder names only. It is safe to commit because it holds no real values:
```
DATABASE_URL=YOUR_DATABASE_URL
STRIPE_SECRET_KEY=YOUR_STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET=YOUR_STRIPE_WEBHOOK_SECRET
AUTH_SECRET=YOUR_AUTH_SECRET
APP_URL=http://localhost:3000
```
2. Add these lines to `.gitignore` (create the file if it does not exist):
```
.env*.local
node_modules
```
3. Create `.env.local` by copying `.env.example`. Replace each placeholder with your own real test value from your password manager.
`.env.local` is never committed. The `.gitignore` file tells Git to skip it. If you ever see it listed by `git status`, stop and ask Claude Code to fix the `.gitignore` before you commit anything.
## Step 8: Check the model and effort
1. In Claude Code, run:
```
/model
```
It should show **sonnet**. The project settings choose Sonnet for everyday work because it costs less.
2. Run:
```
/effort medium
```
## Step 9: Understand `/goal`
`/goal` gives Claude Code a finish line. Claude keeps working toward it. After each step, a small, fast checker model reads what Claude has shown in the conversation and decides whether the stated end state is met. Claude stops when the checker agrees, or when the turn limit is reached.
Three things make `/goal` work well:
- **The end state is measurable.** "All four commands exit with code 0" can be checked. "Make it nice" cannot.
- **Each phase has a check.** The check is the commands Claude must run and show. The checker judges only what appears in the conversation, so Claude must print the output, not just say it passed.
- **The turn bound stops runaway work.** If the goal is not met after the set number of turns, Claude stops and reports what is blocked.
You do not need to memorize this. The phase prompts in PLAN.md already follow the pattern.
## Step 10: Start the first phase
1. Copy the kickoff prompt from your plan. It starts with `/goal Phase 1 Foundation`.
2. Paste it into Claude Code and press Enter.
3. Watch the first few steps. Claude asks before running commands that need approval. Answer those questions carefully.
4. If something looks wrong, press **Escape** to stop Claude.
5. When Phase 1 reports its results, check that each command shows exit code 0.
After Phase 1, run `/clear` before starting Phase 2. Each phase begins with a fresh conversation.
## Keep costs down
- Run `/clear` between unrelated tasks. A long conversation makes every reply more expensive.
- Run `/usage` now and then to see where your tokens went.
- Use `/model` to switch to a stronger model only when a problem is hard. Switch back afterwards.
- Use `/effort` to change how hard Claude thinks. Changing effort mid-task is cheaper than switching models.
## Troubleshooting
**Port 3000 is busy.**
Another program is using the port. Stop it, or start the app on another port with `npm run dev -- -p 3001`. Then change `APP_URL` in `.env.local` to match.
**Prisma cannot connect to the database.**
Check that `DATABASE_URL` in `.env.local` is the full connection URL from Neon or Supabase, and that it includes the password. Make sure your database is running and that your network allows the connection. Then run `npx prisma migrate status` to see the result.
**Stripe webhook secret does not match.**
Run `stripe listen --forward-to localhost:3000/api/stripe/webhook` and copy the signing secret it prints into `STRIPE_WEBHOOK_SECRET` in `.env.local`. Restart `npm run dev` after changing the file. The stripe-test skill has the full steps.
**Node version is too old.**
Run `node --version`. If it does not start with `v22`, install Node.js 22 LTS from nodejs.org, then close and reopen your terminal.
## Next step
Paste the kickoff prompt into Claude Code, as described in Step 10, and let Phase 1 run.
PLAN.md# Build plan
This plan has five phases. Each phase ends with a measurable `/goal` prompt.
## How to run each phase
1. Run `/clear`.
2. Explore: ask Claude to read the relevant files and SPEC.md.
3. Plan: ask for a short list of steps. Review it.
4. Code: let Claude make small changes.
5. Verify: run the verify skill and the phase check.
Do not start a phase until the previous phase check passes.
## Phase 1: Foundation
**Scope**
- Scaffold Next.js (App Router, TypeScript strict).
- Add Tailwind CSS.
- Add Prisma with Postgres.
- Add Auth.js email sign-in.
- Add `.env.example` with placeholders only.
- Write the first passing test: signed-out `/dashboard` redirects to `/login`.
**Check commands:** `npm run typecheck`, `npm run lint`, `npm test`, `npm run build` (all exit 0), and `npx prisma migrate status` (up to date).
**Turn bound:** 25
**Goal text**
```
/goal Phase 1 Foundation is complete when all of these are true. (1) A Next.js App Router project with TypeScript strict mode, Tailwind CSS, Prisma configured for Postgres from DATABASE_URL, and Auth.js email sign-in exists. (2) .env.example lists only placeholder values and .gitignore excludes .env*.local and node_modules. (3) A Vitest test in tests/auth.test.ts proves a signed-out request to /dashboard redirects to /login, and it passes. (4) npm run typecheck, npm run lint, npm test and npm run build each exit with code 0; show the last lines of each output. (5) npx prisma migrate status reports the schema is up to date. CHECK: run those commands yourself and paste their results before saying done. CONSTRAINTS: do not write real keys or connection strings into any file; do not run prisma migrate reset or prisma migrate deploy; do not push to git; do not edit CLAUDE.md, SPEC.md or PLAN.md except to tick a checkbox; do not touch files outside this folder; ask me before running any command that needs approval. Use the verify skill at the end. TURN BOUND: stop after 25 turns and report what is done and what is blocked.
```
## Phase 2: Invoice editor
**Scope**
- Create, edit and list draft invoices with line items.
- Compute totals on the server in integer cents.
- Assign invoice numbers inside a transaction, with no gaps.
- Validate input with Zod.
- Put server actions in `app/actions/`.
**Check commands:** unit tests for totals pass (rounding and tax cases included), and a Playwright test creates an invoice end to end.
**Turn bound:** 30
**Goal text**
```
/goal Phase 2 Invoice editor is complete when all of these are true. (1) A signed-in freelancer can create, edit and list draft invoices with line items, client name and email, tax percent, due date and notes. (2) Totals (subtotal, tax, total) are computed on the server in integer cents, with unit tests covering rounding and tax. (3) Invoice numbers are assigned inside a transaction, sequential per owner, with no gaps, and a test proves it. (4) Every invoice query is scoped to the signed-in owner. (5) Input is validated with Zod in server actions under app/actions/. CHECK: npm test passes including the totals tests; npx playwright test includes a test that creates an invoice end to end and passes; npm run typecheck, npm run lint and npm run build exit 0. Paste the last lines of each output. CONSTRAINTS: do not use floating point for money; do not write real keys into any file; do not run prisma migrate reset or prisma migrate deploy; do not push to git; ask me before running any command that needs approval; do not edit tests just to make them pass. Use the verify skill at the end. TURN BOUND: stop after 30 turns and report what is done and what is blocked.
```
## Phase 3: Public link and checkout
**Scope**
- Publish action: marks the invoice sent, creates a 128-bit token from a cryptographic random source, shows the link with a Copy button.
- `/i/[token]` page: printable invoice and a Pay button.
- Stripe Checkout session for the stored total, created only in `lib/stripe.ts`.
- Unknown token returns 404.
**Check commands:** a Playwright test reaches a Stripe Checkout URL for the correct amount, a test shows tokens are unique, and a test shows an unknown token returns 404.
**Turn bound:** 30
**Goal text**
```
/goal Phase 3 Public link and checkout is complete when all of these are true. (1) A Publish action marks a draft invoice as sent and creates a public token of at least 128 bits from a cryptographic random source, shown as https://example.com with a Copy link button. (2) /i/[token] works without login and shows a clean printable invoice with a Pay button. (3) Pay creates a Stripe Checkout session for the exact stored total; all Stripe calls live only in lib/stripe.ts. (4) An unknown token returns 404. (5) A test shows generated tokens are unique. CHECK: npx playwright test includes a test that reaches a Stripe Checkout URL for the correct amount and passes; npm test passes; npm run typecheck, npm run lint and npm run build exit 0. Paste the last lines of each output. CONSTRAINTS: use test-mode keys only, referenced by env name, never written into a file; do not run prisma migrate reset or prisma migrate deploy; do not push to git; ask me before any command that needs approval. Use the verify skill and the stripe-test skill at the end. TURN BOUND: stop after 30 turns and report what is done and what is blocked.
```
## Phase 4: Payments and status
**Scope**
- Stripe webhook at `/api/stripe/webhook`, verifying the signature against the raw body.
- Idempotent Payment creation (`stripeSessionId` unique).
- Invoice status changes to paid.
- Dashboard status column, including overdue, sorted by due date.
**Check commands:** a webhook test with a signed fixture marks the invoice paid once when delivered twice, and the dashboard shows correct status.
**Turn bound:** 30
**Goal text**
```
/goal Phase 4 Payments and status is complete when all of these are true. (1) POST /api/stripe/webhook verifies the Stripe signature against the raw request body using STRIPE_WEBHOOK_SECRET and returns 400 on a bad signature. (2) On checkout.session.completed, the app creates one Payment row per Stripe session id (unique constraint) and marks the invoice paid. (3) A signed fixture delivered twice produces one Payment and one paid status change; a test proves it. (4) The success page does not mark an invoice paid by itself. (5) The dashboard shows draft, sent, paid and overdue (sent and past due date), sorted by due date; a test proves the overdue label. CHECK: npm test passes including the webhook tests; npx playwright test passes; npm run typecheck, npm run lint and npm run build exit 0. Paste the last lines of each output. CONSTRAINTS: never log card data or full secrets; do not run prisma migrate reset; run prisma migrate deploy only after I approve it; do not push to git; ask me before any command that needs approval. Use the verify skill and the stripe-test skill at the end. TURN BOUND: stop after 30 turns and report what is done and what is blocked.
```
## Phase 5: Polish and launch prep
**Scope**
- Print-friendly invoice view.
- Mobile layout check at 375px.
- Security review using REVIEW.md.
- Deploy notes for Vercel and a hosted Postgres.
- Write `docs/launch.md`.
**Check commands:** the verify skill passes, REVIEW.md has no open high-severity items, and `docs/launch.md` exists.
**Turn bound:** 30
**Goal text**
```
/goal Phase 5 Polish and launch prep is complete when all of these are true. (1) The invoice page has a print-friendly view that prints on one page for a typical invoice. (2) Layouts work at 375px width; a Playwright test checks the dashboard and /i/[token] at that width. (3) A security review against REVIEW.md is done and written to docs/review-phase5.md; every high-severity finding is fixed or listed with a reason. (4) docs/launch.md exists with deploy notes for Vercel and a hosted Postgres, the environment variables needed (names only), the Stripe live-mode switch steps, and a pre-launch checklist. CHECK: the verify skill passes; npx playwright test passes; REVIEW.md has no open high-severity items; docs/launch.md exists. Paste the results. CONSTRAINTS: do not deploy and do not push to git without my approval; do not change production settings; do not write real keys into any file; ask me before any command that needs approval. TURN BOUND: stop after 30 turns and report what is done and what is blocked.
```
REVIEW.md# Review checklist
Use this list for every change. `/review` and the reviewer both read it. Work through it top to bottom. Money and security come first.
## Severity labels
- **high**: money wrong, data leak, auth bypass, secret exposed. Must be fixed before the change is merged.
- **medium**: missing validation, missing test, unclear error. Fix before shipping.
- **low**: style, naming. Fix when convenient.
## Money (high)
- Amounts are integer cents. Flag any money value stored or passed as a decimal or float.
- Totals are recomputed on the server from the stored line items. Flag any total read from the client.
- No floating point math on money. Tax is calculated in one helper and rounds the same way everywhere.
- The Stripe Checkout amount equals the stored invoice total.
## Security (high)
- Every invoice read or write is scoped to the signed-in owner or to a valid public token. Flag any query that looks up an invoice by id alone.
- Public tokens are at least 128 bits from a cryptographic random source. Flag `Math.random()` and flag `randomUUID()` on its own, because a UUID v4 has only 122 random bits.
- The Stripe webhook signature is verified against the raw request body before any parsing.
- No secrets in code, logs, client components or any `NEXT_PUBLIC_` variable. The Stripe publishable key is the only key allowed in the browser.
- Server actions validate all input with Zod before use.
- Card data and full client email lists are never logged.
## Payments (high)
- Webhook handling is idempotent. A repeated event must not create a second Payment or change state twice.
- Payment rows are unique per Stripe session id, enforced in the database and not only in code.
- The success page does not mark an invoice paid. Only the verified webhook does.
- Stripe calls live only in `lib/stripe.ts`.
## Data (medium)
- Invoice numbers are assigned inside a transaction, sequential per freelancer, with no gaps.
- Migrations are reviewed before they run and are reversible where practical.
- Schema changes match the data model in SPEC.md, or SPEC.md is updated in the same change.
## Tests (medium)
- Each new behavior has a test.
- Payment paths have an integration or end-to-end test.
- Tests are not weakened or edited just to pass. If a test changed, the reason is written down.
## UI (medium)
- The page works at 375px width.
- Buttons have clear labels, such as "Send invoice" rather than "Submit".
- Errors show a human message, not a stack trace or a raw error code.
## Style (low)
- Server actions live in `app/actions/`.
- Names describe the thing. Files sit where CLAUDE.md says they belong.
- Comments explain why, not what.
## Output format
List findings by severity, highest first. For each finding give:
- severity, file and line
- one sentence on the problem
- one sentence on the fix
Then end with one line:
**Verdict:** ship, fix first, or rethink.
Any high finding means at least "fix first". Use "rethink" when the approach itself is wrong, not just the code.
SPEC.md# Freelancer invoice builder: product spec
## Summary
Freelancers build a clean invoice, publish it as a private link, and clients pay by card through Stripe Checkout. Clients do not need an account.
## Users
- **Freelancer.** Signs in with an email link. Creates and manages invoices.
- **Client.** Opens a link, reads the invoice, pays by card, and sees a receipt. No account needed.
## Freelancer flow
1. Sign in by email link (Auth.js).
2. Set business name and default currency (USD).
3. Create an invoice with:
- client name and client email
- line items: description, quantity, unit price in cents
- tax percent
- due date
- notes
4. The invoice number is assigned automatically when the invoice is created. Numbers are sequential per freelancer with no gaps.
## Editor and live preview
- The invoice preview updates while the freelancer edits.
- Subtotal, tax and total are computed on the server and shown on the page.
- The client never sends a trusted total. The server recalculates it on every save.
## Publish
- The Publish action marks the invoice as sent.
- It creates a random public token of at least 128 bits from a cryptographic random source.
- The public link looks like `https://example.com`.
- A Copy link button copies the URL.
## Client flow
1. The client opens `/i/TOKEN`. No login is needed.
2. The page shows a clean, printable invoice with line items, tax, total and due date.
3. The client presses **Pay**. The app creates a Stripe Checkout session for the exact stored total.
4. Stripe takes the card payment.
5. The success page says thanks and shows the invoice number. It does not mark the invoice paid by itself.
## Webhook
- Stripe sends `checkout.session.completed` to `/api/stripe/webhook`.
- The app verifies the signature with `STRIPE_WEBHOOK_SECRET` using the raw request body.
- On a valid event, the app marks the invoice as paid and stores the Stripe payment id.
- Duplicate webhooks must not create duplicate payments. `Payment.stripeSessionId` is unique.
- A bad signature returns 400.
## Dashboard
- Lists the freelancer's invoices.
- Status values: draft, sent, paid, overdue.
- Overdue means sent and past the due date.
- Sorted by due date.
## Data model
**User**
- id, email, businessName, defaultCurrency
**Invoice**
- id
- number (sequential per owner, unique per owner)
- ownerId
- clientName
- clientEmail
- currency (stored per invoice)
- taxPercent
- dueDate
- status (draft, sent, paid)
- publicToken (unique)
- notes
**LineItem**
- invoiceId
- description
- quantity
- unitPriceCents (integer)
**Payment**
- invoiceId
- stripeSessionId (unique)
- amountCents (integer)
- paidAt
Money is stored as integer cents. Totals are recomputed on the server.
## Acceptance criteria
- A stranger cannot read another freelancer's invoices.
- The public link works without login.
- An unknown token returns 404.
- Totals match between the editor, the client page and the Stripe charge amount.
- Duplicate webhook deliveries create one payment and one paid status change.
- The site works on a phone screen (375px width and up).
- Errors show a plain message a person can act on.
## Out of scope for v1
- Recurring invoices
- Multiple users per account
- Tax engines (tax is a single percent typed by the freelancer)
- Refunds UI
- PDF file generation (the print view is enough)
- Multiple currencies in one invoice
## Open questions
- Which hosted Postgres to use for launch (Neon or Supabase are both fine for v1).
- Whether to send reminder emails before the due date (not in v1).