# Billing and entitlements

> Stripe subscriptions as source of truth, feature-key entitlements that gate the API and UI by plan, and the admin screen that maps Stripe products to features. Includes a full walkthrough for adding a new entitlement.

Slicekit ships subscription billing that is **Stripe-as-source-of-truth**. You define products and
prices in the Stripe dashboard; the app never holds a Plan aggregate. It stores only the subscription
state it learns from Stripe webhooks, and it maps your own **feature keys** onto Stripe product ids.
Those feature keys are the entitlements: they gate endpoints and UI by what the user's plan grants.

This guide explains how the pieces fit, then walks the full loop of adding a new entitlement across the
backend, the admin screen, and the SPA. For the request pipeline that runs the entitlement filter see
[authentication](/docs/authentication); for the slice anatomy see [adding a vertical slice](/docs/vertical-slices).
The agent-facing reference lives in `docs/api/billing.md` and `docs/frontend/billing.md`.

## How it works

Three ideas carry the whole feature:

1. **Stripe owns the catalogue and the money.** Products, prices, trials, coupons, invoices, and the
   Customer Portal all live in Stripe. The app calls Stripe only through `IStripeGateway`, so the
   `Stripe.*` SDK never leaks into handlers.
2. **Webhooks are the only writer of subscription state.** Checkout and cancellation kick off a flow
   in Stripe; the domain changes only when the matching webhook arrives and is verified. This keeps the
   app's `Subscription` rows honest even if a user closes the tab mid-checkout.
3. **Entitlements are computed, never stored per user.** A user's features are derived on demand by
   joining their active subscriptions to the product to feature-key mapping, then cached for two
   minutes.

### The pieces

| Piece | Where | Role |
|-------|-------|------|
| `BillingAccount` / `Subscription` | `Slicekit.Core.Domain` | One account per user; subscriptions are mutated only through the account aggregate. |
| `ProductEntitlement` | `Slicekit.Core.Domain` | One row per `(stripeProductId, featureKey)`. This is the map admins edit. |
| `FeatureKeys` | `Slicekit.Core.Billing.Contracts` | The typed catalogue of every entitlement key and its kind. |
| `EntitlementsService` | `Slicekit.Core.Billing` | Resolves a user's features and limits, with a 2-minute `HybridCache`. |
| `IStripeGateway` | `Slicekit.Core.Billing.Stripe` | The only seam to Stripe. |
| `StripeWebhookEndpoint` | `Slicekit.Api/.../Webhooks` | Verifies the signature and hands off to idempotent handlers. |
| `billing` feature | `frontend/src/features/billing` | Plan grid, current-plan card, admin mapping screen, entitlement hooks. |

### The user flow

- **Subscribe.** `POST /billing/checkout` validates the requested price against the published catalogue,
  creates a Stripe Checkout session (with the configured trial and Stripe-managed promotion codes), and
  returns the hosted URL. The app refuses a second checkout while a subscription is already active;
  plan changes go through the portal.
- **Manage.** `POST /billing/portal` opens the Stripe Customer Portal where the user updates their card
  or changes plan.
- **Cancel.** `POST /billing/cancel` asks Stripe to cancel; the domain flips only when the
  `customer.subscription.deleted` / `.updated` webhook confirms it.

Each inbound webhook is verified, recorded once (the `StripeWebhookEvent` row is the idempotency
ledger), and dispatched to a typed handler. Transient faults retry; malformed payloads are recorded as
failed and never retried. The authoritative subscription is always re-fetched from Stripe rather than
trusting the event body.

## Entitlements: flags and limits

An entitlement is a `(featureKey, value?)` pair. Every key declares a **kind** in `FeatureKeys`:

- **Flag** (`FeatureKind.Flag`): a boolean. The user either has the feature or not (for example
  `priority-support`).
- **Limit** (`FeatureKind.Limit`): a numeric quota with a catalogue `DefaultValue` (the baseline when no
  plan grants it). Resolution takes the **max** across the user's active grants, floored at the default,
  so a plan never drops a user below baseline. `api-keys.limit` is the shipped example (default 2).

`EntitlementsService` joins `ProductEntitlement` rows to the user's subscriptions whose status is
`Active`, `Trialing`, or `PastDue` (past-due keeps access during Stripe's grace/dunning window):

```csharp
await entitlements.HasFeatureAsync(userId, FeatureKeys.PrioritySupport);     // flag check
await entitlements.GetLimitAsync(userId, FeatureKeys.ApiKeyLimit);           // resolved number
await entitlements.RequireWithinLimitAsync(userId, FeatureKeys.ApiKeyLimit, currentCount); // 409 if over
```

You enforce a flag on an endpoint with `.RequireEntitlement(...)`, the entitlement-world parallel to
`.RequirePermission(...)`:

```csharp
routes.Reports().MapGet("/export", HandleAsync)
    .RequireEntitlement(FeatureKeys.PrioritySupport); // 403 ForbiddenEntitlement if the plan lacks it
```

> Permissions answer "is this user allowed to do this action" (role-based). Entitlements answer "does
> this user's plan include this" (money-based). They compose: an endpoint can require both.

## Add a feature entitlement

Say you are selling a "Custom branding" flag and a "5 / 25 / unlimited projects" limit. Here is the
full loop.

### 1. Declare the key in the backend catalogue

`FeatureKeys` (`api/src/Slicekit.Core/Billing/Contracts/FeatureKeys.cs`) is the single source of truth.
Add the key and append it to `All`:

```csharp
public static readonly FeatureKey CustomBranding = new("custom-branding");

public static readonly FeatureKey ProjectLimit =
    new("project.limit", FeatureKind.Limit, DefaultValue: 5);

public static readonly IReadOnlyList All =
[
    PrioritySupport,
    ApiKeyLimit,
    CustomBranding,
    ProjectLimit,
];
```

Naming is lowercase, dotted for limits (`project.limit`). The catalogue drives validation: the admin
screen will only accept keys that exist here, and `EntitlementsService.GetLimitAsync` falls back to
`DefaultValue` for any user without a grant. No migration is needed; entitlement keys are owned by code,
and the `ProductEntitlement` table stores only `(productId, key, value?)`.

### 2. Enforce it where the action happens

For a **flag**, gate the endpoint:

```csharp
.RequireEntitlement(FeatureKeys.CustomBranding)
```

For a **limit**, check it inside the slice that performs the action, exactly like `CreateApiKey` does:

```csharp
public sealed class CreateProjectCommandHandler(AppDbContext db, EntitlementsService entitlements)
{
    public async Task HandleAsync(CreateProjectCommand command, CancellationToken ct)
    {
        var activeCount = await db.Projects.CountAsync(p => p.OwnerId == command.UserId, ct);

        var withinLimit = await entitlements.RequireWithinLimitAsync(
            command.UserId, FeatureKeys.ProjectLimit, activeCount, ct);
        if (withinLimit.IsFailure) return withinLimit.Error; // LimitReachedError -> 409

        // ... create the project
    }
}
```

### 3. Mirror the key in the SPA

`frontend/src/features/billing/feature-keys.ts` is a hand-kept mirror of the backend catalogue. Add the
matching entries so the UI can label and gate them:

```ts

  { key: 'priority-support', kind: 'flag', label: 'Priority support' },
  { key: 'api-keys.limit', kind: 'limit', label: 'API keys', defaultValue: 2 },
  { key: 'custom-branding', kind: 'flag', label: 'Custom branding' },
  { key: 'project.limit', kind: 'limit', label: 'Projects', defaultValue: 5 },
];
```

`featureLabel('project.limit', 25)` now renders "25 Projects" on the pricing grid and plan cards. Gate a
paid surface in the UI with the entitlement hook:

```tsx
const { has } = useMyEntitlements();
return has('custom-branding') ?  : ;
```

The UI gate is a UX nicety; the API filter is the real boundary.

### 4. Map the key to a Stripe product (admin)

Entitlements are attached to Stripe **products**, not prices, so monthly and yearly prices of the same
plan share them. An admin does this at runtime, no deploy required (see below). In code the mapping is a
single `ProductEntitlement` row per `(product, key)`, replaced wholesale by the
`SetProductEntitlements` command.

### 5. Verify

```sh
dotnet build api/slicekit.slnx        # fails on a typo'd FeatureKeys.X reference
cd frontend && pnpm typecheck         # fails if the mirror is missing a key you gate on
```

Then assign the key to a test product in `/admin/billing`, subscribe a test user via Stripe test mode,
and confirm `GET /billing/entitlements` returns the key (and the resolved limit value).

## The admin screen

`/admin/billing` (permission `Admin.BillingManageProductEntitlements`) lists your live Stripe products
and, for each, renders the catalogue: a toggle per flag key and a number input per limit key. Saving
calls `PUT /admin/billing/stripe-products/{id}/entitlements` with the full desired set, which the
`SetProductEntitlements` handler validates against `FeatureKeys` and writes as `ProductEntitlement`
rows. This is how you wire "Pro grants custom branding and 25 projects" without touching code or
running a migration; the keys must already exist in the catalogue from step 1.

Admin changes are bounded by the 2-minute entitlement cache TTL rather than evicted per user, so a newly
mapped feature reaches existing subscribers within two minutes. A user's own subscription changes
(start, update, cancel) evict their cache entry immediately via `EntitlementsCacheEvictionHandler`.

The read-only side of admin billing is `GET /admin/users/{id}/billing` (permission
`Admin.BillingViewUser`), which powers a summary card on the admin user-detail page with a deep link to
the customer in the Stripe dashboard. Refunds and forced cancellations are intentionally left to the
Stripe dashboard rather than rebuilt in-app.

## Configuration

`StripeSettings` binds the `Billing:Stripe` section: `ApiKey`, `WebhookSecret`, `PublishableKey`,
`ReturnUrlBase`, `DefaultCurrency`, `TrialDays` (0 disables the trial), `BodyRetentionDays`, and
`ProductMetadataKey`. Billing is considered configured (and its endpoints become live) only when an API
key, a webhook secret, and an absolute return-url base are present. Supply real secrets via environment
variables
(`Billing__Stripe__ApiKey`, `Billing__Stripe__WebhookSecret`, ...); the committed `appsettings.json`
ships placeholders only. See [configuration](/docs/configuration) and [the settings pattern](/docs/settings-pattern).

A Stripe account usually holds more products than you sell (test fixtures, archived experiments, other
apps). `ProductMetadataKey` (default `slicekit_plan`) filters the catalog to only products whose Stripe
metadata carries that key with value `true`: tag a product with `slicekit_plan=true` to publish it to the
pricing page, admin mapping screen, and checkout guard at once. Set the key to an empty string to list
every product instead.

Stored raw webhook bodies (which can contain customer PII) are redacted after `BodyRetentionDays` by a
background sweep; the idempotency rows themselves are kept.

## Checklist

- [ ] Key added to `FeatureKeys` (`Flag` or `Limit` + `DefaultValue`) and appended to `All`.
- [ ] Enforced: `.RequireEntitlement(...)` for a flag, `RequireWithinLimitAsync(...)` for a limit.
- [ ] Mirrored in `frontend/src/features/billing/feature-keys.ts` (key, kind, label, default).
- [ ] Mapped to the right Stripe product(s) in `/admin/billing`.
- [ ] `dotnet build` and `pnpm typecheck` pass; `GET /billing/entitlements` shows the key for a subscriber.
