# Feature 09 — the store's own portal

**Status:** T9.1–T9.11 built. Migrations verified against MariaDB 10.4 (up, down and up again),
and the whole loop is covered end to end by `StorePortalEndToEndTest`.
**Depends on:** Feature 08 (`docs/feature-08-store-integration.md`), whose `store_clients`,
`webhook_deliveries` and signature machinery this feature puts a human face on.
**Contract for the store's engineers:** `docs/store-api-ar.md`, §8.4 — **changed by this
feature**: the signature header may now carry more than one `v1`, and a verifier that reads only
the first will reject our callbacks during a key rotation.

## What was built, and what the build changed about the plan

| Task | State | Deviation from the plan above |
|---|---|---|
| T9.1 plan + contract | done | §8.4 of `store-api-ar.md` rewritten; `integration.ping` added to the event table |
| T9.2 identity, guard, auth | done | As planned. `store_user` guard, public registration, OTP recovery reusing `OtpService` |
| T9.3 back-office review | done | Added `store_clients.{view,review,manage}`. Also **fixed three pre-existing permissions with no label at all** — `orders.cancel`, `integration.view`, `integration.replay` — and the missing `integration` group, all left behind by Feature 08 |
| T9.4 settings + SSRF rule | done | As planned |
| T9.5 rotation with overlap | done | `SignatureService::header()` now takes one secret *or several*; `parse()` collects every `v1`; verification tries each acceptable secret against each received value |
| T9.6 orders, timeline | done | As planned — uncached, tenant-first |
| T9.7 delivery log | done | Replay was **unified**: `IntegrationLogService::replay()` used to reset the row itself, and now both it and the portal go through one `StoreWebhookService::requeue()` |
| T9.8 statement | done | As planned, assumptions returned with every response |
| T9.9 settlement ledger | done | Draft → settled → (or cancelled). Figures are **copied in, not joined**, so a payment already made cannot change when an order is corrected later. Drafts are invisible to the store |
| T9.10 Swagger `portal` group | done | Fourth document at `/api/documentation/portal`, 20 paths, 7 schemas; `SwaggerIsolationTest` extended to four |
| T9.11 runbook section | done | `docs/runbook.md` §1, "Store portal (Feature 09)" |
| **Added beyond the plan** | | |
| Staff invite | done | The plan left `Staff` unreachable — the role existed and nothing could create one. An owner now invites colleagues, who set their own password through the ordinary recovery flow, so no password ever crosses between them |
| `WebhookUrlGuard` | done | §9 admitted the form rule cannot resolve host names. The host is now re-checked **at send time**, against every address it resolves to, which turns a DNS-rebind hole into a race |
| `EnsureStoreClientIsActive` | fixed | It gated the machine API on `is_active` alone. Safe only because the review service happened to set the two together — so flipping `is_active` by hand on a never-approved store would have opened its order feed. Now gated on `isLive()` |

**One bug the build found, worth recording.** `store_clients.status`, the review trail and the
rotated secrets are all deliberately absent from the model's `#[Fillable]` list — and
`BaseRepository::update()` calls `$model->update()`, which silently drops anything unfillable. So
the first implementation of the review flow *reported success and changed nothing*. It is now
`StoreClientRepository::updateInternal()`, the same arrangement `createWithProvenance()` has for
an order's provenance, and `StoreClientReviewTest` exists mostly to keep it that way.

---

# القسم الأول — الشرح بالعربية

## ١. ما المطلوب

اليوم، حين يريد متجر أن يتكامل معنا، يجري هذا كله يدويًا: مسؤول عندنا يشغّل
`php artisan integration:issue-token`، يقرأ المفتاحين من الطرفية، ثم يرسلهما إلى المتجر
عبر بريد أو رسالة. ورابط الـ Webhook يُكتب في قاعدة البيانات بيد إنسان. وإذا أراد المتجر
أن يغيّر الرابط أو يدوّر المفاتيح، يفتح تذكرة دعم وينتظر.

المطلوب: **بوابة للمتجر نفسه**. يسجّل صاحب المتجر، ويعتمده مسؤول عندنا، ثم يدخل ليضبط
إعدادات الـ Webhook بنفسه، ويرى طلباته وسجلّها وحساباته المالية — ولا شيء غير ذلك.

## ٢. القرار المعماري الأهم — من هو «صاحب المتجر»؟

ثلاثة احتمالات، واحد منها فقط سليم:

**(أ) أن يكون `Admin` بدور مقيّد.** مرفوض، وهو الخيار الذي يبدو الأسهل ويُخفي أخطر ثغرة.
السبب ملموس لا نظري: الدالة `OrderRepository::paginateWithFilters()` تخزّن صفحاتها في
الكاش بمفتاح **لا يحتوي على هوية المتجر إطلاقًا**. فلو دخل صاحب متجر من نفس باب لوحة
التحكم بصلاحية `orders.view`، لرأى طلبات كل المتاجر — ومن الكاش نفسه الذي يخدم المشرفين.
وكل مسار في لوحة التحكم يبعد صلاحية واحدة عن سجلّات الكباتن والمركبات.

**(ب) نموذج `StoreUser` جديد على حارس `store_user` جديد.** هذا هو الصواب، وله سابقة في
المشروع نفسه: ملف `routes/store-integration.php` يحمل بادئته وحارسه ووسطاءه لأنه
«لا يتبع حارس تطبيق الكابتن ولا حارس المكتب الخلفي». البوابة كذلك تمامًا. والعزل هنا
**بِنيوي** لا اعتمادي: كل استعلام في هذا المسار يُكتب مقيّدًا بـ `store_client_id` من
أوله، بدل أن نتذكّر إضافة الشرط في كل مرة.

**(ج) أن يُعطى `StoreClient` نفسه كلمة مرور.** مرفوض: `StoreClient` هوية **آلة** تحمل
رمزًا قادرًا على حقن الطلبات. ربط كلمة مرور بشرية به يعني أن تسريب كلمة المرور يساوي
تسريب قدرة حقن الطلبات. وللمتجر الواحد عدّة أشخاص (مالك، محاسب، دعم) وآلة واحدة.

## ٣. دورة التسجيل والاعتماد

نفس نمط الكابتن في هذا المشروع: يسجّل من الخارج ← `Pending` ← يعتمده مسؤول ← يعمل.

1. يسجّل صاحب المتجر: يُنشأ `StoreUser` بحالة `Pending`، ويُنشأ معه `StoreClient` **غير
   مفعّل** ليعلّق عليه إعداداته.
2. يستطيع الدخول وهو `Pending` — ويرى شاشة الإعدادات فقط. هذا مقصود: يجهّز الرابط
   ويستقبل مفاتيحه ويجرّب اتصاله بينما المراجعة جارية، فيتوازى العمل بدل أن يتسلسل.
   ولا خطر في ذلك: العميل غير مفعّل، فلا رمز API صدر، ولا طلب يدخل، ولا Webhook يخرج.
3. يعتمد المسؤول من لوحة التحكم ← يُفعَّل `StoreClient` ويصدر الرمز.
4. الرفض أو الإيقاف يمنع الدخول فورًا ويُبطل الرموز.

## ٤. القاعدة التي تحكم ما يراه صاحب المتجر

**البوابة لا تكشف حرفًا واحدًا أكثر ممّا يكشفه الـ Webhook أصلًا.**

المرجع هو `StoreOrderDetailResource` — وهو بالفعل الشكل الذي يصل المتجر في كل حدث. أي حقل
خارجه (ملاحظة العمليات الداخلية، سجلّ الاقتراحات، ملف الكابتن الكامل) لا يظهر في البوابة.
هذه قاعدة واحدة تُغلق فئة كاملة من التسريبات، وتُختبر باختبار واحد يقارن مفاتيح الاستجابة.

## ٥. الأسرار — وهي جوهر الميزة

زرّ «تدوير المفاتيح» بلا **نافذة تداخل** ليس ميزة بل قاطع خدمة: لحظة الضغط تفشل كل
الطلبات الموقّعة بالمفتاح القديم. لذلك تبني هذه الميزة ما تركته الميزة ٠٨ مفتوحًا:

- **الوارد:** نقبل المفتاح الجديد **والقديم** طوال ٢٤ ساعة بعد التدوير.
- **الصادر:** نوقّع بالمفتاحين معًا ونرسل قيمتَي `v1` في الترويسة نفسها — وصيغة Stripe
  التي اخترناها أصلًا تسمح بذلك. المتجر يقبل إن طابقت إحداهما.
- **الكشف عن مفتاح يتطلّب إعادة إدخال كلمة المرور**، لأن سرقة جلسة لا يجوز أن تساوي
  سرقة مفتاح التوقيع.

## ٦. التحقّق من رابط الـ Webhook — أخطر حقل إدخال في الميزة

نحن من يُرسل طلب POST إلى الرابط الذي يكتبه المستخدم، **من داخل شبكتنا**. رابط مثل
`http://169.254.169.254/...` يحوّل حقل إدخال إلى أداة قراءة لبيانات اعتماد الخادم
(SSRF). لذلك: HTTPS إلزامي، ومنع كل النطاقات الخاصة والمحليّة، ومنع المنافذ غير القياسية.

## ٧. «المدفوعات والتسوية»

لا يوجد في المشروع اليوم أي جدول للتسويات (FR-18 مؤجّلة ومعطّلة على جواب المتجر S6).
لكن **كشف الحساب** قابل للبناء الآن من بيانات الطلبات نفسها: ما حصّله الكباتن نقدًا
لصالح المتجر، مقابل رسوم التوصيل المستحقّة لنا، والصافي. نبني الكشف أولًا (للقراءة فقط)،
ثم سجلّ تسويات يسجّل فيه المسؤول دفعة فعلية ويراها المتجر.

**تنبيه على افتراض:** لا يوجد عمود `amount_collected` في الجدول. فالمبلغ المحصَّل يُحسب
بأنه `amount_to_collect` للطلبات المسلَّمة نقدًا. هذا صحيح ما لم يحصّل الكابتن مبلغًا
مخالفًا، وهو ما لا يسجّله النظام اليوم.

---

# Part II — the detailed plan

## 1. The ask, and the one-line answer

> Store owners register through our dashboard, sign in, configure their own webhook, and see
> orders, order history and Payments & Reconciliation — nothing else.

The answer is a **fourth audience**: a `store_user` guard under `/api/store-portal`, alongside
the captain app (`driver`), the back office (`admin`) and the machine feed (`store`).

## 2. What exists today, and the seam

| Fact | Consequence for this feature |
|---|---|
| `StoreClient` is a machine identity: `HasApiTokens`, two `encrypted` `#[Hidden]` secrets, `allowed_ips`, `is_active` | The thing a portal user configures already exists. This feature adds the human who may configure it |
| Secrets are issued by `IssueStoreTokenCommand` and read off a terminal | The portal replaces an out-of-band handover with an authenticated one over TLS |
| `EnsureStoreClientIsActive` gates the machine API on `is_active` alone | Must also gate on the new approval `status` (§6) |
| `StoreOrderDetailResource` is *already* the store's view of an order, used by both the read endpoint and every webhook | It is the portal's disclosure envelope too — §5 |
| `OrderRepository::findForClient()` and `findByExternalId()` are already client-scoped | The portal's order reads extend that pattern; nothing new is invented |
| `OrderRepository::paginateWithFilters()` caches under a key with **no client in it** | **Must not be reused.** §10 |
| `WebhookDeliveryRepository` is deliberately not `Cacheable` | Its scoped variants stay uncached for the same reason |
| Driver onboarding is: register → `Pending` → admin review → `Approved`, with `canSignIn()` deciding who may log in mid-review | The exact lifecycle this feature needs, already proven here |
| `OtpService::send($identifier, $notifiable)` is generic — it binds a code to any identifier | Password reset for a store user reuses it with their phone. No new OTP machinery |
| `RateLimiter::for('admin-auth')` limits per email **and** per IP | `store-auth` is the same shape |
| `CheckApiHeaderMiddleware` 401s any `api/*` without `Accept: application/json` **and** `Accept-Language` | Applies to the portal. The portal's UI sends the owner's locale, unlike the machine API which is pinned to `en` |
| No settlement, payout or invoice table anywhere | §11 builds the statement from orders and defers the two-way ledger |
| `l5-swagger` already runs three documentation groups | A fourth, `portal`, is a config entry and an `Info` class |

## 3. The identity decision

### Rejected: a store owner as an `Admin` with a restricted role

This is the cheap option and it is the dangerous one. Three concrete reasons, not one general one:

1. **The order list cache is not tenant-keyed.** `paginateWithFilters()` remembers pages under
   `paginate:page:N:limit:N:search:…:status:…:mismatch:…`. Nothing in that key says who asked.
   A store owner reaching that method sees every order in the system, and worse, *serves* their
   page to the next admin who asks with the same filters.
2. **Blast radius.** Every back-office route is `auth:admin` + one permission. A store owner on
   that guard is one mis-seeded role away from captain records, vehicle documents and the
   dispatch settings. Isolation would rest on getting every future permission grant right.
3. **`Admin` is what decisions point at.** `orders.created_by`, `order_status_history.actor`,
   the review trail on a captain's application — all `admins`. Putting outsiders in that table
   makes "who approved this captain" a question with a wrong answer available.

### Rejected: credentials on `StoreClient` itself

`StoreClient` holds a token that can create orders. Binding a human password to it makes
password theft equal to order injection. It is also one row per *integration*, while a store has
several people: an owner who may rotate secrets, a finance user who may only read the statement.

### Chosen: `StoreUser` on a `store_user` guard

```
store_clients (1) ──< store_users (N)
  machine identity      human logins
```

The isolation is structural, not remembered: the portal has its own route file, its own
controllers, and repository methods that take `int $storeClientId` as their **first argument**.
A query that forgets the tenant does not compile into something that silently works — there is
no unscoped method on the portal path to call.

### Roles: an enum, not a second spatie guard

Two roles, and what separates them is real:

| Role | May |
|---|---|
| `Owner` | everything below, plus: webhook settings, secret reveal and rotation, IP allowlist, inviting staff |
| `Staff` | read orders, order history, the statement |

Changing `webhook_url` redirects another company's order data to a new address; that is not the
same grant as reading a report. But nothing else varies, and nothing is asked to vary per user,
so `StoreUserRole` is an enum with a `can()` method enforced by **middleware on the route group**
— the project's stated rule — rather than a second spatie guard, a second permission catalogue
and a second seeder. When a store asks for per-user permissions, that is when spatie earns its
cost here.

## 4. Onboarding lifecycle

```
POST /register  →  StoreUser(status=Pending, role=Owner)
                   StoreClient(status=Pending, is_active=false)
        │
        ├─ owner may sign in and reach ONLY /settings/*  (prepare the integration in parallel)
        │
   admin reviews in the back office
        │
        ├─ approve  →  StoreClient.status=Approved, is_active=true, API token issued
        │              owner may now reach orders, history, statement
        ├─ reject   →  sign-in refused
        └─ suspend  →  sign-in refused, tokens revoked, machine API 403s
```

Two states, deliberately, mirroring `Driver`: `status` is where the **application** stands,
`is_active` is the **operational switch**. An approved store can be paused for a billing dispute
without being pushed back into the review queue, and a paused store is not a rejected one.

**Why a pending owner may sign in.** They can do nothing harmful — the client is inactive, no
token exists, no order can arrive and no webhook can leave — and they can do the one useful
thing: stand up their receiver and prove it works before the approval lands. Serial onboarding
is the thing this feature exists to kill.

**Registration is public and therefore abusable.** It is throttled per email and per IP
(`store-auth`), the slug is derived by us rather than chosen, and approval is manual. The
endpoint creates an application, never an integration.

## 5. The disclosure rule

> **The portal never returns a field the store is not already sent by webhook.**

The reference is `StoreOrderDetailResource`, which is already both the read-back shape and the
webhook body. The portal's order resources are built from it, and a test asserts the portal's
key set is a subset of it. One rule, one test, and a whole class of "this field happened to be
on the model" leaks cannot occur.

Where the portal legitimately shows *more rows* than the API (the delivery log, the statement),
those are new shapes with their own resources — but still only about that client's own data.

## 6. Schema

| Migration | Contents |
|---|---|
| `*_create_store_users_table` | `id, uuid, store_client_id FK cascade, name, email unique, phone, password, role (enum), status (enum), is_active, last_login_at, timestamps`; index `(store_client_id, status)` |
| `*_add_status_to_store_clients_table` | `status` enum default `pending`, **then an UPDATE setting every existing row to `approved`** — the clients created by the console command are live and must not be locked out by their own migration |
| `*_add_secret_rotation_to_store_clients_table` | `signing_secret_previous` (encrypted, nullable), `webhook_secret_previous` (encrypted, nullable), `secrets_rotated_at` nullable |
| `*_create_store_settlements_table` (T9.9) | `id, uuid, store_client_id FK, reference, period_start, period_end, cash_collected, fees_charged, net_amount, currency, status (enum), settled_at, note, created_by FK admins` |

New enums in `app/Enums/StorePortal/`: `StoreUserRole`, `StoreUserStatus`, `StoreClientStatus`,
and `SettlementStatus` (T9.9). Each gets a translated `label()` and both lang files.

`password` is `hashed`; `#[Hidden(['password'])]`. `store_client_id` is **not** fillable — it is a
fact about who registered, set once by the registration service, never by a payload.

## 7. Routes and middleware

`routes/store-portal.php`, required from `routes/api.php`, prefix `store-portal`, name
`api.portal.` — the same arrangement `store-integration.php` has, and for the same reason.

```
[global] throttle:api → HandleCors → CheckApiHeaderMiddleware
[auth]   throttle:store-auth              (register, login, forgot, resend, reset)
[group]  auth:store_user → portal.active  (everything else)
[owner]  portal.role:owner                (settings writes, secrets, staff)
[live]   portal.approved                  (orders, history, statement)
```

| Method | Path | Gate | Purpose |
|---|---|---|---|
| POST | `auth/register` | throttle | create the application |
| POST | `auth/login` | throttle | token |
| POST | `auth/forgot-password` · `resend-code` · `reset-password` | throttle | OTP to the phone on the record |
| POST | `auth/logout` | auth | |
| GET/PUT | `profile` · POST `profile/password` | auth | own account |
| GET | `settings` | owner | client record, secret **metadata** only |
| PUT | `settings/webhook` | owner | url — §9 |
| PUT | `settings/ips` | owner | allowlist |
| POST | `settings/secrets/reveal` | owner + password | §8 |
| POST | `settings/secrets/rotate` | owner + password | §8 |
| POST | `settings/webhook/test` | owner | ping through the real delivery path |
| GET | `orders` · `orders/{order}` · `orders/{order}/history` | approved | §10 |
| GET | `webhooks` · `webhooks/{delivery}` · POST `webhooks/{delivery}/replay` | approved (replay: owner) | §10 |
| GET | `payments/statement` · `payments/orders` · `payments/settlements` | approved | §11 |

`portal.active` checks **both** the user (`status`, `is_active`) and the client
(`status`, `is_active`) on every request, so a suspension takes effect on the next call rather
than when a token happens to expire.

## 8. Secrets — reveal and rotation

### Reveal

`POST settings/secrets/reveal` takes the caller's **current password** and returns the two
secrets once. Session theft must not equal signing-key theft, and re-authentication is the
cheapest thing that separates them. The response is never cached (`Cache-Control: no-store`)
and the secrets are never logged.

`GET settings` returns only metadata: `has_secrets`, `secrets_rotated_at`,
`previous_secret_valid_until`.

### Rotation with an overlap window — the part that makes the button safe

A rotate button with no overlap is an outage button. So rotation writes the current secret into
`*_previous`, stamps `secrets_rotated_at`, and for
`config('integration.secret_overlap_hours')` (default 24):

- **Inbound** — `SignatureService::verifyRequest()` tries the current secret, then the previous
  one if the window is open. A store that has not yet deployed the new key keeps working.
- **Outbound** — we sign with **both** and send two values in one header:
  `t=…,v1=<new>,v1=<old>`. The store accepts if either matches. The Stripe format we already
  chose permits exactly this, which is why it was worth choosing.

**This is a contract change** to `docs/store-api-ar.md` §4: the verifier must iterate every `v1`
in the header rather than read the first. It is free to make now — no store has integrated yet
(S1 is still open) — and expensive to make later.

`integration:issue-token` gains `--rotate` so the console path and the portal path do the same
thing rather than drifting.

## 9. The webhook URL — SSRF is the real risk here

We issue an outbound POST, from inside our network, to a string a user typed.
`http://169.254.169.254/latest/meta-data/iam/…` turns a settings form into a credential reader.
`StoreWebhookUrlRule` enforces:

- scheme **https** only (the payload is the store's own order data)
- a public host: reject `localhost`, `*.localhost`, `*.internal`, and any literal address in
  `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `::1`, `fc00::/7`
- default port only, no credentials in the URL, no fragment
- length capped

A **local override** (`integration.allow_private_webhook_urls`, default false) exists for the
`--sink` harness, off everywhere but local.

`POST settings/webhook/test` queues a real `ping` event through `StoreWebhookService` so it
lands in `webhook_deliveries` and the owner watches it succeed or fail with the real signature,
the real retry curve and the real ledger. Onboarding stops being a guess.

## 10. Orders, history and the delivery log

New repository methods, each taking the tenant first:

```php
paginateForStoreClient(int $storeClientId, StoreOrderFilterData $f, int $perPage, int $page)
findForClientWithHistory(int $storeClientId, string $uuid)
```

**Not cached.** The existing cached list is the hazard described in §3; a per-tenant page is low
volume and the failure mode of getting the key wrong is cross-tenant disclosure. If volume ever
justifies it, the key gains `client:{id}` and a test asserts two clients get two keys.

Filters: status, date range, `external_order_id`, order number, payment status. The `search`
filter matches only their own rows because the tenant clause is applied before it, not after.

The order shape is `StoreOrderDetailResource` (§5). The timeline is a new
`StorePortalTimelineResource` over `order_status_history` exposing **status, label, note,
created_at** — and deliberately **not** the actor: which of our staff moved an order is our
operational record, and the store is told the transition, not the person. The actor is already
absent from the webhook, so this is the disclosure rule holding rather than a new decision.

The delivery log is the store's answer to "did you tell us?", scoped to their rows. Replay is
`Owner` only and re-sends to their own endpoint — it cannot be aimed anywhere else.

## 11. Payments & Reconciliation

Nothing financial exists today, so this is built in two layers and only the first is unblocked.

### T9.8 — the statement (read-only, derived)

For a date range, over that client's orders only:

| Figure | Derivation |
|---|---|
| Orders delivered | `order_status = delivered`, `delivered_at` in range |
| Cash collected for the store | Σ `amount_to_collect` where `payment_method = cash` and delivered |
| Prepaid value delivered | Σ `amount_to_collect` where `payment_method = prepaid` and delivered |
| Delivery fees owed to us | Σ `fee` over delivered orders |
| Net position | cash collected − fees |
| Failed / cancelled | counted separately and **never** netted silently |

Broken down per day, and per order through `payments/orders` so a disputed figure resolves to
the line that produced it.

**Two assumptions, both printed with the figures rather than buried:**
1. **There is no `amount_collected` column.** Collected is *assumed equal to*
   `amount_to_collect` on a delivered cash order. That holds unless a captain collected a
   different amount, which this system does not currently record. Making it recordable is a
   captain-app change and is out of this feature's scope — but the statement must say so, or it
   invites a dispute it cannot answer.
2. Currency is taken per order; a client with mixed currencies is grouped, never summed across.

### T9.9 — the settlement ledger (in scope, later task)

A statement with nothing to reconcile *against* is a report. An **admin** records an actual
payout — period, amount, reference, note — and the store owner sees it next to the statement,
with the difference. This closes the loop without needing anything from the store's side, unlike
FR-18's `POST /settlements` (blocked on **S6**), which stays deferred.

## 12. The back office side

New permissions on the existing `admin` guard: `store_clients.view`, `store_clients.review`,
`store_clients.manage`, and `settlements.manage` (T9.9). Added to `AdminPermission`, seeded by
`PermissionSeeder`, both lang files.

`routes/store-client-management.php`: list applications, show one, approve / reject / suspend,
toggle activation, list its users, and issue or revoke its API token — the console command's
job, moved to where the people who do it already work.

## 13. Tasks

| # | Task | Files |
|---|---|---|
| **T9.1** | This document; contract note for §8 | `docs/feature-09-store-portal.md`, `docs/store-api-ar.md`, `docs/task-list.md` |
| **T9.2** | `StoreUser`, guard, enums, registration, login, OTP reset, profile, `portal.*` middleware, `store-auth` limiter | `config/auth.php`, `app/Models/StoreUser.php`, `app/Enums/StorePortal/*`, `app/Services/StorePortal/StorePortalAuthService.php`, `app/Repositories/StorePortal/*`, `app/Http/{Controllers,Requests}/StorePortal/Auth/*`, `routes/store-portal.php`, `app/Providers/RateLimiterProvider.php` |
| **T9.3** | Back-office review of applications + permissions | `routes/store-client-management.php`, `app/Http/Controllers/Dashboard/StoreClient/*`, `app/Services/StorePortal/StoreClientReviewService.php`, `AdminPermission`, `PermissionSeeder` |
| **T9.4** | Settings: read, webhook URL (SSRF rule), IPs, test ping | `app/Services/StorePortal/StoreSettingsService.php`, `app/Rules/Integration/StoreWebhookUrlRule.php`, `config/integration.php` |
| **T9.5** | Secret reveal + rotation with the overlap window, dual `v1` outbound | `SignatureService`, `StoreWebhookService`, migration, `IssueStoreTokenCommand --rotate`, `docs/store-api-ar.md` |
| **T9.6** | Orders list / detail / timeline, tenant-scoped and uncached | `OrderRepository`, `app/DTOs/StorePortal/StoreOrderFilterData.php`, `app/Http/Resources/StorePortal/*` |
| **T9.7** | Delivery log + replay, scoped | `WebhookDeliveryRepository`, `IntegrationLogService` |
| **T9.8** | The statement | `app/Services/StorePortal/StoreStatementService.php`, `OrderRepository` aggregate methods |
| **T9.9** | Settlement ledger | migration, model, repository, admin + portal endpoints |
| **T9.10** | Fourth Swagger group `portal` | `config/l5-swagger.php`, `app/OpenApi/Operations/StorePortal/*`, `SwaggerIsolationTest` |
| **T9.11** | Runbook, `docs/store-api-ar.md` update, onboarding walkthrough | `docs/runbook.md` |

## 14. Risks

| Risk | Handling |
|---|---|
| **Cross-tenant disclosure** | Separate guard, separate routes, tenant-first repository methods, no shared cached list, and a test that signs in as client B and asks for client A's order by uuid expecting 404 (not 403 — never confirm existence) |
| **SSRF via `webhook_url`** | §9. The single highest-severity input in the feature |
| **Signing-secret theft via a stolen session** | Reveal requires the password; metadata only otherwise; `no-store` |
| **Rotation outage** | 24 h overlap inbound, dual `v1` outbound, and the console path uses the same service |
| **Registration spam / account enumeration** | Throttled per email and per IP; approval manual; login and forgot-password answer the same way for an unknown address as for a known one |
| **A suspended store keeps working** | `portal.active` re-checks user *and* client on every request; suspension revokes tokens; the machine API's `EnsureStoreClientIsActive` gains the `status` check |
| **The statement is wrong and trusted** | The `amount_collected` assumption is printed with the figures, and every total drills down to its orders |
| **Scope creep into a settlement workflow** | T9.9 records what an admin already does by hand. FR-18's two-way settlement stays blocked on S6 |
| **A fourth Swagger group breaks the other three** | `SwaggerIsolationTest` already asserts no two groups share a filename or route; extend it to four |

## 15. Verification

1. **Unit** — `StoreWebhookUrlRule` against a table of hostile URLs; `SignatureService` accepting
   a previous-secret signature inside the window and refusing it outside; the dual-`v1` header.
2. **Tenant isolation** — a dedicated test class where client B, fully approved, is refused every
   one of client A's resources by uuid, and B's list contains none of A's rows.
3. **Lifecycle** — pending owner can reach settings and is refused orders; rejected cannot sign
   in; suspension takes effect on the next request; approval issues a token the machine API
   accepts.
4. **Disclosure** — the portal's order payload keys are a subset of `StoreOrderDetailResource`'s.
5. **Rotation end to end** — rotate, then push an order signed with the *old* secret and have it
   accepted; travel past the window and have it refused; assert the outbound header carries two
   `v1` values during the window and one after.
6. **Statement** — a fixture of delivered, failed, cancelled, cash and prepaid orders with a
   hand-computed expected total.
7. `php artisan test --filter=StorePortal`, then the full suite; `vendor/bin/pint.bat --dirty`
   and `php scripts/check-architecture.php StorePortal` both clean.

## 16. Open questions — answered by the build

The five below were open when this was planned. Four were settled while building; the fifth is
still a decision for somebody else.

1. **More than one portal user on day one?** **Yes, built.** An owner invites `Staff`, who set
   their own password by asking for a recovery code. Leaving it out would have made the `Staff`
   role dead code.
2. **Should a pending owner sign in?** **Yes, kept.** They reach settings only, can do nothing
   harmful, and can prove their receiver works while the review runs.
3. **Does the store get the delivery log?** **Yes, kept.** It is the other half of a webhook
   settings screen, and it answers the commonest support question without a ticket.
4. **Who may see the statement?** **Both roles, kept.** Say the word to narrow it to `Owner`.
5. **Email or phone for the OTP?** **Still phone**, copying the back office. If store owners
   abroad have no local number this needs mail, which this project does not send today.

## 17. Original open questions

1. **Can a store have more than one portal user on day one?** The schema allows it and `Staff`
   exists; the invite endpoint is the only missing piece. Build it now or leave the owner alone?
2. **Should a pending owner really be able to sign in?** §4 argues yes. If compliance prefers a
   closed door until approval, that is a one-line change to `StoreUserStatus::canSignIn()`.
3. **Does the store owner get the delivery log?** It is not one of the three named areas, but it
   is the natural other half of a webhook settings screen. Included; say the word to drop it.
4. **Who may see the statement — `Owner` only, or `Staff` too?** Currently both. Finance data is
   often narrower than operational data.
5. **Email or phone for the OTP?** The admin flow identifies by email and sends the code by
   phone. The portal copies that. If store owners abroad have no local number, this needs mail.
