# Trade License Verification — Vendor-App API Specification

**Purpose of this document:** a paste-in spec for the **vendor-facing app/API codebase** to implement the vendor side of Trade License Verification (TLV). The **admin/review side already exists in VisoAdmin** (this repo) — do not rebuild it there. Both codebases talk to the **same MySQL database**, so this document is a *data contract* as much as an API contract: get the table/enum/status details wrong and the two codebases will corrupt each other's state.

Status: ready to implement. Confirm the two open decisions in [§7](#7-open-decisions-confirm-before-building) before starting.

---

## 1. Scope

| Capability | Owner |
|---|---|
| Vendor submits a trade license for verification | **New codebase (this doc)** |
| Vendor views their submission(s) status & history | **New codebase (this doc)** |
| Vendor resubmits after rejection / clarification request | **New codebase (this doc)** |
| Vendor reads issuing authorities / rejection-code reference data | **New codebase (this doc)** |
| Admin reviews, approves, rejects, requests clarification, uploads NOC | VisoAdmin (existing — [LicenseVerificationController](../../app/Http/Controllers/LicenseVerificationController.php), [LicenseSubmissionApiController](../../app/Http/Controllers/Api/LicenseSubmissionApiController.php)) |
| Expiry monitoring dashboard, issuing-authority CRUD | VisoAdmin (existing) |

**Do not** give the vendor app write access to `verified_*` columns, `status=verified`, `rejection_*` columns, `noc_document_url`, or `verified_by`/`verified_at` — those are admin-only and are set exclusively by VisoAdmin. The vendor app only ever writes `status` to `pending` (new submission or resubmission) — never `verified`.

---

## 2. Architecture

```
┌───────────────────────────┐        ┌───────────────────────────┐
│   VENDOR APP CODEBASE      │        │   VISOADMIN (this repo)   │
│   (new — this spec)        │        │   staff review console    │
│                             │        │                             │
│  • Submit license           │        │  • Review queue            │
│  • View status               │        │  • Approve / reject        │
│  • Resubmit                  │        │  • Request clarification   │
│  • Read reference data       │        │  • Upload NOC               │
└──────────────┬──────────────┘        └──────────────┬──────────────┘
               │              writes/reads             │
               ▼                                       ▼
        ┌─────────────────────────────────────────────────────┐
        │              SHARED MySQL DATABASE                   │
        │  trade_license_submissions | service_verifications   │
        │  license_audit_log | issuing_authorities              │
        │  users | vendor_services | services                   │
        └─────────────────────────────────────────────────────┘
               ▲
               │ shared file storage (see §7.1 — must resolve)
        ┌─────────────────────────────┐
        │ license documents / NOC PDFs │
        └─────────────────────────────┘
```

No HTTP calls between the two codebases are needed for this feature — both read/write the same tables directly. The only cross-cutting concern is **file storage** (§7.1), since license documents uploaded by the vendor must be visible to VisoAdmin's review UI and vice versa for the NOC.

---

## 3. Shared database contract

These tables already exist (created by VisoAdmin's migrations). **The new codebase must not migrate/alter them** — only read/write through the columns below. Schema pulled from the live DB (`mvpold_db`) on 2026‑08‑11.

### 3.1 `trade_license_submissions` (the core record — one per license submission)

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `vendor_id` | bigint, FK → `users.id` | **Always scope vendor-app queries to `vendor_id = auth vendor's users.id`.** |
| `license_number` | varchar(255) | Vendor-entered, required |
| `issuing_authority_id` | bigint, FK → `issuing_authorities.id`, nullable | Set when vendor picks from the dropdown |
| `issuing_authority_custom` | varchar(255), nullable | Set when vendor picks "Not listed" instead of an `issuing_authority_id` |
| `declared_expiry_date` | date, nullable | Vendor-declared, **not authoritative** |
| `document_url` | varchar(255), nullable | Path to uploaded license file (see §7.1) |
| `legal_status` | varchar(255), nullable | Free text, e.g. "LLC", "Sole Proprietor" |
| `activity_codes` | json, nullable | Array of strings, vendor-pasted activity codes |
| `verified_business_name` | varchar(255), nullable | **Admin-only — do not write from vendor app** |
| `verified_legal_status` | varchar(255), nullable | **Admin-only** |
| `verified_portal_status` | varchar(255), nullable | **Admin-only** — one of `Active`, `Suspended`, `Expired`, `Cancelled` |
| `verified_expiry_date` | date, nullable | **Admin-only** — authoritative expiry date |
| `noc_document_url` | varchar(255), nullable | **Admin-only** — read-only for vendor app |
| `status` | enum | `pending`, `under_verification`, `clarification_requested`, `verified`, `rejected`, `renewal_pending` — see state machine §5 |
| `rejection_code` | varchar(10), nullable | `REJ-01`…`REJ-07`, **admin-only write, vendor-app read** |
| `rejection_reason` | text, nullable | **admin-only write, vendor-app read** |
| `rejection_scope` | enum(`vendor`,`services`), nullable | **admin-only write, vendor-app read** |
| `clarification_count` | tinyint unsigned, default 0 | Capped at 1 by admin side. Vendor app just reads it. |
| `expiry_reminders_sent` | json, nullable | Owned by VisoAdmin's expiry cron — do not touch |
| `verified_by` / `verified_at` | bigint / timestamp, nullable | **Admin-only** |
| `created_at` / `updated_at` | timestamp | |

### 3.2 `service_verifications` (per-service status — one row per (submission, vendor_service))

A single license can cover **multiple** vendor services (e.g. Photography + Videography both verified off one trade license). Every service the vendor wants covered by a submission needs its own row here.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `vendor_service_id` | bigint, FK → `vendor_services.id` | |
| `submission_id` | bigint, FK → `trade_license_submissions.id` | cascade delete |
| `status` | enum(`pending`,`under_verification`,`verified`,`rejected`) | |
| `rejection_code` / `rejection_reason` | varchar(10) / text, nullable | admin-only |
| `verified_by` / `verified_at` | bigint / timestamp, nullable | admin-only |
| `created_at` / `updated_at` | timestamp | |

Unique constraint on (`vendor_service_id`, `submission_id`) — a service can only appear once per submission.

### 3.3 `license_audit_log` (append-only history — never update or delete rows)

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `submission_id` | bigint, FK | |
| `from_status` | varchar(50), nullable | null on the very first row |
| `to_status` | varchar(50) | required |
| `actor_user_id` | bigint, nullable | the `users.id` who triggered the transition — **for vendor actions, set this to the vendor's own `users.id`**; use `null` only for system/scheduler actions |
| `reason` | varchar(255), nullable | short label, e.g. `REJ-02` or `"Resubmitted by vendor"` |
| `note` | text, nullable | free text |
| `created_at` | timestamp | auto-set, no `updated_at` column exists |

**Every status transition your codebase performs must insert a row here.** VisoAdmin's timeline/history UI reads straight from this table — if the vendor app changes `status` without logging it, the audit trail (and any compliance reporting built on it) silently breaks.

### 3.4 `issuing_authorities` (read-only reference data for the vendor app)

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `name` | varchar(255) | e.g. "Dubai Multi Commodities Centre" |
| `acronym` | varchar(30), unique | e.g. "DMCC" |
| `type` | enum(`mainland`,`free_zone`,`freelance`) | drives which portal link/help text to show |
| `is_active` | tinyint(1), default 1 | filter `is_active = 1` for the dropdown |

CRUD on this table stays admin-only (VisoAdmin already has `POST/PUT/DELETE /api/admin/issuing-authorities`). The vendor app only ever reads it.

### 3.5 Relevant existing tables (already in your shared DB, not new)

- `users` — vendor identity. Relevant columns: `id`, `name`, `company_name`, `phone`, `email_id` (⚠️ **not** `email`), `user_type` (`'vendor'` for vendors).
- `vendor_services` — `id`, `service_id`, `vendor_id`, `description`, … A vendor's list of service listings; this is what `vendor_service_id` in `service_verifications` points to. Note: `vendor_services.license` (mediumtext) is an old free-text field predating this module — unrelated to `trade_license_submissions`, do not conflate the two.
- `services` — `id`, `name`, … the service catalog (`vendor_services.service_id` → `services.id`).

---

## 4. Endpoints to build in the vendor-app codebase

Suggested prefix: `/api/vendor/trade-license/*` — adjust to match your existing route/versioning conventions. All endpoints require your existing vendor auth middleware; resolve `vendor_id` from the authenticated user (`users.id` where `user_type = 'vendor'`), never accept it from the request body.

### 4.1 `GET /trade-license/issuing-authorities`
Read-only dropdown source.

- **Query:** `search` (optional, matches `name`/`acronym`)
- **DB:** `SELECT id, name, acronym, type FROM issuing_authorities WHERE is_active = 1 ORDER BY name`
- **Response 200:**
```json
{ "data": [ { "id": 3, "name": "Dubai Multi Commodities Centre", "acronym": "DMCC", "type": "free_zone" } ] }
```

### 4.2 `GET /trade-license/eligible-services`
Lists the authenticated vendor's own service listings, so they can pick which ones this license covers.

- **DB:** `SELECT vs.id, s.name FROM vendor_services vs JOIN services s ON s.id = vs.service_id WHERE vs.vendor_id = :authVendorId`
- **Response 200:**
```json
{ "data": [ { "vendor_service_id": 12, "name": "Photography" }, { "vendor_service_id": 13, "name": "Videography" } ] }
```

### 4.3 `POST /trade-license/submissions`
Create a new submission. This is the vendor-app equivalent of VisoAdmin's `storeSubmission` (see [LicenseVerificationController.php:374](../../app/Http/Controllers/LicenseVerificationController.php#L374)) — but **accepts multiple services per submission**, matching how the admin's approve/reject flows already work over arrays.

- **Auth:** vendor
- **Validation:**
  - `license_number` — required, string, max 100
  - `issuing_authority_id` — nullable, integer, must exist in `issuing_authorities`
  - `issuing_authority_custom` — nullable, string, max 255 (required if `issuing_authority_id` is empty)
  - `declared_expiry_date` — nullable, date
  - `legal_status` — nullable, string, max 100
  - `activity_codes` — nullable, array of strings
  - `vendor_service_ids` — **required, array, min 1** — each must belong to the authenticated vendor (`vendor_services.vendor_id = authVendorId`), else reject with 422/403
  - `document` — file, mimes `pdf,jpg,jpeg,png`, max 5 MB (match VisoAdmin's limit)
- **Business rules:**
  - Reject if the vendor already has a submission for the **same** `vendor_service_id` in a non-terminal state (`pending`, `under_verification`, `clarification_requested`) — surface a clear 409 telling them to use resubmit instead.
  - Create one `trade_license_submissions` row with `status = 'pending'`.
  - Create one `service_verifications` row per `vendor_service_id`, `status = 'pending'`.
  - Insert `license_audit_log`: `from_status = null`, `to_status = 'pending'`, `actor_user_id = authVendorId`, `reason = 'Submitted by vendor'`.
  - Store the uploaded file per §7.1 and save the resulting path to `document_url`.
- **Response 201:**
```json
{ "message": "Submission created", "data": { "id": 41, "status": "pending" } }
```

### 4.4 `GET /trade-license/submissions`
List the authenticated vendor's own submissions.

- **Query:** `status` (optional filter), `page`/`per_page` or `start`/`length` (match your app's existing pagination style)
- **DB:** filter `WHERE vendor_id = :authVendorId`, left join `issuing_authorities` for the name
- **Response 200:** array of `{ id, license_number, issuing_authority, status, declared_expiry_date, verified_expiry_date, created_at }`

### 4.5 `GET /trade-license/submissions/{id}`
Full detail of one submission — **must verify `vendor_id = authVendorId` or return 403/404**, never trust the path param alone.

- **Response 200:** all safe columns from §3.1 (omit nothing sensitive is exposed here — everything on the row is the vendor's own data) plus the joined `service_verifications` rows (service name, per-service status, rejection code/reason) and the resolved, publicly-fetchable URLs for `document_url` and `noc_document_url` (null if not yet set).
```json
{
  "data": {
    "id": 41,
    "license_number": "DMCC-2024-88213",
    "issuing_authority": "DMCC",
    "status": "clarification_requested",
    "declared_expiry_date": "2026-12-01",
    "verified_expiry_date": null,
    "rejection_code": null,
    "rejection_reason": null,
    "clarification_count": 1,
    "document_url": "https://.../license-documents/xyz.pdf",
    "noc_document_url": null,
    "services": [
      { "vendor_service_id": 12, "name": "Photography", "verification_status": "pending" }
    ],
    "created_at": "2026-08-01T10:00:00Z"
  }
}
```

### 4.6 `POST /trade-license/submissions/{id}/resubmit`
Vendor updates and re-sends a submission that is `rejected` or `clarification_requested`. This is the vendor-side counterpart to admin's reject/clarification actions.

- **Auth:** vendor, must own the submission
- **Preconditions:** 422 if current `status` is not in `['rejected', 'clarification_requested']`
- **Body:** same optional fields as §4.3 (`license_number`, `issuing_authority_id`/`_custom`, `declared_expiry_date`, `legal_status`, `activity_codes`, `document` file) — only provided fields are updated
- **DB writes:**
  - Update the submission row; set `status = 'pending'`; **clear** `rejection_code`, `rejection_reason`, `rejection_scope` (they're stale once resubmitted)
  - Reset every linked `service_verifications` row back to `status = 'pending'` (clear their `rejection_code`/`rejection_reason` too) so the whole submission re-enters the review queue
  - Insert `license_audit_log`: `from_status = <previous>`, `to_status = 'pending'`, `actor_user_id = authVendorId`, `reason = 'Resubmitted by vendor'`
- **Response 200:** `{ "message": "Resubmitted for review", "data": { "id": 41, "status": "pending" } }`

> Do **not** let resubmit touch `clarification_count` — that counter belongs to the admin side (caps how many times *admin* can request clarification, not how many times the vendor can resubmit).

### 4.7 `GET /trade-license/rejection-codes`
Static reference data so the vendor app can render human-readable rejection reasons. Mirror VisoAdmin's list exactly (kept in sync manually — see [LicenseSubmissionApiController.php:15](../../app/Http/Controllers/Api/LicenseSubmissionApiController.php#L15)) so a `REJ-03` shown to a vendor always means the same thing on both sides:

```json
{
  "data": [
    { "code": "REJ-01", "label": "Licence number not found", "reason": "The licence number provided could not be found on the government portal." },
    { "code": "REJ-02", "label": "Licence expired/suspended", "reason": "The trade licence is expired or has been suspended." },
    { "code": "REJ-03", "label": "Number doesn't match form", "reason": "The licence number on the document does not match the number you entered." },
    { "code": "REJ-04", "label": "Not a trade licence", "reason": "The document uploaded does not appear to be a valid trade licence." },
    { "code": "REJ-05", "label": "Document illegible", "reason": "The uploaded document is illegible or too low resolution to verify." },
    { "code": "REJ-06", "label": "Activities don't match OOTBO", "reason": "The activities on your trade licence don't match the services you've offered on OOTBO." },
    { "code": "REJ-07", "label": "Different business entity", "reason": "The business entity on the licence is different from the business registered on OOTBO." }
  ]
}
```

### 4.8 `GET /trade-license/summary`
Lightweight counts for a vendor dashboard badge/notification.

- **DB:** `SELECT status, COUNT(*) FROM trade_license_submissions WHERE vendor_id = :authVendorId GROUP BY status`
- **Response 200:**
```json
{
  "pending": 1,
  "under_verification": 0,
  "clarification_requested": 1,
  "verified": 3,
  "rejected": 0,
  "renewal_pending": 0,
  "needs_attention": 2
}
```
`needs_attention` = count where `status IN ('rejected', 'clarification_requested')` — drives an "action needed" badge in the vendor UI.

---

## 5. State machine (who can trigger what)

```
                 vendor: POST /submissions
                          │
                          ▼
                     ┌─────────┐
        ┌───────────►│ pending │
        │            └────┬────┘
        │                 │ admin picks up for review
        │                 ▼
        │      ┌─────────────────────┐
        │      │ under_verification   │
        │      └──────────┬───────────┘
        │        admin: approve │ reject │ request clarification
        │          ┌────────────┼────────────────┐
        │          ▼            ▼                 ▼
        │     ┌──────────┐ ┌──────────┐  ┌───────────────────────┐
        │     │ verified │ │ rejected │  │ clarification_requested│
        │     └────┬─────┘ └────┬─────┘  └────────────┬────────────┘
        │          │            │                       │
        │   expiry cron    vendor: POST .../resubmit ────┘
        │   (VisoAdmin)         │
        │          ▼            │
        │  ┌──────────────────┐ │
        └──┤ renewal_pending   │◄┘  (resubmit → back to "pending")
           └──────────────────┘
```

- **Vendor app writes:** `pending` only (on create and on resubmit from `rejected`/`clarification_requested`).
- **Admin (VisoAdmin) writes:** `under_verification`, `verified`, `rejected`, `clarification_requested`, `renewal_pending`.
- Vendor app must treat every other status as **read-only**.

---

## 6. File uploads

- Allowed types: `pdf`, `jpg`, `jpeg`, `png`. Max size: **5 MB** (match VisoAdmin's existing limit so behavior is consistent for vendors regardless of which panel they used).
- Store the license document and expose it back as `document_url` on the submission. VisoAdmin's review UI renders this file directly, so wherever it's stored **must be reachable from VisoAdmin's server** — see the storage decision in §7.1 below before writing this code.

---

## 7. Open decisions (confirm before building)

### 7.1 Shared file storage — **must resolve first**

Today, VisoAdmin stores license documents and NOC files on its **local disk** (`storage/app/public/license-documents`, `.../noc-documents`), not on S3 — see [config/filesystems.php](../../config/filesystems.php) (`'public' => driver: 'local'`). If the vendor-app codebase runs on a different server, its uploads won't be visible to VisoAdmin's reviewers, and VisoAdmin's NOC uploads won't be visible to the vendor app, unless one of these is done:

- **Recommended:** point both codebases' upload logic at the **same S3 bucket** (an `s3` disk is already configured in VisoAdmin's `filesystems.php`, currently used for other vendor assets — reuse that bucket or provision a dedicated one for license docs) and store the *same* relative path convention (e.g. `license-documents/{uuid}.pdf`) in `document_url` so either side can resolve it.
- Alternative: if both codebases are deployed on the same host/VM, point both at the same mounted `storage/app/public` path.
- Whichever is chosen, **VisoAdmin's controllers will need a small follow-up change** to resolve `document_url`/`noc_document_url` via the new shared disk instead of the local one — flag this back to the VisoAdmin team once decided.

### 7.2 Duplicate submissions for the same service
§4.3 above assumes one active (non-terminal) submission per `vendor_service_id` at a time. Confirm this matches product intent before building the uniqueness check — if a service can legitimately have two licenses in flight (e.g. a renewal alongside an active one), the 409 rule needs to be relaxed.

---

## 8. Non-goals

Do **not** implement in the vendor-app codebase (these stay in VisoAdmin, unchanged):
- Approve / reject / clarification-request actions (`status → verified/rejected/clarification_requested`)
- NOC document upload
- Expiry monitoring dashboard / cron
- Issuing-authority CRUD (create/update/delete) — vendor app only reads
