# Subscriptions

A subscription is an entity of its own, not a date on the client. This document
is about why, and about the rules that follow from it.

## Shape

```json
{
  "id": "9f8e7d6c5b4a3928",
  "product": "market_engine" | "live_intelligence",
  "starts_at": "2026-10-08T00:00:00.000Z",
  "expires_at": "2026-11-08T00:00:00.000Z",
  "created_at": "2026-10-08T07:40:11.004Z"
}
```

`expires_at: null` means no end. `starts_at` defaults to now.

## The boundary — C1

```
active  ⟺  starts_at <= now < expires_at
```

The start is **inclusive**, the end is **exclusive**. So `expires_at` is the
first instant without entitlement, and two consecutive periods
(`… → T`, `T → …`) leave no gap and no overlap, not even for a millisecond.

| moment | active |
|---|---|
| `starts_at - 1ms` | no — `NOT_STARTED` |
| `starts_at` | **yes** |
| `expires_at - 1ms` | **yes** |
| `expires_at` | no — `EXPIRED` |

## Per-product expiry — the whole point

```
client "Shoqëria XH"
  market_engine      2026-10-08 → 2026-11-01
  live_intelligence  2026-10-08 → 2026-11-15
```

On 2026-11-05 that client holds Live Intelligence and not MarketEngine. The
`features` map follows: `pricing` is gone, `momentum` remains.

A single `client.expires_at` would have switched off both. This is the failure
the model is shaped to prevent, and it is the first thing the gate checks — in
both directions (ME expired with LI active, and LI expired with ME active).

## Overlap and renewal — C3

History may hold several subscriptions for one product. The rules:

- **active** if at least one period is active;
- the reported `expires_at` is the **maximum** of the active periods;
- `null` means no end, so it wins and is never overwritten;
- an expired period next to an active one does not make the product expired.

The maximum does not depend on array order. (The gate inserts the long period
first and the short one second, precisely so that a "last one wins" bug cannot
pass.)

### Renewal extends; it does not add

```js
addSubscription(id, { product: 'live_intelligence', expires_at: … })
// -> { ok: false, code: 'ACTIVE_SUBSCRIPTION_EXISTS',
//      error: 'this product already has an active subscription — use extendSubscription' }
```

A product with an active period is renewed with `extendSubscription`. Adding a
second parallel period is possible but must be asked for explicitly with
`parallel: true` — otherwise ordinary renewal would quietly accumulate parallel
entitlements that nobody intended.

```js
extendSubscription(id, subscription_id, '2027-01-31T23:59:59Z', actor,
                   { operation_id: 'invoice-7781' })
```

## Renewal never touches the token — C6

The token is an identity credential. The subscription is an entitlement. They
are two different things, and the code keeps them that way:

| act | changes | leaves alone |
|---|---|---|
| `extendSubscription` | `expires_at` | token, salt, hash, prefix |
| `rotateToken` | salt, hash, prefix, `rotated_at` | every subscription and feature |

Both directions are asserted, because a renewal that invalidated a client's
token would break a working integration on the day they paid.

## Idempotency — C7

`addSubscription`, `extendSubscription` and `rotateToken` accept an
`operation_id`. The same id delivered twice takes effect once and the second
call returns the first result with `idempotent: true`.

```js
addSubscription(id, { product: 'live_intelligence',
                      expires_at: '2026-12-01T00:00:00Z',
                      operation_id: 'invoice-7781' })
// first  -> { ok: true, subscription_id: 'abc…' }
// again  -> { ok: true, subscription_id: 'abc…', idempotent: true }
```

This exists for one concrete reason: **a duplicated crypto payment webhook must
not add two months.** When billing arrives, a confirmed invoice will call
`extendSubscription` with the invoice id as the `operation_id`, and nothing else
in the model needs to change.

For `rotateToken`, a replay does **not** rotate again and returns `token: null`.
The token was never stored, so it cannot be returned twice; saying "already
applied" is better than keeping a secret around to repeat it.

## Status wins — C5

`SUSPENDED` or `REVOKED` on the client disables every product regardless of its
subscriptions. The subscriptions are preserved: reactivating a suspended client
restores exactly what it held. Reasons are reported as `SUSPENDED` / `REVOKED`
rather than `EXPIRED`, so an operator can tell a lapse from a decision.

## UTC — C2

Every date passes through `toUtc()` before storage:

| input | stored |
|---|---|
| `2027-01-31T10:00:00+02:00` | `2027-01-31T08:00:00.000Z` |
| `2027-01-31` | `2027-01-31T00:00:00.000Z` |
| `''` | `null` (no expiry) |
| `tomorrow` | rejected, nothing written |

A local-time string never reaches the registry.
