> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Subscriptions & Recovery

> The reader subscription lifecycle mirrored from Duro, dual-path activation, and the recovery-first creator metrics Quill inherits from Duro's dunning engine.

Quill does not run a billing engine. It holds a `subscriptions` row that **mirrors** the state of a Duro subscription, and it reacts to Duro's events. The value of building on Duro shows up most clearly here: recovery-first billing is Duro's product, so Quill gets at-risk tracking and recovery metrics by reflecting webhooks, not by implementing dunning.

## The status model

Quill's `subscription_status` enum has six states (`src/database/schema/enums.ts`):

```
pending → active → past_due → active   (recovered)
                 → paused  → active     (resumed)
                 → canceled
                 → expired
```

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending: subscribe() creates checkout
    pending --> active: verify poll OR subscription_activated webhook
    active --> past_due: subscription_payment_failed / subscription_past_due
    past_due --> active: subscription_payment_recovered / subscription_recovered
    active --> paused: pause() / subscription_paused
    paused --> active: resume() / subscription_resumed
    active --> canceled: cancel() / subscription_cancelled
    past_due --> canceled: subscription_unpaid / subscription_expired
    canceled --> [*]
```

Transitions come from two directions: **reader/creator actions** (which call Duro and then update the row) and **Duro webhooks** (which update the row to match Duro). Duro remains authoritative; Quill's row is a cache for rendering and content gating.

## Subscribing: create then verify

The subscribe flow is in `SubscriptionsService.subscribe()` (`src/subscriptions/subscriptions.service.ts`).

```mermaid theme={null}
flowchart TD
    START["POST /subscriptions { publicationId }"] --> SELF{"own publication?"}
    SELF -->|"yes"| ERR1["400 cannot subscribe to your own"]
    SELF -->|"no"| DUP{"already active?"}
    DUP -->|"yes"| ERR2["409 already subscribed"]
    DUP -->|"no"| FREE{"monthlyPriceKobo == 0?"}
    FREE -->|"yes"| GRANT["mark active locally,<br/>no Duro call, receipt email"]
    FREE -->|"no"| ENSURE["ensurePlan + ensureCustomer (Duro)"]
    ENSURE --> CK["createCheckout (Duro, Idempotency-Key)"]
    CK --> ROW["upsert sub → pending<br/>store duroCustomerId + duroCheckoutId"]
    ROW --> OUT["return { subscription, checkout: { checkoutUrl, publicKey, expiresAt } }"]
```

* **Free publications** (`monthlyPriceKobo === 0`) skip Duro entirely and are granted locally, with the `checkoutUrl` pointing straight at the reader view.
* **Paid publications** return a Duro `checkoutUrl`. The reader completes payment on Duro's hosted checkout, then is redirected back to Quill's `/checkout/{reference}` page.

### Dual-path activation

Activation can arrive two ways, and Quill handles both idempotently:

```mermaid theme={null}
flowchart LR
    subgraph sync["Synchronous (reader on page)"]
        V["POST /subscriptions/verify { reference }"] --> GC["getCheckoutSession(duroCheckoutId)"]
        GC -->|"completed + subscriptionId"| GS["getSubscription(id)"]
        GS --> PA1["processActivation()"]
    end
    subgraph async["Asynchronous (source of truth)"]
        WH["POST /webhooks/duro<br/>subscription_activated"] --> GA["grantActive()"]
    end
    PA1 --> STATE["sub → active, period synced, ledger charge"]
    GA --> STATE
```

`verify` polls Duro's checkout status: if it is `completed` and carries a `subscriptionId`, Quill fetches the subscription for the real period dates and calls `processActivation`. Independently, the `subscription_activated` webhook calls `grantActive`. Whichever wins, `processActivation` guards the ledger so the charge is recorded **exactly once** (see below). This is the same "the page may return before the webhook" reality Duro's own checkout handles, mirrored on the merchant side.

## Exactly-once revenue recording

`processActivation` writes an append-only `ledger_entries` row, but only after a dedup check. The charge reference is deterministic (`dinv_{invoiceId}` when Duro supplies an invoice, else `{subId}:{periodEnd}`), and an existing row with that reference short-circuits:

```mermaid theme={null}
flowchart TD
    PA["processActivation(input)"] --> FIND["find sub by reference or duroSubscriptionId"]
    FIND --> UPD["sub → active, set period start/end"]
    UPD --> DEDUP{"ledger row with chargeRef exists?"}
    DEDUP -->|"yes"| STOP["return (no double charge)"]
    DEDUP -->|"no"| INSERT["insert ledger_entries<br/>(recovery ⇒ 'Subscription recovery')"]
    INSERT --> MAIL{"recovered? first charge?"}
    MAIL -->|"recovered"| RMAIL["sendSubscriptionRecovered"]
    MAIL -->|"first charge"| WMAIL["sendSubscriptionReceipt"]
```

This is what lets the synchronous `verify` and the asynchronous webhook both call activation safely: the second one to run finds the ledger row already present and stops.

## Reader and creator controls

`SubscriptionsController` exposes the lifecycle actions (`src/subscriptions/subscriptions.controller.ts`). Each mutating action calls the matching Duro method (when a `duroSubscriptionId` exists) and then updates the local row:

| Endpoint                            | Guard               | Duro call                                      | Local effect                  |
| ----------------------------------- | ------------------- | ---------------------------------------------- | ----------------------------- |
| `POST /subscriptions`               | reader              | `ensurePlan`/`createCustomer`/`createCheckout` | upsert `pending`              |
| `POST /subscriptions/verify`        | reader              | `getCheckoutSession` + `getSubscription`       | activate if completed         |
| `POST /subscriptions/:id/cancel`    | owner of sub        | `cancelSubscription(id, false)`                | `canceled` + `canceledAt`     |
| `POST /subscriptions/:id/pause`     | owner of sub        | `pauseSubscription(id)`                        | `paused` (only from `active`) |
| `POST /subscriptions/:id/resume`    | owner of sub        | `resumeSubscription(id)`                       | `active` (only from `paused`) |
| `POST /subscriptions/:id/recover`   | owner of sub        | re-runs `subscribe()`                          | new checkout for a lapsed sub |
| `GET /subscriptions/mine`           | reader              | none                                           | list the reader's subs        |
| `GET /subscriptions/subscribers`    | `@Roles('creator')` | none                                           | the creator's subscriber list |
| `GET /subscriptions/recovery-stats` | `@Roles('creator')` | none                                           | the recovery view             |

**Recover** is the reader-initiated counterpart to Duro's automated dunning: for a `past_due` or `canceled` subscription, it opens a fresh Duro checkout so the reader can re-pay. (A `paused` subscription is redirected to `resume` instead of `recover`.)

## The recovery-first creator view

This is where building on Duro pays off. `recoveryStatsForOwner` (guarded `@Roles('creator')`) computes a recovery summary directly from Quill's mirrored state and ledger, populated by Duro's webhooks:

```mermaid theme={null}
flowchart LR
    subgraph inputs["Inputs"]
        BY["subscriptions grouped by status<br/>(active / past_due / paused)"]
        LED["ledger_entries where<br/>description = 'Subscription recovery'"]
    end
    BY --> M1["activeCount"]
    BY --> M2["atRiskCount = past_due count"]
    BY --> M3["atRiskMrrKobo = SUM(price) of past_due"]
    LED --> M4["recoveredCount"]
    LED --> M5["recoveredRevenueKobo = SUM(amount)"]
    M2 --> M6
    M4 --> M6["recoveryRate = recovered / (recovered + atRisk)"]
```

The metrics (`RecoveryStats`):

* **`atRiskCount` / `atRiskMrrKobo`** come from subscriptions in `past_due` (set by Duro's `subscription_past_due` webhook). This is "money currently in dunning."
* **`recoveredCount` / `recoveredRevenueKobo`** are a filtered aggregate over `ledger_entries` tagged `Subscription recovery`, written when a `subscription_payment_recovered` webhook lands. This is "money Duro brought back."
* **`recoveryRate`** = `recovered / (recovered + atRisk)`, rounded to one decimal.

Quill never decides *when* to retry, *which rail* to use, or *how* to sequence dunning emails. All of that is Duro's [dunning engine](/billing/dunning) and [renewal engine](/billing/renewal-engine). Quill's job is to reflect the outcome and put "what you almost lost, and got back" in front of the creator. That mirroring is the strongest evidence that Duro's recovery-first design is consumable by a real product.

## Content gating keys off the mirrored status

The mirrored `status` is load-bearing beyond analytics: it gates premium content. A premium post or video is unlocked only for a user with an `active` subscription to that publication. Video goes further and gates the HLS decryption key itself (`VideosService.getKey` refuses non-`active` subscribers). So a lapse Duro reports via `subscription_past_due` immediately, and correctly, revokes access, and a recovery restores it, with no extra code path in Quill.

Next: [Payouts & VAS](/quill/payouts-vas), the money-out side of the same integration.
