API · SaaS bundle

The SaaS plugin bundle in one domain — billing entitlements (a quota gate), in-app + console notifications, analytics events, and live presence, wired turnkey. One projects.create mutation exercises billing + analytics + notifications together. Zero-infra boot.

The four cross-cutting SaaS plugins wired turnkey in one cohesive domain — the billing / notifications / analytics / presence slice of the framework end to end, the way api-durable bundles the durable surface. A single projects.create mutation pulls three of them together. Boots zero-infra (store: 'memory'). Template id: api-saas.

Scaffold

voltro create-project acme --api=api-saas

What ships

apps/acme/api/
├── app.config.ts                       # billingPlugin + notificationsPlugin + presencePlugin
├── package.json                        # + the four SaaS plugins
├── database/schema.ts                  # a tenant-scoped `projects` table
├── queries/projects.list.query.ts      # reactive list (+ .server.ts)
└── mutations/projects.create.mutation.ts  # the bundle in one handler (+ .server.ts)

Each plugin contributes its own routes + tables automatically — you wire them in app.config.ts and consume their services in handlers. The boot banner shows them all: billing.subscription/startCheckout/…, notifications.inbox/markRead/…, presence.heartbeat/list/leave.

The four plugins

// app.config.ts
import { billingPlugin } from '@voltro/plugin-billing'
import { presencePlugin } from '@voltro/plugin-presence'
import { notificationsPlugin, consoleChannel } from '@voltro/plugin-notifications'

plugins: [
  billingPlugin({
    provider: 'mock',                                   // in-memory; no Stripe key
    plans: {                                            // tier → quota, in code
      free: { entitlements: { projects: 3 } },
      pro:  { priceId: 'price_demo_pro', entitlements: { projects: 'unlimited' } },
    },
  }),
  notificationsPlugin({ channels: [consoleChannel()] }),  // + a durable in-app inbox
  presencePlugin(),                                       // presence.heartbeat / list / leave
]
  • Billing — subscriptions + entitlements; plans is the single source of tier→quota truth. The free default caps projects at 3.
  • Notifications — one NotificationService.send(...) across channels + an in-app inbox (useInbox() / useUnreadCount() on the client).
  • Presence — ephemeral "who's online" per channel, plus the usePresence(channel) hook.

The bundle, in one handler

projects.create is Effect-mode so it can yield* the plugin services the framework provides in the per-request stack:

// mutations/projects.create.mutation.server.ts
import { EffectStore, useAnalytics } from '@voltro/runtime'
import { requireEntitlement } from '@voltro/plugin-billing'
import { NotificationService } from '@voltro/plugin-notifications'

const execute = (input: { name: string }, ctx) =>
  Effect.gen(function* () {
    yield* requireEntitlement(ctx, 'projects', 1)                 // 1. BILLING — quota gate
    const store = yield* EffectStore
    const row = yield* store.insert('projects', { name: input.name })  // tenant() auto-stamps
    yield* (yield* useAnalytics()).track({                        // 2. ANALYTICS — event
      name: 'project_created', subjectId: ctx.request.subject.id, properties: { name: input.name },
    })
    yield* Effect.promise(() => (yield* NotificationService).send({  // 3. NOTIFICATIONS — inbox + console
      to: ctx.request.subject.id ?? 'system', category: 'project',
      title: 'Project created', body: `"${input.name}" is live.`,
    }))
    return { id: row['id'] as string, name: row['name'] as string, tenantId: row['tenantId'] as string }
  })

The billing plugin auto-merges its typed EntitlementExceeded (+ BillingError) into this procedure's wire-error union — the client decodes the over-quota failure typed, without a manual error: declaration.

Try it (curl)

projects.create and the plugin routes are rpc procedures — call them with useMutation / useSubscription from a web app, or over HTTP via the dev inspect endpoint:

# 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, "used": 3 } }

Each successful create also logs [notify:project] → …: Project created (console channel) and persists a notification_inbox row.

Enable durable analytics

useAnalytics().track(...) is noop-safe with no sink configured (it never crashes), so the event API is reachable on the zero-infra memory store. To actually STORE + query events (aggregate / timeseries / topN), add a sink AND a SQL store — the postgres-lite sink needs a SqlClient:

// 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 priceIds
Email / Slack / SMS notifications add emailChannel() / webhookChannel() / smsChannel() to channels
Durable analytics at scale postgresAnalytics() on postgres, or swap to @voltro/plugin-clickhouse / @voltro/plugin-duckdb
Live presence roster in the UI subscribe a reactive query over _voltro_presence, or use usePresence(channel)

Anti-patterns

  • Hand-rolling notifications.* / billing.* / presence.* procedures. The plugins own those tag namespaces — boot fails on a collision. Use the plugin routes + services.
  • Expecting analytics to persist on memory. The postgres-lite sink needs a SqlClient. track() is reachable + safe on memory; events land in _voltro_events only 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" — orthogonal. A paid action needs both.

See also