# e-Lodge API

Backend API for the **e-Lodge** hotel property-management system. Laravel 12,
PHP 8.2+, MySQL 8.0 (or MariaDB 10.4+).

This service is the system of record for reservations, guests, folios and
staff permissions. The frontend lives in a separate repository
([stayflow](https://github.com/dakamola-alt/stayflow)) and consumes this API
under `/api/v1`.

---

## Contents

- [Requirements](#requirements)
- [Installation](#installation)
- [Environment configuration](#environment-configuration)
- [Database](#database)
- [Running the application](#running-the-application)
- [Running the tests](#running-the-tests)
- [Architecture](#architecture)
- [Authorization model](#authorization-model)
- [Production deployment](#production-deployment)
- [API documentation](#api-documentation)
- [Frontend integration](docs/frontend-integration.md)

---

## Requirements

| Requirement | Version | Notes |
|---|---|---|
| PHP | 8.2+ | with `pdo_mysql`, `mbstring`, `openssl`, `bcmath` |
| Composer | 2.x | |
| MySQL | 8.0+ | MariaDB 10.4+ also works |

MySQL is not optional. The double-booking guarantee depends on a `UNIQUE`
constraint and on InnoDB's behaviour when one is violated inside a
transaction — see [Architecture](#architecture). SQLite will not reproduce it.

---

## Installation

```bash
git clone <this-repository> elodge-api
cd elodge-api

composer install
cp .env.example .env
php artisan key:generate
```

Then fill in the database credentials in `.env` and continue to
[Database](#database).

---

## Environment configuration

`.env.example` documents every variable. It contains no real credentials and
never should — `.env` is git-ignored.

The settings that matter most:

| Variable | Purpose |
|---|---|
| `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` | Working database. |
| `DB_TEST_DATABASE` | Separate database for the test suite. **Must differ from `DB_DATABASE`** — the suite drops every table it finds, and refuses to start if the two match. |
| `SANCTUM_STATEFUL_DOMAINS` | Hosts allowed to authenticate with the SPA cookie flow. |
| `CORS_ALLOWED_ORIGINS` | Comma-separated origins. Cannot be `*`: the cookie flow sends credentials, and browsers reject a wildcard on a credentialed response. |
| `RATE_LIMIT_API` / `RATE_LIMIT_LOGIN` / `RATE_LIMIT_PUBLIC` | Requests per minute for authenticated, login and public traffic. |
| `SEED_DEMO_PASSWORD` | Password for the seeded demo staff logins. Unused in production, where that seeder refuses to run. |
| `ELODGE_UPLOAD_DISK` / `ELODGE_MAX_IMAGE_KB` / `ELODGE_MAX_GALLERY_IMAGES` | Image storage disk, per-image size cap, and gallery length cap. |

If your database is reachable only from the server (the usual arrangement),
tunnel to it rather than exposing the port:

```bash
ssh -N -L 3307:127.0.0.1:3306 user@your-server
# then set DB_HOST=127.0.0.1 and DB_PORT=3307
```

---

## Database

```bash
# Create the databases (once)
mysql -u root -p -e "CREATE DATABASE elodge CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u root -p -e "CREATE DATABASE elodge_testing CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"

php artisan migrate
php artisan db:seed

# Uploaded images are served from public/storage, so link it once.
php artisan storage:link
```

### What the seeders create

| Seeder | Creates | Runs in production? |
|---|---|---|
| `RoleSeeder` | The 11 built-in roles | yes |
| `PropertySeeder` | 3 room categories, 46 rooms | yes |
| `StaffSeeder` | 5 demo staff logins | **no** — throws if the environment is production |
| `DemoDataSeeder` | Sample guests, bookings, folios, reviews, agents | no |

`PropertySeeder` mirrors the frontend's `mockData.ts` exactly — same
categories, rates and room numbers — so the existing UI shows the same
property it always has, now from the database.

`DemoDataSeeder` creates its bookings **through `BookingService`** rather than
inserting rows. Seed data therefore cannot express a state the API would
reject: rooms are genuinely available, folios balance, and payment plans are
legal for the booking's shape.

All seeders are idempotent.

### Demo logins

Created by `StaffSeeder` with the password from `SEED_DEMO_PASSWORD`:

| Email | Role |
|---|---|
| `admin@e-lodge.com` | admin |
| `manager@e-lodge.com` | manager |
| `supervisor@e-lodge.com` | supervisor |
| `receptionist@e-lodge.com` | receptionist |
| `accountant@e-lodge.com` | accountant |

---

## Running the application

```bash
php artisan serve          # http://localhost:8000
php artisan queue:work     # only if you enable queued work
```

Health check: `GET /api/v1/health`.

---

## Running the tests

```bash
php artisan test                              # everything
php artisan test --filter=DoubleBookingTest   # one class
```

The suite runs against **MySQL**, using `DB_TEST_DATABASE`. `tests/TestCase`
refuses to start if that variable is unset or equal to `DB_DATABASE`, because
`RefreshDatabase` drops every table in the target.

If your database is behind an SSH tunnel, keep it in a reconnect loop — a
dropped tunnel surfaces as a `QueryException` mid-run and looks like a test
failure when it is not:

```bash
while true; do ssh -N -L 3307:127.0.0.1:3306 user@server; sleep 2; done
```

---

## Architecture

```
app/
├── Domain/
│   ├── Access/       AccessResolver, ModuleRegistry (generated)
│   ├── Booking/      BookingService, StayLifecycleService, FolioService,
│   │                 WalletService, AvailabilityService, PaymentOptionPolicy
│   └── Reporting/    DashboardService
├── Enums/            Domain value sets (folio kinds, statuses, payment plans)
├── Http/             Controllers, form requests, resources, middleware
├── Models/           Eloquent models
└── Support/          ApiResponse — the one place response shape is decided
```

Controllers validate, resolve models and delegate. Every rule about what is
*allowed* lives in a domain service, so the same rule applies whether a
request arrives from the SPA, a script, or a seeder.

### Preventing double-booking

MySQL has no range-exclusion constraint, so overlap has to be prevented some
other way. `room_stay_nights` materialises **one row per room per occupied
night** under `UNIQUE(room_id, night_date)`. "Two bookings overlap" becomes
"duplicate key", which the database itself refuses.

Room selection is optimistic rather than lock-based: pick a free room, try to
claim its nights, and let the constraint arbitrate. A duplicate key means
another request won the race, so the next candidate room is tried. Each
attempt runs in a savepoint, so a lost race rolls back its stay row too rather
than leaving an orphan.

This is preferred to locking the category: it never blocks readers, and the
guarantee comes from the database rather than from remembering to take a lock.
The table doubles as the occupancy index — counting rows for a date is far
cheaper than a range join.

### Money

- Every amount is `DECIMAL(15,2)`. Never float.
- **No balance is stored.** A booking's position is `SUM(amount)` over the
  folio lines of its stays, so a total cannot drift from its ledger.
- The folio is **append-only** and sign-conventioned: positive is a charge,
  negative a payment or discount. Corrections are new offsetting lines, never
  edits. `FolioService` owns the sign; nothing else may write a negative.
- Guest wallets are the deliberate exception — they cache a `balance` column
  because it is read constantly and must be lockable. Every mutation re-reads
  the wallet with `SELECT ... FOR UPDATE`, and `derivedBalance()` re-computes
  it from the ledger to prove the two agree.

### Room state

Occupancy is **not** a column on `rooms`. It is a function of the room's stays
over time, so it is derived rather than duplicated — a stored flag would drift
the moment a check-in failed halfway. What *is* stored is housekeeping state
and any operational block, because neither can be inferred from bookings.

### Stay lifecycle

```
Reserved → Occupied → Inspecting → ClearedForBilling → CheckedOut
```

Each transition is atomic because each moves more than one thing. Checking in
updates the stay, the parent booking and the room's housekeeping state.
Requesting checkout sweeps unsettled department charges onto the folio and
opens a porter inspection. Submitting the inspection posts penalties and
minibar consumption, then advances the stay.

Checkout recomputes the balance from the ledger and refuses while anything is
owed or any room is still occupied or under inspection.

---

## Authorization model

The frontend already specifies a precise model in
`src/lib/admin-types.ts`: 110 permission categories, 111 nav modules, and
three access levels (`none` / `view` / `edit`) per role.

`app/Domain/Access/ModuleRegistry.php` is **generated** from that file so the
two cannot silently diverge. Do not hand-edit it — regenerate:

```bash
scripts/sync-access-catalogue.sh /path/to/stayflow
```

`AccessResolver` ports the frontend's resolution order step for step. The
browser copy decides what to draw; this one decides what is permitted.

Routes carry a `module:<key>` gate. Where no level is given, the HTTP verb
decides — reads need `view`, writes need `edit`:

```php
Route::get(...)->middleware('module:reservations');        // view
Route::post(...)->middleware('module:reservations,edit');  // edit
```

Money movement is gated on `guests-billing` rather than `reservations`, so a
role can manage bookings without being able to take payment.

### A known inconsistency in the source data

`DEFAULT_ACCESS_LEVELS_BY_ROLE` contains five keys that match no permission
category: `front-office`, `housekeeping`, `restaurant`, `kitchen` and `bar`.
They are *group* names, and because access is resolved by category those
grants evaluate to nothing.

The visible effect in the existing UI is that a receptionist is shown
"Check-In / Out" in the sidebar (gated by `roleAccess`) while
`canRead('front-desk')` returns false for them (gated by the access levels).
The two mechanisms disagree.

Taken literally, the backend would ship a receptionist who cannot check a
guest in. `AccessResolver::LEGACY_CATEGORY_ALIASES` therefore maps the two
unambiguous cases — `front-office → front-desk`, `housekeeping → rooms` — and
takes the strongest of the direct grant and the alias. `restaurant`, `kitchen`
and `bar` are deliberately left unmapped: they grant only `view`, they name an
area rather than one category, and there is no F&B surface here yet.

**This is worth fixing in the frontend too.**

---

## Production deployment

```bash
composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan event:cache
php artisan migrate --force
```

Checklist:

- `APP_DEBUG=false` and `APP_ENV=production`. With debug off, unhandled
  errors return a generic message and are logged server-side; stack traces,
  SQL and paths are never returned to a client.
- Set `APP_KEY` once and keep it — rotating it invalidates encrypted data.
- Set `CORS_ALLOWED_ORIGINS` and `SANCTUM_STATEFUL_DOMAINS` explicitly.
- Serve over HTTPS. The SPA cookie flow depends on secure cookies.
- Point the web root at `public/`.
- `php artisan db:seed` is safe in production: it seeds roles and rooms, and
  skips the demo logins and sample data.
- Set `LOG_LEVEL=warning` or above, and ship `storage/logs` somewhere durable.
- Never commit `.env`.

---

## API documentation

See **[docs/api.md](docs/api.md)** for the full endpoint reference: methods,
paths, required permissions, request bodies, validation rules, response shapes
and error codes.

See **[docs/frontend-integration.md](docs/frontend-integration.md)** for how
the stayflow PMS consumes this API — what has been wired up, what still reads
from `localStorage`, and a suggested order for the rest.

The response envelope is uniform across every endpoint:

```jsonc
// success
{ "data": …, "message": "OK", "meta": { … } }   // meta on list endpoints

// failure
{ "message": "…", "errors": { "field": ["…"] }, "code": "VALIDATION_FAILED" }
```
