# Book Pricing — Internal Storage Update

Migrates how the store prices books internally so it matches Courses,
Training Programs, and Consultations: one dedicated per-country prices
table (`book_prices`) with an `is_default` flag, instead of the old
flat `books.price`/`books.currency_country_id` columns plus the shared
polymorphic `item_price_overrides` table. This is the same migration
Consultations went through on 2026-09-14
(`consultation_prices`), applied to Books.

## Does this affect the frontend? No.

**The request and response shapes are unchanged.** `prices[]` was
already the only way to send book pricing (`BookRequest` already
`prohibited` the flat `price`/`currency_country_id` fields and required
`prices[]` with exactly one `is_default: true` row) — this update just
makes the backend actually *store* it that way instead of splitting it
across a base column and an override table. If you integrated against
the existing Book endpoints, there is nothing to change.

This doc exists for reference (in case anything looks different at the
DB/admin level) and so this internal change is on record the same way
other structural updates are.

## What changed

### Data model

New table `book_prices` (migration
`2026_09_24_143600_create_book_prices_table.php`), one row per country a
book is priced in:

| Column | Type | Meaning |
|---|---|---|
| `book_id` | FK → `books.id`, cascade delete | |
| `country_id` | FK → `countries.id` | |
| `price` | decimal(10,2) | |
| `is_default` | boolean, default `false` | The book's own/base price row — exactly one per book |

`books.price` and `books.currency_country_id` are dropped. The book's
default price now lives as the `is_default = true` row in `book_prices`
instead of on the `books` row itself. `Book::prices()` is the new
`HasMany` relation (same as `Course::prices()` / `TrainingProgram::prices()`
/ `TrainerConsultant::prices()`).

The generic `item_price_overrides` table (still used by nothing else
now that Consultations and Books have both moved off it) no longer
holds book rows.

### API surface (unchanged)

Admin create/update (`POST /PUT /api/v1/admin/books`) still take:

```json
{
  "prices": [
    { "country_id": "<country-uuid>", "price": 120, "is_default": true },
    { "country_id": "<country-uuid>", "price": 450, "is_default": false }
  ]
}
```

Admin show/list (`GET /api/v1/admin/books`, `GET /api/v1/admin/books/{book}`)
still return the same `prices[]` array shape (list of
`country_id`/`country_name`/`currency_code`/`currency_symbol`/`price`/`is_default`)
and the same flat `price`/`currency` summary fields on the list row.

Client catalog/cart/checkout responses (`price`, `price_type`,
`currency`, etc. on `BookCardResource`/`BookDetailsResource`, and cart
line pricing) are resolved the same way as before — same
`base`/`override` semantics, same fallback rules — just read from
`book_prices` instead of `item_price_overrides`.

### Behavior differences worth knowing about (admin/back-office only)

- **Deleting a book now cleanly cascades its price rows** via a real
  foreign key, instead of a manual cleanup step. No observable
  difference in the API response.
- **`item_price_overrides` rows for books were migrated, not dropped
  blind** — the migration backfills `book_prices` from both the old
  `books.price`/`currency_country_id` (→ the `is_default` row) and any
  existing `item_price_overrides` rows for `item_type = 'book'` (→
  ordinary rows), so no book's pricing changes as a result of this
  migration.

## Files touched

- `database/migrations/2026_09_24_143600_create_book_prices_table.php` (new)
- `app/Models/BookPrice.php` (new)
- `database/factories/BookPriceFactory.php` (new)
- `app/Models/Book.php` — `prices()` relation, dropped `currencyCountry()`/`priceOverrides()`/the `price` cast
- `app/Services/Admin/BookService.php` — replace-diff `prices[]` sync into `BookPrice` (same pattern as `CourseService`/`TrainerConsultantService`)
- `app/Services/Books/BookPricingService.php` — resolves from the `prices` relation instead of `item_price_overrides` + flat columns; same public method signature and `BookPrice` value-object output
- `app/Services/Client/BookCatalogService.php`, `app/Services/Client/CartService.php`, `app/Services/Client/BookCheckoutService.php`, `app/Services/Client/GlobalSearchService.php` — eager-load `prices.country` instead of `currencyCountry`
- `app/Http/Controllers/Api/V1/Admin/BookController.php`, `app/Http/Controllers/Api/V1/Client/BookController.php` — `with`/`load` config
- `app/Http/Resources/Admin/Book/BookResource.php`, `app/Http/Resources/Admin/Book/BookDetailsResource.php`, `app/Http/Resources/Client/Book/BookResource.php` — read the `prices` relation
- `app/Support/Books/BookPrice.php` — docblock only (value object, unchanged shape)
- `database/factories/BookFactory.php` — `price`/`currency_country_id` kept as convenience pseudo-attributes that create the default `book_prices` row, for backward-compatible test factories
- `database/seeders/BookSeeder.php` — inserts into `book_prices`
- `docs/postman-collection.json`, `docs/api/{admin,user,trainer,README}.md` — regenerated (`postman:generate`, `postman:markdown`, `postman:frontend-guide`); no schema changes, only fresh example timestamps

## Tests

Updated (still assert the same externally-visible behavior, against
the new table):
`tests/Feature/Books/AdminBookCrudTest.php`,
`tests/Feature/Books/BookCatalogTest.php`,
`tests/Feature/Books/BookPricingTest.php`,
`tests/Feature/Books/BookStoreSchemaTest.php`,
`tests/Feature/Books/BuyNowTest.php`,
`tests/Feature/Books/CartTest.php`,
`tests/Feature/Books/CheckoutTest.php`.

Full suite: 1065 passing before and after this change (same 5
pre-existing, unrelated failures both before and after — verified
against the base branch — none touch Book pricing).
`vendor/bin/pint --dirty` is clean.
