# Books API — Users

Public catalog browsing (guests + users), the physical-book cart (guests + users), checkout, digital "Buy now", كتبي (My library) with the signed PDF reader, طلباتي (My orders), and reviews.

- **Base path:** `{{base_url}}/user` (e.g. `http://localhost:8000/api/v1/user`)
- **Auth:** mixed — see the **Auth** note per endpoint. `Bearer {{user_token}}` where required; the cart also accepts guests
- **Source:** `app/Http/Controllers/Api/V1/Client/{Book,Cart,Library,MyOrder}Controller.php`, `app/Services/Client/*`, `app/Services/Books/*`, `app/Support/PricingCountry.php`

## Conventions

Same envelope conventions as [books-dashboard.md](./books-dashboard.md#conventions) — `{status, message, data}`. Headers on every request: `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`, plus whatever's noted per endpoint below (`X-Guest-Token`, `X-Country`, `Idempotency-Key`).

**Guest cart** — the cart works for anonymous visitors. There is **no bearer token and no `guest_token` query/body param** — the guest cart is addressed purely by the `X-Guest-Token` header:
- No `Authorization` and no `X-Guest-Token` → an empty, ownerless cart (reads only; writes create a guest cart).
- First guest write (`POST cart/items`) returns **201** with the cart, and `CartResource` includes `guest_token` / `guest_token_expires_at` — store that token client-side and send it as `X-Guest-Token` on every later cart call.
- `Authorization: Bearer {{user_token}}` present → always resolves to that user's own cart; any `X-Guest-Token` header is ignored.
- An unknown/expired/already-merged `X-Guest-Token` → `422 GUEST_CART_INVALID`.
- The guest cart's expiry slides forward on every successful call; unattended guest carts are pruned after `books.guest_cart_ttl_days` (default 30) days.

**Pricing country — exact resolution order** (`App\Support\PricingCountry`, used by the catalog, book detail, and cart — this is narrower than the app-wide `GeoContext` chain described elsewhere in the project, so don't assume the same header/profile/IP fallback applies here):
1. `X-Country` header — a country's `short_name` (e.g. `EG`, `SA`), matched case-insensitively.
2. Else `config('geo.default_country')`.
3. `GET /user/cart?address_id=` (signed-in users, their own saved address only) overrides both of the above with that address's country.

There is **no `country_id` query parameter** on any endpoint in this document — sending one has no effect.

### How the catalog list works (index pipeline)

`GET /user/books` runs through the same shared pipeline as the admin list — see [books-dashboard.md § How list endpoints work](./books-dashboard.md#how-list-endpoints-work-index-pipeline) for the full mechanics and pitfalls (translated-field filters 500; `search=` is the correct way to text-search). It additionally always applies `.visible()` — hidden books never appear here regardless of filters.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 404 | *(fail, `Data not found`)* | Book/order uuid doesn't resolve, or a cart line for that book doesn't exist |
| 422 | `NOT_PHYSICAL` | Adding a digital/research book to the cart |
| 422 | `BOOK_UNAVAILABLE` | Adding a book with `is_available=false` to the cart |
| 422 | `INSUFFICIENT_STOCK` | Requested quantity exceeds `stock_quantity` (checked on add/update, re-checked under lock at checkout) |
| 422 | `CART_EMPTY` | Checkout with nothing in the cart |
| 422 | `GUEST_CART_INVALID` | `X-Guest-Token` is unknown, expired, or already merged into an account |
| 422 | *(fail, no code — 422 with the fresh cart as `data`)* | Checkout with items that became un-purchasable between cart and checkout (out of stock, hidden, unavailable) |
| 422 | `NOT_DIGITAL` | `POST books/{book}/purchase` (Buy now) on a physical book |
| 422 | `NOT_PURCHASABLE` | Buy now on a book that fails availability (hidden, out of stock equivalent for digital) |
| 422 | `ALREADY_OWNED` | Buy now on a digital/research book the user already owns |
| 422 | `NOT_ELIGIBLE_FOR_REVIEW` | Reviewing before purchase+delivery (physical) or ownership (digital/research) |
| 422 | `ALREADY_REVIEWED` | A second review from the same user on the same book |
| 403 | *(fail)* | كتبي reader link/file for a book not owned, or an inactive/deactivated account |
| 401 / 403 | *(fail)* | Cart resolved from a bad/expired bearer token, or a non-`user` account type |

---

## Cycle

Two independent purchase flows share the same catalog: **physical** books go through the cart → checkout; **digital books & research** skip the cart entirely via "Buy now".

### A. Browse → buy a physical book

| # | When | Guard | Method & Endpoint |
|---|---|---|---|
| 1 | Books section opens | Public | `GET /user/book-categories` |
| 2 | Category tapped / search | Public | `GET /user/books?filters[content_type]=physical_book&filters[category_id]=...` |
| 3 | Book detail opened | Public | `GET /user/books/{book}` |
| 4 | "Add to cart" | Public (guest or user) | `POST /user/cart/items` — `{book_id, quantity}` |
| 5 | Cart icon / cart screen | Public | `GET /user/cart` |
| 6a | Change quantity | Public | `PUT /user/cart/items/{book}` |
| 6b | Remove a line | Public | `DELETE /user/cart/items/{book}` |
| 6c | Empty the cart | Public | `DELETE /user/cart` |
| 7 | User must sign in to check out (cart persists — a guest cart is merged into the account on login, outside this domain) | User | — |
| 8 | Pick a saved address | User | `GET /user/addresses` *(Addresses domain)* |
| 9 | Checkout | User | `POST /user/cart/checkout` — `{address_id, payment_method?}`, header `Idempotency-Key` |
| 10 | Order confirmation | User | *(response of step 9)* → `OrderDetailsResource` |
| 11 | Order history | User | `GET /user/my-orders` → `GET /user/my-orders/{order}` |

### B. Browse → buy a digital book / research paper

| # | When | Guard | Method & Endpoint |
|---|---|---|---|
| 1–3 | Same browse steps as above, `content_type=digital_book\|research` | Public | `GET /user/books`, `GET /user/books/{book}` |
| 4 | "Buy now" | User | `POST /user/books/{book}/purchase` — `{payment_method?}`, header `Idempotency-Key` |
| 5 | Book appears in كتبي immediately (order auto-delivered) | User | `GET /user/my-library` |
| 6 | Open to read | User | `GET /user/my-library/{book}/read` → returns a short-lived signed URL |
| 7 | Reader fetches the PDF | Signed URL (no bearer) | `GET /user/books/{book}/file?signature=...` |
| 8 | Leave a review (after purchase, either flow) | User | `GET /user/books/{book}/reviews` → `POST /user/books/{book}/reviews` |

---

## Catalog (public)

### `GET /user/book-categories` — Categories with at least one visible book

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `content_type` | enum | `digital_book` | plain (not `filters[...]`-wrapped) equality on `books.content_type`; limits which books count toward "has at least one visible book" |

**200 OK**
```json
{ "status": "success", "message": "Data retrieved successfully", "data": [ { "id": "cat-uuid", "name": "Finance", "image": "https://cdn.example.com/cat.jpg" } ] }
```

### `GET /user/books` — Browse

**Query parameters** (same mechanics as [the admin list](./books-dashboard.md#get-adminbooks--list-books), plus the pricing header):

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | max 100 per page |
| `search` | string | `financial modelling` | `title`/`author`/`description` (translated, current locale) |
| `sort[created_at]` | `asc`\|`desc` | `desc` | or `sort[title]`/`sort[author]`/`sort[description]` (translation-aware) |
| `filters[content_type]` | enum | `digital_book` | `physical_book`\|`digital_book`\|`research` |
| `filters[category_id]` | uuid | `{{category_id}}` | accepts the category **uuid** |
| `filters[is_available]` | `1`\|`0` | `1` | redundant with `is_visible` scoping for most catalog use — books with `is_available=0` still show as browsable but `is_purchasable: false` |
| **Header** `X-Country` | string | `EG` | pricing country (see **Conventions**); omitted → `config('geo.default_country')` |

Always scoped to `visible()` — a hidden book 404s on direct `GET .../{book}` and never appears in this list regardless of filters.

**200 OK** (`BookCardResource` — price/availability resolved for the request's pricing country):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "5b6b6e2a-...-01",
      "title": "Building Your First Model",
      "author": "Dr. Farooq Al-Bateel",
      "description": "A practical course covering financial modelling from first principles.",
      "content_type": "digital_book",
      "cover_image": "https://cdn.example.com/cover.jpg",
      "price": 49.99,
      "price_type": "default",
      "currency": { "code": "USD", "symbol": "$" },
      "in_stock": null,
      "is_purchasable": true,
      "unavailable_reason": null,
      "rating": { "average": 4.5, "count": 12 }
    }
  ]
}
```
`price_type` is `"default"` or `"override"` depending on whether the resolved country has its own `prices[]` row. `in_stock` is `null` for anything but `physical_book`. `unavailable_reason` (e.g. `out_of_stock`, `unavailable`) explains a `false` `is_purchasable` — same vocabulary as the cart's `blocker`/`checkout_blockers`.

### `GET /user/books/{book}` — Detail

**Path variables:** `book`. **Header:** `X-Country` (optional, same as above).

`BookDetailsResource` — the card plus `description`, `category` (`{id, name}` or `null`), and — **only when a valid bearer token is present** — `is_owned` (digital/research: real ownership check; physical: always `false`) and `cart_quantity` (physical only: how many are already in this user's cart). Both keys are entirely absent for a guest request (not `null` — omitted).

### `GET /user/books/{book}/reviews` — Reviews list

**Path variables:** `book` (must be a **visible** book, else 404). **Query parameters:** `page`, `per_page` (max 100) — **no `search`/`filters`/`sort` support**, always newest first.

**200 OK** — wrapped `{"reviews": [ {"id": 1, "rating": 5, "comment": "Great read!", "reviewer": "Sara Ahmed", "created_at": "2026-09-14T07:54:11+00:00"} ], "current_page": 1, "per_page": 15, "total": 1, "last_page": 1 }` (Laravel's standard paginator envelope inside `data`).

### `POST /user/books/{book}/reviews` — Add a review — **User**

**Body:** `{ "rating": 5, "comment": "Great read!" }` — `rating` 1–5 required, `comment` optional (max 2000).

**422 — not eligible yet**
```json
{ "status": "error", "message": "You can review this book only after it is purchased and delivered.", "data": { "error_code": "NOT_ELIGIBLE_FOR_REVIEW" } }
```
**422 — duplicate**
```json
{ "status": "error", "message": "You already reviewed this book.", "data": { "error_code": "ALREADY_REVIEWED" } }
```
**201 Created:** `{ "id": 1, "rating": 5, "comment": "Great read!", "reviewer": "Sara Ahmed", "created_at": "2026-09-14T07:54:11+00:00" }`

---

## Cart (physical books — guest or user)

All five endpoints return the **whole cart** (`CartResource`):
```json
{
  "guest_token": "…64 chars…",
  "guest_token_expires_at": "2026-10-14T07:54:11+00:00",
  "country": { "code": "EG" },
  "currency": { "code": "EGP", "symbol": "ج.م" },
  "items": [
    {
      "book": { "id": "book-uuid", "title": "...", "author": "...", "content_type": "physical_book", "cover_image": "..." },
      "quantity": 2,
      "unit_price": "49.99",
      "line_total": "99.98",
      "available_quantity": 8,
      "blocker": null
    }
  ],
  "items_count": 2,
  "subtotal": "99.98",
  "shipping_fee": "10.00",
  "total": "109.98",
  "checkout_blockers": [],
  "can_checkout": true
}
```
`guest_token*` keys are present only for a guest cart. `blocker` / `checkout_blockers` explain why a line (or the whole cart) can't check out right now (e.g. `out_of_stock`, `unavailable`, `hidden`) — `can_checkout` is `false` whenever any blocker exists, and is **always `false` for guests** (checkout requires signing in — `CartOwner::isUser()`).

### `GET /user/cart` — Show

**Headers:** `X-Guest-Token` (guest carts). **Query:** `address_id` — signed-in users' own saved address only; prices the cart in that address's country instead of the header/default resolution. Sending someone else's/a nonexistent address id is a 422 validation error (`address_id: Choose one of your saved addresses.`), not a 403/404.

### `POST /user/cart/items` — Add a book

**Body:** `{ "book_id": "{{book_id}}", "quantity": 2 }`. Adding more of a line already in the cart increases its quantity (re-checks stock for the new total, not just the delta). **201** on the very first guest write, **200** otherwise.

**422 examples**
```json
{ "status": "error", "message": "Only paper books go in the cart — buy digital books and research directly.", "data": { "error_code": "NOT_PHYSICAL" } }
{ "status": "error", "message": "This book is not available right now.", "data": { "error_code": "BOOK_UNAVAILABLE" } }
{ "status": "error", "message": "Only :count copies of this book are available.", "data": { "error_code": "INSUFFICIENT_STOCK" } }
```

### `PUT /user/cart/items/{book}` — Set a line's quantity

**Path variables:** `book`. **Body:** `{ "quantity": 3 }` (`book_id` is `prohibited` here — the book comes from the URL; sending it is a 422).

### `DELETE /user/cart/items/{book}` — Remove a line

**Path variables:** `book`. No body. **404** if that book isn't in this owner's cart.

### `DELETE /user/cart` — Clear the whole cart

No body.

---

## Checkout & digital purchase

### `POST /user/cart/checkout` — Checkout the physical cart — **User**, `Idempotency-Key` header required

**Body:** `{ "address_id": 12, "payment_method": "wallet" }` — `address_id` (integer, **not** a uuid — the internal id, `exists:addresses,id` scoped to the caller's own addresses) required; `payment_method` optional free-form string (max 50, no enum enforced server-side beyond that).

Runs in one transaction (locks user → cart lines → books, in that order) so stock can never go negative under concurrent checkouts. On success: creates the `Order` (`type=physical`, `status=processing`), decrements stock per line, creates the `Payment` row, and **empties the cart**.

**422 — empty cart**
```json
{ "status": "error", "message": "Your cart is empty.", "data": { "error_code": "CART_EMPTY" } }
```
**422 — a line became un-purchasable since it was added** (no error_code — `fail`, `data` is the fresh cart so the client can re-render it immediately):
```json
{ "status": "fail", "message": "Some books in your cart cannot be bought right now.", "data": { "items": [ "..." ], "checkout_blockers": [ { "book": {"id": "...", "title": "..."}, "reason": "out_of_stock" } ], "can_checkout": false } }
```
**201 Created:** `OrderDetailsResource` — `items[]`, `recipient` (`{name, phone}`, `null` for digital), `address` (rendered from the snapshot taken at checkout — later address edits never change past orders), `payment`, `delivered_at` (`null` until an admin marks it delivered — see [books-dashboard.md](./books-dashboard.md#book-orders)).

### `POST /user/books/{book}/purchase` — Buy now (digital / research) — **User**, `Idempotency-Key` header required

**Path variables:** `book`. **Body:** `{ "payment_method": "wallet" }` (optional, max 50). One book, delivered instantly — creates an `Order` (`type=digital`, `status=delivered`) with no shipping and (if the price is non-zero) an immediate `success` `Payment` row.

**422 examples**
```json
{ "status": "error", "message": "Add this book to your cart instead.", "data": { "error_code": "NOT_DIGITAL" } }
{ "status": "error", "message": "You already own this book.", "data": { "error_code": "ALREADY_OWNED" } }
{ "status": "error", "message": "This book cannot be bought right now.", "data": { "error_code": "NOT_PURCHASABLE" } }
```
**201 Created** (`LibraryItemResource`):
```json
{ "id": "book-uuid", "title": "Building Your First Model", "author": "Dr. Farooq Al-Bateel", "content_type": "digital_book", "cover_image": "...", "has_file": true, "purchased_at": "2026-09-14T07:54:11+00:00" }
```

---

## كتبي — My library — **User**

### `GET /user/my-library` — Every owned digital/research book

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page` | — | — | max 100; **no `paginate=false`/unpaginated option** on this endpoint |
| `filters[content_type]` | enum | `digital_book` | the only filter; **no `search`/`sort`** — always ordered by first-purchase date, newest first |

**200 OK** — wrapped `{"books": [ {"id": "book-uuid", "title": "...", "author": "...", "content_type": "digital_book", "cover_image": "...", "has_file": true, "purchased_at": "2026-09-01T10:00:00+00:00"} ], "current_page": 1, ... }`.

### `GET /user/my-library/{book}/read` — Get a signed reader link

**Path variables:** `book`. **200 OK:** `{ "url": "http://.../user/books/{book}/file?...signature", "expires_at": "2026-09-14T08:04:11+00:00" }` (default 10-minute TTL, `books.reader_url_minutes`).

**403** if the book isn't owned, isn't digital, or has no stored file:
```json
{ "status": "fail", "message": "You do not have access to this book.", "data": null }
```

### `GET /user/books/{book}/file` — Stream the PDF (the signed URL from above — no bearer token)

Re-checks ownership and the account's active status on every request (so a link keeps working only as long as both hold), and answers HTTP Range requests for incremental loading. Not a JSON response — raw `application/pdf`.

---

## طلباتي — My orders (physical only — digital purchases live in كتبي) — **User**

Always scoped to `user_id = <caller>` and `type = physical` — a digital order or another user's order is outside this query and resolves to **404**, never 403.

### `GET /user/my-orders` — List

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | same mechanics as the [admin order list](./books-dashboard.md#get-adminbook-orders--list-orders) |
| `search` | string | `ORD-2026` | across `Order::$searchableFields` — practically only `order_number` is useful |
| `sort[created_at]` | `asc`\|`desc` | `desc` | — |
| `filters[status]` | enum | `processing` | `processing`\|`delivered` |
| `filters[type]`, `filters[user_id]` | — | — | technically listed on the model but pointless here — both are already fixed by the scope above |

### `GET /user/my-orders/{order}` — Detail

**Path variables:** `order`. Same shape as the [admin order detail](./books-dashboard.md#get-adminbook-ordersorder--order-detail) minus admin-only user-contact fields.
