---
title: Service packs
path: concepts/service-packs
status: published
---

# Service packs

A service pack is a curated bundle: one billable line that includes several `(service, plan)` pairs. Useful for "everything you need to evaluate ScaiLabs" (a trial bundle), regional add-on combos, or seat-priced enterprise bundles.

## Data model

```
service_packs
  slug, name, description, icon_url,
  billing_period (monthly | yearly | quarterly | one_time),
  base_price_cents, currency,
  discount_type (fixed_price | percentage),
  discount_percentage (optional, for percentage),
  trial_days, features (JSON),
  is_active, is_public, sort_order

service_pack_items
  pack_id, service_id, plan_id,
  override_price_cents (optional — null = inherit plan price),
  sort_order

pack_subscriptions   (per-tenant subscription rows for a pack)
  partner_id, tenant_id, pack_id,
  status, activated_at, trial_ends_at, cancelled_at, cancellation_reason
```

## Pricing

The pack's headline price is `service_packs.base_price_cents`. It is **not** the sum of constituent plan prices — packs typically apply a discount. The two `discount_type` values:

- `fixed_price` — `base_price_cents` IS the customer's monthly bill. The constituent plans are documentation, not the source of truth.
- `percentage` — multiply the sum of constituent (or override) prices by `(100 - discount_percentage) / 100`. Use this when the bundle math should be transparent.

Per-item `override_price_cents` lets you tweak individual lines (e.g. "the ScaiVault plan is half-price in this bundle"). Setting it to null inherits the underlying plan's `base_price_cents`.

## Subscription model

When a tenant subscribes to a pack:

1. A `pack_subscriptions` row is created.
2. One `subscriptions` row is created per pack item, with `pack_subscription_id` pointing back.
3. The child subscription rows share the pack's `status` and lifecycle.

When the pack is cancelled, the cancel cascades to every child subscription with `pack_subscription_id = <pack_sub.id>`.

## Why both rows?

The duplication isn't free, but it serves two real needs:

1. **Quota enforcement.** Per-service quota checks (e.g. ScaiKey's "monthly active users") run against the `subscriptions` table joined to `plans`. If the only record of a tenant's access were the pack row, every quota check would have to JOIN through `pack_items` — slower and less indexable.
2. **Mixed subscriptions.** A tenant can have both pack subscriptions AND standalone subscriptions to services NOT in any pack. The single subscriptions table makes this work without branching every query.

## Events

Pack lifecycle has its own event topics, separate from regular subscription events:

| Topic | Trigger |
|---|---|
| `pack_subscription.activated.v1` | Pack subscribe succeeds |
| `pack_subscription.changed.v1` | (Reserved for future — no live producer yet) |
| `pack_subscription.cancelled.v1` | Pack cancellation, immediate or scheduled |

Child subscription rows do NOT independently emit `subscription.activated.v1` / `cancelled.v1` — subscribers see one event per pack action, with `pack_includes[]` enumerating the constituent services. This avoids double-counting on the CRM side.

## Admin surface

- **List packs**: `GET /admin/packs`
- **Create pack**: `POST /admin/packs` with `items[]`
- **Update pack**: `PUT /admin/packs/{id}` (replaces items wholesale)
- **Deactivate pack**: `DELETE /admin/packs/{id}` (soft — `is_active=false`)
- **Pack subscriptions across tenants**: `GET /admin/packs/subscriptions`

The admin UI lives at `/admin/packs`. See [Admin — service packs](../reference/api/admin-packs) for the full request/response shapes.

## Trial bundles

The most common pattern: a `trial-bundle` pack that includes the `trial` plan of every service. €0 / 14-day trial / public. Tenants subscribing to it land on every service simultaneously at zero cost; when the 14 days lapse, the reaper flips each child subscription to `cancelled` (unless they upgraded individual services in the meantime).
