---
title: Plans and pricing
path: concepts/plans-and-pricing
status: published
---

# Plans and pricing

A plan is the smallest billable unit: a price + a billing period + a set of quotas + a tier. Every active service has one or more plans; subscriptions reference plans by ID.

## Schema

```
plans
  service_id (FK service_registry),
  slug,                       -- unique within service (trial, starter, pro, enterprise)
  name,                       -- human label
  description,
  tier,                       -- trial | starter | pro | enterprise (or your own)
  billing_period,             -- monthly | yearly | quarterly | one_time
  base_price_cents,           -- in cents, currency below
  currency,                   -- ISO 4217
  features,                   -- JSON {items: [string], unit: string}
  quotas,                     -- JSON service-specific limits
  is_active, is_public,
  trial_days,                 -- 0 means no auto-trial on signup
  cost_multiplier,            -- partner-cost markup (rarely used)
  sort_order, metadata

plan_usage_pricing
  plan_id, metric_slug,
  tier_start, tier_end,       -- usage tier brackets
  unit_price_cents,
  unit_label, unit_divisor
```

## Tier conventions

The four canonical tier values map to standard product expectations:

| Tier | Use |
|---|---|
| `trial` | 14-day free trial. `base_price_cents = 0`, `trial_days = 14`, low quotas. |
| `starter` | Entry paid tier. Small quotas, basic features. |
| `pro` | Mainstream paid tier. The "sweet spot" most customers land on. |
| `enterprise` | Top tier. Often custom-quoted; the plan in the DB is a reference price. |

Tier names are free-form strings — these are just the conventions ScaiLabs' own services use.

## Quotas

`plans.quotas` is service-specific JSON. Examples:

```json
// ScaiKey
{ "monthly_active_users": 10000 }

// ScaiVault
{ "secrets": 25000 }

// ScaiSend
{ "monthly_emails": 100000 }

// ScaiFlow
{ "monthly_runs": 250000, "active_workflows": 50 }
```

A quota value of `0` (or absent) is the conventional "unlimited" sentinel for Enterprise tiers.

Quotas are enforced by each service itself, not by ScaiControl. ScaiControl publishes them; the service polls (or webhooks back) for the current `plan_id` and reads its own quota.

## Pricing normalisation

For events and reports, prices are normalised to monthly (MRR):

| `billing_period` | MRR formula |
|---|---|
| `monthly` | `base_price_cents` |
| `yearly` | `base_price_cents / 12` |
| `quarterly` | `base_price_cents / 3` |
| `weekly` | `base_price_cents * 4` |
| `daily` | `base_price_cents * 30` |
| `one_time` | `base_price_cents` (pass-through; consumer interprets) |

`services/events/builders.py:_normalize_mrr()` is the canonical implementation; subscription event payloads (`subscription.activated.v1` etc.) carry the normalised value as `mrr_amount_cents`.

## Plan keys for stable referencing

Plan IDs are UUIDs, which churn between re-seeds. For event payloads and cross-system references, ScaiControl emits a composite `plan_key` of the form:

```
<service_slug>.<plan_slug>
```

Examples: `scaikey.starter`, `scaivault.pro`, `scaiwave.enterprise`. The `plan_key` is stable across re-seeds (assuming you keep slug conventions consistent), while `plan_id` is still emitted alongside for the database-level link.

## Usage-based pricing

For metered services (API calls, storage GB, model invocations), `plan_usage_pricing` adds tiered overage:

```python
PlanUsagePricing(
    plan_id="<plan-id>",
    metric_slug="api_calls",
    tier_start=0,           # First N free under the plan's base price
    tier_end=100_000,       # ...up to 100k
    unit_price_cents=1,
    unit_label="call",
    unit_divisor=1,
)
PlanUsagePricing(
    plan_id="<plan-id>",
    metric_slug="api_calls",
    tier_start=100_000,
    tier_end=None,          # Open-ended above 100k
    unit_price_cents=2,     # 2¢ per call beyond
)
```

The monthly billing job (`services/billing/invoice.py:generate_monthly_invoices`) joins this with the metering data (`usage_events`, partitioned by month) to compute overage lines.

## Editing plans without orphaning subscriptions

Best practice:

- **Never delete a plan that has live subscriptions.** Make it `is_active = false` and `is_public = false` so it stops appearing in the catalog, but existing subscriptions keep working.
- **Price changes only take effect on the next billing cycle.** Already-finalised invoices snapshot their VAT but not their plan price (the line item carries the exact `unit_price_cents`). So an active subscription whose plan price changed mid-month bills the new price on the next cycle.
- **Trial-day changes** apply only to newly-created subscriptions. An in-flight `trialing` subscription keeps its original `trial_ends_at`.

## Admin surface

- List plans for a service: `GET /catalog/services/{slug}/plans` (also returned in `GET /admin/registry/services` with `?expand=plans`).
- Create/edit plans: `POST/PUT /admin/registry/services/{slug}/plans` (see [Admin — service registry](../reference/api/registry)).
- The admin UI exposes plan management on each service's detail page.

## See also

- [Service packs](./service-packs) for bundled `(service, plan)` combinations.
- [Subscriptions](./subscriptions) for the lifecycle of "tenant on a plan".
- [VAT and reverse charge](./vat-and-reverse-charge) for how prices flow into invoices.
