# {{capProjectName}} {{capAppName}} — the full SaaS backend

The whole SaaS loop wired end to end in ONE api — **sign up → hit a paywall →
upgrade → invite a teammate** — composing two shipped plugins that are normally
wired separately, plus a small tenant-scoped domain. Boots **zero-infra**
(`store: 'memory'`, `mock` billing provider, in-process users).

Pair it with the **frontend-saas** web template for the dashboard UI:

```bash
voltro init my-saas --api=api-saas-starter --web=frontend-saas
```

## What it composes

- **Auth** ([`@voltro/plugin-auth`](https://voltro.cloud/docs/plugins/auth)) —
  password **sign-up / sign-in / sign-out** over an HttpOnly **session cookie**,
  and `voltroPasswordStrategy` resolving that cookie into a typed `Subject` on
  every rpc/ws call. `session.me` returns it (the frontend's SSR gate reads it).
- **Billing** ([`@voltro/plugin-billing`](https://voltro.cloud/docs/plugins/overview)) —
  subscriptions + **entitlements** over the in-memory `mock` provider. `plans`
  is the single source of tier→quota truth: `free` caps `projects` at 3 and
  `seats` at 1; `pro` lifts both.
- **Domain** — tenant-scoped `projects` + `invites`. `projects.create` spends
  the `projects` quota; `invites.create` spends the `seats` quota. Over quota →
  typed **`EntitlementExceeded`** (the paywall signal), the row never written.

## The loop

| Step | Call | Result |
|---|---|---|
| Sign up | `POST /auth/sign-up` | session cookie; lands in tenant `acme` on the `free` plan |
| Work | `projects.create` ×3 | ok |
| **Paywall** | `projects.create` (4th) | typed `EntitlementExceeded { entitlement: 'projects', limit: 3 }` |
| Upgrade | `billing.startCheckout` → `pro` | `projects` becomes `unlimited`, `seats` → 10 |
| Invite | `invites.create` | spends a `seats` entitlement |
| Buy seats | `billing.changeSeats` | raises the seat quantity |

`billing.*` (`subscription`, `startCheckout`, `changePlan`, `changeSeats`,
`invoices`) and `/auth/*` are contributed by the plugins — you don't author them.

## Run it

```bash
voltro dev .
# → http://localhost:4000   (rpc + /auth/* HTTP routes)
```

`voltro dev` mints a unique `VOLTRO_SESSION_SECRET` into a gitignored
`.env.local` on first boot — no secret ships with the template.

### Try the loop (curl)

```bash
# Sign up (CSRF-protected — grab a token first, keep the cookie jar)
curl -s -c jar.txt http://localhost:4000/auth/csrf                     # → { "csrfToken": "<csrf>" }
curl -s -b jar.txt -c jar.txt -X POST http://localhost:4000/auth/sign-up \
  -H 'content-type: application/json' -H 'x-csrf-token: <csrf>' \
  -d '{"email":"ada@example.com","password":"hunter2hunter2"}'

# Create projects — the free plan allows 3, the 4th trips the paywall
for i in 1 2 3 4; do
  curl -s -b jar.txt -X POST http://localhost:4000/_voltro/inspect/invoke \
    -H 'content-type: application/json' \
    -d "{\"tag\":\"projects.create\",\"input\":{\"name\":\"Project $i\"}}"
done
# → the 4th: { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, … } }
```

## Files

```
app.config.ts                 authRoutesPlugin + voltroPasswordStrategy + billingPlugin (free/pro plans)
database/schema.ts            core actors/tenants + tenant-scoped projects + invites
actions/me.action.ts(.server) session.me — the resolved Subject (SSR gate reads it)
queries/
  projects.list.query.*       live, tenant-scoped subscription
  invites.list.query.*        live, tenant-scoped subscription
mutations/
  projects.create.*           entitlement-gated (projects quota → paywall)
  invites.create.*            entitlement-gated (seats quota)
tests/                        descriptor pins + tenant isolation + session.me
```

## Going to production

| Want… | Do |
|---|---|
| Durable accounts | swap `memoryUserStore()` for `postgresUserStore({ sql })` + `store: 'postgres'` |
| Real Stripe checkout | `billingPlugin({ provider: 'stripe', apiKey, webhookSecret, plans })` with real `priceId`s |
| Email flows (magic-link, reset) | pass `sendEmail: mailSender(mailService)` from `@voltro/plugin-mail` |
| Cookie hardening | `cookieSecure: true` (+ `cookieDomain`) — already ON in production here |

## Anti-patterns

- **Authoring your own `billing.*` / `/auth/*` procedures.** The plugins own
  those namespaces — boot fails on a collision. Use the plugin routes + services.
- **Skipping the entitlement gate on a paid action.** A scope says "may you call
  this"; an entitlement says "do you have quota left". `requireEntitlement` is
  one line and fails closed — that's what makes the paywall real.
- **Reading `VOLTRO_SESSION_SECRET` at config time for a fallback.** The plugin
  reads it per request, after the env gate mints it. A hardcoded fallback is a
  published signing key.
