# Admin · Brand Top-Up Requests — Frontend Spec

> Owner: Backend  ·  Audience: Admin Dashboard FE  ·  Status: BE shipped & live on staging

This document covers the admin-side workflow for reviewing brand wallet top-ups (both **credit card** and **bank transfer**), changing their status (approve / reject), and the resulting wallet ledger writes that happen automatically server-side.

All endpoints are **already deployed** on `https://sanad.work/staging/api`. No backend work is pending. This is a build spec for the admin dashboard page only.

---

## 1. The two top-up paths — what the admin sees

There are two payment methods a brand can use to top up their wallet. They land in the same `mp_wallet_charges` table but flow differently:

| Payment method | Status when admin sees it | Admin action required? |
|---|---|---|
| `card` (Moyasar) | `approved` from the moment the card charge succeeds | **No** — listed for audit only, no buttons, wallet was credited at payment time |
| `bank_transfer` | `pending` until an admin reviews it | **Yes** — this is the page's primary purpose |

The admin page **must show both** so the team has a complete audit trail. Card rows are "view only"; bank-transfer rows are the actionable ones.

### Status state machine (bank transfer)

```
   Brand submits bank transfer + uploads proof file
                         │
                         ▼
                    ┌─────────┐
                    │ pending │ ◄── Admin sees this in the queue
                    └────┬────┘
                         │
              ┌──────────┴──────────┐
              │                     │
       admin clicks            admin clicks
        "Approve"               "Reject"
              │                     │
              ▼                     ▼
         ┌──────────┐          ┌──────────┐
         │ approved │          │ rejected │
         └──────────┘          └──────────┘
              │                     │
              │                     └─► brand sees rejection_reason
              │                         on their "Top-Up Requests" tab
              │
              └─► wallet credited atomically
                  ledger row written  (mp_wallet_transactions, type=credit, source=top_up)
                  charge.wallet_transaction_id linked
                  brand's balance refreshes on next /brand/wallet poll
```

Card status is always `approved` and never transitions — they bypass the admin entirely.

---

## 2. Endpoints

Base URL: `https://sanad.work/staging/api`
All four require a token from a user with the Spatie `admin` role. Anyone else gets `403 "This endpoint is restricted to platform admins."`

### 2.1 List top-up charges

```http
GET /admin/marketplace/wallet-charges
Authorization: Bearer <admin-token>
```

**Query parameters** (all optional):

| Param | Values | Notes |
|---|---|---|
| `status` | `pending` / `approved` / `rejected` | Tab filter |
| `payment_method` | `bank_transfer` / `card` | Method filter |
| `brand_id` | integer | Filter by a specific brand |
| `from` | ISO date / datetime | `created_at >= from` |
| `to` | ISO date / datetime | `created_at <= to` |
| `page` | integer, default 1 | |
| `per_page` | 1..100, default 25 | |

**Response 200:**

```json
{
  "status": true,
  "data": {
    "items": [
      {
        "id": 12,
        "brand_id": 2,
        "amount": 500,
        "currency": "SAR",
        "payment_method": "bank_transfer",
        "status": "pending",
        "sender_bank_name": "Al Rajhi",
        "sender_account_holder": "Test Brand LLC",
        "proof_file_url": "https://sanad.work/staging/storage/wallet-charge-proofs/abc.png",
        "external_payment_id": null,
        "wallet_transaction_id": null,
        "reviewed_at": null,
        "approved_at": null,
        "rejected_at": null,
        "rejection_reason": null,
        "admin_notes": null,
        "created_at": "2026-04-30 00:11:15",
        "updated_at": "2026-04-30 00:11:15"
      }
    ],
    "meta": {
      "current_page": 1,
      "last_page": 3,
      "per_page": 25,
      "total": 64
    }
  }
}
```

**Card-method row** (for comparison) — same shape, different fields populated:

```json
{
  "id": 8,
  "brand_id": 2,
  "amount": 1000,
  "currency": "SAR",
  "payment_method": "card",
  "status": "approved",
  "sender_bank_name": null,
  "sender_account_holder": null,
  "proof_file_url": null,
  "external_payment_id": "moy_charge_abc123",
  "wallet_transaction_id": 42,
  "reviewed_at": null,
  "approved_at": "2026-04-29 18:02:11",
  "rejected_at": null,
  "rejection_reason": null,
  "created_at": "2026-04-29 18:02:09"
}
```

### 2.2 Get one charge (detail panel)

```http
GET /admin/marketplace/wallet-charges/{id}
Authorization: Bearer <admin-token>
```

**Response 200**: same shape as a single item from §2.1.
**Response 404**: `{ "status": false, "message": "Charge not found.", ... }`

### 2.3 Approve a pending charge → credit the brand wallet

```http
POST /admin/marketplace/wallet-charges/{id}/approve
Authorization: Bearer <admin-token>
Content-Type: application/json

{}
```

No body fields required.

**Response 200:**

```json
{
  "status": true,
  "message": "Charge approved and wallet credited.",
  "data": {
    "id": 12,
    "status": "approved",
    "approved_at": "2026-04-30 00:55:10",
    "wallet_transaction_id": 87,
    "amount": 500,
    "currency": "SAR",
    "...": "...all other fields..."
  }
}
```

**What happens server-side, atomically inside one DB transaction:**

1. Charge row → `status = approved`, `approved_at = now()`, `reviewed_at = now()`
2. New row inserted in `mp_wallet_transactions`:
   `type=credit, source=top_up, amount=<charge.amount>, currency=<charge.currency>, balance_after=<new balance>, related_type=WalletCharge, related_id=<charge.id>, actor_id=<admin user id>`
3. `mp_brand_wallets.balance += amount` (with `lockForUpdate`)
4. Charge row → `wallet_transaction_id` set to the new ledger row's id

Concurrency is safe: two admins clicking Approve at the same moment cannot double-credit — one wins, the other gets a 422.

**Possible non-200 responses:**

| Code | Reason | UI hint |
|---|---|---|
| 401 | No token | Redirect to login |
| 403 | Token not admin role | "You don't have permission to approve top-ups." |
| 404 | Charge id doesn't exist | "Charge not found." (refresh list) |
| 422 | Charge is not in `pending` status (already approved/rejected) | "This charge has already been processed." (refresh row to see new status) |

### 2.4 Reject a pending charge

```http
POST /admin/marketplace/wallet-charges/{id}/reject
Authorization: Bearer <admin-token>
Content-Type: application/json

{
  "rejection_reason": "Bank receipt amount doesn't match requested amount.",
  "admin_notes": "Brand wired 480 SAR for a 500 SAR request. Asked them to wire the 20 SAR delta."
}
```

**Field rules:**

| Field | Required | Validation | Visible to brand? |
|---|---|---|---|
| `rejection_reason` | **Yes** | 3–1000 chars, plain text | **Yes** — shown on brand "Top-Up Requests" tab |
| `admin_notes` | No | ≤ 1000 chars | **No** — internal only, for the admin team |

**Response 200:**

```json
{
  "status": true,
  "message": "Charge rejected.",
  "data": {
    "id": 12,
    "status": "rejected",
    "rejected_at": "2026-04-30 00:57:22",
    "rejection_reason": "Bank receipt amount doesn't match requested amount.",
    "admin_notes": "Brand wired 480 SAR for a 500 SAR request...",
    "wallet_transaction_id": null,
    "...": "..."
  }
}
```

No money moves on reject. The brand's balance is unchanged; their `pending` total drops because the charge is no longer pending.

**Possible non-200 responses:** same 401/403/404/422 set as approve. Plus `422` with field errors if `rejection_reason` is missing or under 3 chars.

---

## 3. Frontend page — recommended structure

### 3.1 Page layout

```
┌─────────────────────────────────────────────────────────────────────┐
│  Brand Top-Up Requests                                              │
│  Review pending bank transfers, approve to credit wallets, or       │
│  reject with a reason. Card top-ups are auto-approved at payment.   │
├─────────────────────────────────────────────────────────────────────┤
│  [ Pending (12) ] [ Approved ] [ Rejected ] [ All ]                 │
│                                                                     │
│  Filters:  [Brand ID]  [From date]  [To date]  [Method ▾]  [Apply] │
├─────────────────────────────────────────────────────────────────────┤
│  ID │ Brand          │ Amount │ Method        │ Submitted │ Status │
│ ────┼────────────────┼────────┼───────────────┼───────────┼─────── │
│  12 │ Test Brand LLC │ 500 SAR│ Bank Transfer │ 2 hr ago  │ ⏳     │
│  11 │ Lina Cosmetics │ 1000 SR│ Card (Moyasar)│ 5 hr ago  │ ✅     │
│  10 │ Acme Café      │ 750 SAR│ Bank Transfer │ Apr 28    │ ❌     │
│  ...                                                                │
├─────────────────────────────────────────────────────────────────────┤
│  ◀ Page 1 of 3 ▶                                                    │
└─────────────────────────────────────────────────────────────────────┘
```

### 3.2 Tabs → query mapping

| Tab | Call |
|---|---|
| `Pending` (default) | `?status=pending` |
| `Approved` | `?status=approved` |
| `Rejected` | `?status=rejected` |
| `All` | no `status` param |

The Pending count badge should come from a small ping to `?status=pending&per_page=1` on page load — read `meta.total`.

### 3.3 Detail side panel

When admin clicks a row → slide-out panel on the right showing:

- **Top section (always)**: Brand name + id, amount + currency, payment method, status badge, submitted-at
- **For `bank_transfer`**:
  - Sender bank name (`sender_bank_name`)
  - Sender account holder (`sender_account_holder`)
  - **Proof image** — render `<img src={proof_file_url} />` clickable to open full-size in a new tab. The URL is publicly accessible (served from Laravel's public disk via `/storage/...`).
- **For `card`**:
  - External payment id (`external_payment_id`) — link to Moyasar dashboard if you have one
  - Approved-at timestamp
  - "Auto-approved at payment time. No admin action available."
- **For `pending` rows**: two buttons at the bottom — `Approve` (primary green) and `Reject` (secondary red)
- **For `approved` rows**: read-only — show `approved_at`, `wallet_transaction_id` (clickable → deep-link to wallet ledger row)
- **For `rejected` rows**: read-only — show `rejection_reason` and `admin_notes` and `rejected_at`

### 3.4 Approve flow

1. User clicks **Approve** → confirmation modal:
   > "Approve top-up of **500 SAR** for **Test Brand LLC**? This will credit their wallet immediately."
   > [Cancel] [Approve & Credit]
2. On confirm → `POST /admin/marketplace/wallet-charges/{id}/approve` with `{}`
3. On 200:
   - Toast: "Charge approved. 500 SAR credited to Test Brand LLC."
   - Update the row inline with the response payload (status flips to ✅, `wallet_transaction_id` populated)
   - Decrement the Pending tab badge
4. On 422 ("already processed"): toast + refresh the row from `GET /{id}` to show the actual current status
5. On 5xx / network: toast + leave the row as-is, button remains clickable

### 3.5 Reject flow

1. User clicks **Reject** → modal with required text area:
   ```
   Reason for rejection (will be shown to the brand)
   [____________________________________]
   * required, 3–1000 characters

   Internal notes (admin team only, optional)
   [____________________________________]
   ```
   [Cancel] [Reject]
2. On confirm → `POST /admin/marketplace/wallet-charges/{id}/reject` with both fields
3. On 200: toast "Charge rejected." + update row inline (status flips to ❌)
4. On 422 with field errors: surface them under the inputs (don't close the modal)

### 3.6 Empty / loading / error states

- **Empty Pending tab**: "No pending top-up requests. You're all caught up." + an icon
- **Loading**: skeleton rows
- **List request fails (5xx)**: full-page error block with a Retry button
- **List request returns 401**: kick to login
- **List request returns 403**: full-page "You don't have permission to view this page" — should never happen if admin role is correctly assigned, but defensive

---

## 4. Auth checklist (one-time setup, do not skip)

The `/admin/marketplace/*` route group is gated by an `admin.only` middleware that requires the Spatie `admin` role. If the FE shows blanket 403s on every endpoint, the cause is usually one of:

1. **Admin user has no Spatie role assigned** → run on the staging DB:
   ```bash
   php artisan tinker --execute="
   \$role = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']);
   \App\Models\User::where('email', 'admin@sanad.biz')->firstOrFail()->assignRole(\$role);
   "
   ```
   Note `guard_name => 'api'` (staging) vs `'web'` (some local setups). Use whichever matches your Spatie config.

2. **Token is from a brand or creator account, not an admin**. Verify by decoding the Sanctum token's owner — admin actions require a user record that has the `admin` role.

3. **`Authorization: Bearer ...` header is being stripped by axios interceptor for these specific routes**. Check Network tab — the header must be present on every `/admin/*` call.

---

## 5. Quick reference — every BE response field

Returned by all four endpoints (`index` items / `show` / `approve` / `reject` data):

| Field | Type | Notes |
|---|---|---|
| `id` | int | Charge id |
| `brand_id` | int | Owning brand user id |
| `amount` | float | In `currency` |
| `currency` | string | Always `"SAR"` today |
| `payment_method` | `"card"` \| `"bank_transfer"` | |
| `status` | `"pending"` \| `"approved"` \| `"rejected"` | |
| `sender_bank_name` | string \| null | Bank-transfer only |
| `sender_account_holder` | string \| null | Bank-transfer only |
| `proof_file_url` | string \| null | Public URL to the uploaded screenshot. Bank-transfer only. |
| `external_payment_id` | string \| null | Card only — Moyasar charge id |
| `wallet_transaction_id` | int \| null | Set on approve (card or bank). Deep-link this to the ledger row. |
| `reviewed_at` | datetime \| null | Set whenever admin acted (approve OR reject) |
| `approved_at` | datetime \| null | |
| `rejected_at` | datetime \| null | |
| `rejection_reason` | string \| null | Brand-visible |
| `admin_notes` | string \| null | Internal only |
| `created_at` | datetime | Brand submitted at |
| `updated_at` | datetime | |

---

## 6. Out of scope (intentionally not included)

- **Editing a charge after the fact** — once approved or rejected, the row is immutable. If a mistake was made, BE will need a separate "admin adjustment" flow against `WalletService::credit/debit` (already exists for the Money Center). Don't try to call approve/reject twice.
- **Bulk approve / bulk reject** — not built. Each charge is one click.
- **Refunds** — not in this module. A future "refund from approved top-up" flow will be a separate ticket.
- **Notifications to the brand on approve/reject** — not auto-sent today. Brand sees the new status next time they load their wallet page. If you want push/email on approve, flag it as a separate ticket.
