> ## 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.

# Customer Portal

> One WhatsApp login, every subscription across every merchant — with ownership enforced and queries batched.

The portal is the consumer face of the [universal identity](/identity/universal-identity). A customer logs in with their phone (WhatsApp OTP, no password) and sees — and manages — **every subscription they hold across every Duro merchant**.

## Listing across merchants, scoped to one mode

A customer's `Customer` rows are spread across many tenants (one per merchant they've paid). The portal finds them all by the identity's phone, then their subscriptions, plans, and merchant names — in a fixed number of batched queries, never N+1:

```mermaid theme={null}
flowchart TB
    ID["identity → phone"] --> C["data(mode).customers<br/>where phone = …<br/>(cross-tenant)"]
    C --> S["data(mode).subscriptions<br/>where customerId IN (…)"]
    S --> P["plans where id IN (…)"]
    S --> T["core.tenants where id IN (…)<br/>→ merchant names"]
    P & T --> MAP["map in memory → views"]
```

Four queries regardless of how many merchants or subscriptions — `customers`, `subscriptions IN`, `plans IN`, `tenants IN`. (An earlier version looped both modes and ran a query per subscription; this is the documented fix.)

<Note>
  **Mode is explicit, never doubled.** The dashboard has a test/live switcher, so the portal takes the current mode as a parameter and lists only that schema — real customers see `live`. The list endpoint defaults to `live`; it never silently merges both modes.
</Note>

## Ownership is enforced on every action

Reads are filtered by the identity's phone. **Actions** (cancel, pause, resume) re-verify ownership before touching anything:

```mermaid theme={null}
flowchart LR
    ACT["pause subscription X<br/>(identity token)"] --> SUB["load subscription X<br/>(by mode + id)"]
    SUB --> CUST["load its customer"]
    CUST --> CHECK{"customer.phone ==<br/>identity.phone?"}
    CHECK -->|"no"| DENY["403 — not yours"]
    CHECK -->|"yes"| PERM{"merchant allows<br/>this action?<br/>(portal permissions)"}
    PERM -->|"no"| OFF["403 — disabled by merchant"]
    PERM -->|"yes"| DO["drive SubscriptionService"]
```

A stranger with a valid identity token for a *different* phone cannot read or mutate someone else's subscription — verified live, including the negative case. And the merchant's [appearance config](/platform/appearance) governs which actions the portal even offers (`cancelSubscription`, `pauseSubscription`, `updatePlan`…), so a merchant who disables cancellation has it disabled here too.

## What the customer can do

* See all active subscriptions, with merchant, plan, next charge, status.
* View invoices and receipts per subscription.
* Pause / resume / cancel (where the merchant allows).
* **Fix a past-due charge** — update the card on their phone wallet and trigger an instant retry, closing the recovery loop from the customer's side.

Next: the [webhook system](/webhooks/delivery) that powers the merchant's view of all this.
