# e-Lodge API reference

Base URL: `/api/v1`

All requests and responses are JSON. Send `Accept: application/json` — without
it Laravel may negotiate an HTML error page instead of the JSON envelope.

---

## Response envelope

Every endpoint uses the same shape, so a client can handle responses
generically.

**Success**

```jsonc
{
  "data": { … },        // object, array, or null
  "message": "OK",
  "meta": { … }         // list endpoints only
}
```

**Failure**

```jsonc
{
  "message": "The given data was invalid.",
  "errors": { "email": ["These credentials do not match our records."] },
  "code": "VALIDATION_FAILED"
}
```

`errors` is present only for validation failures. `204 No Content` carries no
body.

### Status codes

| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 204 | Success, no body |
| 401 | Not authenticated |
| 403 | Authenticated, but not permitted |
| 404 | Not found |
| 409 | Well-formed, but not allowed right now (business rule) |
| 422 | Validation failed |
| 429 | Rate limited |
| 500 | Server error |

The 409/422 split matters: **422 means your input was malformed, 409 means the
operation is not permitted in the current state.** Trying to check out with an
unpaid balance is a 409, not a 422 — the request was fine, the world was not.

### Error codes

| `code` | Status | Meaning |
|---|---|---|
| `VALIDATION_FAILED` | 422 | See `errors` |
| `UNAUTHENTICATED` | 401 | No or invalid session/token |
| `FORBIDDEN` | 403 | Role lacks the required module access |
| `NOT_FOUND` | 404 | No such resource |
| `CONFLICT` | 409 | Generic business-rule refusal |
| `ROOM_UNAVAILABLE` | 409 | No room free for those dates |
| `PAYMENT_OPTION_NOT_ALLOWED` | 422 | Plan illegal for this booking's shape |
| `OUTSTANDING_BALANCE` | 409 | Checkout blocked by an unpaid balance |
| `INSUFFICIENT_FUNDS` | 409 | Wallet debit would overdraw |
| `GALLERY_LIMIT_REACHED` | 422 | Gallery is at its configured maximum |
| `UNSUPPORTED_IMAGE_TYPE` | 422 | File is not a JPEG, PNG, WebP or GIF |
| `FILE_TOO_LARGE` | 422 | Image exceeds the configured size cap |
| `GUEST_IDENTITY_REQUIRED` | 422 | Check-in attempted without identifying the guest |
| `RATE_LIMITED` | 429 | Too many requests |
| `SERVER_ERROR` | 500 | Unhandled — details are logged, not returned |

### Pagination

List endpoints accept `page` and `per_page` and return:

```jsonc
"meta": {
  "current_page": 1, "per_page": 25, "total": 143,
  "last_page": 6, "from": 1, "to": 25
}
```

`per_page` is clamped to `elodge.pagination.max_per_page` (default 100). Asking
for 1000 returns 100, not an error.

### Sorting and search

Where supported: `?sort=<column>&direction=asc|desc`. Columns come from a
per-endpoint allow-list; anything else falls back to the default rather than
erroring. `?search=` does a partial match across that resource's natural
fields.

---

## Authentication

Two supported styles:

- **SPA cookie session** — call `GET /sanctum/csrf-cookie` first, then log in
  without `device_name`. The browser carries the session cookie. Requires the
  origin to be listed in `SANCTUM_STATEFUL_DOMAINS`.
- **Bearer token** — log in *with* `device_name` and send
  `Authorization: Bearer <token>`. Intended for mobile and server-to-server.

### `POST /auth/login`

Public. Rate limited per IP + email (`RATE_LIMIT_LOGIN`, default 5/min).

```jsonc
{
  "email": "manager@e-lodge.com",
  "password": "…",
  "device_name": "ios-app",   // optional; issues a token instead of a session
  "remember": false           // optional; session flow only
}
```

**200**

```jsonc
{
  "data": {
    "user": { "id": 2, "name": "Alice Johnson", "email": "…", "role": "manager", … },
    "permissions": { "reservations": "edit", "finance-gl": "none", … },
    "token": "1|abc…"          // only when device_name was sent
  },
  "message": "Signed in."
}
```

`permissions` is the complete nav-module → access-level map. The frontend
should build its sidebar from this rather than reimplementing the rules.

Failures return **422** with a generic message. The response is deliberately
identical for an unknown address and a wrong password, and a hash is always
computed either way, so neither the message nor the timing reveals whether an
address is registered. A correct password on a suspended account also fails.

### `POST /auth/logout`

Authenticated. Revokes the presented token, or destroys the session.

### `GET /auth/me`

Authenticated. Returns the same `user` + `permissions` payload as login.

---

## Public endpoints

No authentication. Rate limited per IP (`RATE_LIMIT_PUBLIC`, default 60/min).

### `GET /public/room-categories`

Active categories with rates, amenities and room counts.

### `GET /public/availability`

| Param | Rules |
|---|---|
| `check_in` | required, `Y-m-d`, today or later |
| `check_out` | required, `Y-m-d`, after `check_in` |

```jsonc
{
  "data": {
    "check_in": "2026-09-15", "check_out": "2026-09-17", "nights": 2,
    "categories": [{
      "category": { "id": 1, "name": "Standard Room", "base_price": 120, … },
      "available": 3, "total_rooms": 20, "nights": 2, "total_price": 240
    }]
  }
}
```

`check_out` is **exclusive** — the 15th to the 17th is two nights, and the room
is free again on the 17th.

### `GET /public/availability/rooms`

Individual free rooms. Params: `check_in`, `check_out`, optional `category_id`,
optional `exclude_stay_id` (so an existing stay does not collide with itself
when being moved or extended).

### `POST /public/availability/payment-options`

Which payment plans a booking of this shape may use. Call it rather than
duplicating the rules client-side.

```jsonc
{ "room_count": 2, "check_in_dates": ["2026-09-15", "2026-09-15"] }
```

```jsonc
{
  "data": {
    "immediate": false,
    "options": [
      { "id": "full",      "label": "Full Payment",       "percent": 100, "description": "…" },
      { "id": "half",      "label": "Half Payment",       "percent": 50,  "description": "…" },
      { "id": "deposit20", "label": "Commitment Deposit", "percent": 20,  "description": "…" }
    ]
  }
}
```

The rules, enforced server-side on every booking:

- **Any room arriving today ⇒ full payment only.** There is no later chance to
  collect before the guest is in the room.
- Two or more future rooms may instead hold the booking on a 20% deposit.
- A single future room may pay half, or nothing until arrival.

### `POST /public/bookings`

Creates a booking from the website. Same body as the authenticated endpoint
below.

### `GET /public/reviews` · `POST /public/reviews`

Published reviews, and submission. Submissions always arrive as `pending`;
`status` cannot be set by the client.

### `POST /public/travel-agents/register`

Agent registration. Always lands as `pending`.

---

## Bookings

### `POST /bookings` — *reservations: edit*

Also available unauthenticated at `POST /public/bookings`.

```jsonc
{
  "guest_id": 12,                       // either this…
  "guest": {                            // …or this
    "full_name": "Ada Obi",
    "email": "ada@example.com",
    "phone": "+2348012345678",
    "billing_profile": "Personal"       // optional
  },
  "rooms": [{
    "category_id": 1,
    "check_in": "2026-09-15",
    "check_out": "2026-09-17",
    "guest_name": "Ada Obi",            // optional; defaults to "Guest TBD"
    "guests": 2,                        // optional
    "room_id": 7                        // optional; front desk pins a room
  }],
  "payment_option": "full",             // full | half | deposit20 | on_arrival
  "payment_method": "Card",             // optional
  "payment_reference": "ch_123",        // optional
  "source": "website",                  // website | ota | walk-in
  "booking_source": "self",             // self | travel-agent
  "travel_agent_id": 3                  // optional
}
```

Validation: 1–10 rooms; `check_in` today or later; `check_out` after
`check_in`; supplying `guest` requires name, email and phone.

An existing guest is matched on email rather than duplicated, so a returning
guest keeps one wallet and one stay history.

**201** returns the booking with its stays and assigned rooms.

Refusals:

- **409 `ROOM_UNAVAILABLE`** — nothing free in that category for those dates.
- **422 `PAYMENT_OPTION_NOT_ALLOWED`** — plan illegal for the booking's shape.
  Enforced here even though the wizard hides the option.

### `GET /bookings` — *reservations: view*

Filters: `search` (reference, guest name, email, phone), `status`, `source`,
`guest_id`, `from`, `to`. Sort: `created_at`, `reference`, `status`.

Each row carries `totals` (`gross`, `paid`, `outstanding`), attached for the
whole page in one aggregate query rather than one per booking.

### `GET /bookings/{booking}` — *reservations: view*

Booking with guest, stays, rooms, folio lines, and a `financials` breakdown.

### `GET /bookings/{booking}/folio` — *guests-billing: view*

The full ledger, oldest first, plus totals.

```jsonc
{
  "data": {
    "lines": [
      { "id": 1, "kind": "room",     "description": "Standard 101 × 2 night(s)", "amount": 240 },
      { "id": 2, "kind": "payment",  "description": "Full Payment (100%)",       "amount": -240,
        "payment_method": "Card" }
    ],
    "financials": {
      "gross": 240, "total_deposits": 240, "total_wallet": 0,
      "total_discounts": 0, "total_other": 0, "outstanding": 0
    }
  }
}
```

Sign convention: **positive is a charge, negative is a credit.**

### `POST /bookings/{booking}/payments` — *guests-billing: edit*

```jsonc
{ "amount": 120.00, "payment_method": "Cash", "reference": "…", "description": "…" }
```

`amount` must be `> 0`. `payment_method` is one of `Cash`, `Card`,
`Bank Transfer`, `POS`, `Wallet`. Returns the new line and updated financials.

### `POST /bookings/{booking}/discounts` — *guests-billing: edit*

```jsonc
{ "amount": 50.00, "reason": "Loyalty adjustment" }
```

Refused with **409** if it would exceed the outstanding balance — a discount
may not put the guest in credit.

### `POST /bookings/{booking}/wallet-payment` — *wallets: edit*

Settles part of the booking from the guest's wallet. The wallet debit and the
folio credit are one transaction: money cannot leave one ledger without
appearing on the other. **409 `INSUFFICIENT_FUNDS`** if the wallet is short.

### `POST /bookings/{booking}/cancel` — *reservations: edit*

```jsonc
{ "reason": "Guest changed plans" }   // optional
```

Releases the room-nights immediately so the inventory is resellable; the stays
remain as `Cancelled` for the record. Refused if any room is still occupied, or
if the booking is already checked out.

### `POST /bookings/{booking}/check-out` — *front-desk: edit*

Checks out the whole booking. Refused with **409** while any room is still
`Occupied` or `Inspecting`, or with **409 `OUTSTANDING_BALANCE`** while
anything is owed. The balance is recomputed from the ledger, never trusted
from the client.

---

## Stays (front desk)

### `GET /stays` — *front-desk: view*

`view=arrivals|departures|in-house` is shorthand for the boards the desk uses.
Also filterable by `status`, `room_id`, `from`, `to`.

### `POST /stays/{stay}/check-in` — *front-desk: edit*

```jsonc
{ "guest_name": "Ada Obi", "id_type": "NIN", "id_number": "12345678901" }
```

`id_type` ∈ `NIN`, `International Passport`, `Driver's License`, `Tax ID`.

The room may have been booked as "Guest TBD", but somebody real is being handed
a key: a missing or still-placeholder name is refused with **422
`GUEST_IDENTITY_REQUIRED`**. Only a `Reserved` stay can be checked in.

Side effects: the stay becomes `Occupied`, the booking becomes `In-House`, and
the room is marked dirty.

### `POST /stays/{stay}/request-checkout` — *front-desk: edit*

Begins departure. Sweeps unsettled department charges for that room number onto
the folio, moves the stay to `Inspecting`, and opens a porter checklist.
Returns `swept_charges`.

### `POST /stays/{stay}/inspection` — *porter: edit*

```jsonc
{
  "penalties": [{ "key": "towels", "flagged": true }],
  "minibar":   [{ "key": "beer",   "consumed": 2 }],
  "notes": "Optional"
}
```

**The payload carries keys and quantities, never prices.** Amounts are re-read
from `config/elodge.php`, and `consumed` is capped at the item's `max_stock`, so
a tampered request cannot change what the guest is charged.

Flagged penalties and consumed minibar items become folio lines. A flagged item
with no configured price posts at zero and is marked `needs_review` for a
manager rather than being dropped. The stay moves to `ClearedForBilling`.

### `POST /stays/{stay}/check-out` — *front-desk: edit*

Checks out one room. Requires `ClearedForBilling`. Closes the booking when its
last room departs.

### `POST /stays/{stay}/transfer` — *front-desk: edit*

```jsonc
{ "room_id": 12, "reason": "Air conditioning fault" }
```

Moves a guest for the remainder of their stay. Old nights are released and
re-claimed against the new room in one transaction, so a failed move leaves the
guest where they were. **409** if the target is not free for the whole stay.

### `POST /stays/{stay}/extend` — *front-desk: edit*

```jsonc
{ "check_out": "2026-09-20" }
```

Charges the extra nights at the category rate. **409 `ROOM_UNAVAILABLE`** if the
room is already booked for part of the extension.

---

## Guests

| Endpoint | Permission |
|---|---|
| `GET /guests` | guests: view |
| `POST /guests` | guests: edit |
| `GET /guests/{guest}` | guests: view |
| `PUT /guests/{guest}` | guests: edit |
| `DELETE /guests/{guest}` | guests: edit |
| `GET /guests/{guest}/bookings` | guests-history: view |
| `GET /guests/{guest}/wallet` | wallets: view |

Search covers name, email, phone and ID number. Deletion is a soft delete and
is refused with **409** for a guest who has bookings — they are part of the
financial record.

---

## Rooms and housekeeping

| Endpoint | Permission |
|---|---|
| `GET /rooms` | rooms: view |
| `GET /rooms/board` | rooms: view |
| `GET /rooms/{room}` | rooms: view |
| `PATCH /rooms/{room}/status` | rooms: edit |
| `POST /rooms` · `PUT` · `DELETE` | hotel-setup: edit |
| `GET /room-categories` | rooms: view |

### `GET /rooms/board`

The housekeeping board for a date (`?date=Y-m-d`, defaults today). One query,
regardless of property size. Each room resolves to a single bucket:

```jsonc
{ "room_number": "101", "status": "occupied", "occupant": "Ada Obi",
  "stay_id": 4, "departs_on": "2026-09-17", "housekeeping_status": "dirty" }
```

`status` ∈ `occupied`, `vacant-clean`, `vacant-dirty`, `maintenance`,
`out-of-service`.

Room configuration is separated from room *state* on purpose: housekeeping can
mark a room clean (`rooms: edit`) without being able to reconfigure the
property (`hotel-setup: edit`).

### `PATCH /rooms/{room}/status`

```jsonc
{ "housekeeping_status": "clean", "operational_status": "available" }
```

Taking an occupied room out of service is refused with **409** — it would
strand the guest.

---

## POS charges

### `POST /charges` — *pos: edit*

```jsonc
{ "room_number": "101", "kind": "bar", "description": "2 × Beer", "amount": 9000 }
```

`kind` ∈ `bar`, `restaurant`, `laundry`, `room-service`, `minibar`, `event`.

If the room is occupied the charge lands on that guest's folio
(`posted_to: "folio"`). If it is not, it becomes a walk-in charge to settle at
the till (`posted_to: "walk-in"`). Either way the charge is recorded — nothing
is dropped because a room lookup missed.

### `GET /charges/walk-ins` · `POST /charges/walk-ins/{id}/settle` — *pos*

Unsettled walk-in charges, and settlement.

---

## Image uploads

| Endpoint | Permission |
|---|---|
| `POST /staff/{staff}/avatar` | staff: edit |
| `DELETE /staff/{staff}/avatar` | staff: edit |
| `POST /rooms/{room}/images` | hotel-setup: edit |
| `DELETE /rooms/{room}/images` | hotel-setup: edit |
| `POST /room-categories/{category}/images` | hotel-setup: edit |
| `DELETE /room-categories/{category}/images` | hotel-setup: edit |

Send `multipart/form-data`: a single `image` for an avatar, an `images[]` array
for a gallery. Gallery uploads **append** to what is already there.

Deleting takes the stored path in the body:

```jsonc
{ "path": "rooms/12/Xn3k....jpg" }
```

The path must already belong to that gallery — otherwise the endpoint would
delete any file on the disk a caller cared to name. A path that does not
belong returns **404**.

Note the permission split: galleries need `hotel-setup: edit`, not the
`rooms: edit` that housekeeping holds. A cleaner can mark a room clean; they
cannot change how the property is advertised.

### What is enforced

Nothing the client says about a file is believed — the filename, the extension
and the `Content-Type` header are all attacker-controlled:

- The stored extension is derived from the **image data itself**
  (`getimagesize`), not from the upload. A PHP script named `payload.jpg` and
  sent as `image/jpeg` fails to decode and is refused with **422**.
- Filenames are discarded and replaced with 40 random characters, so an upload
  can neither overwrite an existing file, nor traverse out of its directory,
  nor be guessed at by URL.
- Only JPEG, PNG, WebP and GIF are accepted. SVG is deliberately excluded: it
  is a document format that can carry script, and serving one from our own
  origin would be stored XSS.
- Size is capped by `ELODGE_MAX_IMAGE_KB` (default 4096) and gallery length by
  `ELODGE_MAX_GALLERY_IMAGES` (default 12) — **422** with
  `GALLERY_LIMIT_REACHED` past the limit.
- Replacing an image writes the new file first and deletes the old one only
  once that succeeds, so a failed upload never leaves a record pointing at
  nothing.

Files go to the `ELODGE_UPLOAD_DISK` disk (default `public`); run
`php artisan storage:link` once so they are served.

---

## Wallets

| Endpoint | Permission |
|---|---|
| `GET /wallets` | wallets: view |
| `GET /wallets/{wallet}/transactions` | wallets: view |
| `POST /wallets/{wallet}/deposits` | wallets: edit |
| `PATCH /wallets/{wallet}/status` | wallets: edit |

Wallets are created on first access. Every movement re-reads the wallet under a
row lock, so concurrent debits cannot overdraw it. Each transaction records
`balance_after`, so a statement can be reprinted exactly as issued.

---

## Dashboard

### `GET /dashboard` — *dashboard: view* (every signed-in role)

`?date=Y-m-d` (default today), `?trend_days=7`.

```jsonc
{
  "data": {
    "summary": {
      "rooms": { "total": 46, "occupied": 12, "vacant_clean": 30,
                 "vacant_dirty": 3, "maintenance": 1, "out_of_service": 0 },
      "occupancy_rate": 26.7, "arrivals_today": 4, "departures_today": 2,
      "in_house": 12, "revenue": { "today": 2400, "month_to_date": 48200, "total": 91000 },
      "adr": 200, "revpar": 53.3, "outstanding_balance": 8400, "average_rating": 4.7
    },
    "categories": [ … ], "revenue_trend": [ … ], "channels": [ … ]
  }
}
```

Definitions, because these are easy to get subtly wrong:

- **Occupancy** — occupied ÷ *sellable* rooms. Rooms under maintenance or out
  of service are excluded from the denominator, not counted as vacant.
- **Revenue** — charges posted. Payments are *collection*, not revenue, and are
  excluded.
- **ADR** — revenue ÷ rooms **sold**.
- **RevPAR** — the same revenue ÷ rooms **available**.
- **Outstanding** — owed across bookings that are not yet closed.
- **Average rating** — published reviews only.

Everything is a database aggregate computed on request. Nothing is hardcoded
and nothing is cached: occupancy and balances change constantly, and a stale
dashboard is worse than a slow one.

### `GET /dashboard/rooms` — *dashboard: view*

Just the room-state counts.

---

## Reviews

| Endpoint | Permission |
|---|---|
| `GET /reviews` | reviews: view |
| `PATCH /reviews/{review}/moderate` | reviews: edit |
| `DELETE /reviews/{review}` | reviews: edit |

`status` ∈ `pending`, `published`, `rejected`. Only `published` reviews reach
the public endpoint or the dashboard average.

---

## Travel agents

| Endpoint | Permission |
|---|---|
| `GET /travel-agents` | travel-agents: view |
| `GET /travel-agents/{agent}` | travel-agents: view |
| `GET /travel-agents/{agent}/bookings` | travel-agents: view |
| `POST /travel-agents/{agent}/decision` | travel-agents: edit |

```jsonc
{ "status": "approved", "commission_rate": 10 }
```

Approval mints a referral code, and keeps an existing one on re-approval so
codes already circulating keep working. The agent's NIN is never returned — it
is collected for verification and has no use in a client view.

---

## Staff and roles

| Endpoint | Permission |
|---|---|
| `GET /staff` | staff-directory: view |
| `POST /staff` · `PUT` · `DELETE` | staff: edit |
| `POST /staff/{staff}/password` | staff: edit |
| `GET /roles` | role-management: view |
| `GET /roles/permission-catalogue` | role-management: view |
| `PUT /roles/{role}/access` | role-management: edit |

Passwords are write-only and must pass `Password::min(10)` with mixed case,
numbers, symbols, and a check against known breached passwords. Resetting a
password revokes that account's tokens. You cannot deactivate or delete your
own account.

### `GET /roles/permission-catalogue`

The full catalogue — every category, its group, the nav-module map, the valid
levels and the write-intent actions. The role-management screen should render
from this rather than hardcoding the matrix.

### `PUT /roles/{role}/access`

```jsonc
{ "access_levels": { "reservations": "edit", "finance-gl": "view" } }
```

Unknown category keys are rejected rather than stored. The `admin` role cannot
be restricted.

---

## Audit logs

### `GET /audit-logs` — *audit-logs: view*

Read-only by design: there is no endpoint to write or amend an entry, because a
trail that can be edited is not a trail. Filters: `search`, `module`, `action`,
`actor_id`, `from`, `to`.

---

## Rate limits

| Group | Default | Keyed on |
|---|---|---|
| `login` | 5/min | IP + email |
| `public` | 60/min | IP |
| `api` | 120/min | user id, else IP |

Exceeding a limit returns **429** with `code: "RATE_LIMITED"`.
