> For the complete documentation index, see [llms.txt](https://docs.phptravels.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.phptravels.com/support/for-developers/adding-a-modules.md).

# Adding a Modules

A complete backend guide to how modules work in PHPTRAVELS v10 and how to add a new **module** or **supplier** to any service (stays, flights, tours, cars, ferries, esim, bus…). This document is backend-focused: routing, database, credentials, search adapters, pagination, markup, currency, booking, payment, invoicing, and admin wiring.

<figure><img src="/files/DNrb5a4SpyIfP31lqVGt" alt=""><figcaption></figcaption></figure>

***

### 1. Two Integration Models — Local Inventory vs Live API

Every service on the platform is powered by one of two model types. **The mechanics are almost identical; the only real difference is where the inventory comes from.**

|                  | **Local Inventory**                        | **Live API Supplier**                                                            |
| ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------- |
| Source of data   | Your own MySQL tables                      | A third-party API                                                                |
| How it's fetched | **Medoo** (`$db->get / select`)            | **cURL** request to the supplier                                                 |
| Credentials      | none                                       | required — stored on the `modules` row (`c1`–`c5`) and sent with every cURL call |
| Example          | `bus`, local `stays`/`hotels`, `cars/cars` | `hotelbeds`, `duffel`, `cartrawler`, `airalo`                                    |
| `modules` row    | one row per service (e.g. `bus`)           | one row per supplier (e.g. `hotelbeds`)                                          |
| Adapter folder   | usually none (query tables inline)         | `modules/{type}/{supplier}/`                                                     |

**Local example** (query the DB directly):

```php
$routes = $db->select('bus_routes', '*', [
    'origin[~]' => $originName,
    'destination[~]' => $destName,
    'status' => '1',
]);
```

**Live-API example** (read credentials from the module row, then cURL):

```php
$module   = $db->get('modules', '*', ['name' => 'cartrawler', 'status' => '1']);
$clientId = $module['c1'] ?? '';   // credential 1
$apiKey   = $module['c2'] ?? '';   // credential 2

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $baseUrl,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $requestBody,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
    CURLOPT_TIMEOUT        => 30,
]);
$apiResponse = curl_exec($ch);
```

Both models ultimately return the **same normalized result shape** to the listing UI, so everything downstream (filters, booking, payment, invoice) is shared and identical.

***

### 2. Architecture Overview — Two PHP Apps

There are **two separate front controllers**, each with its own router:

1. **Main app** — `index.php` (repo root). Handles all normal pages and APIs: home, listing, booking, invoice, admin, `/api/...`. Routes are registered from `app/routes/_routes.php`.
2. **Module sub-app** — `modules/index.php` (+ `modules/.htaccess`). Handles **everything under `/modules/...`**. It has its **own** `Router` instance and `include`s each supplier's `index.php` to register supplier endpoints (search, issue, cancel, etc.). It exposes `$db` (Medoo) and `$pdo` (`$db->pdo`).

> ⚠️ **Critical:** a request to `modules/{type}/{supplier}/search` is served by `modules/index.php`, **not** the main app. If you register a supplier endpoint on the main app router it will never fire for a `/modules/...` URL — it must be included from `modules/index.php`.

**Request lifecycle (booking):** search → listing (aggregate suppliers / query DB) → cache draft (`logs_bookings`) → booking page → submit (create `bookings` row) → payment → callback (confirm + PNR) → invoice / PDF voucher.

***

### 3. Tech Stack, Conventions & Database Connection

* **PHP 8.2**, **Medoo** ORM, **Alpine.js** + **Tailwind** on the front, **MPDF** for PDFs.
* **Medoo `$db`** is created in `config.php` from `.env`:

```php
// config.php
use Medoo\Medoo;
$db = new Medoo([
    'type'     => $env['DB_TYPE'] ?? 'mysql',
    'host'     => $env['DB_HOST'] ?? 'localhost',
    'database' => $env['DB_DATABASE'],
    'username' => $env['DB_USERNAME'],
    'password' => $env['DB_PASSWORD'],
]);
```

`.env` keys: `DB_TYPE, DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD`. Inside the module sub-app, `$pdo = $db->pdo;` gives the raw PDO handle.

* **Translations:** every visible string uses `T::key`. The `T` class **throws a fatal "Undefined constant" if the key does not exist** — the `?? 'fallback'` only covers keys that exist but are null. **Always add new keys to `app/lang/en.json`** (and keep the other language files in sync). See §20.
* **Admin lists** are built with the `crud()` fluent builder (see §19).

***

### 4. The `modules` Table — Registering a Module

Every module/supplier is one row in `modules`. This row controls activation, credentials, markup, currency, and dev/live mode.

Full column reference (`SHOW COLUMNS FROM modules`):

| Column                                     | Type                             | Meaning                                                                                                               |
| ------------------------------------------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `id`                                       | int                              | PK                                                                                                                    |
| `name`                                     | varchar                          | Unique identifier / adapter folder name (`bus`, `hotelbeds`, `cartrawler`)                                            |
| `active`                                   | enum('1','0')                    | Shown on the site                                                                                                     |
| `type`                                     | enum                             | Service group: `flights, stays, tours, cars, bus, rail, cruises, visa, insurance, umrah, hajj, events, esim, ferries` |
| `status`                                   | enum('1','0')                    | API/module enabled                                                                                                    |
| `c1`–`c6`                                  | text                             | Generic credential slots (API key, secret, org id…)                                                                   |
| `dev_mode`                                 | enum('1','0')                    | Sandbox vs production                                                                                                 |
| `payment_mode`                             | enum('1','0')                    | Supplier settle/payment mode flag                                                                                     |
| `currency`                                 | varchar                          | Currency the supplier returns prices in                                                                               |
| `module_color`                             | varchar                          | Tab/badge colour (hex)                                                                                                |
| `order`                                    | int                              | Display order in tabs                                                                                                 |
| `prn_type`                                 | enum('1','0')                    | PNR handling flag                                                                                                     |
| `markup_type_b2c`                          | enum('percentage','fixed')       | Public markup type                                                                                                    |
| `markup_b2c`                               | int                              | Public markup value                                                                                                   |
| `markup_type_b2b`                          | enum('percentage','fixed')       | Agent markup type                                                                                                     |
| `markup_b2b`                               | varchar                          | Agent markup value                                                                                                    |
| `icon`                                     | varchar                          | Material Symbols icon name                                                                                            |
| `check_balance`                            | enum('1','0')                    | Wallet/balance check flag                                                                                             |
| `content_import` / `import_database`       | enum / tinyint                   | Static-content import flags                                                                                           |
| `logging_enabled`                          | tinyint                          | Per-module request logging                                                                                            |
| `host`, `database`, `username`, `password` | varchar                          | For DB-import suppliers (direct DB feed)                                                                              |
| `tax`, `tax_type`                          | int / enum('fixed','percentage') | Per-module tax                                                                                                        |
| `ancillaries_enabled`, `emd_enabled`       | tinyint                          | Flights extras (bags/seats, EMD)                                                                                      |

**Local module row** (one per service):

```sql
INSERT INTO modules (name, type, icon, active, status, currency,
    markup_b2c, markup_type_b2c, `order`)
VALUES ('bus', 'bus', 'directions_bus', 1, 1, 'USD', 5, 'percentage', 15);
```

**Live supplier row** (one per supplier; credentials filled later in the admin):

```sql
INSERT INTO modules (name, type, icon, active, status, dev_mode, currency,
    markup_b2c, markup_type_b2c, `order`)
VALUES ('hotelbeds', 'stays', 'hotel', 1, 1, 1, 'EUR', 8, 'percentage', 2);
```

Active modules are cached: `index.php` loads `$GLOBALS['modules']` via `appCacheRemember('modules:active', 60, …)` (60s APCu cache). New rows appear within a minute (or clear the cache).

***

### 4A. Feature Columns by Module Type — What to Enable for Which Module

The `modules` table is **shared by every service**, so it carries the union of all features. Any single module only uses a subset. The admin edit screen (`app/views/admin/settings/modules/manage.php`) shows **every** toggle, so when you add a module you must enable only the columns that make sense for its **`type`** (and for whether it's local or live). Enabling an irrelevant feature is usually harmless (the code that reads it never runs for that type) but confusing — enabling the **wrong** one (e.g. a supplier DB import for a live-only API) can cause errors.

#### Columns every module uses (all types, local + live)

| Column                                                             | Purpose                                       |
| ------------------------------------------------------------------ | --------------------------------------------- |
| `name`, `type`, `icon`, `module_color`, `order`                    | Identity + how it appears in tabs             |
| `active`                                                           | Show/hide on the public site                  |
| `status`                                                           | Module/API enabled                            |
| `currency`                                                         | Currency the module's prices are in           |
| `markup_b2c` / `markup_type_b2c`, `markup_b2b` / `markup_type_b2b` | Selling markup (public + agent)               |
| `tax` / `tax_type`                                                 | Optional per-module tax                       |
| `payment_mode`                                                     | Enable/disable taking payment for this module |

#### Columns only LIVE-API suppliers use (never for local inventory)

| Column            | Purpose                                           | Notes                                                                   |
| ----------------- | ------------------------------------------------- | ----------------------------------------------------------------------- |
| `c1`–`c6`         | API credentials (key, secret, org id…)            | Labelled per supplier in `modules-settings.php` (§18). Empty for local. |
| `dev_mode`        | Sandbox ↔ production                              | Meaningless for local.                                                  |
| `logging_enabled` | Log API request/response                          | Only meaningful when there **is** an API.                               |
| `check_balance`   | Verify supplier wallet/deposit **before** booking | For GDS/wallet suppliers (e.g. flight consolidators).                   |

#### Type-specific feature columns

| Column                                     | Belongs to TYPE                             | What it does                                                                                                                            |
| ------------------------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ancillaries_enabled`                      | **flights**                                 | Enable ancillary services (extra bags, seat selection). Read by `isAncillariesEnabled()` and the flights booking page.                  |
| `emd_enabled`                              | **flights**                                 | Enable EMD (Electronic Miscellaneous Document) issuance. `isEmdEnabled()`.                                                              |
| `prn_type`                                 | **flights** (and ferries)                   | PNR handling mode for the supplier.                                                                                                     |
| `content_import`                           | **stays**                                   | Enable importing static hotel **content** (names, photos, geo, facilities) from the supplier — separate from live availability/pricing. |
| `import_database`                          | **stays**                                   | Store that imported content in a **separate database** (recommended for large hotel catalogues so it doesn't bloat the main DB).        |
| `host`, `database`, `username`, `password` | **stays** (only when `import_database = 1`) | Connection for that separate content database.                                                                                          |
| Airalo **Packages** tab / rules            | **esim**                                    | Per-country package availability + commission rules (esim-specific admin tab).                                                          |

#### The admin edit tabs (which appear when)

The module edit screen is split into tabs, each mapping to files under `app/views/admin/settings/modules/`:

* **General** (`manage.php`) — identity, `active`/`status`/`dev_mode`/`payment_mode`, plus the feature toggles above. **All types.**
* **Credentials** (`credentials.php`) — the `c1`–`c5` inputs (labelled via `modules-settings.php`) and the **Test** button. **Live suppliers only.**
* **Database** (`database.php`) — `host`/`database`/`username`/`password` for the separate content DB. **Stays suppliers with `content_import` + `import_database`.**
* **Markup** (`markup.php`) — B2C/B2B markup + tax. **All types.**
* **Airalo Packages** (`airalo-packages.php`) — country/package/commission rules. **eSIM (Airalo) only.**

#### Quick "what to enable" by type

* **flights (live GDS/NDC):** credentials `c1…`, `dev_mode`, `logging_enabled`, and as the supplier supports — `ancillaries_enabled`, `emd_enabled`, `prn_type`, `check_balance`. No content/database import.
* **stays (live hotel API):** credentials, `dev_mode`, and if you import static content, `content_import` (+ `import_database` and the DB connection). No ancillaries/EMD/PRN.
* **stays / cars / bus (local inventory):** **no** credentials, `dev_mode`, content/database, ancillaries, EMD, PRN, or check-balance — only `currency`, `markup_*`, `status`, `active`, `payment_mode`.
* **cars / tours / ferries (live):** credentials, `dev_mode`, `logging_enabled`. Ancillaries/ EMD/PRN/content-import do **not** apply.
* **esim (Airalo):** credentials, `dev_mode`, plus the **Packages** tab for country/commission rules. No content-import/ancillaries/PRN.

> **Rule of thumb:** start from the type. `content_import`/`import_database`/DB fields are a **stays** concept; `ancillaries_enabled`/`emd_enabled`/`prn_type` are **flights**; the Packages tab is **esim**. `c1–c6`, `dev_mode`, `logging_enabled`, `check_balance` are **live-supplier** concepts (leave them off for local inventory).

***

### 5. Folder & File Placement (Where Everything Lives)

Three locations matter:

```
app/routes/{type}/            ← main-app routes (registered in app/routes/_routes.php)
    homeRoutes.php            ← module home
    listingRoutes.php         ← results page (aggregates suppliers / queries local tables)
    detailRoutes.php          ← details/seat page (optional)
    bookingRoutes.php         ← cache-draft + /{type}/booking/{hash} + submit
    invoiceRoutes.php         ← /invoice/{type}/{id}
    locationSuggestionRoutes.php (optional autocomplete)

app/views/modules/{type}/     ← view templates
    {type}-search.php         ← search widget
    index.php                 ← module home
    listing/                  ← results view
    booking/index.php         ← checkout page
    invoice/index.php         ← invoice page

modules/{type}/{supplier}/    ← LIVE-API supplier adapter (module sub-app)
    index.php                 ← includes the files below; registers routes
    creds.php                 ← validate credentials (c1–c5)
    search.php                ← the search adapter (paginated)
    details.php / booking.php / issue.php / cancel.php / refund.php / void.php
```

Local modules typically skip `modules/{type}/{supplier}/` entirely (they query tables in `app/routes/api/{type}/listingRoutes.php`). A **local module still creates a `modules/{type}/{name}/index.php`** if it needs admin booking actions (see §17).

***

### 6. URL Structure & Routing

**Main app** uses `vendor/qaxim/php-router`. Routes are registered in `app/routes/_routes.php` (order matters — the router returns on the **first** match).

Typical registration order for a module (note catch-all placement):

```php
require_once 'app/routes/{type}/homeRoutes.php';        // '/{type}/' exact
require_once 'app/routes/{type}/locationSuggestionRoutes.php';
require_once 'app/routes/{type}/detailRoutes.php';      // distinct paths BEFORE catch-all
require_once 'app/routes/{type}/bookingRoutes.php';     // '/{type}/booking/{hash}'  BEFORE catch-all
require_once 'app/routes/{type}/invoiceRoutes.php';     // '/invoice/{type}/{id}'
require_once 'app/routes/{type}/listingRoutes.php';     // CATCH-ALL '/{type}/(.*)'  LAST
```

`.htaccess` rewrites any non-file/non-dir request to `index.php?url=$1`; the router derives the path from `REQUEST_URI` minus the base dir.

**Listing SEO URL examples:**

```
/bus/{origin}/{destination}/oneway/{date}/{adults}-{children}
/bus/{origin}/{destination}/return/{date}/{returnDate}/{adults}-{children}
```

**Module sub-app** (`/modules/...`) has its own router and includes each supplier `index.php`. Supplier endpoints are registered like:

```php
// modules/{type}/{supplier}/index.php
$router->post('{type}/{supplier}/search', function () use ($db) { /* … */ });
$router->post('{type}/{supplier}/cancel', function () use ($db) { /* … */ });
```

Then add one line to `modules/index.php`:

```php
include __DIR__ . '/{type}/{supplier}/index.php';
```

***

### 7. Search / Listing Backend & Supplier Aggregation

The listing results are produced by aggregating **all suppliers of a type** (live) and/or querying **local tables** (local), then merging into one normalized array.

**Supplier aggregation pattern** (from `app/routes/cars/listingRoutes.php`):

```php
$modules = glob("modules/cars/*/search.php");   // every installed car supplier
foreach ($modules as $m) {
    // each supplier's search.php registers modules/cars/{supplier}/search
}
```

The frontend then POSTs to each supplier's search endpoint in parallel: `POST /modules/{type}/{supplier}/search`.

**Supplier `search.php` adapter contract:**

* Reads `$_POST` search params (origin, destination, dates, pax, `page`, `per_page`).
* Reads credentials from its `modules` row (`$db->get('modules', '*', ['name' => '…'])`).
* Calls the supplier API via cURL.
* **Normalizes** each result to the shared shape and applies markup (§9).
* Returns JSON (array of results, or `{results, total, page, total_pages}`).

**Local listing** (from `app/routes/api/bus/listing`) queries tables directly and returns the same shape — no supplier loop:

```php
$routes = $db->select('bus_routes', ['[>]bus' => ['bus_id' => 'id']], [...], [
    'bus_routes.origin[~]' => $originName,
    'bus_routes.destination[~]' => $destName,
    'bus.status' => '1',
]);
```

**Normalized result shape** (what every adapter must return per item): `service_name / operator, origin, destination, times, duration, price (marked-up), currency, image, amenities, rating, source, and an id to book`.

***

### 8. Pagination (Progressive Loading)

Suppliers are paginated **per supplier**, loaded progressively ("load more").

**Backend contract** (supplier `search.php`):

* Accepts `$_POST['page']` (default 1) and `$_POST['per_page']` (default 20–25).
* Returns that page of results, plus either a JSON envelope `{results, total, page, total_pages}` or a plain array, and may set response headers such as `X-Grand-Total`, `X-Current-Page`, `X-Per-Page`.

**Frontend loop** (stays reference, `app/views/modules/stays/listing/stays.php`):

* Keeps `currentPage[supplier]` and `hasMorePages[supplier]`.
* `fetchSupplier(supplier, page)` → `POST modules/stays/{supplier}/search` with `page` + `per_page`.
* `loadMoreHotels()` increments the page for every supplier that still `hasMorePages` and fetches the next page in parallel, appending to the list.
* `hasMore` is derived from `page < total_pages` (envelope) or "got a full page of results" (array) — an empty array means no more pages.

For a **local** module, pagination is simple SQL `LIMIT/OFFSET` inside the listing route, or you can return everything at once if the volume is small.

***

### 9. Markup & Pricing — the `MARKUP()` Function

Selling price = supplier/base price + module markup. Use the shared helper:

```php
// app/lib/functions.php
MARKUP($price, $module, $db, $fromCurrency = null, $toCurrency = null);
```

Behaviour:

* Reads `markup_b2c` / `markup_type_b2c` (public) or `markup_b2b` / `markup_type_b2b` (logged-in **agents**) from the `modules` row.
* Supports **percentage** and **fixed** markup.
* Honours **agent custom markup** (`users.apply_markup = 'custom'` → `markup_value` / `markup_type`) which overrides the module markup.
* Optionally converts currency in the same call (`$fromCurrency` → `$toCurrency`).

`$module` may be the module **name** (it will look up the row) or the already-fetched module **array**. Related helpers: `calculateCompletePrice()` (base + tax + markup) and `CURRENCY_CONVERT()` (see §10).

Local modules apply the same markup. Example (bus listing):

```php
$module = $db->get('modules', ['markup_b2c','markup_type_b2c','currency'], ['type'=>'bus','name'=>'bus']);
$final  = $markupType === 'fixed' ? $price + $markup : $price * (1 + $markup/100);
```

***

### 10. Currency & Conversion

Currencies live in the `currencies` table:

| Column    | Meaning                                               |
| --------- | ----------------------------------------------------- |
| `name`    | 3-letter code (`USD`)                                 |
| `rate`    | Exchange rate **relative to the default currency**    |
| `default` | `1` on exactly one row — the base/settlement currency |
| `status`  | `1/0` active                                          |

**Three currencies to keep straight:**

1. **Module/base currency** — what the supplier returns (`modules.currency`, e.g. supplier returns EUR). Payments are settled in the site **default** currency.
2. **Default currency** — the `currencies` row with `default = 1`.
3. **Display currency** — the visitor's choice, in `$_SESSION['app_currency']`.

**Conversion helper:**

```php
$out = CURRENCY_CONVERT($price, $db, $fromCurrency, $toCurrency);
// $out = ['price' => 123.45, 'converted' => true, 'conversion_rate' => 0.92]
```

Formula: `converted = ($price / fromRate) * toRate` (both rates from `currencies`, `status = 1`). If `from`/`to` are omitted it converts base → session display currency. The invoice summary and booking summary convert **display** amounts on the fly while storing the **base** amount in `bookings.price_markup` / `currency_markup`.

***

### 11. Cache Booking (Draft) — `logs_bookings`

Before a real booking is created, the selection is cached so the booking page has a stable, shareable URL and the checkout survives refreshes.

Flow (all modules):

1. `POST /api/{type}/booking/save-draft` → insert into `logs_bookings`:

   ```php
   $hash = bin2hex(random_bytes(8));   // 16 hex chars
   $db->insert('logs_bookings', ['hash'=>$hash, 'data'=>json_encode($input),
       'user_id'=>$_SESSION['user_id']??null, 'created_at'=>date('Y-m-d H:i:s')]);
   ```
2. Redirect to **`/{type}/booking/{hash}`** — the booking page loads the draft from `logs_bookings` by hash.
3. On confirm, the submit endpoint reads the draft, creates the real `bookings` row, and **deletes the draft**.

`logs_bookings`: `hash`, `data` (JSON draft), `user_id`, `created_at`.

***

### 12. Booking / Checkout Page & Shared Components

The checkout page (`app/views/modules/{type}/booking/index.php`) reuses shared includes — do **not** rewrite these:

* `views/includes/booking/booking-auth.php` — **Guest Details** card (login / guest). **Requires a `$countries` array** defined by the page before the include, or it fatals and silently truncates the rest of the page:

  ```php
  $countries = $db->select('countries', ['iso','nicename','phonecode'],
      ['status' => 'active', 'ORDER' => ['nicename' => 'ASC']]);
  ```
* `views/includes/booking/payment-methods.php` — payment gateway selector (binds `formData.selected_payment`).
* `views/includes/booking/loading.php` — full-page loader (`showBookingLoader`).
* Standard layout: left form column + sticky **Booking Summary** sidebar, a **Booking Options** card (special requests, terms), and the submit button at the page bottom.

Submit → `POST /api/{type}/booking/submit`.

***

### 13. Creating the Real Booking — the `bookings` Table

The submit endpoint validates the guest, computes pricing, and inserts one row into `bookings`. Key columns to set:

| Column                               | Notes                                                                 |
| ------------------------------------ | --------------------------------------------------------------------- |
| `invoice_id`                         | 8-char public id (`strtoupper(substr(bin2hex(random_bytes(4)),0,8))`) |
| `module` / `module_type`             | The service (`bus` / `bus`, or `hotelbeds` / `stays`)                 |
| `booking_status`                     | `pending` → `confirmed` (on payment)                                  |
| `payment_status`                     | `unpaid` → `paid` / `refunded`                                        |
| `price_original`                     | **base** (supplier) total — before markup                             |
| `price_markup`                       | **customer** total — after markup (settlement currency)               |
| `commission`                         | `price_markup − price_original` → this is the admin **Earning**       |
| `currency_markup`                    | Currency of the stored totals                                         |
| `first_name/last_name/email/phone/…` | Lead guest                                                            |
| `adults/childs/infants`              | Pax                                                                   |
| `booking_data`                       | JSON snapshot of the draft (trip, dates, etc.)                        |
| `travellers`                         | JSON passenger list                                                   |
| `pnr`                                | Generated on confirmation (local) or returned by supplier             |
| `payment_gateway`                    | Selected gateway id                                                   |

> **Earnings depend on this:** the admin bookings list shows `commission`. If you store the customer total in both `price_original` and `price_markup`, earning shows `0`. Always set `price_original = base`, `price_markup = customer total`, `commission = difference`.

***

### 14. Payment Flow

1. Invoice page **Make Payment** → `processPayment()` posts to **`/payment/process`** (`invoice_id`, `gateway_id`).
2. `/payment/process` creates a payment token + transaction log and redirects to the secure **`/payment/{hash}`** page, which renders the gateway.
3. The gateway redirects back to the **module-aware callback URL**. The prefix map lives in `app/routes/globalRoutes.php` — add your module:

   ```php
   $_iPrefix = match($_mType) {
       'stays' => 'invoice/stays/', 'flights' => 'invoice/flights/',
       'bus' => 'invoice/bus/', /* … */ default => 'invoice/',
   };
   ```
4. The module invoice route handles `?payment_status=success|cancel|failure&token=…` by calling `handle_payment_callback($token, $action, $extra)` (with `module_type`/`module`), then sets a `$_SESSION['payment_notice']`.
5. On success: mark `paid` + `confirmed`, record `transaction_id`, and **generate the PNR** (local inventory has no supplier PNR, so generate one). Decrement inventory here.

***

### 15. Invoice Page & Shared Summary

`app/routes/{type}/invoiceRoutes.php` → `GET /invoice/{type}/{id}` loads the booking (scoped by `module_type`) and renders `app/views/modules/{type}/invoice/index.php`.

The right column is the **shared** component — include it, don't rebuild it:

```php
<?php $moduleType = '{type}'; include __DIR__ . '/../../../includes/invoice/summary.php'; ?>
```

It renders status chips, totals (with currency conversion), the gateway selector, and the Make Payment / Download Invoice / Resend / Request Cancellation buttons. The view must also define the standard JS the summary calls: `processPayment()`, `downloadInvoice()`, `requestCancellation()` (copy from any module, swap the module name in the endpoints).

***

### 16. PDF Voucher — `GENERATE_BOOKING_PDF()`

`GENERATE_BOOKING_PDF($invoiceId)` (in `app/lib/functions.php`) selects a per-module HTML template and renders it with MPDF to `uploads/invoices/invoice_{id}.pdf`. Add your module to the template map:

```php
} elseif ($moduleType === 'bus') {
    $templatePath = __DIR__ . '/../views/notifications/emails/bus/booking_pdf.php';
}
```

Create `app/views/notifications/emails/{type}/booking_pdf.php` (copy the cars template; it's the simplest). The template receives `$booking`, `$bookingData`, `$travellersData`, `$settings`, `$invoiceId`.

Download route: `GET /api/{type}/booking/download-invoice/{id}` — **clear the output buffer before sending the PDF** (`while (ob_get_level()) ob_end_clean();`) or a stray UTF-8 BOM corrupts the `%PDF-` header.

***

### 17. Module Actions — Issue / Cancel / Void / Refund

The shared admin booking editor (`app/views/admin/bookings/edit.php`) posts action buttons to **`/modules/{module_type}/{module}/{action}`** — handled by the **module sub-app**.

* **Live suppliers** implement these by calling the supplier API (issue ticket, cancel PNR, request refund) — see `modules/cars/cartrawler/cancel.php`, `modules/esim/airalo/{issue,cancel}.php`.
* **Local modules** implement them against the DB. Register the routes on the module sub-app router and add the include to `modules/index.php`:

```php
// modules/{type}/{module}/index.php  →  include actions
$router->post('{type}/{module}/issue',  fn() => busModuleAction($db, 'issue'));
$router->post('{type}/{module}/cancel', fn() => busModuleAction($db, 'cancel'));
$router->post('{type}/{module}/void',   fn() => busModuleAction($db, 'void'));
$router->post('{type}/{module}/refund', fn() => busModuleAction($db, 'refund'));
```

Each handler: admin-guard → load booking (scoped by `module_type`) → mutate status → return JSON `{status:true, success:true, message:…}`. On cancel/void, restore inventory (seats, availability) if it was decremented. `Mark Paid` / `Mark Confirmed` are handled generically by `POST /admin/bookings/action/{invoice_id}` (module-agnostic, already works).

***

### 18. Admin — Settings → Modules (Status, Credentials, Currency, Markup)

**Settings → Modules** (`/admin/settings/modules`, `app/routes/admin/modulesRoutes.php`) is where an admin manages every module row **without SQL**:

* Toggle **active** / **status** (enable the module / its API).
* Toggle **dev\_mode** (sandbox ↔ production).
* Set **currency**, **markup\_b2c/b2b** and their types.
* Enter **credentials** (`c1`–`c5`), test them, and save.

#### Naming the credential fields (c1–c5 rename) — for LIVE suppliers

The generic `c1`–`c5` columns are re-labelled per supplier in **`app/views/admin/settings/modules-settings.php`** via the `$moduleCredentials` map. This is where you tell the admin UI what each credential means for **your** API:

```php
$moduleCredentials = [
  'stays' => [
    'hotelbeds' => [
      'c1' => ['label' => 'API Key', 'required' => true, 'placeholder' => 'Enter your Hotelbeds API Key'],
      'c2' => ['label' => 'Secret',  'required' => true, 'placeholder' => 'Enter your Hotelbeds Secret'],
    ],
    'ratehawk' => [
      'c1' => ['label' => 'Key ID',   'required' => true, 'placeholder' => 'Enter your Ratehawk Key ID'],
      'c2' => ['label' => 'Key Type', 'required' => true, 'placeholder' => 'Enter your Ratehawk Key Type'],
      'c3' => ['label' => 'API Keys', 'required' => true, 'placeholder' => 'Enter your Ratehawk API Keys'],
    ],
    // a LOCAL module has no credentials:
    'hotels' => ['api_crendential' => false, 'api_testing' => false, 'env' => false, 'currency' => false],
  ],
];
```

* Each mapped `cN` renders a labelled input on the module edit screen; unmapped slots are hidden.
* `api_testing` / `env` toggle the "Test credentials" button and sandbox/live switch.
* The **Test** button posts to your `modules/{type}/{supplier}/creds` endpoint (`creds.php`), which validates the keys against the supplier (e.g. requests an OAuth token) and returns success/failure JSON.
* Saved credentials land in `modules.c1…c5`; your `search.php`/`booking.php` read them via `$db->get('modules', '*', ['name' => '{supplier}'])`.

**Local modules** simply set the credential map to `false` (no fields shown) and are configured only for markup/currency/status.

***

### 19. Admin — CRUD Lists, Sidebar, Bookings & Cancellations

* **Admin lists** use the `crud()` builder: `->table()`, `->col()`, `->label()`, `->row()` (string templates or `function($row){…}` formatters), `->actions()`, `->custom_button()`, `->col_width()`, and **`->extra_fetch('col1,col2')`** to make extra columns available to row formatters. Row-format expressions like `{{myHelper(id)}}` are eval'd — the parser skips identifiers that are real PHP functions.
* **Sidebar:** add the module menu, shown when the module is active.
* **Bookings list** (`app/views/admin/bookings/bookings.php`) is module-agnostic. It shows status badges and a **"Cancellation Requested"** badge when `cancellation_request = 1` and `cancellation_status = 0`.
* **Dashboard** counts pending cancellations with `cancellation_request = 1 AND cancellation_status = 0 AND booking_status != 'cancelled'`.

***

### 20. Translations (i18n)

* Use `T::key` everywhere. `T::key ?? 'Fallback'` only guards **null** values — an **undefined** key throws a fatal, blanking the page.
* Add every new key to **`app/lang/en.json`** and mirror it into the other language files (`ar, tr, ru, fr, ch, de, es, it, pl, bg, vn`) so counts stay in sync.
* Audit before shipping: grep each `T::` in your files against `en.json`.

***

### 21. Notifications, Emails & Webhooks

* `NOTIFY::booking($module, $booking, $data)` and `NOTIFY::payment(...)` send the confirmation/payment emails (templates under `app/views/notifications/emails/{type}/`).
* `triggerWebhook('{type}/{event}', '{type}.{event}.name', $payload)` fires outbound webhooks (e.g. `booking.draft_created`, `payment.completed`).

***

### 22. Step-by-Step: Add a LIVE API Supplier

Example: add supplier **`acme`** to **stays**.

1. **Register the module row** (or add it via Settings → Modules → Add):

   ```sql
   INSERT INTO modules (name, type, icon, active, status, dev_mode, currency,
       markup_b2c, markup_type_b2c, `order`)
   VALUES ('acme', 'stays', 'hotel', 1, 1, 1, 'USD', 8, 'percentage', 9);
   ```
2. **Create the adapter folder** `modules/stays/acme/` with:
   * `index.php` — `require` the files below.
   * `creds.php` — register `stays/acme/creds`: validate `c1`/`c2` against the API, return JSON.
   * `search.php` — register `stays/acme/search`: read `$_POST` params + `page`/`per_page`, read `c1…c5` from the module row, cURL the supplier, **normalize + `MARKUP()`**, return paginated JSON.
   * `booking.php` / `issue.php` / `cancel.php` / `refund.php` as needed.
3. **Wire it into the sub-app:** add `include __DIR__ . '/stays/acme/index.php';` to `modules/index.php`.
4. **Label the credentials:** add an `acme` entry under `stays` in `app/views/admin/settings/modules-settings.php` (`c1 => ['label'=>'API Key', …]`).
5. **Confirm the listing aggregates it:** stays listing globs `modules/stays/*/search.php`, so the new supplier is picked up automatically; the frontend fetches `modules/stays/acme/search` per page.
6. **Actions:** implement `issue/cancel/refund` so the admin editor buttons work.
7. **Test:** enter credentials in Settings → Modules → **Test**, run a search, book, pay in sandbox, check the invoice/PDF, and try Cancel/Refund.

**The rest is free** — booking cache, checkout page, payment, invoice, PDF, currency, and markup are shared and already work once `search.php` returns the normalized shape.

***

### 23. Step-by-Step: Add a LOCAL Inventory Module

Example: a new local module **`{type}`** (the bus/local-cars pattern).

1. **Inventory tables** — create your `{type}` tables (+ append to `install/db.sql`), e.g. a listing table, a variants/routes table, a per-date calendar, and a settings table.
2. **Module row** — insert a single local row (`name = {type}`, `type = {type}`, currency, markup). No credentials.
3. **Routes** (`app/routes/{type}/…`): `homeRoutes`, `locationSuggestionRoutes` (optional), `listingRoutes` (catch-all last), `bookingRoutes` (save-draft + `/{type}/booking/{hash}`
   * submit), `invoiceRoutes`; register them in `_routes.php` **before** the catch-all.
4. **Listing API** (`app/routes/api/{type}/listing`): query your tables with Medoo, apply the module markup, return the normalized shape. Guard past/unscheduled dates.
5. **Views** (`app/views/modules/{type}/…`): search widget, listing, booking (reuse shared includes + `$countries`), invoice (reuse `includes/invoice/summary.php`).
6. **Admin** — `crud()` lists for your inventory, sidebar entry, and manage the module's markup/currency via Settings → Modules.
7. **Actions** — create `modules/{type}/{type}/index.php` + `actions.php` registering `issue/cancel/void/refund` on the sub-app router; add the include to `modules/index.php`.
8. **Payment/PNR/PDF** — add your module to the callback prefix map (§14), generate a PNR on payment success, decrement inventory, and add the PDF template (§16).

***

### 24. Testing & Go-Live Checklist

* `php -l` every touched file; validate `app/lang/en.json`.
* All routes registered in `_routes.php`; catch-all is last.
* Every `T::` key exists in all language files.
* Edge cases: past dates, unscheduled dates, sold-out, empty results, expired draft hash, missing guest fields.
* Payment sandbox round-trip → `paid` + `confirmed` + `transaction_id` + PNR + inventory decrement.
* Invoice loads; PDF downloads (valid `%PDF-` header); Make Payment / Resend / Cancel work.
* Admin actions (Issue/Cancel/Void/Refund) return JSON and mutate correctly; cancellation request surfaces on list + dashboard.
* Earnings (`commission`) correct on the bookings list.
* `install/db.sql` contains the new tables + module row for a fresh install.

***

### 25. Appendix — Quick Reference

**Key tables:** `modules` (registration + `c1–c5` + markup), `bookings` (final booking), `logs_bookings` (draft cache), `currencies` (`rate`/`default`), `payment_gateways`, `booking_logs`, plus your module's inventory tables.

**Key files/paths:**

* `config.php` — Medoo `$db`.
* `app/routes/_routes.php` — main-app route registration order.
* `app/routes/{type}/` — module routes.
* `app/views/modules/{type}/` — module views.
* `modules/index.php` — module sub-app (add supplier `include`s here).
* `modules/{type}/{supplier}/` — live adapter (`index/creds/search/booking/issue/cancel`).
* `app/views/admin/settings/modules-settings.php` — **c1–c5 credential labels** per supplier.
* `app/views/includes/booking/*` and `app/views/includes/invoice/summary.php` — shared UI.
* `app/lib/functions.php` — helpers below.

**Helper signatures:**

* `MARKUP($price, $module, $db, $fromCurrency = null, $toCurrency = null)` — apply markup (B2C/B2B/agent) + optional conversion.
* `calculateCompletePrice($basePrice, $moduleName, $db, $from = null, $to = null)` — base + tax + markup.
* `CURRENCY_CONVERT($price, $db, $fromCurrency = null, $toCurrency = null)` → `['price','converted','conversion_rate']`.
* `GENERATE_BOOKING_PDF($invoiceId)` — render the per-module PDF voucher.
* `handle_payment_callback($token, $action, $extra)` — process a gateway callback.

**Normalized listing result (per item):** `source`, `route_id`/`id`, `service_name`/ `operator`, `origin`, `destination`, `departure_time`, `arrival_time`, `duration`, `price` (marked-up), `currency`, `img`, `amenities[]`, `rating`, plus any id needed to build the booking draft.

***

### 26. Technical Reference (Conventions, Constants, Boilerplate)

#### 26.1 Naming conventions

* **Route files:** `{action}Routes.php` in `app/routes/{type}/` — `homeRoutes.php`, `listingRoutes.php`, `detailRoutes.php`, `bookingRoutes.php`, `invoiceRoutes.php`, `locationSuggestionRoutes.php`. API routes live in `app/routes/api/{type}/`.
* **View folders:** `app/views/modules/{type}/` with `{type}-search.php`, `index.php`, `listing/`, `booking/`, `invoice/`, `details/`.
* **Admin views:** `app/views/admin/{type}/` (`{plural}.php` for lists, `manage.php` / `{singular}.php` for forms). PDF templates: `app/views/notifications/emails/{type}/booking_pdf.php`.
* **Supplier adapters:** `modules/{type}/{supplier}/` with `index.php`, `creds.php`, `search.php`, `booking.php`, `issue.php`, `cancel.php`, `refund.php`, `void.php`.
* **Sub-app routes** are named **without** a leading slash and **without** `modules/`: `$router->post('{type}/{supplier}/search', …)`. The public URL is `/modules/{type}/{supplier}/search`.
* **Helper functions:** shared cross-cutting helpers are `UPPER_SNAKE_CASE` (`MARKUP`, `CURRENCY_CONVERT`, `GENERATE_BOOKING_PDF`, `ADMIN_AUTH`, `NOTIFY`), module-local helpers are `camelCase` (`busRoutesForBus`, `busModuleAction`). Always guard definitions with `if (!function_exists('name'))` in files that may be included twice.
* **DB tables:** module inventory is prefixed by type — `bus`, `bus_routes`, `bus_routes_calendar`, `bus_settings`; `stays`, `stays_rooms`, `stays_rooms_calendar`.
* **Translation keys:** `snake_case` in `app/lang/*.json`, referenced as `T::snake_case`.

#### 26.2 Global constants & route-file boilerplate

Defined in `config.php`:

| Constant  | Value                                        | Use                                              |
| --------- | -------------------------------------------- | ------------------------------------------------ |
| `root`    | site base URL (e.g. `http://localhost/v10/`) | build URLs / redirects                           |
| `admin`   | `'admin'`                                    | admin URL segment → `root . admin . '/bookings'` |
| `views`   | `__DIR__ . '/app/views/'`                    | `require_once views . 'includes/header.php'`     |
| `uploads` | `__DIR__ . '/uploads/'`                      | file paths / `uploads` in templates              |

Every route file follows this shape. `$router`, `$db` (Medoo) and `$SECURE` are already in scope (the file is `require_once`'d from `_routes.php`):

```php
<?php
@$SECURE or die('Access Denied!');           // block direct file access

$router->get('/{type}/', function () use ($SECURE, $db) {
    $title = 'Module home';
    require_once views . 'includes/header.php';
    require_once views . 'modules/{type}/index.php';
    require_once views . 'includes/footer.php';
});

$router->post('/api/{type}/listing', function () use ($SECURE, $db) {
    while (ob_get_level()) { ob_end_clean(); }        // clean buffer before JSON
    header('Content-Type: application/json; charset=utf-8');
    // … query / aggregate … echo json_encode([...]); exit(0);
});
```

Admin routes must call `ADMIN_AUTH()` first (see 26.5).

#### 26.3 Router inclusion order (`app/routes/_routes.php`)

Files are `require_once`'d top-to-bottom; the router returns on the **first** matching route, so **specific paths must be included before catch-alls**, and the generic module gateway is last:

```php
// --- {TYPE} MODULE ---
require_once 'app/routes/{type}/homeRoutes.php';              // '/{type}/'  (exact)
require_once 'app/routes/{type}/locationSuggestionRoutes.php';
require_once 'app/routes/{type}/detailRoutes.php';           // distinct paths
require_once 'app/routes/{type}/bookingRoutes.php';          // '/{type}/booking/{hash}'
require_once 'app/routes/{type}/invoiceRoutes.php';          // '/invoice/{type}/{id}'
require_once 'app/routes/{type}/listingRoutes.php';          // CATCH-ALL '/{type}/(.*)'  ← LAST
// … later …
require_once 'app/routes/api/{type}/listingRoutes.php';      // '/api/{type}/listing'
// … globalRoutes.php (generic '/modules/(...)/(...)/(...)' gateway) is required near the end
```

Sub-app: register the supplier include in `modules/index.php` alongside the others:

```php
include __DIR__ . '/{type}/{supplier}/index.php';
```

#### 26.4 Router error / JSON response contract

The **main** router has a global error handler; the **module sub-app** returns a structured JSON 404: `{"error":true,"code":404,"message":"Endpoint not found","path":"…","method":"…"}`. Adapter/action endpoints should always answer with:

```json
{ "status": true,  "success": true,  "message": "…", "pnr": "…" }      // success
{ "status": false, "success": false, "message": "…", "response_error": "…" }  // failure
```

`edit.php`'s `handleAction()` treats `status === true || success === true` as success.

#### 26.5 Security — CSRF & admin auth

* **`ADMIN_AUTH()`** (in `functions.php`) redirects to `login` unless `$_SESSION['user_id']` is set **and** `$_SESSION['user_role'] === 'admin'`. Call it at the top of every admin route.
* For JSON admin endpoints in the sub-app (no redirect), guard inline:

  ```php
  if (empty($_SESSION['admin_logged_in']) && strtolower($_SESSION['user_role'] ?? '') !== 'admin') {
      http_response_code(403);
      echo json_encode(['status'=>false,'success'=>false,'message'=>'Unauthorized']); exit;
  }
  ```
* **CSRF** (`app/lib/Csrf.php`): put `<?= CSRF::tokenField() ?>` in admin forms and verify with `CSRF::validateToken($_POST['csrf_token'])` (also `CSRF::getToken()`, `CSRF::generateToken()`, `CSRF::verifyRequest()`). Public booking/listing AJAX endpoints typically use their own hash/session validation rather than CSRF tokens.

#### 26.6 Session keys & caching

* `$_SESSION['user_id']`, `$_SESSION['user_role']` (`admin` / `agent` / user), `$_SESSION['admin_logged_in']` (true), `$_SESSION['app_currency']` (display currency), `$_SESSION['app_language']` (i18n), `$_SESSION['payment_notice']` (invoice flash), `$_SESSION['booking_data']` / `$_SESSION['booking_hash']` (checkout).
* **Cache helper:** `appCacheRemember($key, $ttl, callable $resolver)` (APCu, seconds). Active modules use key `modules:active` (60s). Clear/expire it after changing module rows.

#### 26.7 `crud()` admin builder — method reference

`echo crud()->table('…') … ->render();` Chainable methods:

```
->table($name)                       // DB table
->title($title)
->col('a,b,c')                       // displayed columns (id auto-added)
->label(['a' => 'A'])                // column headers
->row([                              // per-cell formatter:
    'a' => '<b>{{a}}</b> {{b}}',      //   string template ({{col}}, {{func(col)}} eval'd)
    'b' => function ($row) { … },     //   or a callback returning HTML
])
->relation($col, $table, $display, $fk) // resolve FK to a display column
->where(['status' => '1'])
->order('id', 'DESC')
->col_width('img', '60px')
->extra_fetch('c1,c2')               // fetch extra cols for row callbacks (not displayed)
->actions(['add'=>true,'edit'=>true,'delete'=>false,'status'=>true,
           'featured'=>true,'search'=>true,'view'=>false,'bulk_delete'=>true])
->action_urls(['add'=>'admin/bus/manage','edit'=>root.admin.'/bus/manage/{id}'])
->action_icons([...]) ->id_column('id')
->custom_button(['label'=>'Routes','icon'=>'route','url'=>root.admin.'/bus/manage/{id}',
                 'class'=>'text-blue-600','title'=>'Manage','confirm'=>'Are you sure?'])
->protect([1,2,3])                    // undeletable ids
->render();
```

Notes: displayed columns are validated against real table columns (invalid → exception); the status/featured toggles POST to crud's own `toggle_status` handler; row-format expressions are `eval`'d after substituting `{{col}}` values, and identifiers that are real PHP functions are skipped (so `{{myHelper(id)}}` calls your function).

#### 26.8 Full table schemas (for fresh installs / reference)

**`bookings`** (the final booking record):

```
id, invoice_id, booking_status enum('confirmed','pending','cancelled'),
payment_status enum('paid','unpaid','refunded'), price_original double,
price_markup, agent_earning, tax_type, tax, first_name, last_name, email,
phone_country_code, phone, country, address, adults, infants, childs, child_ages,
currency_markup, paid_at, cancellation_request, cancellation_status, cancellation_response,
booking_data longtext, transaction_id, user_id, user_data, travellers, nationality,
payment_gateway, payment_intent, module_type, pnr, booking_response, error_response,
commission, module, special_requests, promo_codes, created_at, booking_date, updated_at,
booking_payment_issue
```

**`logs_bookings`** (draft cache): `id, hash, data longtext, created_at, updated_at, user_id`.

**`currencies`:** `id, name varchar(3), country, default enum('1','0'), status enum('1','0'), rate`.

**`payment_gateways`:** `id, status, name, c1–c5 text, dev_mode, currency, order, active, note, type enum(credit_card,…,module_gateway), module, default enum('1','0')`. (Gateways use the same `c1–c5` credential pattern as modules; the `module_gateway` type is for supplier-hosted payment.)

**`booking_logs`** (supplier action/error trail): `id, booking_id, action varchar(100), details longtext, created_at`. Referenced by the admin editor's "Booking Issue Logs" panel.

#### 26.9 Code skeletons

**Supplier `search.php`** (`modules/{type}/{supplier}/search.php`):

```php
<?php @$SECURE or die('Access Denied!');
$router->post('{type}/{supplier}/search', function () use ($db) {
    while (ob_get_level()) { ob_end_clean(); }
    header('Content-Type: application/json; charset=utf-8');
    try {
        $module = $db->get('modules', '*', ['name' => '{supplier}', 'status' => '1']);
        $key    = $module['c1'] ?? '';                 // labelled in modules-settings.php
        $page   = max(1, (int)($_POST['page'] ?? 1));
        $per    = min(100, max(1, (int)($_POST['per_page'] ?? 20)));

        $ch = curl_init(/* build URL from $_POST + $module credentials */);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key]]);
        $raw = curl_exec($ch); curl_close($ch);
        $data = json_decode($raw, true) ?: [];

        $results = [];
        foreach (($data['items'] ?? []) as $it) {
            $results[] = [
                'source' => '{supplier}', 'id' => $it['id'],
                'service_name' => $it['name'], /* origin, destination, times, duration … */
                'price'    => MARKUP((float)$it['price'], $module, $db, $module['currency']),
                'currency' => $module['currency'], 'img' => $it['image'] ?? '',
            ];
        }
        echo json_encode(['results' => $results, 'total' => $data['total'] ?? count($results),
            'page' => $page, 'total_pages' => $data['total_pages'] ?? 1]);
        exit;
    } catch (\Throwable $e) {
        echo json_encode(['results' => [], 'error' => $e->getMessage()]); exit;
    }
});
```

**Admin action handler** (`modules/{type}/{module}/actions.php`, local module):

```php
<?php
if (!function_exists('{type}ModuleAction')) {
  function {type}ModuleAction($db, $action) {
    while (ob_get_level()) { ob_end_clean(); }
    header('Content-Type: application/json; charset=utf-8');
    if (empty($_SESSION['admin_logged_in']) && strtolower($_SESSION['user_role'] ?? '') !== 'admin') {
        http_response_code(403); echo json_encode(['status'=>false,'message'=>'Unauthorized']); exit;
    }
    try {
        $inv = trim((string)($_POST['invoice_id'] ?? ''));
        $b   = $db->get('bookings', '*', ['invoice_id'=>$inv, 'module_type'=>'{type}']);
        if (!$b) throw new Exception('Booking not found');
        switch ($action) {
            case 'issue':  /* generate PNR, booking_status=confirmed */ break;
            case 'cancel': /* restore inventory, booking_status=cancelled, cancellation_status=1 */ break;
            case 'void':   /* restore inventory, booking_status=voided */ break;
            case 'refund': /* payment_status=refunded */ break;
        }
        echo json_encode(['status'=>true,'success'=>true,'message'=>'Done']); exit;
    } catch (\Throwable $e) {
        echo json_encode(['status'=>false,'success'=>false,'message'=>$e->getMessage()]); exit;
    }
  }
}
global $router;
foreach (['issue','cancel','void','refund'] as $a)
    $router->post('{type}/{module}/'.$a, fn() => {type}ModuleAction($db, $a));
```

**Admin editor action map** (`app/views/admin/bookings/edit.php`, for reference): buttons post `Mark Paid`/`Mark Confirmed` → `/admin/bookings/action/{invoice_id}` (generic), and `Issue`/`Void`/`Cancel`/`Refund` → `/modules/{module_type}/{module}/{action}` (sub-app).
