# DALSEEN — Onboarding Chain Endpoint Mapping

> **Verified accurate:** 2026-05-02 — D1–D10 references resolve via `docs/handoff/DECISIONS.md §5a` (created 2026-05-02). Flow names (signup → triage → KYC → provision → company → branch → setup-progress) match the live source under `front/owner/onboarding.compiled.js` and `front/platform/*`. Setup-progress derivation (D10) is implemented in `api-namespace.js §2.2`.
> **Status:** signed-off endpoint mapping; §11 Cursor checklist still actionable.

> **Companion to:** `handoff/POS-flow-endpoint-mapping.md`. Same conventions,
> same locked decisions (D1–D10 signed off owner, 2026-04-27).
> **Source of truth (contract):** `docs/02-CONVENTIONS.md` + D1–D10.
> **Source of truth (examples):** `uploads/DALSEEN.postman_collection.json §02`
> ("Tenancy & Onboarding") — but where Postman contradicts D1–D10,
> **D1–D10 win**.
> **Implementation target:** `Mohamkh93/DS-PL` (Laravel 11 + Sanctum).
> **Scope:** Public Signup → Platform Triage → KYC → Provision →
>            Tenant KYC → Company → First Branch → Setup Progress.
>            Eight flows, end-to-end, **no new product decisions** —
>            everything inherits D1–D10.
>
> **Goal:** When Cursor finishes the items in §11 (consolidated), the
> prototype's onboarding screens (currently mocked) can talk to the real
> Laravel backend with the same `MOCK_MODE=false` flip used for POS.
>
> **Status legend per row:**
> - ✅ aligned with D1–D10 + Postman
> - ⚠️ Postman example is thin or stale; assumptions called out
> - ❓ MISSING in Postman — flow needs it but no documented endpoint
> - 🔧 needs Postman refresh (stale fixtures from D1/D2/D10 changes)

---

## 0. Two-actor model

This chain has **two distinct actors** with separate auth:

| Actor | Auth | Purpose |
|---|---|---|
| **Public visitor** | none | Submits the signup form (Flow 1). Anonymous. |
| **Platform staff** (DALSEEN ops, not the tenant) | `X-Platform-Token: Bearer {{platformToken}}` + `Authorization: Bearer {{token}}` | Reviews queue, runs KYC, provisions tenant (Flows 2-5). |
| **Tenant owner** (just-provisioned) | `Authorization: Bearer {{token}}` (Sanctum, post-login) | Submits company KYC docs, profile, first branch (Flows 6-8). |

The handoff between platform and tenant is the `owner_invite_url` returned
from `POST /platform/tenants/provision` (Flow 5). The owner clicks the
link, sets a password, logs in, and from that moment is a tenant actor.

⚠️ **Cursor task:** confirm the platform routes are gated by **both**
`auth:sanctum` (a real DALSEEN staff user) **and** a separate
`platform.staff` middleware that checks `X-Platform-Token` against a
short-lived service token. Tenant routes are gated by `auth:sanctum` only.
Mixing these is a security incident waiting to happen.

---

## Flow 1 · Public signup (anonymous → submitted)

### 1.1 Endpoint

| | |
|---|---|
| Method | `POST` |
| Path | `/signup` |
| Auth | **none** (public) |
| Branch-scoped | no |
| Idempotent | yes (`Idempotency-Key` required) |

### 1.2 Request body

```json
{
  "company_name":  "Acme Trading",
  "legal_name_ar": "شركة أكمي",
  "cr_number":     "1010123456",
  "vat_number":    "300123456789003",
  "contact_name":  "Faisal",
  "contact_email": "owner@acme.sa",
  "contact_phone": "+9665XXXXXXX",
  "requested_modules": ["retail", "pay"]
}
```

### 1.3 Response (201)

```json
{ "data": {
  "id":     "01HXG99...",   // signup ULID  (D1 ✅)
  "status": "submitted"     // submitted | kyc_review | screening | contract | mdr_assign | approved | rejected
} }
```

🔧 **Postman shows `"id": 99`** — stale per D1. Refresh to ULIDs.

### 1.4 Validation rules

| Field | Rule | Error code |
|---|---|---|
| `company_name` | required, min 2 | `VALIDATION` |
| `legal_name_ar` | required, must be Arabic | `VALIDATION` (with `fields.legal_name_ar: ['arabic_required']`) |
| `cr_number` | required, 10 digits, [Saudi CR format](https://...) — Cursor confirm pattern | `VALIDATION` |
| `vat_number` | optional but if present 15 digits ending `003` | `VALIDATION` |
| `contact_email` | required, email, unique within "open signups" only (allow re-signup after rejection) | `EMAIL_TAKEN` |
| `contact_phone` | required, E.164 KSA format `+9665XXXXXXXX` | `VALIDATION` |
| `requested_modules` | required, non-empty, subset of `[retail, dine, pay, ecom]` | `VALIDATION` |

### 1.5 Error scenarios

| Scenario | Status | `error.code` | UI behavior |
|---|---|---|---|
| Validation failure | 422 | `VALIDATION` | Inline field errors |
| Duplicate active signup (same email, status≠rejected) | 409 | `SIGNUP_DUPLICATE` | "You have a pending signup. Check your email." |
| Idempotency replay (same key) | 200 | — | Returns the previous response |
| Rate-limited (>3 signups/hour from same IP) | 429 | `THROTTLED` | "Too many attempts" |
| Invalid CR (CR registry lookup fails async) | 201 with `status: "submitted"` | — | Backend fails-open; CR check happens async in §2 |

⚠️ **Cursor task:** decide if CR lookup is sync (slows form, blocks bad data
at the door) or async (form succeeds, ops sees the failure in the queue).
Recommend **async** — Saudi CR registry has occasional outages, you don't
want to block legitimate signups on third-party uptime.

### 1.6 Frontend call site (today)

There is **no signup form** in the prototype. The session is bootstrapped
already-authenticated. Suggested location:
`front/onboarding/signup-form.compiled.js`, mounted at `/signup` (public,
no shell).

### 1.7 Cursor checklist for Flow 1

- [ ] `Route::post('signup', ...)` outside `auth:sanctum`
- [ ] Idempotency middleware applies (200 on replay, 409 on conflict)
- [ ] Saudi-specific validators (CR, VAT, KSA phone) live in dedicated rule classes
- [ ] All 5 error scenarios produce documented envelope
- [ ] Email confirmation triggered on successful submission (out of scope for this handoff but Cursor should add a job)
- [ ] DB verification: `select id, status, contact_email, cr_number, created_at from signups order by id desc limit 1;`

---

## Flow 2 · Platform queue (staff lists pending signups)

### 2.1 Endpoint

| | |
|---|---|
| Method | `GET` |
| Path | `/platform/signups?status={status}&page={n}` |
| Auth | `Authorization` (staff user) **+** `X-Platform-Token` |
| Branch-scoped | no |

### 2.2 Query params

| Param | Values | Default |
|---|---|---|
| `status` | `submitted, kyc_review, screening, contract, mdr_assign, approved, rejected` or omit for all-active | omit |
| `page` | 1+ | 1 |
| `per_page` | 1-100 | 25 |
| `q` | free-text search on company name, CR, contact email | — |

### 2.3 Response (200)

```json
{ "data": [
  { "id":            "01HXG99...",
    "company_name":  "Acme Trading",
    "legal_name_ar": "شركة أكمي",
    "contact_email": "owner@acme.sa",
    "contact_phone": "+9665XXXXXXX",
    "cr_number":     "1010123456",
    "status":        "kyc_review",
    "submitted_at":  "2026-04-27T08:12:00Z",
    "assigned_to":   { "id": "01HXU...", "name": "Reema" } | null,
    "requested_modules": ["retail", "pay"]
  }
], "meta": { "page": 1, "per_page": 25, "total": 17 } }
```

### 2.4 Decisions applied

- D1 ✅ ULIDs.
- D10 ✅ `legal_name_ar` is Pattern A (indexed name in Arabic only — English version is `company_name`).

### 2.5 Cursor checklist for Flow 2

- [ ] `Route::get('platform/signups', ...)` middleware: `['auth:sanctum', 'platform.staff']`
- [ ] Index controller with `status`/`q`/`page` filters
- [ ] Default sort: `submitted_at desc`
- [ ] Eager-load `assigned_to` (the staff user reviewing it)
- [ ] DB: `select * from signups where status='submitted' order by submitted_at desc;`

---

## Flow 3 · Platform workbench (single signup detail)

### 3.1 Endpoint

| | |
|---|---|
| Method | `GET` |
| Path | `/platform/signups/{id}` |
| Auth | staff |

### 3.2 Response (200)

```json
{ "data": {
  "id":     "01HXG99...",
  "status": "kyc_review",
  "kyc": {
    "cr_number":   "1010123456",
    "cr_doc_url":  "https://signed.s3...",
    "vat_doc_url": "https://signed.s3...",
    "verified":    false,
    "notes":       null
  },
  "screening": {
    "sanctions_check": "pending",
    "pep_check":       "pending",
    "risk_score":      null
  },
  "contract": {
    "template": "ksa_retail_v3",
    "signed_at": null,
    "signed_doc_url": null
  },
  "mdr": {                               // merchant discount rate (Pay only)
    "tier": null,
    "fees": null
  },
  "audit_log": [
    { "at": "...", "actor": "01HXU...", "action": "submitted",   "data": {} },
    { "at": "...", "actor": "01HXU2...","action": "kyc_review",  "data": { "verified": true } }
  ]
}}
```

### 3.3 Cursor checklist for Flow 3

- [ ] Staff-only route
- [ ] Audit log table `signup_events` (signup_id, actor_id, action, data jsonb, created_at)
- [ ] Signed S3 URLs for `cr_doc_url` / `vat_doc_url` / `signed_doc_url` (≥ 1h lifetime)
- [ ] Sub-resources (`kyc`, `screening`, `contract`, `mdr`) can be null until that step is reached

---

## Flow 4 · Platform: KYC / screening / contract / mdr_assign / approve step

### 4.1 Endpoint (the "advance the workflow" route)

| | |
|---|---|
| Method | `POST` |
| Path | `/platform/signups/{id}/{step}` |
| Auth | staff |
| Idempotent | yes |

### 4.2 The step state machine

```
submitted → kyc_review → screening → contract → mdr_assign → approved → (provision) → tenant created
                                                                       └──────→ rejected (terminal)
```

`step` ∈ `{kyc_review, screening, contract, mdr_assign, approve, reject}`.

| Step | What it does | Body |
|---|---|---|
| `kyc_review` | Marks CR/VAT docs verified or rejected | `{ verified: bool, notes?: string }` |
| `screening` | Logs sanctions/PEP results | `{ sanctions: 'clear'\|'hit', pep: 'clear'\|'hit', risk_score: 0-100 }` |
| `contract` | Records contract signature | `{ signed_doc_url: string, signed_at: ISO8601 }` |
| `mdr_assign` | Assigns merchant fee tier (Pay only) | `{ tier: 'tier_1', fees: { ... } }` |
| `approve` | Final approval; unlocks Flow 5 (provision) | `{}` |
| `reject` | Terminal rejection with reason | `{ reason: string, reason_code: string }` |

### 4.3 Response (200)

Returns the **same shape as Flow 3** (full signup detail with updated
status and audit_log entry).

### 4.4 Errors

| Scenario | Status | `error.code` |
|---|---|---|
| Illegal transition (e.g. `screening` before `kyc_review` complete) | 409 | `INVALID_TRANSITION` |
| Step body missing required fields | 422 | `VALIDATION` |
| Already at terminal state | 409 | `TERMINAL_STATE` |

### 4.5 Cursor checklist for Flow 4

- [ ] Single controller, dispatches by `step` param to a step handler class
- [ ] Each step appends to `signup_events` audit log
- [ ] State transitions enforced (don't allow skipping steps)
- [ ] Idempotency: same key + same body returns cached response, different body returns 409
- [ ] DB after a `kyc_review` call: `select * from signups where id='...';` should show updated `status`, and `select * from signup_events where signup_id='...' order by created_at;` should show a new row

---

## Flow 5 · Platform provisioning (signup → tenant)

### 5.1 Endpoint

| | |
|---|---|
| Method | `POST` |
| Path | `/platform/tenants/provision` |
| Auth | staff |
| Idempotent | yes |

### 5.2 Request

```json
{
  "signup_id":         "01HXG99...",
  "plan_code":         "growth_v1",
  "modules":           ["retail", "pay"],
  "seed_coa_template": "ksa_retail_v3",
  "first_branch": {
    "name_ar": "العليا",          // D10 ✅ Pattern A
    "name_en": "Olaya",
    "code":    "RUH-01",
    "city":    "Riyadh"
  }
}
```

🔧 **Postman has `name: "Olaya"`** (single field) — stale per D10. Pattern A
requires both Arabic and English siblings.

### 5.3 Response (201)

```json
{ "data": {
  "tenant": {
    "id":     "01HXC...",
    "slug":   "acme",                // generated from company_name; unique
    "status": "provisioned"          // provisioned → setup → ready → active
  },
  "owner_invite_url": "https://app.dalseen.sa/invite/{token}",
  "owner_invite_expires_at": "2026-05-04T08:12:00Z"
}}
```

### 5.4 What this endpoint actually does (server-side)

⚠️ **Cursor task — atomic transaction:**
1. Mark signup as `approved` if not already
2. Create `tenants` row (status `provisioned`)
3. Create the **owner user** (random password, must use invite to set)
4. Attach owner role to the user
5. Create the first branch (with `code` + bilingual name)
6. Generate signed invite URL (7-day expiry)
7. Send invite email
8. Append `signup_events` row: `{ action: 'provisioned', data: {tenant_id, branch_id} }`

If **any** step fails, **the whole thing rolls back**. A half-provisioned
tenant (user without branch, branch without owner) is the worst possible
state — manual unsticking, support escalation. Use a DB transaction +
queue the email **after** commit (Laravel `DB::afterCommit`).

### 5.5 Errors

| Scenario | Status | `error.code` |
|---|---|---|
| Signup not approved yet | 409 | `INVALID_TRANSITION` |
| Slug collision (very rare) | 422 | `SLUG_TAKEN` (Cursor: append `-2`, `-3` automatically; only error after 5 attempts) |
| Email send failure | 201 (commit succeeds; email retried by queue) | — |
| Idempotency replay | 200 | — |

### 5.6 Cursor checklist for Flow 5

- [ ] Wrap in `DB::transaction()`
- [ ] Owner-invite-token table with `signup_id`, `tenant_id`, `user_id`, `expires_at`, `used_at`
- [ ] Email job scheduled `DB::afterCommit` (so a rollback doesn't send a "welcome to DALSEEN" email for a tenant that doesn't exist)
- [ ] DB after curl:
      `select id, slug, status from tenants order by id desc limit 1;`
      `select id, email, tenant_id from users order by id desc limit 1;`
      `select id, tenant_id, name_ar, name_en, code from branches order by id desc limit 1;`

---

## Flow 6 · Tenant KYC submit (owner uploads docs)

### 6.1 Endpoint

| | |
|---|---|
| Method | `POST` |
| Path | `/setup/kyc` |
| Auth | tenant owner (Sanctum) |
| Branch-scoped | no |

### 6.2 Request

Postman shows base64 inline. **Recommend** signed-URL upload pattern instead:

**Option A — base64 inline (Postman example, simple but bloats DB/logs):**
```json
{ "cr_doc": "<base64>", "vat_doc": "<base64>" }
```

**Option B — signed-URL upload (recommended, scales):**
```json
// Step 1: GET /setup/kyc/upload-url?type=cr|vat
// → { data: { put_url, key, expires_at } }
// Step 2: PUT the file directly to put_url
// Step 3: POST /setup/kyc with the keys
{ "cr_doc_key": "kyc/01HX.../cr.pdf", "vat_doc_key": "kyc/01HX.../vat.pdf" }
```

⚠️ **Decide once.** Both work; Option B is what every other SaaS does for
KYC uploads. Postman's base64 example is fine for v1 if files are < 5MB
and you're OK with bloated logs.

### 6.3 Response (200)

```json
{ "data": {
  "status":       "submitted",   // submitted → reviewed → verified | rejected
  "submitted_at": "..."
}}
```

### 6.4 Side effect

This endpoint must update `me/setup-progress` so the `kyc` step flips to
`complete` (or `submitted`/`pending_review` depending on whether you want
the UI to show "in review" while platform staff verify).

### 6.5 Cursor checklist for Flow 6

- [ ] `Route::post('setup/kyc', ...)->middleware('auth:sanctum')`
- [ ] Decide upload pattern (Option A vs B)
- [ ] Update `tenants.kyc_status` field
- [ ] Update setup-progress derivation (Flow 8 reads from this)

---

## Flow 7 · Update company profile

### 7.1 Endpoint

| | |
|---|---|
| Method | `PATCH` |
| Path | `/companies/{id}` |
| Auth | tenant owner |
| Branch-scoped | no |

### 7.2 Request

```json
{
  "name_ar":          "شركة أكمي للتجارة",     // D10 ✅ Pattern A
  "name_en":          "Acme Trading Co.",
  "default_locale":   "ar",
  "default_currency": "SAR",
  "fiscal_year_start": "01-01"
}
```

🔧 Postman has `"name": "Acme Trading Co."` only — stale per D10.

### 7.3 Response (200)

```json
{ "data": {
  "id":      "01HXC...",
  "name_ar": "شركة أكمي للتجارة",
  "name_en": "Acme Trading Co.",
  "default_locale":   "ar",
  "default_currency": "SAR",
  "fiscal_year_start": "01-01"
}}
```

### 7.4 Authorization

⚠️ **Cursor task:** `{id}` must equal `auth()->user()->tenant_id`. Otherwise
403 `FORBIDDEN`. A user can never PATCH another tenant's company.

### 7.5 Cursor checklist for Flow 7

- [ ] Route-model bind to `Tenant`, scope by `auth()->user()->tenant_id`
- [ ] Update `companies` (or `tenants` — Cursor confirm naming)
- [ ] Validate `default_currency` ∈ supported list (`SAR` for v1)
- [ ] Validate `default_locale` ∈ `[ar, en]`

---

## Flow 8 · Setup progress (the master read endpoint)

### 8.1 Endpoint

| | |
|---|---|
| Method | `GET` |
| Path | `/me/setup-progress` |
| Auth | tenant owner |
| Branch-scoped | no |

### 8.2 Response (200)

```json
{ "data": {
  "steps": [
    { "key": "kyc",             "status": "complete",    "completed_at": "..." },
    { "key": "company_profile", "status": "complete",    "completed_at": "..." },
    { "key": "first_branch",    "status": "in_progress", "completed_at": null  },
    { "key": "coa_seed",        "status": "pending",     "completed_at": null  }
  ],
  "completion": 0.5
}}
```

### 8.3 Step semantics

| Step | Marked complete when |
|---|---|
| `kyc` | Flow 6 returned 200 AND platform staff verified (Flow 4 `kyc_review` with `verified: true`) |
| `company_profile` | Flow 7 successful PATCH (any field updated post-provision) |
| `first_branch` | At least one branch exists for this tenant (auto-true after Flow 5) |
| `coa_seed` | `POST /accounting/coa/seed` returned 201 |

⚠️ **Cursor task:** this endpoint is a **derivation**, not a stored state.
Do NOT add a `setup_progress` table. Compute from:
- `tenants.kyc_status`
- `tenants.profile_completed_at` (set by Flow 7 trigger)
- `branches.count() > 0`
- `chart_of_accounts.count() > 0`

A stored progress table will drift from reality the first time someone
manually mutates DB.

### 8.4 What drives the dashboard

When the tenant owner first logs in, `/me/setup-progress` powers the
"Welcome — finish setup" checklist screen. As they complete each step, this
endpoint reflects it. When `completion === 1.0`, the dashboard hides the
checklist permanently (write a `setup_completed_at` column to remember).

### 8.5 Cursor checklist for Flow 8

- [ ] Pure read endpoint, no writes
- [ ] Derivation logic in a `SetupProgressService`
- [ ] Cache-friendly (HTTP `Cache-Control: private, max-age=10` is fine; the dashboard polls)
- [ ] Add `coa_seed` and any future steps without breaking response shape

---

## 9. Frontend onboarding shell — what the prototype needs to build

The prototype today **bypasses onboarding entirely**. To wire this chain:

### 9.1 New routes / screens

| Path | Component | Auth |
|---|---|---|
| `/signup` | `SignupForm` | public |
| `/signup/thanks` | `SignupSubmitted` (post-201 confirmation) | public |
| `/invite/:token` | `OwnerInviteAccept` (sets password, logs in) | public-with-token |
| `/onboarding` | `OnboardingShell` (renders setup checklist) | tenant owner, gated by `setup_completed_at IS NULL` |
| `/onboarding/kyc` | `KYCUpload` | tenant owner |
| `/onboarding/company` | `CompanyProfileForm` | tenant owner |
| `/onboarding/branch` | `FirstBranchForm` | tenant owner |
| `/onboarding/coa` | `COASeedPicker` | tenant owner |

### 9.2 The onboarding shell

`OnboardingShell` polls `GET /me/setup-progress` every 10s while mounted,
renders a vertical stepper with the 4 steps, and routes the user to the
next pending step on click. When `completion === 1.0`, redirects to the
main dashboard and POSTs a no-op marker so the backend can write
`setup_completed_at`.

### 9.3 Platform staff console

Out of scope here — separate handoff. The prototype today has a "Console"
screen at `front/owner/console.compiled.js` that mocks platform-staff
queue UI; that's the eventual home for Flows 2-5.

---

## 10. End-to-end happy path (one tenant, start to finish)

For Cursor verification — run these in order against a fresh DB and
confirm each step succeeds:

1. **Public visitor:** `POST /signup` → 201 with `id: "01HXG99..."`, `status: "submitted"`
2. **Staff:** `GET /platform/signups?status=submitted` → list contains `01HXG99...`
3. **Staff:** `POST /platform/signups/01HXG99.../kyc_review` body `{ verified: true }` → 200, status flips to `screening`
4. **Staff:** `POST /platform/signups/01HXG99.../screening` body `{ sanctions: 'clear', pep: 'clear', risk_score: 12 }` → 200, status `contract`
5. **Staff:** `POST /platform/signups/01HXG99.../contract` body `{ signed_doc_url: '...', signed_at: '...' }` → 200, status `mdr_assign`
6. **Staff:** `POST /platform/signups/01HXG99.../mdr_assign` body `{ tier: 'tier_1', fees: {...} }` → 200, status `approved`
7. **Staff:** `POST /platform/tenants/provision` body `{ signup_id: '01HXG99...', plan_code: 'growth_v1', modules: ['retail','pay'], seed_coa_template: 'ksa_retail_v3', first_branch: {name_ar, name_en, code, city} }` → 201 with `tenant.id`, `owner_invite_url`
8. **Owner clicks invite link → sets password → POST /auth/login** (Login flow handoff)
9. **Owner:** `GET /me/setup-progress` → `kyc: pending, company_profile: pending, first_branch: complete, coa_seed: pending` (first_branch already done by step 7)
10. **Owner:** `POST /setup/kyc` with docs → 200
11. **Staff:** verifies docs via `POST /platform/signups/.../kyc_review` (or a tenant-side variant — Cursor decide)
12. **Owner:** `GET /me/setup-progress` → `kyc: complete`
13. **Owner:** `PATCH /companies/{id}` with bilingual name → 200
14. **Owner:** `POST /accounting/coa/seed` (separate handoff, not in this doc)
15. **Owner:** `GET /me/setup-progress` → `completion: 1.0`, dashboard unlocks

Each step's DB query is in the per-flow checklist above. If any step
returns the wrong shape, the chain breaks — fail fast.

---

## 11. Consolidated Cursor checklist (the actually-do-this list)

### 11.1 Routes to confirm/add

```
POST   /signup                                  public
GET    /platform/signups                        staff
GET    /platform/signups/{id}                   staff
POST   /platform/signups/{id}/{step}            staff   (kyc_review|screening|contract|mdr_assign|approve|reject)
POST   /platform/tenants/provision              staff
POST   /setup/kyc                               tenant
PATCH  /companies/{id}                          tenant
GET    /me/setup-progress                       tenant
```

All Postman entries exist for these. No new routes needed (vs POS handoff
which added `/sales` listing, `/shifts/current`).

### 11.2 Conventions to enforce (D1–D10)

- [ ] All `id` fields are ULIDs (refresh Postman fixtures from integers)
- [ ] All `name`, `legal_name` etc become `name_ar`/`name_en` (Pattern A) — refresh Postman
- [ ] Money: not relevant to this chain (no monetary fields in onboarding)
- [ ] Idempotency middleware on every POST in this chain
- [ ] Two-tier auth: `auth:sanctum` + `platform.staff` middleware on `/platform/*` routes; `auth:sanctum` only on tenant routes

### 11.3 New tables / columns

- [ ] `signups` table (id, status, submitted_at, contact_*, cr_*, vat_*, requested_modules, assigned_to_id)
- [ ] `signup_events` table (id, signup_id, actor_id, action, data jsonb, created_at) — append-only audit
- [ ] `tenants.setup_completed_at` (nullable timestamp)
- [ ] `tenants.kyc_status` (enum: not_submitted, submitted, verified, rejected)
- [ ] `tenants.profile_completed_at` (nullable timestamp)
- [ ] `owner_invite_tokens` table (token, signup_id, tenant_id, user_id, expires_at, used_at)

### 11.4 DB happy-path verification (consolidated)

```sql
-- After Flow 1
select id, status, contact_email from signups order by id desc limit 1;

-- After Flow 4 (each step)
select * from signup_events where signup_id='01HXG99...' order by created_at;

-- After Flow 5
select id, slug, status from tenants order by id desc limit 1;
select id, email, tenant_id from users where tenant_id=(select max(id) from tenants);
select id, tenant_id, name_ar, name_en, code from branches where tenant_id=(select max(id) from tenants);
select * from owner_invite_tokens order by created_at desc limit 1;

-- After Flow 6
select id, kyc_status from tenants where id='01HXC...';

-- After Flow 7
select id, name_ar, name_en, default_locale, default_currency, profile_completed_at from tenants where id='01HXC...';

-- Flow 8 (derivation, no specific row)
select
  kyc_status,
  profile_completed_at IS NOT NULL as profile_done,
  (select count(*) from branches where tenant_id=t.id) > 0 as branch_done,
  (select count(*) from chart_of_accounts where tenant_id=t.id) > 0 as coa_done
from tenants t where id='01HXC...';
```

### 11.5 Postman refresh tasks

- [ ] All IDs → ULIDs (D1)
- [ ] `name_ar` + `name_en` siblings instead of single `name` field (D10) — affects `first_branch` in Flow 5, and Flow 7 body
- [ ] `legal_name_ar` is already correct (Pattern A indexed Arabic-only field)
- [ ] Add error response examples for `INVALID_TRANSITION`, `TERMINAL_STATE`, `SLUG_TAKEN`, `SIGNUP_DUPLICATE`
- [ ] Add idempotency replay examples for `/signup` and `/platform/tenants/provision`

---

## 12. What this doc is **not**

- Not a contract spec — `docs/02-CONVENTIONS.md` + Postman are.
- Not the platform-staff console handoff — that's a separate doc (the staff *UI* for Flows 2-5; this doc covers the API).
- Not the COA seeding handoff — `POST /accounting/coa/seed` is documented in Postman §02 but its UI lives in the Accounting handoff.
- Not validated against a running server — every "✅" refers to alignment with signed-off decisions and Postman *as it should be after refresh*.
