# {{capProjectName}} {{capAppName}} — the SaaS plugin bundle

Four cross-cutting **SaaS plugins** wired turnkey, in one cohesive domain — the
billing / notifications / analytics / presence slice of the framework end to
end. A single `projects.create` mutation pulls three of them together.

## What it demonstrates

- **Billing** ([`@voltro/plugin-billing`](https://voltro.cloud/docs/plugins/overview)) —
  subscriptions + entitlements over the in-memory `mock` provider. The `free`
  plan caps `projects` at 3; `requireEntitlement(ctx, 'projects', 1)` gates the
  create and fails typed **`EntitlementExceeded`** on the 4th.
- **Notifications** ([`@voltro/plugin-notifications`](https://voltro.cloud/docs/plugins/overview)) —
  one `NotificationService.send(...)` across channels + a durable in-app inbox.
- **Analytics** ([`@voltro/plugin-analytics-postgres`](https://voltro.cloud/docs/plugins/overview)) —
  `useAnalytics().track('project_created', …)`. Noop-safe out of the box; one
  config line + a SQL store makes it durable (see below).
- **Presence** ([`@voltro/plugin-presence`](https://voltro.cloud/docs/plugins/overview)) —
  ephemeral "who's online": `presence.heartbeat` / `list` / `leave` + the
  `usePresence(channel)` hook.

Each plugin contributes its OWN routes + tables automatically — you wire them in
`app.config.ts` and consume their services in handlers. Boots **zero-infra**
(`store: 'memory'`).

## The bundle, in one handler

```ts
// mutations/projects.create.mutation.server.ts (Effect-mode)
yield* requireEntitlement(ctx, 'projects', 1)          // billing quota gate
const row = yield* (yield* EffectStore).insert('projects', { name: input.name })
yield* (yield* useAnalytics()).track({ name: 'project_created', subjectId: ctx.request.subject.id })
yield* Effect.promise(() => (yield* NotificationService).send({
  to: ctx.request.subject.id ?? 'system', category: 'project', title: 'Project created', body: `"${input.name}" is live.`,
}))
```

## Run it

```bash
voltro dev .
```

`projects.create` / `projects.list` and the plugin routes (`billing.*`,
`presence.*`, `notifications.*`) are rpc procedures — call them from a web app
with `useMutation` / `useSubscription`, or over HTTP via the dev inspect endpoint:

```bash
# Create projects — the free plan allows 3
for i in 1 2 3; do
  curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
    -H 'content-type: application/json' \
    -d "{\"tag\":\"projects.create\",\"input\":{\"name\":\"Project $i\"}}"
done
# → { "ok": true, "result": { "id": "proj_…", … } }

# The 4th trips the billing entitlement
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
  -H 'content-type: application/json' \
  -d '{"tag":"projects.create","input":{"name":"Project 4"}}'
# → { "ok": false, "error": { "_tag": "EntitlementExceeded", "entitlement": "projects", "limit": 3, … } }

# Presence — announce yourself, then read the roster
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke \
  -H 'content-type: application/json' \
  -d '{"tag":"presence.heartbeat","input":{"channel":"lobby"}}'
```

## Enable durable analytics

`useAnalytics().track(...)` is noop-safe without a sink. To STORE + query events
(`aggregate` / `timeseries` / `topN`), add a sink AND a SQL store — the
postgres-lite sink needs a `SqlClient`:

```ts
// app.config.ts
import { postgresAnalytics } from '@voltro/plugin-analytics-postgres'

export default {
  type: 'api' as const, name: 'AcmeApi',
  store: 'postgres' as const,          // or 'sqlite' (file:./.voltro/db.sqlite)
  analytics: postgresAnalytics(),       // events → _voltro_events
  plugins: [ /* … */ ],
}
```

## Going to production

| Want… | Do |
|---|---|
| Real subscriptions / checkout | `billingPlugin({ provider: 'stripe', apiKey, webhookSecret, plans })` with real `priceId`s |
| Email / Slack / SMS notifications | add `emailChannel()` / `webhookChannel()` / `smsChannel()` to `channels` |
| Durable analytics + warehouse | `postgresAnalytics()` on postgres, or swap to `@voltro/plugin-clickhouse` / `@voltro/plugin-duckdb` at scale |
| Live presence roster in the UI | subscribe a reactive query over `_voltro_presence`, or use `usePresence(channel)` |

## Anti-patterns

- **Authoring your own `notifications.*` / `billing.*` / `presence.*` procedures.**
  The plugins own those tag namespaces — boot fails on a collision. Use the
  plugin routes + services, don't hand-roll them.
- **Expecting analytics to persist on `memory`.** The postgres-lite sink needs
  a SqlClient. `track()` is reachable + safe on memory, but events only land in
  `_voltro_events` once you wire a sink + a SQL store.
- **Skipping the entitlement gate on a paid action.** A scope says "may you call
  this"; an entitlement says "do you have quota left". A create-project action
  needs the quota check — `requireEntitlement` is one line.
