# Booking Window — Activity Service: Architecture & Flow Notes

_Last updated: 2026-08-30. Written after a full read-through of the codebase and `fdk_activity_db.sql`; updated as the performance-fix-plan items below shipped (see `PERFORMANCE_FIX_PLAN.md` for the status table). Keep this file up to date as the code changes — it's meant to save the next person (human or Claude) from re-deriving all of this from scratch._

## 1. What this service is

A Node.js/Express microservice that manages **activity/tour products** (the "FD Activity" product line) for Booking Window: catalog (activities, sub-activities, categories, time slots, guides), inventory (own + multi-supplier with priority fallback), a front-end listing/pricing API (with markup + currency conversion), booking creation/management, a financial transaction ledger, and a GlobalTix third-party supplier integration. It's one service among several (it reaches into sibling databases like `fdk_holidays`, `fdk_hotels`, `fdk_transportation_package` for users/suppliers/markup/transport data).

No ORM. Direct `mysql2` queries via a shared pool, callback-style controllers (`(req, callback)` where `callback(httpStatus, err, responseBody)`), mixed with `async/await` and promise-wrapped pool connections. Matches project instructions: preserve this pattern, don't introduce Sequelize/Prisma/etc.

## 2. Entry point — read this first

**`package.json` → `main`/`start` script points at `bw_activity_manager_api.js`. That is the real, live entry point.**

`index.js` at the repo root is **dead code**: it does `app.use("/", require("./routes/routes"))`, but no `routes/` directory exists anywhere in this repo. It is never started by any npm script. Don't edit it expecting it to affect production behavior; if it's meant to be revived, its `routes/routes.js` needs to be found or rebuilt first.

`bw_activity_manager_api.js` wires Express directly: middleware (body-parser, cors, cookie-parser, static `/uploads`, an optional encrypt/decrypt pair — see §7), then **~65 routes are registered inline** as `app.post('/path', (req,res) => Controller.fn(req, callback))`. There is no separate router file, no route groups — routes and their controller mapping all live in this one file. When adding an endpoint, add it here.

## 3. Layering

```
bw_activity_manager_api.js (routes, inline)
        │
        ▼
controllers/*.js   — request parsing, validation, orchestration, direct SQL
        │
        ▼
services/*.js      — auth/user lookup, markup+currency calc, external API calls, PDF/email
        │
        ▼
database/connection.js (mysql2 pool)  +  external HTTP APIs (FB Admin, GlobalTix)
```

There is **no repository/DAO layer** — controllers execute SQL directly (per project convention, this is intentional, not a gap). Some controllers (`activityController`, `activityInventoryController`, `subActivityController`, `timeSlotController`, `globalTixController`) call services for auth/markup; `webController.js` is the odd one out — it's a 5600-line monolith that contains booking CRUD, listing, payouts, transactions, refunds, and even inline HTML email templates and an SMTP-routing helper, all in one file.

### Two MySQL pools (worth knowing, probably not worth having)
- `database/connection.js` — the pool almost everything uses. `mysql.createPool(...)`, `connectionLimit: 30`.
- `db_connection/mysql_connection.js` — a **second**, separately-created pool with the same credentials, used only by `services/trackerService.js`. Functionally harmless (same DB) but it's an unnecessary duplicate connection pool from what looks like a copy-pasted file. Consolidate onto `database/connection.js` if touching tracking code.

## 4. Database schema (`fdk_activity_db.sql`, database `fdk_activity`)

9 tables, **no `FOREIGN KEY` constraints** — only `PRIMARY`/`UNIQUE`/plain indexes. Relationships are enforced entirely at the application layer.

| Table | Purpose | Key relationships |
|---|---|---|
| `activity` | Master catalog: pricing, schedule, transport config, combo/addon config, supplier info (as JSON) | — |
| `activity_category` | Named categories under an activity (Standard/VIP/Premium/Shared Tour) | `activity_id → activity.id`, unique on `(activity_id, category_name)` |
| `activity_guides` | Guide options per activity, priced by pax range | `activity_id → activity.id` |
| `activity_inventory` | Date-wise **sell-side** stock/price per `(activity_id, category_id, activity_date)` | `activity_id → activity.id`; `category_id` → `activity_category.id` (0 = no category, uses activity-level price); `group_key` links to supplier inventory |
| `activity_supplier_inventory` | Date-wise **buy-side** stock/price, one row per supplier per `(activity_id, group_key, activity_date, category_id, supplier_id)`; `priority` (lower = preferred) drives multi-supplier fallback | `group_key` → `activity_inventory.group_key` |
| `fd_activities_booking` | The actual booking. `reference_type` is a polymorphic discriminator (0=Day Activity,1=Event Activity,2=Hotel,3=Flight,4=Transport,5=Guide,6=Misc) but **in code, only `reference_type = 1` is currently accepted** (`create_booking` hard-rejects anything else). Very wide: sell/purchase price (raw + `_inr`), confirmation/voucher fields, ops/delivery/accounts confirmation flags, cancellation/refund fields, payout flags. | `reference_id` → `activity.id` when `reference_type=1` |
| `booking_transactions` | Financial ledger. `rc_type` enum (REFUND/CANCELLATION/DISCOUNT/MISCS/…/BW_MARKUP/AGENT_MARKUP/…_REVERSE). `transaction_type` DR/CR. | `refrence_id`/`refrence_type` (note: misspelled in the actual schema — `refrence`, not `reference`) point polymorphically; `package_day_activity_id` likely → `fd_activities_booking.id` |
| `sub_activity` | Child items nested under an activity | `activity_id → activity.id` |
| `time_slot` | Time-slot definitions, own polymorphic pair `ref_from` (`'activity'`/`'sub-activity'`) + `ref_id` | — |

**New as of 2026-08-25** (not in `fdk_activity_db.sql`, added via `migrations/2026_08_25b_globaltix_reference_tables.sql`): `globaltix_countries` (`gt_id`, `gt_code`, `name`, `is_listing`) and `globaltix_cities` (`gt_id`, `name`, `gt_country_id`, `gt_country_code` denormalized at sync time, `is_capital`) — a local mirror of GAPI's own country/city reference lists, refreshed via `POST /gt/sync_countries`/`POST /gt/sync_cities`, used to resolve our free-text `activity.country`/`activity.city` values to GAPI's codes/ids for the GlobalTix list merge (see §6.7). An earlier migration, `2026_08_25_globaltix_country_city_map.sql` (a hand-curated `our_value → gt_id` mapping design), is superseded by this one — skip/drop it if it was ever run.

**New as of 2026-08-30** (Out API, §18): `activity_users` (API partners — `user_reference_id` doubles as the `X-API-KEY`) and `activity_markup` (Out API markup/discount rules). **Created by hand on the database, with no DDL anywhere in this repo** — they are not in `fdk_activity_db.sql`, and the two migration files `scripts/apply_out_api_migrations.js` reads do not exist, so nothing in version control can recreate them. See §18.3. `activity_users` also gains `allowed_sources` (per-partner inventory grant, default `'BW'`) via `migrations/2026_08_31_activity_users_allowed_sources.sql` and a widened `password` via `migrations/2026_08_31_activity_users_password_hash.sql` — both **are** in the repo and **both still need applying**. Note that `scripts/apply_out_api_migrations.js` does not read either of them (it still points at the two non-existent `2026_08_30_*` files, §18.3), so they have to be run by hand for now.

**Schema/code mismatch to flag**: `controllers/globalTixWebhookController.js` writes to a table called `${DB_PREFIX}activity_booking` (singular reference/ticket fields: `eticket_url`, `is_ticket_ready`, `ticket_details`, `supplier_ref_no`, `booking_reference_no`). **That table does not exist in `fdk_activity_db.sql`** — only `fd_activities_booking` (different name, different columns) exists. Either the webhook handler is unfinished/untested, or `activity_booking` is a table that exists in production but wasn't included in this schema dump. Worth confirming before relying on GlobalTix webhooks working end-to-end.

## 5. Route map (from `bw_activity_manager_api.js`)

| Prefix | Controller | Covers |
|---|---|---|
| `/activity/*` | `activityController.js` | Activity CRUD, categories, image upload, bulk upload, copy activity, country/city lookups, `get_activity_by_ids` (holiday-package cross-sell lookup w/ markup+currency), vendor-city lookup |
| `/sub_activity/*` | `subActivityController.js` | Sub-activity CRUD |
| `/time_slot/*` | `timeSlotController.js` | Time slot CRUD + bulk create/update by weekday pattern |
| `/inventory/*` | `activityInventoryController.js` | Own inventory CRUD, supplier inventory CRUD, activity guides CRUD |
| `/web/*` | `webController.js` | Front listing (duplicated logic, see §6), sub-activity/time-slot front lookups, **booking CRUD, operations list, payouts, booking transactions, refund status** |
| `/v1/*` | `activityFrontController.js` | Front listing v2 (`getAllActivities`, `getlActivityById` — near-identical to `webController`'s versions), supplier-inventory-for-booking lookup. **`getAllActivities` (`/v1/activity_list`) now also merges in live GlobalTix results via `services/globalTixActivityAdapter.js` — see §6.7.** |
| `/gt/*` | `globalTixController.js` → `services/globalTixService.js` / `globalTixMappingService.js` | GlobalTix (3rd-party ticketing supplier) auth, product catalog, availability, reserve/confirm/release booking, revoke, webhook; `get_countries`/`get_cities` (raw GAPI passthrough) + `sync_countries`/`sync_cities` (mirror into `globaltix_countries`/`globaltix_cities` — see §4, §6.7) |
| `/api_user/*` | `activityUserController.js` | API-partner CRUD for the Out API (§18). **Unauthenticated.** |
| `/activity_markup/*` | `activityMarkupController.js` | Out API markup-rule CRUD (§18). **Unauthenticated.** |
| `/banner/*` | `bannerController.js` | Banner / promo image CRUD for the site (§21) + `front_list` public read. **Unauthenticated.** |
| `/xApi/*` | `xApiController.js` → `services/xApiActivityList.service.js`, `services/xApiActivityDetail.service.js` | Partner-facing Out API. **Its own listing path**, not a wrapper over `/v1` — Bearer-JWT identity from `/xApi/login` (§18.2), net rates (`userId = null`), per-partner source grant, trimmed response contract, `activity_markup` as the only markup layer. See §18. |

## 6. Notable business logic / flows

### 6.1 Inventory pricing waterfall
Three-tier fallback, implemented **inline, separately, in at least 3 places** (`activityFrontController.getAllActivities`/`getlActivityById`, `webController.getAllActivities`/`getMealActivities`/`getlActivityById`, and again in the newer unused `services/activityPricing.service.js`):
1. If the activity has `activity_category` rows → price per category from `activity_inventory` (keyed by `category_id`).
2. Else if there's an `activity_inventory` row for `(activity_id, category_id=0, date)` → activity-level date pricing.
3. Else fall back to the static `adult_price`/`child_price`/`infant_price` on the `activity` row itself.

Availability/close-out uses `close_before_days` (activity- or inventory-level) combined with a `booking_closed` computed column (SQL `CASE`) comparing "now" to a cutoff derived from the activity date minus `close_before_days`, with a 30-minute grace window.

### 6.2 Multi-supplier buy-side
`activity_supplier_inventory` allows multiple suppliers per `(activity_id, group_key, category_id, date)`; `priority` (lower = preferred) and `adult_purchase_price` decide who to book from. `getActivitySupplierInventoryForBooking` (front controller) returns all eligible suppliers sorted by priority then price, each with its own cancellation policy (joined from `fdk_holidays.supplier_cancellation_policies`/`_items`).

### 6.3 Markup + currency conversion
`services/globalService.js` is the shared pricing-adjustment engine:
- `calculateCountryGlobalMarkup` / `calculateCountryGlobalMarkupActivity` / `calculateCountryGlobalMarkupTransport` — resolves markup from **user's markup group first, country-pair markup second** (both can stack in the `...Activity`/`...Transport` variants — they sum group + country markup; the older `calculateCountryGlobalMarkup` only uses one or the other, "group OR country").
- Results are cached in-memory (`Map`, 60s TTL) per user/country pair, **and, as of the §6 fix, also in Redis** (`services/redisClient.js`, best-effort — any Redis error or unavailability falls straight through to the DB, never throws) so the cache is now shared across instances/replicas instead of drifting. TTL kept at 60s (unchanged) pending confirmation from whoever owns markup/currency data before extending it.
- `convertCurrency(from, to, amount)` — looks up `fdk_transportation_package.currency` (base rates) and `fdk_transportation_package.vendor_currency_settings` (a currency-specific surcharge %), cached the same way (now also L2 Redis).
- Applied per-activity, per-category, per-addon, per-guide, per-transport-vehicle in the listing endpoints. **As of the §7 partial fix**, the three `activityFrontController.js` copies of this pipeline (`getAllActivities`/`getlActivityById`/`getActivityHolidaybyIds`) are no longer separately maintained — they now share one implementation via `createActivityProcessor(...)` in `services/activityMarkupPipeline.service.js`. `webController.js` still has its own, deliberately simpler, independent copies (confirmed intentional — different consumer, not drift — see `MARKUP_PIPELINE_DEDUP_FINDINGS.md`).

### 6.4 Booking creation (`webController.create_booking`, `/web/create_booking`)
1. Resolves SMTP "from" address / branding by request `Origin`/`Referer` hostname (fdking.com vs bookingwindow.com vs default).
2. Only `reference_type = 1` (activity) is accepted; anything else is rejected.
3. **Wallet balance check**: reads `fdk_holidays.website_accounts.available_balance` for the agent and compares to `total_price`. This is a read-only check — no row lock/transaction around it, so two concurrent bookings from the same agent could both pass the check before either deduction lands (the deduction itself happens via an external API call — see next point — so the actual source of truth may enforce it server-side, but that should be verified rather than assumed).
4. Duplicate-booking guard: same `reference_id` + `agent_id` + `price` within the last 1 minute → rejected.
5. Generates a random `booking_ref_no` (`ACT` + 6 random base36 chars), retrying until unique.
6. Inserts into `fd_activities_booking`, bumps `activity.score` (used for `ORDER BY score DESC` in listings — a simple popularity signal).
7. **Money actually moves via an external HTTP call**: `DiductbalanceInWebsiteUserWallet()` (a local helper in `webController.js`, not the unused `controllers/wallet.js`) calls `services/accountService.insertAccountTransaction()`, which POSTs to `${Config.FB_ADMIN_API_URL}/v1/account/insert` with retry/backoff (3 attempts, exponential). **This service does not itself own wallet balance mutation — it delegates to a separate "FB Admin" backend.** If that call fails after retries, the booking row still exists (already committed) but the ledger/balance update may not have happened — no rollback of the booking on ledger failure.
8. Fires-and-forgets (`setImmediate`) a PDF voucher generation (Puppeteer, `services/bookingPdfService.js`) + confirmation email (`nodemailer`) to the agent + staff (not the guest).

### 6.5 Booking listing/ops/payouts
`get_booking_list`, `get_operations_list`, `listActivitiesPayouts` are three separate, very similar large query-builders over `fd_activities_booking` (joined to `fdk_holidays.website_users`/`staffs`), differing mainly in which status flags they filter on (`ops_confirmed`, `create_payout`, `payout_status`) and role-based city/country restriction (`assigned_cities`/`assigned_countries` on the authenticated user, via `FIND_IN_SET`). `get_booking_list` also returns an `analytics` block (total/confirmed/pending/cancelled counts, revenue, cost, profit) computed in the same query pass.

### 6.6 Financial ledger (`booking_transactions`)
`addBookingTransaction` inserts a row; `getBookingTransactions` lists them; `ChangeRefundStatus` updates a row's `status`. See §8 for two serious bugs living in exactly this trio.

### 6.7 GlobalTix integration

**Status as of 2026-08-25/26: live in the `/v1/activity_list` merge flow, list endpoint only, working end to end against staging.**

`services/globalTixService.js` wraps GlobalTix's REST API (countries, cities, product list/info/options/changes, availability, reserve→confirm→get-details→release booking flow, revoke transaction). `controllers/globalTixController.js` is a thin pass-through, exposed under `/gt/*`.

**Auth (confirmed against live curl examples — the official GlobalTix API PDF documents a *different*, non-working `username`+`password` → `/api/auth/login` flow for this account; don't trust the PDF over this):**
1. `POST /api/auth/authorize` with `x-api-key` + `x-api-agent` headers (+ `{"username": GT_USERNAME}` as a `text/plain` body) → returns a JWT at `data.accessToken` (camelCase — the docs' snake_case `access_token` is wrong for this response).
2. Every other call uses that JWT as `Authorization: Bearer <jwt>` **plus** `x-api-agent` again — but NOT `x-api-key` (only used to obtain the JWT).
`getAccessToken()` caches the token, keyed off decoding the JWT's own `exp` claim directly (not whatever expiry field the wrapper response does or doesn't provide), with in-flight de-dupe so concurrent callers on a cold cache don't all hit `/authorize` at once. Agent code / API key derive from `GT_USERNAME`/`GT_PASSWORD` unless `GT_API_AGENT`/`GT_API_KEY` are set explicitly.

**List merge (`activityFrontController.getAllActivities`, i.e. `/v1/activity_list`):** calls `services/globalTixActivityAdapter.js#listActivities(...)`, which fetches GAPI's `product/list`, normalizes it into the same activity shape as own-DB rows (`source: "globaltix"`, `source_id`), runs it through the exact same `GlobalService.calculateCountryGlobalMarkupActivity`/`convertCurrency` pipeline as own-DB activities (§6.3), and concatenates the result onto the own-DB array. Never throws — any GlobalTix failure (auth, network, timeout, unmappable filter) degrades to `[]` so a GlobalTix outage never breaks the endpoint for own-DB results.

**Country/city filtering:** our free-text `activity.country`/`activity.city` request values don't match GAPI's own country codes / numeric city ids, so `services/globalTixMappingService.js` mirrors GAPI's own reference lists into two local tables — `globaltix_countries`, `globaltix_cities` (see `migrations/2026_08_25b_globaltix_reference_tables.sql`; **supersedes** the earlier, now-unused `migrations/2026_08_25_globaltix_country_city_map.sql` hand-curated design — drop/skip that first migration if it was ever run) — refreshed on demand via `POST /gt/sync_countries` / `POST /gt/sync_cities` (run countries first; cities denormalize each city's country code from it at sync time). Lookups then match our country/city text against those mirrored names case-insensitively, in-process-cached 5 min. When a request has a city but no country (or an unresolved one), city lookup falls back to matching by name alone **only if that name is unique across all synced cities**; a name that collides across two+ countries is treated as unmapped rather than guessed, and a successful match backfills the country code onto the request. If a country was requested but doesn't resolve, GlobalTix is skipped entirely for that request (rather than querying unfiltered and mixing in irrelevant countries); same if a city was requested, doesn't resolve, and there's still no country either. If a country DOES resolve but the city doesn't, it falls back to country-level filtering only (city is a narrower refinement, not a hard gate).

**Pricing (revised 2026-08-26 — read this before touching pricing here):** the list merge takes its price straight off each `product/list` item — `fromPrice` when GAPI provides one, else `originalPrice`, with the item's own `currency` — confirmed against a live Postman response. **This was NOT the original design.** The first version assumed (wrongly, from docs alone) that `product/list` carried no price, so it fired an extra `product/options` call per product to derive one via `Promise.allSettled`. That assumption was false, and the extra per-item fan-out was the direct cause of two separate production bugs: (a) a `getProductOptions` timeout under an unconstrained/large result set (root-caused to the country/city scoping bug described above, now fixed), and (b) results silently coming back empty even for a normally-scoped, successful request (root-caused to this exact per-item call — either erroring per-product or hitting a `product/options` response shape that was never actually confirmed live). **Lesson applied twice now: don't write parsing code against this API's documented shape without confirming it against a real response first** — the docs have been wrong for the auth token field, and for whether `product/list` returns pricing at all.

**Explicitly out of scope for now:** GlobalTix detail/option-review/booking integration. `getProductOptions()` stays defined in `globalTixService.js`/`globalTixController.js` for that future phase, but its *real* response shape is still unconfirmed against a live call — confirm it the same way before writing any parsing code against it, don't reuse the removed `extractCheapestOption`/`ticketTypes[].nettPrice` guess as-is.

**Open follow-up (not yet investigated):** whether any product that's actually sellable in the real catalog can have neither `originalPrice` nor `fromPrice` set (vs. only sold-out/unlisted products lacking both) — if so, those would currently be silently dropped from the list and that may need a different fallback.

`controllers/globalTixWebhookController.js` handles inbound webhook events (`BOOKING_TRANSACTION_UPDATE`, `BOOKING_TICKET_UPDATE`, `TICKET_REDEEM`, `TICKET_REVOKE`, `TICKET_EXPIRED`, product/price update — the last two are logged only, not persisted) — but writes to the `activity_booking` table that isn't in this schema (see §4). Unrelated to the list-merge work above and still unresolved.

### 6.8 Encryption middleware
`services/encryptMiddleware.js` / `decryptMiddleware.js` (AES-256-ECB, hardcoded key in `services/encryption.js`) are wired into `bw_activity_manager_api.js` but only activated `if (config.ENCRYPTION == 'true')` (env-driven, off unless explicitly enabled). When on: responses shaped `{replyCode:'success', data:...}` or `{status:true, data:...}` get their `data` field encrypted to a base64 blob; incoming `application/json` bodies that look like base64 get decrypted before `req.body` is set. ECB mode has no IV, so identical plaintext blocks always encrypt identically — a known weakness — and the key is committed in source rather than pulled from env/secrets.

## 7. Dead / broken code (do not build on these without fixing first)

| File | Status | Why |
|---|---|---|
| `index.js` | **Dead.** | `require("./routes/routes")` — that path doesn't exist. Not the npm start target. |
| `controllers/backup-functions.js` | **Dead.** | Exports `getAllActivities` but is `require`d nowhere in the codebase. Looks like a snapshot kept "just in case." |
| `controllers/wallet.js` | **Dead, and would crash if loaded.** | `require('../../db_connection/mysql_connection.js')` (wrong relative depth — resolves outside the project), `require('../services/wallet')` (doesn't exist), `require('../controllers/admin.js')` (doesn't exist), `require('paypal-rest-sdk')` (not in `package.json`). Nothing in the app requires this file, so it never actually loads — but if anyone wires a route to it, the process will crash on require. Looks like a copy-paste from a different, larger legacy app. The booking-flow wallet deduction that's actually live is the local `DiductbalanceInWebsiteUserWallet` helper inside `webController.js` (§6.4), not this file. |
| `services/activityListing.service.js`, `services/activityPricing.service.js`, `services/activityInventory.service.js` | **Dead (unwired), and broken.** | Self-contained trio implementing the same 3-tier inventory pricing waterfall described in §6.1, apparently a cleaner rewrite-in-progress. Nothing in `bw_activity_manager_api.js` calls `activityListing.service.prepareActivities`. `activityInventory.service.js` does `require("../config/db")` — that path doesn't exist (should presumably be `../database/connection`) — so this trio would throw immediately if anything tried to use it. |

## 8. Confirmed bugs (found while reading — not exhaustive, but real)

**Status (2026-08-25): all 11 fixed.** Each item below is kept for the historical description of the bug; the fix is noted inline. Verified with `node --check` on every touched file plus targeted behavioral tests (fake pool/connection harnesses exercising the actual modified code) for the highest-risk ones — SQL injection payloads confirmed to land as bound params rather than in the query string, and the two "no try/catch at all" auth call sites confirmed to no longer throw/unhandled-reject on a bad token.

1. **✅ FIXED. SQL injection — `webController.ChangeRefundStatus`** (`/web/change_refund_status`): was raw string concatenation (`'UPDATE booking_transactions SET status=' + status + ' WHERE id=' + id`); now `status=?, id=?` with a bound params array.
2. **✅ FIXED. SQL injection — `webController.getBookingTransactions`**: `ref_no` was concatenated directly into the WHERE clause; now `QRT.ref_no = ?` with a bound param, consistent with the other filters in the same function.
3. **✅ FIXED. `getBookingTransactions` ignores its own `refrence_id` filter**: replaced the `refrence_id != ?` (with inconsistent hardcoded params between the count and main queries) with a real `QRT.refrence_id = ?` filter, applied only when `refrence_id` is provided in the request — so the "browse everything" case (no `refrence_id` passed) still works exactly as before, and the "get transactions for this one reference" case now actually filters to that reference instead of returning almost everything.
4. **✅ FIXED. `activityController.getActivityTypes`** (`/activity/get_master_activity_types`): replaced the undefined `db.execute(...)` with `pool.promise().query(...)`, and switched the function from `res.status().json()` (wrong — the route wires it through the `(status, err, body)` callback convention like every other controller, so `res` was actually the callback function) to `callback(status, null, body)`. This endpoint could never have worked before.
5. **✅ FIXED. `activityController.getActivityHolidaybyIds`**: `agent_id = globalData.id` → `agent_id = userdetails.id`, matching the variable actually declared in this function.
6. **✅ FIXED. `activityController.UploadActivity`**: all three raw-interpolated queries (the `activity` INSERT, the `supplier_contracts` INSERT, and the `supplier_contract_id` UPDATE) are now parameterized with `?` placeholders and bound params arrays.
7. **✅ FIXED. `globalService.calculateCountryGlobalMarkup_BC`**: added `incomingEmail = null` as a fourth parameter so the existing `if (incomingEmail && country.user_email)` check no longer references an undefined name. (Still dead code — zero callers found anywhere in the codebase — so this only matters if/when someone starts calling it.)
8. **✅ FIXED. `globalService.verifyUserDetails`**: the bare `getUserDetails(...)` call is now `exports.getUserDetails(...)`. Verified: calling `verifyUserDetails` with a bad token now correctly returns `{message: "Invalid authorization token", status: false}` instead of throwing `ReferenceError: getUserDetails is not defined`.
9. **✅ FIXED. `webController.update_booking`**: removed the duplicate `await connection.query(updateQuery, updateParams)` — runs exactly once now (verified via test).
10. **✅ FIXED. `webController.update_booking`**: added an explicit `if (!globalData) return callback(400, ...)` check right after the `.catch(() => null)`, before `globalData.id`/`.name`/etc. are dereferenced.
11. **✅ FIXED — full audit, ~50 call sites across 6 files.** Every `GlobalService.getUserDetails(...)` call site in `webController.js`, `activityController.js`, `activityFrontController.js`, `activityInventoryController.js`, `timeSlotController.js`, and `subActivityController.js` now either already had a `.catch()` + safe fallback (left alone), already had a `.catch()`-free call inside a try/catch with a downstream `?.id` check (just needed `.catch(() => null)` added so that existing check fires instead of falling through to a generic 500), or had neither (added `.catch(() => null)` **and** an explicit clean-error return). Two call sites had **no enclosing try/catch at all** — `webController.create_booking` (the main booking-creation endpoint — a bad/expired staff token here would have been an unhandled promise rejection on the single most important write path in the service) and `subActivityController.changeSubActivityStatus` and `activityController.getVendorActivityCityWise` — these were the highest-severity instances and are now wrapped. `controllers/wallet.js` and `controllers/backup-functions.js` were deliberately left untouched — both are dead code, never `require`d anywhere (see §7), so fixing call sites in them has no production effect.

## 9. Performance bottlenecks (confirmed by reading the schema + query code)

Ranked roughly by how much this will hurt as data/traffic grows:

1. **`fd_activities_booking` and `booking_transactions` have zero secondary indexes.** Checked the schema dump directly — both tables only carry `ADD PRIMARY KEY (id)`, nothing else. These are the two hottest, fastest-growing tables in the service (every booking, every ledger entry), and every one of `get_booking_list`, `get_operations_list`, `listActivitiesPayouts`, `getBookingTransactions`, `getBookingDetails` filters/sorts on columns like `agent_id`, `staff_id`, `reference_id`, `status`, `ops_confirmed`, `source`, `created`, `from_date`, `booking_ref_no`, `ref_no`, `user_id` — none of which are indexed. Today, on a small table, MySQL just scans it and it's invisible. As booking volume grows this becomes a full table scan on every list/ops/payout screen and will degrade linearly (then worse, once it stops fitting in buffer pool). This is the single highest-value fix: add indexes on `fd_activities_booking(agent_id)`, `(staff_id)`, `(reference_id)`, `(booking_ref_no)`, `(created)`, `(from_date)`, `(ops_confirmed)`, `(source)`, plus a composite covering the common list filter combo; and on `booking_transactions(refrence_id, refrence_type)`, `(ref_no)`, `(user_id)`, `(created)`.
2. **`activity` and `sub_activity` also have no secondary indexes** (only `UNIQUE KEY (id)`), yet `getAllActivities`/front listing filter on `status`, `country`, `city`, `meal_activity`, `is_combo`, `is_pre_purchase`, plus a `JSON_EXTRACT(week_days, '$.<Day>')` check that runs on **every single front-listing request**. None of that can use an index today. Lower urgency than #1 only because the activity catalog is usually much smaller than the booking log, but the per-request JSON weekday check is paid on every listing call regardless of table size.
3. **`city`/`country` are comma-separated strings, not normalized.** Filters use `LIKE '%value%'` (leading wildcard — can't use an index even if one existed) or `FIND_IN_SET(...)` (also not index-friendly). Same for the keyword search in `activityController.getAllActivities`, which runs `LIKE ?` across 13 columns including two `text` columns (`description`, `highlights`) with no full-text index — a full scan per keyword search, and it gets more expensive as `description`/`highlights` content grows.
4. **✅ FIXED (2026-08-25).** ~~A booking request holds a DB pool connection open for the duration of an outbound HTTP call.~~ `create_booking` now releases its connection (via `finally`) before the `DiductbalanceInWebsiteUserWallet(...)` external call, and the pointless second connection acquisition inside that helper (never queried) was deleted outright. See `controllers/webController.js` and `PERFORMANCE_FIX_PLAN.md` §2.
5. **✅ FIXED (2026-08-25).** ~~No shared cache layer~~ — `services/redisClient.js` (new) wires in `ioredis`; `globalService.js`'s markup/currency lookups now go L1 in-memory `Map` → L2 Redis → L3 DB, best-effort (Redis errors/unavailability fall straight through to the DB, never throw). `ioredis` is in `package.json` but not yet `npm install`ed and no Redis instance is provisioned yet from this session — until both exist, behavior is unchanged (falls straight to L1/L3 as before). Full listing-response caching (mentioned as a follow-up in the original plan) is still not done. See `PERFORMANCE_FIX_PLAN.md` §6.
6. **Still open.** The markup + currency conversion pipeline in listing endpoints is not batched — within each activity's `processActivity`, categories/addons/guides are still marked up via sequential `await`s rather than `Promise.all`'d. The §7 work so far only **deduplicated** `activityFrontController.js`'s three copies into `services/activityMarkupPipeline.service.js` (see §6.3) — the inner-loop batching described in `PERFORMANCE_FIX_PLAN.md` §7 step 3 was not part of that change and hasn't been done. `webController.js` still has its own separate, unbatched copies (confirmed intentional, not touched).
7. **🔶 Mitigated (2026-08-25), not eliminated.** `services/bookingPdfService.js` still launches Puppeteer per call (Option B — a shared browser instance — was not done), but concurrent PDF generation is now capped via `services/concurrencyLimiter.js` (a vendored zero-dependency limiter, `PDF_CONCURRENCY` env var, default 2) so a booking burst can no longer launch unbounded concurrent Chromium processes. See `PERFORMANCE_FIX_PLAN.md` §5 (Option A).
8. **Still open.** Wide `SELECT activity.*` / `SELECT *` on list endpoints — not started, needs frontend coordination first per `PERFORMANCE_FIX_PLAN.md` §4.
9. **✅ FIXED (2026-08-25).** ~~Verbose logging on hot paths.~~ All `console.log` dumps of SQL/params/results across `webController.js`, `activityController.js`, `activityFrontController.js`, `activityInventoryController.js` are now gated behind `Config.DEBUG_SQL` (`DEBUG_SQL` env var, default off); the hardcoded `inventoryMap[873]` debug dumps were deleted outright. See `PERFORMANCE_FIX_PLAN.md` §3.

None of these are exotic — they're the standard "no indexes on the busiest tables, no shared cache, connection held across a network call, and a lot of duplicated per-row async work" pattern. #1 and #4 are the ones worth prioritizing first: #1 because it will silently get worse every month as bookings accumulate, and #4 because it's a live connection-pool exhaustion risk today under concurrent booking load.

## 10. Smaller things worth knowing before touching code

- `Config.DB_PREFIX` (env `DB_PREFIX`, defaults to `''`) is prefixed onto cross-database references like `${Config.DB_PREFIX}fdk_holidays.website_users` — used inconsistently (some queries hardcode `fdk_holidays.` / `fdk_hotels.` / `fdk_transportation_package.` without the prefix). If `DB_PREFIX` is ever set to something non-empty in an environment, audit for these misses.
- **Updated 2026-08-25**: `activityFrontController.js`'s `getAllActivities`/`getlActivityById`/`getActivityHolidaybyIds` are **no longer** independent copies — they share one implementation, `createActivityProcessor(...)` in `services/activityMarkupPipeline.service.js` (confirmed byte-identical before merging, verified equivalent after via an end-to-end test). `webController.js`'s `getAllActivities`/`getMealActivities`/`getlActivityById` remain independent, and are a **deliberately simpler** pipeline for a different consumer (confirmed intentional, not drift — see `MARKUP_PIPELINE_DEDUP_FINDINGS.md`), so a bug fix or business-rule change to the shared pipeline still does not automatically apply to `webController.js` and vice versa. The unused `services/activityPricing.service.js` trio is still unused and still doesn't match either pipeline's current behavior — not a starting point to wire in as-is.
- `booking_transactions.refrence_id` / `refrence_type` — the misspelling (`refrence`, missing the second "e") is the actual column name in the schema, not a typo to "fix" casually; code has to match it.
- Puppeteer is used synchronously inside the booking-confirmation path (`services/bookingPdfService.js`, launched from `sendBookingMail`, itself fired via `setImmediate` after `create_booking` responds) — CPU/memory-heavy per booking; worth watching under load since it's per-request browser launch/close, not a shared instance.

## 11. "BW inventory" — terminology and the canonical list-item shape

**Terminology (adopted 2026-08-26).** Our own inventory — the `activity` /
`activity_category` / `activity_inventory` tables in `fdk_activity`, tagged
`source: "BW"` in list responses — is called **BW inventory** from here on.
Third-party GlobalTix inventory (tagged `source: "globaltix"`) is **GT** or
**GAPI**. Use these names in code comments, commit messages and docs; "own-DB"
and "own inventory" in older notes mean BW.

A real, confirmed BW list item is checked in at
**`docs/samples/bw_activity_list_item.json`** (activity id 4, "Safari world with
Marine Park", `checkDate` 2026-09-04, `guest_currency` INR). Every field and
every number is verbatim from a live `/v1/activity_list` response; only the long
HTML prose blocks and the repetitive `created_by` audit entries were shortened.
Treat it the way §6.7 says to treat GAPI responses: **this file, not anyone's
assumption, is the description of the BW shape.**

### 11.1 Field semantics confirmed against the pipeline

These were each traced back to `services/activityMarkupPipeline.service.js`
rather than inferred from the sample, because several of them read the opposite
of how they look.

- **`currency` does NOT describe the prices next to it.** The sample says
  `"currency": "THB"` while `adult_price: 297.72` is *rupees*
  (`org_adult_price: 100` THB × 2.9772 markup+FX). The prices on a BW item are
  always in **`guest_currency`**; `currency` is the untouched source currency,
  copied up from the cheapest category (which in turn is `activity_inventory.currency`)
  and never rewritten after conversion. Anything rendering or comparing a BW
  price must read `guest_currency`, never `currency`.
  Note GT items behave the *opposite* way — `globalTixActivityAdapter` sets
  `item.currency = toCurUpper` — so the same field name means source currency on
  BW items and guest currency on GT items. See §11.2.
- **`org_*` = raw pre-markup, pre-conversion value** in the source currency
  (`org_adult_price: 100` THB vs `adult_price: 297.72` INR). Same for
  `org_mrp_*`, `org_selling_price` on guides, `org_*_sale_price` on addons.
- **Top-level `adult_price` on a category activity is the cheapest category's
  price, not a separate activity-level price.** The pipeline nulls the master
  price ("Hide master price") when `inventory_source === "category"`, then near
  the end sorts `act.categories` by `adult_price` ascending, sets
  `default_category` to `categories[0].category_id`, and copies that category's
  `adult_price`/`child_price`/`infant_price`/`mrp_*`/`org_*`/`available_quantity`/
  `total_quantity`/`capacity`/`group_key`/`activity_date`/`currency` back onto the
  activity. So **`act.adult_price` is already the "from" price** — there is no
  need to min() over `categories[]` to find it, and `categories` arrives
  pre-sorted cheapest-first.
- **Top-level `booking_closed` is not the bookability answer for a category
  activity.** The sample has `"booking_closed": 1` at the activity level and is
  still (correctly) listed, because it has categories and all three of them have
  `booking_closed: 0`. Activity-level `close_before_days` is 40 (cutoff long
  past), but each inventory row carries its own `close_before_days: 1`, and
  `COALESCE(NULLIF(ai.close_before_days,0), activity.close_before_days)` means the
  inventory value wins. This is exactly the branch structure the bookable
  predicate added to `getAllActivities` in §12 relies on — the activity-level
  check applies **only** when there are no active categories. The sample is a
  live confirmation that the predicate's branch A/C split is right.
- **`expired`** is computed (`checkDate` between `from_date` and `to_date`) but
  is **not** a drop condition — an expired activity is still returned, flagged.
- **`contract_price: "\"\""`** is a double-encoded empty JSON string. Cosmetic,
  but don't `JSON.parse` it once and assume you're done.
- **`source` is `"BW"`, not `"own"`.** Corrected 2026-08-26. `'BW'` is what
  this platform already uses for our own inventory everywhere else:
  `fd_activities_booking.source` is `varchar(100) DEFAULT 'BW'`,
  `webController.create_booking` inserts `source = 'BW'`, and the hotel code
  branches on `source === 'BW'` (vs `'TBO'`). The `'own'` tag was invented
  during the GlobalTix merge work and disagreed with the column the very same
  records get booked into. The reference sample below was captured before this
  fix and has been updated to `"BW"` — it is the one value in that file that is
  not verbatim from the original response.
- **`created_by` is an append-only audit array**, duplicates included (21 entries
  on this one activity), and it ships in full on every row of the list response.
  Part of the `SELECT *` payload problem in §9.8 — this is the field that makes
  it expensive, not the description text.

### 11.2 BW vs GT item shape (input for the shape-alignment work)

`globalTixActivityAdapter.normalizeProduct` currently emits 13 fields. A BW item
has ~80. What a GT item is missing that a list UI plausibly needs:

| Concern | BW field(s) | GT today |
|---|---|---|
| Headline price | `adult_price` / `child_price` / `infant_price` | `price` only — **different field name**, so any sort or filter keyed on `adult_price` silently skips every GT item, and vice versa |
| Currency meaning | `currency` = source, `guest_currency` = actual | `currency` = actual, no `guest_currency` — **same name, opposite meaning** |
| Strike-through pricing | `mrp_adult` / `mrp_child` / `mrp_infant` | absent |
| Pre-markup values | `org_*` | absent |
| Bookability | `booking_closed`, `expired`, `close_before_days` | absent |
| Availability | `available_quantity`, `total_quantity`, `capacity` | absent |
| Fare options | `categories[]`, `default_category`, `inventory_source` | absent (`ticket_type_id` is hardcoded `null`) |
| Scheduling | `week_days`, `from_date`, `to_date`, `time_slots[]`, `duration`, `opening_time` | absent |
| Commercial | `cancellation_policy_items[]`, `addon[]`, `activity_guides[]` | absent |
| Ranking | `score` | absent — this is why GT items can only tail the merged array today |
| Media | `images` (string) + `other_images[]` | ~~`images` (array)~~ — **resolved 2026-08-26**, see §14: GT now returns `icon`/`images` as strings and `other_images` as `[{image}]`, matching BW's types |

The three "same name, different meaning/type" rows (`currency`, `images`, and
price field naming) are the dangerous ones: they will not throw, they will just
render or sort wrongly.

### 11.3 Confirmed bug found while checking the sample — SIC transport markup is discarded

In `activityMarkupPipeline.service.js`, the preferred-transport SIC branch
computes the markup and then **converts the wrong variable**:

```js
const orig = Number(row.sale_price);
let markedUpSale = orig;
try {
    const mk = await GlobalService.calculateCountryGlobalMarkupTransport(...);
    markedUpSale = mk && mk.total_amount ? mk.total_amount : orig;
} catch { }
...
const conv = await convertValue(srcCur, toCurUpper, orig);   // <-- orig, not markedUpSale
if (conv.ok) {
    act.preffered_transport_price = conv.converted;              // markup never applied
    act.org_preferred_transport_sale_price = markedUpSale;       // markup parked in an org_ field
```

`preffered_transport_price` — the customer-facing number — is the **raw** supplier
price converted, with **no markup**. The marked-up figure is written into
`org_preferred_transport_sale_price`, which by the §11.1 convention is supposed to
hold the raw pre-markup value (and `preffered_transport_raw` already does hold it).

Everywhere else in this pipeline the order is markup → convert, with `org_*`
holding the raw: activity prices do `applyMarkupToPriceWithCountry` then
`convertTopPrice`, and the **PVT branch of this very same function** does
`convertValue(srcCur, toCurUpper, vSaleWithMarkup)` — the marked-up value. SIC and
PVT disagreeing inside one function is what makes this a bug rather than a
deliberate rule.

Not visible in the reference sample only because markup happened to resolve to
zero there (`org_preferred_transport_sale_price: 225` === `preffered_transport_raw: 225`,
and `669.88 / 225 = 2.9772`, pure FX). Any non-zero transport markup is being given
away. **Not fixed** — this changes what customers are charged, so per the project
rules it needs a side-by-side comparison against production data before touching
it. Affects `/v1/activity_list`, `getlActivityById` and `getActivityHolidaybyIds`
(all three share this pipeline); `webController.js` has its own separate copies
that have not been checked for the same defect.

### 11.4 Open question raised by the sample

The activity is `time_slot: 1` and its only slot has `booking_closed: 1` and
`available_quantity: 0`, yet the activity is listed as bookable off its category
inventory. The pipeline's drop rules never look at `time_slots[]`. Whether a
slot-based activity with every slot closed should still be listed is a business
rule nobody has stated — worth confirming before the pagination counts are
treated as authoritative.

## 12. Pagination on `/v1/activity_list` (2026-08-26)

**Before**: no `LIMIT`, no `OFFSET`, no `page` — the endpoint returned every
matching BW activity, `ORDER BY score DESC`, with `totalRecords = COUNT(*)`. The
GT adapter meanwhile hardcoded `page = 1`, so GT results were silently truncated
to GAPI's first page while BW results were complete, and `totalRecords` reported
that truncated GT count as if it were the whole GT catalog for the filter.

**Now**: `page` and `limit` are accepted on `req.body`, both validated as
positive integers.

- `limit` is what engages BW pagination. **Absent → no `LIMIT` clause at all,
  behavior identical to before**, so no existing caller is affected.
- `page` alone only advances the GT page (their catalog is paged at source, ours
  is not). A caller asking for the next GT page has not asked to have the BW
  result set cut up.
- Response gains `ownTotalRecords`, `page`, `limit`. `totalRecords` keeps its old
  meaning (BW + GT) but is now truthful. `ownTotalRecords` is broken out because
  it is the only one of the two that can actually be paged against — GAPI never
  reports its total.

**The part that made it correct — the bookable-for-`checkDate` predicate.**
`activityMarkupPipeline.service.js#processActivity` drops activities *after* the
query returns, at three points (it returns `null`, and the controller
`.filter(Boolean)`s them away):

- **A** — has active categories, but none has an open inventory row for `checkDate`
- **B** — no active categories, category-0 inventory row exists but is `booking_closed`
- **C** — no active categories, no category-0 inventory row at all, and the
  activity itself is `booking_closed`

While the endpoint returned everything these were invisible: `COUNT(*)` was only
ever compared against the full set. Under `LIMIT`/`OFFSET` they stop being
invisible — a page of 20 could return 3 rows against a `totalRecords` no amount
of paging adds up to. So the same three conditions are now expressed as a
correlated `EXISTS` predicate applied to **both** the COUNT and the SELECT.

The predicate is provably complete: those three `return null`s are
`processActivity`'s *only* drop paths — its outer `catch` returns `act`
unchanged, not `null` — so no error path can remove a row the SQL kept. The JS
drops were deliberately left in place as a safety net: if SQL and JS ever
disagree we drop an item rather than serve one the pipeline cannot price.

Indexing is fine here: both correlated subqueries lead with `activity_id`, which
is the leading column of `activity_inventory.uk_activity_inventory` and
`activity_category.uk_activity_category`. Index lookups, not scans — unusual for
this service (cf. §9).

Two details that are easy to get wrong if this is ever rewritten:

- The category-0 branch must match `(category_id = 0 OR category_id IS NULL)`,
  because the pipeline keys its inventory map with `Number(inv.category_id || 0)`
  and so collapses NULL onto the same bucket as 0.
- The inventory-level `booking_closed` uses
  `COALESCE(NULLIF(ai.close_before_days, 0), activity.close_before_days)` — the
  inventory row's value wins over the activity's. §11.1 has a live example where
  this is the difference between listing an activity and hiding it.

**Verification**: `scripts/verify_activity_list_pagination.js` runs the old query
plus the JS drop rules by hand, runs the new predicate, and asserts identical id
sets *and* identical order; asserts `COUNT(*)` equals the row count; and with
`--limit N` walks the pages and asserts they reassemble the full list with no
ragged non-final page. Exits non-zero on mismatch and names which rule dropped an
id when the predicate is too loose. **Re-run it if either the predicate or
`processActivity`'s drop logic is ever touched.**

**Still open**: a unified price sort across BW + GT (`sort_by=price`) was
deliberately deferred to its own change. Note that once both sources are
paginated, a price sort can only order *within* a page, not globally across the
merged catalog — and §11.2 lists the field-name mismatches that have to be
resolved before any cross-source sort will actually see both sides.

## 13. GT items shaped like BW items — product/options enrichment (2026-08-26)

Supersedes the "explicitly out of scope" note in §6.7: GAPI's `product/options`
response shape has now been confirmed against a live call and is checked in at
**`docs/samples/gapi_product_options.json`** (product 53082, staging; 8 of 15
options kept, covering every structural variant). Product 53082 is a **demo
merchant catalog** — trust it for shape, not for realistic option counts or prices.

### 13.1 The mapping

| GAPI | BW |
|---|---|
| option (`data[]` entry) | one `categories[]` entry |
| `option.id` / `name` / `sortOrder` | `category_id` / `category_name` / `display_order` |
| `option.ticketType[]` | `adult_price` / `child_price` / `infant_price` |
| `ticketType.nettPrice` (our cost) | `org_*_price` |
| `ticketType.originalPrice` (GAPI RRP) | `mrp_*` |
| `ticketType.minimumSellingPrice` | contractual floor — no BW equivalent |
| `ticketType.id` + `sku` | `*_ticket_type_id` / `*_sku` (new, needed to book) |
| `advanceBooking.day` | `close_before_days` |
| `publishStart` / `publishEnd` | `from_date` / `to_date` |
| `definedDuration` | `duration` |
| `timeSlot[]` + `isCapacity` | `time_slots[]` |
| `isCancellable` + `cancellationPolicy` | `cancellation_policy_items[]` |
| `ageFrom` / `ageTo` | `adult_age_min/max`, `child_age_*`, `infant_age_*` |
| `inclusions` / `exclusions` / `termsAndConditions` / `howToUse` | `inclusion` / `exclusion` / `restrictions` / `recommendation` |

`services/globalTixOptionsService.js` owns the mapping and the cache;
`globalTixActivityAdapter.js` applies markup and FX on top and flattens the
cheapest category up onto the activity exactly the way the BW pipeline does
(§11.1), so `adult_price` and `default_category` mean the same thing on both.

### 13.2 Four things that are not obvious

**Ticket-type names are free text.** Across one product: `pax`, `ADULT`,
`CHILD`, `Infant`, `Per Pax`, `per pax` — in three shapes (one generic type for
everyone, ADULT+CHILD, ADULT+CHILD+Infant). `classifyTicketTypes` matches on
name, falls back to `ageFrom`/`ageTo` bands, and applies the agreed rule that a
**single generic type prices all three pax types identically** (flagged
`_flat_rate_ticket_type`). A pax type with no ticket type gets `null`, never
`0` — `0` would render as free and could let someone book children at no charge.

**`minimumSellingPrice` is a hard floor and marking up cost breaches it.** Live
sample: `nettPrice` 0.1 against a `minimumSellingPrice` of 10. So the order is
**markup → clamp up to floor → convert**, and the clamp must happen before
conversion because markup and floor are both quoted in the source currency.
Where the clamp bites it is recorded on the category as
`_min_selling_price_applied` rather than silently changing the margin.

**The fan-out is fenced, and the fence is visible.** One `product/options` call
per product is exactly what caused the two earlier incidents. Three controls,
all in `config.js`: `GT_OPTIONS_MAX_PRODUCTS` (hard cap, default 25 — GAPI picks
its own `product/list` page size, so this and *not* the caller's `limit` is what
actually bounds fan-out), `GT_OPTIONS_CONCURRENCY` (default 4, via the existing
`concurrencyLimiter`), and `GT_OPTIONS_ENRICH=false` as a kill switch. Options
are cached L1 in-process → L2 Redis, TTL `GT_OPTIONS_CACHE_TTL` (default 300s —
short because options carry prices). Products past the cap keep their
list-derived price, return `categories: []`, and **the number skipped is logged**.

**Two behaviour changes to the GT item.** `currency` now means the SOURCE
currency and `guest_currency` is what the prices are actually in — matching BW
(§11.1). Previously this adapter set `currency` to the guest currency, the
opposite of BW, so the same field name meant different things per source. And
`extractListPrice` no longer defaults a missing currency to `'THB'`: the live
options are SGD, and guessing silently mis-converts every price on the item, so
such an item is skipped instead. The old `price` field is kept as a
**deprecated alias** of `adult_price` for one release — remove it once the
frontend reads the BW-shaped fields.

### 13.3 What GT categories still cannot have

`available_quantity` / `total_quantity` are **null**, and will stay null.
`checkEventAvailability` is per **ticketType** per date range, so filling them
would cost products × options × ticketTypes calls — 21 ticket types for this one
demo product. Null means "GAPI does not tell us", not zero. `capacity` is also
null (`isCapacity` is a boolean, not a number), and `time_slots[]` carry a start
time only — no quantity, no per-slot price.

### 13.4 Open, needs confirming before anyone relies on it

- **Which price does `product/list` actually return?** The list fallback marks
  up `fromPrice`/`originalPrice`, but `product/options` reports `originalPrice`
  20 against a `nettPrice` of 5 for the same ticket — so `originalPrice` looks
  like GlobalTix's *retail* price while BW marks up *cost*. If they are the same
  field, unenriched GT items are being marked up on top of GlobalTix's own
  margin. Run `product/list` and `product/options` side by side on one product.
- **`cancellationPolicy.refundDuration` units are unconfirmed** — it was `0` on
  every option in the sample. It is carried through as `to_day`; verify against
  a product that actually sets it before building refund maths on it.

### 13.5 Verification

`scripts/test_gt_options_mapping.js` — 77 assertions, run with plain `node`, no
network, DB or credentials (axios and ioredis are stubbed at the module loader).
It asserts against the **real saved response**, not a hand-written fixture,
because the standing lesson on this integration is that GAPI's documented shape
has been wrong more than once. Covers classification across all three ticket-type
shapes, the floor clamp, `advanceBooking` → `close_before_days`, the BW
`booking_closed` rule including its 30-minute grace window, the activity-level
rollup, and an end-to-end enrichment asserting every BW list field is present on
a GT item with no NaN anywhere. **Re-run it after any change to the mapping.**

Not yet verified against live GAPI — staging is unreachable from the dev
environment this was written in.

## 14. GAPI product images — why they are proxied (2026-08-26)

GlobalTix product images live on a **different host** from the API
(`product-image.globaltix.com`, not `stg-api.globaltix.com`) and require the
same `Authorization: Bearer <jwt>` the API does:

```
curl 'https://product-image.globaltix.com/stg-gtImage/<uuid>' \
  --header 'Authorization: Bearer <jwt>'
```

**A browser cannot send an `Authorization` header on `<img src>`.** Neither can
a redirect: a 302 from us is followed by the browser as a fresh request carrying
none of our headers. So no way of writing GlobalTix's URL into the JSON response
will ever make the image render. The bytes have to pass through something of
ours that can hold the token — that is `GET /gt/image/:id`
(`globalTixController.getProductImage`), which fetches the image server-side
with the Bearer attached and **streams** it back (streamed, not buffered, so a
large image never sits in process memory and first bytes reach the browser
immediately).

Also fixed here: `Config.GT_IMAGE_BASE_URL` had been defined since the
integration started but was **used nowhere**, and the adapter passed
`product.images` straight through — so GT items were coming back with bare
UUIDs, not URLs. They would not have rendered even if the host were public.

### 14.1 The route is deliberately unauthenticated

The same constraint that forces the proxy — `<img src>` cannot carry an
`Authorization` header — means it cannot carry **our** token either. Requiring
auth on `/gt/image/:id` would break every image on the page. So it is public.

The exposure: anyone who can guess or observe an image id can pull that product
photo through our server. Judged acceptable — public marketing images, opaque
ids, and no customer or booking data reachable by this path. **Not a pattern to
copy for any endpoint returning anything else.**

### 14.2 `isSafeImageId` is load-bearing, not defensive tidiness

`globalTixService.isSafeImageId` rejects anything outside `[A-Za-z0-9._-]`, plus
`..` and over-long ids. Without it, `GET /gt/image/:id` is a **server-side
request forgery hole**: an attacker-chosen id would make our server fetch an
arbitrary URL *with our GlobalTix bearer token attached*. It allows a slightly
wider charset than a strict UUID pattern only because GAPI has not been
confirmed to issue UUIDs exclusively. Do not loosen it further without
understanding what it is holding back.

### 14.3 Response shape

`toProxyImageUrl` builds `${APP_PUBLIC_URL}/gt/image/<id>`. `APP_PUBLIC_URL`
empty (the default) yields a **root-relative** `/gt/image/<id>` — correct for any
caller hitting this API directly, and better than emitting a confidently wrong
absolute URL. Set it per environment for fully-qualified URLs. An
already-absolute URL from GAPI is passed through untouched; an unsafe id is
dropped rather than turned into a URL.

Image fields now match BW's **types** (§11.2): `icon` and `images` are single
strings, `other_images` is `[{image: url}]`. This adapter previously returned an
array for `images`, so that field name held a string on a BW item and an array
on a GT item — the same class of collision as `currency`.

Caching is what keeps the proxy off the hot path: `Cache-Control: public,
max-age=GT_IMAGE_CACHE_MAX_AGE` (default 86400). Every cached hit is one
GlobalTix round trip avoided. Upstream failures return **404, not 500** — a
missing image should render as a broken image, not read as our service being
down — and upstream error bodies are never echoed.

### 14.4 Confirm this first

Nobody has verified the image host actually requires auth; the working curl
simply includes the header. Images sitting on a separate host from the API is a
common sign of a public CDN. One command settles it:

```
curl -s -o /dev/null -w '%{http_code}\n' \
  'https://product-image.globaltix.com/stg-gtImage/<uuid>'
```

**200 means the proxy is unnecessary** — `toProxyImageUrl` should then emit
`GT_IMAGE_BASE_URL + id` directly and the route can go, saving us the bandwidth
entirely. The rest of this section only applies if it returns 401/403.

Separately, the shape of `product/list`'s image field is **unconfirmed** —
`toProxyImageUrl` handles a bare string and the common object shapes
(`{url|image|imageId|id|fileName}`) and drops anything else. Confirm it against
a live `product/list` response and simplify.

### 14.5 `.env` parsing landmine (found 2026-08-26)

dotenv v16 accepts `KEY = value` **and** `KEY: value` (colon followed by
whitespace) but **not** `KEY:value` with no space — those lines are silently
ignored, no warning, `process.env.KEY` just comes back `undefined`. The GT block
in `.env` mixes all three styles, so:

| line | loads? | effect |
|---|---|---|
| `GT_BASE_URL: https://...` | yes | fine |
| `GT_USERNAME: ...` / `GT_PASSWORD: ...` | yes | fine |
| `GT_IMAGE_BASE_URL: Product image URL prefix` | yes | **was broken** — the documentation text had been pasted in as the value, overriding the correct default. Fixed to the real URL. |
| `GT_API_AGENT:R014697` | **no** | falls back to derivation from `GT_USERNAME` → `R014697` (same result, by luck) |
| `GT_API_KEY:0efb19...` | **no** | falls back to `getApiKey()` → `"<agentCode>/<GT_PASSWORD>"`, i.e. `R014697/0efb19...` **with** the agent prefix |
| `GT_REQUEST_TIMEOUT_MS:30000` | **no** | falls back to the default, also 30000 |

**Do not "tidy" those three lines into a parsing format without testing auth
first.** The literal `GT_API_KEY` in `.env` has no `R014697/` prefix, but the
derived value that is *actually being sent today* does. Making that line load
would change the `x-api-key` sent to `/api/auth/authorize`. Whichever form is
correct, find out before changing it — right now the file and the runtime
disagree, and the runtime is the one that works.

### 14.6 `*_url` image fields (added 2026-08-26)

Both sources now carry three extra fields alongside their existing image fields,
**purely additive** — nothing existing changed:

| field | BW item | GT item |
|---|---|---|
| `icon` / `images` | bare upload filename, e.g. `img_1749466103251.jpg` (unchanged) | proxy URL |
| `other_images` | `[{image: "<filename>"}]` (unchanged) | `[{image: "<proxy url>"}]` |
| **`icon_url`** | `APP_PUBLIC_URL` + `/uploads/<filename>` | the proxy URL |
| **`image_url`** | same | same |
| **`other_image_urls`** | flat array of absolute URLs | flat array of absolute URLs |

**Why additive rather than rewriting `icon`/`images` in place:** no BW image base
URL is configured anywhere in this service (`URL_UPLOAD` and `IMAGE_FILE_PATH`
are absent from `.env`, and `URL_UPLOAD` is only referenced in the dead
`wallet.js`), which means **the frontend is hardcoding it today**. Rewriting
those fields to absolute URLs would make every existing consumer double-prefix
and break every image the day it shipped. The `*_url` fields let the frontend
drop its hardcoded base whenever it is ready, one field name for both sources.

BW URLs are built by `services/publicUrl.service.js#bwUploadUrl`, which resolves
against `app.use('/uploads', express.static('uploads'))`. It drops any stored
filename containing a slash or `..` rather than emitting a URL pointing outside
the uploads tree, and percent-encodes the rest. Same `APP_PUBLIC_URL` rule as GT
images: unset yields root-relative URLs.

**Scope**: applied in `getAllActivities` (`/v1/activity_list`) only, which is
also the only endpoint that tags `source`. `getlActivityById` and
`getActivityHolidaybyIds` return neither `source` nor `*_url` — worth doing when
the GT detail phase lands.

### 14.6.1 Absolute image URLs, and the mount prefix — Out API (2026-09-19, Darpan)

Partner complaint: `image_url` came back in three shapes, none usable —
`/img_1749466820992.jpg` (BW), and, for the SAME GT image,
`/uploads/gt/<id>.png` once mirrored and `/gt/image/<id>` before that.

What the partner gets now:

| source | `image_url` |
|---|---|
| ACT / BW | `https://bwdevuploads.blob.core.windows.net/activity/img_1749466103251.jpg` |
| GT, mirrored | `https://testpackage.fdking.com/activity/uploads/gt/<id>.png` |
| GT, not yet mirrored | `https://testpackage.fdking.com/activity/gt/image/<id>` |

- **BW images have a configured base, `BW_IMG_BASE_URL`** (Azure blob). They are
  uploaded and served elsewhere, not by this service, so the base cannot be
  derived from our origin — same situation and same treatment as
  `TRANSPORT_IMG_URL`. Unset keeps the old behaviour (`APP_PUBLIC_URL` +
  filename). Commit 182dca7 removed the `/uploads/` segment `bwUploadUrl` used
  to add; that stays removed, since the segment belongs to whatever base is
  configured.
- **GT images stay on the mirrored static URL** (§14.7) with the real file
  extension. The on-demand `/gt/image/<id>` is the fallback for an image that
  could not be mirrored, and it now serves the mirrored bytes off disk when they
  are there (`mirroredPathIfPresent` → `sendFile`) instead of going back to
  GlobalTix, so a URL cached before mirroring costs nothing. It also strips a
  known file extension from the id, so `/gt/image/<id>.png` resolves too.
- **Out API responses absolutise image URLs**, via
  `xApiActivityList.applyAbsoluteImageUrls(items, PublicUrl.requestBaseUrl(req))`
  on `activity_list`, `get_activity_by_id` and `get_activity_by_ids`.

**THE MOUNT PREFIX IS WHY `APP_PUBLIC_URL` IS NOT OPTIONAL HERE.** This service
is reachable at `https://testpackage.fdking.com/activity`, but the proxy strips
`/activity` before Express routes the request — so the prefix is NOT in
`req.path`, `req.baseUrl` or anything else derivable. `requestBaseUrl` therefore
uses `APP_PUBLIC_URL` when set, then `X-Forwarded-Prefix` if the proxy announces
one, and only then falls back to bare scheme+host — which on a prefixed
deployment yields a URL that 404s. `warnAboutPublicUrl()` says exactly this at
startup. The Host header is caller-controlled, hence format-checked; a spoofed
Host only affects that caller's own response.

**`/v1/*` is deliberately NOT absolutised**: its frontend prepends its own base,
so absolute URLs there would double-prefix and break every image — the same
reason §14.6 was additive in the first place.

### 18.y Booking vouchers, both suppliers (2026-09-19)

GlobalTix generates the e-ticket AFTER the booking is confirmed, and not always
at once: `booking/details` answers `isTicketsReady: false` until it exists and
the reference says to retry after a minute. So the voucher cannot be part of the
confirm transaction, and a booking must never be failed, rolled back or held up
because one was slow.

`services/gtVoucher.service.js` is the one place that fetches it and writes
`fd_activities_booking.voucher_pdf` — the same column /web and ops already read,
so nothing downstream learns a new field. Two callers, both best effort, neither
able to throw:

1. **right after a GT confirm** — the usual case, tickets are ready immediately.
   `create_booking` returns `voucher_pdf`, or null when it was not ready yet.
2. **the next `booking_details` read** that finds the column still empty. The
   partner asking for their booking IS the retry — that is why nothing polls
   GlobalTix on a timer. Deferred until after the request's connection is
   released, since it makes a network call and takes a connection of its own.

The UPDATE is guarded `WHERE voucher_pdf IS NULL OR voucher_pdf = ''`, which
makes it idempotent, makes two concurrent readers harmless, and stops a voucher
ops uploaded by hand ever being overwritten. It runs in no transaction: a
confirm that has already committed must not be rolled back over a voucher.

`isTicketsReady: true` with an empty URL is treated as NOT ready — storing an
empty string would end the retries. Only CANCELLED rows and holds (RESERVED /
RELEASED / EXPIRED) are skipped on read; a paid booking ops has not confirmed
yet reads `booking_status: 'PENDING'` and still gets its ticket fetched, because
ops confirmation has nothing to do with the supplier issuing tickets.

A file of its own because both callers live in modules that already require each
other (`xApiBooking` ↔ `xApiBookingList`), and this needs its own connection.

**ACT vouchers (same day, Darpan).** `voucher_pdf` holds OUR voucher too, so
/web, ops and the partner API read one field whoever fulfilled the booking.

The root cause of it being empty was not a missing feature: an ACT voucher PDF
is ALREADY rendered and uploaded for every Out API booking by the confirmation
mail (`xApiBookingMail` -> `bookingPdfService`), which put the CDN URL in the
email body and then THREW IT AWAY. The mail now stores it, which costs nothing
extra because the work was already being done.

`bookingVoucher.generateActVoucher` is the backstop for bookings that predate
that fix or whose mail failed. It is EXPENSIVE -- a headless Chromium launch
(bounded by `PDF_CONCURRENCY`) plus an upload, seconds not milliseconds -- so
`captureVoucher` dispatches on supplier: a GT e-ticket is fetched INLINE (one
HTTP GET, returned in the same response), an ACT render is SCHEDULED with
`setImmediate` and the read answers `voucher_pdf: null` at once. A partner never
waits behind a Chromium queue, and the next read has the voucher: the same "ask
again shortly" contract the GT path already gives.

The generator re-reads the booking before rendering, so a voucher that arrived
while the job was queued (the mail's, usually) costs no second render. A PDF
that renders but uploads to nothing returns no URL and stores nothing -- `''`
would end the retries, the same rule as an `isTicketsReady` with no link.

**`/web` KEEPS ITS VOUCHER TOO (2026-09-22).** `webController.sendBookingMail`
had been rendering and uploading a voucher for every `/web/create_booking` and
using the URL in the email body only -- so `voucher_pdf` stayed empty on every
`/web` row and nothing could be downloaded afterwards. It now calls the same
`storeVoucher`, under the same two conditions the Out API uses: only when the
upload produced a URL, and never for a booking carrying `gt_hold`. `/web` does
not create GlobalTix bookings today, so that second condition guards a future
caller rather than a live case -- it is there because getting it wrong is silent
and costs a guest their admission document.

Rows written before that date still have an empty `voucher_pdf`. The Out API
read path back-fills one on demand (`captureVoucher` -> `generateActVoucher`);
`/web`'s reads do NOT, so a historical `/web` booking has no voucher until
someone decides whether a read may spend a Chromium launch. `get_booking_list`
and `get_operations_list` already SELECT the column, so anything stored shows up
without a contract change. Tests: `scripts/test_web_booking_voucher_store.js`.

The module was renamed `gtVoucher.service.js` -> `bookingVoucher.service.js`
when it stopped being GlobalTix-only.

**THE COLLISION, found and closed 2026-09-21.** Our PDF and the GlobalTix
e-ticket were both being written to `voucher_pdf` through the same guarded
`WHERE voucher_pdf IS NULL OR = ''`. Whoever landed first won -- and since
GlobalTix issues asynchronously, the mail's PDF usually landed first, after
which the real e-ticket could NEVER be stored. The guest got a voucher telling
them to present a confirmation code for tickets GlobalTix had issued.

Fixed by making the mail supplier-aware: **a GT booking's confirmation never
writes `voucher_pdf`.** Our PDF is the SUMMARY and travels as an attachment
only; the e-ticket owns the column. Same change stopped the mail looking a
GlobalTix product id up in our `activity` table (`reference_id` is the product
id on a GT booking), which silently lost the description and inclusions and,
on an id collision, would have rendered a DIFFERENT activity into a guest's
voucher.

**The follow-up mail.** When a confirmation goes out with no e-ticket,
`armVoucherFollowUp` sets `voucher_mail_pending_since`
(migration `2026_09_21_activity_booking_voucher_mail.sql`). When the ticket is
later stored, `claimVoucherFollowUp` clears that column in one guarded UPDATE
and `affectedRows === 1` is the claim -- two readers storing the same ticket
both try, exactly one mails. The follow-up is self-contained (reference,
activity, date), because the confirmation itself may have failed to send.

**Recipients**: partner + guest when `guest_info.email` is set, both in `To`.
A guest address that is not plausibly an address is dropped rather than handed
to the relay -- one malformed address can get the whole message rejected, which
would cost the partner their confirmation over the guest's typo.

**Cancellation mail** (`sendBookingCancelledMail`) goes out from BOTH paths --
`/outApi/cancel_booking` and the ops cancellation in `/web/update_booking` --
after the commit, fire and forget. The charge, refund and balance are PASSED IN
from what was just committed under lock, never re-derived: the mail must say
what was settled, not what a second calculation would produce now.

### 18.x GlobalTix cancellation (2026-09-19, Darpan)

A partner cancelling a CONFIRMED GT booking. Decisions and the shape of it:

**ORDER: GlobalTix first, money second.** `cancel_booking` validates under lock
in one transaction, THROWS THAT TRANSACTION AWAY, calls `transaction/cancel`
with no lock held, then opens a fresh transaction that re-validates and moves
the money (`cancelGtBooking`). The reverse order would risk refunding a partner
for tickets that are still live — the one outcome neither side can undo. A
network call is never made while holding the wallet and booking locks.

The residual risk runs the other way and is accepted: GlobalTix cancels, our
settlement then fails. Tickets dead, booking still CONFIRMED, partner not
refunded. Logged in those words, and the partner gets
`gt_cancelled_not_settled` (500, "do not retry") instead of a silent success.
Between the two phases another cancellation can land; the second transaction
re-reads under lock and returns `already_cancelled` rather than refunding twice.

**REFUND TERMS: GlobalTix's own, snapshotted at RESERVE time** into
`reference_object.gt_hold.cancellation_policy` (`percent_return`,
`refund_duration`, plus the raw policy verbatim). Read at cancel time and never
re-fetched: GlobalTix can edit an option's policy, and a booking we already
charged for must not be re-priced by it. A GT booking has no
`cancellation_policy_id` — fdk_hotels knows nothing about a GlobalTix option —
so without this branch every GT cancellation would fall to NO_POLICY and refund
nothing.

`xApiCancellation.gtPolicyItems` maps the snapshot onto the item shape the
existing engine already prices: `deduction = 100 - percentReturn`,
`from_day = 0`, `to_day = refundDuration` HOURS. **`refundDuration: 0` means NO
CUT-OFF**, not a zero-length window — read literally it would make every GT
booking non-refundable. That is what the `unbounded` flag on a synthetic item
expresses; it is set ONLY there, so no `cancellation_policy_items` row from the
BW table can acquire the behaviour. THE UNIT OF A NON-ZERO `refundDuration` IS
STILL UNCONFIRMED with GlobalTix — it is read as hours, matching `from_day` /
`to_day` for activities. Confirm before a product with a real cut-off goes live.
The schedule now carries `cancellation_policy_source` ('ACT' | 'GT').

**NON-CANCELLABLE PRODUCTS** are refused locally (`gt_not_cancellable`, 409)
from the option's `isCancellable`, also snapshotted at reserve. A booking made
before the snapshot shipped has no flag at all; that is "never recorded", not
"not cancellable", so it is allowed through and GlobalTix decides.

**NO STOCK IS RESTORED.** `reserve_booking` writes `gt_hold` and deliberately no
`inventory_hold`, so there is nothing of ours to give back — the supplier
cancellation IS the release. `inventory_restored` is always false on a GT
cancellation.

**OPS** (`/web/update_booking`) cancels inside one transaction it owns, so the
GlobalTix call cannot live in `settleOpsCancellation`. It is split:
`prepareOpsGtCancellation` runs BEFORE `beginTransaction`, with no lock, and
hands a token to `settleOpsCancellation`, which returns
`skipped: 'gt_not_cancelled'` for a GT booking without one and
`gt_reference_mismatch` for a token naming another booking. A caller that never
learned about this step cannot refund GT tickets that are still live.

### 14.6.2 Why some GT images had no file extension (2026-09-19)

Symptom: in one response, `.../uploads/gt/<id>.png` for some images and
`.../gt/image/<id>` for others. The second form is the fallback for an image
that is not on disk yet, so the question is only ever "why was it not mirrored".

Three causes, and only one of them was a bug:

1. **The per-request cap** (`GT_IMAGE_MIRROR_MAX_PER_REQUEST`, 25). A cold
   mirror serving a 16-product page needs more than that, so the rest fall back
   and are mirrored on later requests. Working as designed; run
   `scripts/warm_gt_image_mirror.js --country=<cc>` to clear the backlog in one
   go instead of waiting for traffic to do it.
2. **A download that cannot succeed** — upstream 403/401, a content-type outside
   `CONTENT_TYPE_EXT`, or a file over `GT_IMAGE_MIRROR_MAX_BYTES`. Each logs its
   own `[GT Mirror]` line saying which.
3. **THE BUG: (2) starved (1).** `mirrorMany` filled the cap in id order and
   retried failures on every single request, so a handful of permanently-broken
   ids ate the whole budget ahead of images that would have succeeded — and an
   image further down the list could stay on `/gt/image/<id>` forever, no matter
   how much traffic the endpoint got.

Fix: a per-process **failure memo** (`failed`, id → timestamp,
`GT_IMAGE_MIRROR_RETRY_AFTER_MS`, default 1h, bounded at 5000 entries). Recently
failed ids are moved to the BACK of the batch rather than dropped, so the cap is
spent on images that can still be downloaded; they are retried once the memo
expires, and a success clears it. Nothing is lost while an id is skipped — the
fallback URL renders, it just costs a GlobalTix fetch.

### 14.7 Image mirroring — the actual answer (2026-08-26)

The proxy (§14) works but keeps every image view on our request path forever,
and it kept not rendering in practice. **Mirroring replaces it as the primary
path.**

The first time an image id is seen, `services/globalTixImageMirror.service.js`
downloads it once (with the token) into `uploads/gt/<id>.<ext>`. From then on it
is an **ordinary static file** served by the same
`app.use('/uploads', express.static('uploads'))` that serves BW images. A GT
image URL becomes `/uploads/gt/<id>.jpg` — indistinguishable in kind from BW's
`/uploads/img_123.jpg`: no auth header, no proxy hop, no per-view GlobalTix
call, and fully browser- and CDN-cacheable. It survives a process restart, since
the file is on disk and a cold process finds it by probing extensions.

`GET /gt/image/:id` **stays** as the fallback for anything not mirrored yet, so
a mirror failure degrades to a working proxy URL rather than to a broken image.

Flow per list request: `applyMirroredImages` collects every image ref on the
page, resolves the ones already on disk for free (a `stat()`), downloads only
the rest, then rewrites `icon` / `images` / `other_images` / `icon_url` /
`image_url` / `other_image_urls`. **Only a cold mirror costs anything** — after
the first request for a given product it is effectively free.

Bounds, all in `config.js`: `GT_IMAGE_MIRROR` (kill switch),
`GT_IMAGE_MIRROR_MAX_PER_REQUEST` (default 25 new downloads per request; the
overflow is logged and falls back to the proxy, to be mirrored on a later
request), `GT_IMAGE_MIRROR_CONCURRENCY` (default 4),
`GT_IMAGE_MIRROR_MAX_BYTES` (default 10 MB), `GT_IMAGE_MIRROR_DIR` (default
`gt`).

Safety properties worth not regressing:

- **Only known image content-types are written** (`jpeg/png/webp/gif/avif`).
  `application/octet-stream` or `text/html` is refused rather than guessed at —
  this directory is served publicly.
- **Written to a temp name then renamed**, so a crash or a concurrent writer can
  never leave a half-written image being served.
- **The size cap is enforced while streaming**, not just against
  `Content-Length`, which can be absent or lie.
- **In-flight de-duplication**: N concurrent requests for the same image cause
  one download.
- `mirrorDir()` resolves against `process.cwd()` **because `express.static('uploads')`
  does**. If that static mount ever becomes an absolute path, change this with it.

Operational notes: `uploads/` is gitignored, so mirrored files are never
committed — correct, they are a cache. On a multi-instance deployment each
instance mirrors its own copy (idempotent, just a little duplicated work) unless
`uploads/` is shared storage. On ephemeral container disks the mirror simply
re-warms after a deploy. Nothing purges it yet — if GlobalTix ever replaces an
image behind an existing id, the stale file wins, so a cleanup/refresh story is
still owed.

**Base64 in the list response was considered and rejected**: it would have made
list responses roughly 3–10 MB, forced every list call to wait on ~25 image
downloads before responding, and thrown away browser caching entirely — the
image bytes would be re-sent on every single list call. Mirroring gets the same
"it just works" property while making images *cheaper* than the proxy rather
than dramatically more expensive.

### 13.6 GlobalTix's seven prices (confirmed from their pricing doc, 2026-08-26)

Each ticket type carries **seven** prices in **two different currencies**. This
is the single most important thing to understand before touching GT pricing —
reading a field in the wrong currency is a ~30x error.

**Merchant prices — quoted in the ATTRACTION's currency** (what `product/list`
reports: THB for a Bangkok product, EUR/USD/SGD for others on the same page):

| field | meaning |
|---|---|
| `originalMerchantPrice` | official retail / walk-in price at the gate |
| `nettMerchantPrice` | price the merchant offers the agent |
| `minimumMerchantSellingPrice` | mandated minimum selling price |

**Agent prices — quoted in the PARTNER (our) currency**, which is the option's
own `currency` field:

| field | meaning |
|---|---|
| `originalPrice` | retail price in our currency |
| `nettPrice` | **deducted from Agent Credit when a transaction is made** — our real cost |
| `minimumSellingPrice` | **selling below this risks being blacklisted by the merchant** |
| `recommendedSellingPrice` | GlobalTix's recommendation, advisory only |

This explains what looked like a contradiction: `product/list` says THB 1799 for
product 33494 while `product/options` says SGD. Neither is wrong — 1799 THB *is*
`originalMerchantPrice`, and the option currency is the agent currency.

**How the adapter uses them:**

- **Sell price** (`adult_price` …) is computed from the **agent** figures:
  markup on `nettPrice` → clamp up to `minimumSellingPrice` → one conversion to
  the guest currency. This is not a presentation choice. `nettPrice` is what
  GlobalTix actually deducts, and `minimumSellingPrice` is the floor we are
  blacklisted for breaching, so the markup and the clamp have to happen against
  those numbers and no others.
- **`org_*` / `org_mrp_*`** are the **merchant** figures (`nettMerchantPrice`,
  `originalMerchantPrice`), so `currency` — which by BW convention describes
  `org_*` — carries the attraction currency and varies per product. `mrp_*` is
  `originalMerchantPrice` converted from the attraction currency.
- **No FX of ours is involved** in that presentation. An earlier version
  converted SGD→THB through `fdk_transportation_package.currency`; GlobalTix
  supplies the merchant figures directly, so that conversion (and the risk of a
  missing currency pair silently mislabelling an SGD number as THB — a ~36x
  under-price) is gone.
- **All-or-nothing per category.** If any published pax type lacks
  `nettMerchantPrice`/`originalMerchantPrice`, the whole category keeps the
  agent currency and `currency` says so, with a warning naming the product.
  Mixing THB and SGD `org_*` fields under one `currency` label would be worse
  than presenting them all in the agent currency.

**Fields carried so nothing is lost:** `pricing_currency` (agent currency —
reserve/confirm settle in it), `<pax>_nett_price` and
`<pax>_min_selling_price` (agent currency — real cost and contractual floor,
needed for booking and margin), `<pax>_merchant_min_selling_price`, plus
item-level `merchant_currency` and `merchant_price` from `product/list`.

## 15. Two-source refactor, Phase 1 — `bwActivityAdapter` (2026-08-26)

`/v1/activity_list` merges two sources, but only one of them looked like a
source. `getAllActivities` was **721 lines**: ~660 of BW SQL and pricing inline,
~60 of orchestration, plus a call to `globalTixActivityAdapter`. You could not
read the function and see "run the sources, merge, respond".

**Phase 1 moves the BW half out, and changes nothing else.**

- **`services/bwActivityAdapter.js`** (new) — the WHERE builder, the
  bookable-for-`checkDate` predicate (§12), COUNT + SELECT with optional
  LIMIT/OFFSET, the category / guide / inventory / time-slot /
  cancellation-policy loads, the transport map, the markup pipeline via
  `createActivityProcessor`, and the `source: 'BW'` / `*_url` tagging.
- **`getAllActivities`** — 721 → 181 lines. Resolves context, runs both sources,
  merges, responds. No SQL.

Both sources now share one contract:

```js
listActivities(ctx) -> { items: [], totalRecords }
```

(GT still returns a bare array in Phase 1; that is aligned in Phase 2.)

### 15.1 Properties preserved on purpose

- **The 775 moved lines are verbatim.** The extraction dedented by exactly four
  spaces and changed nothing else — mechanically verified by comparing the
  whitespace-stripped text of every line before and after. Nothing was retyped.
- **Parallelism is unchanged.** The GlobalTix promise is still kicked off
  *before* the BW await, so its network latency overlaps the BW queries exactly
  as when the SQL was inline. Awaiting BW first and GT second is not a
  sequential rewrite of a parallel flow.
- **The adapter owns its pool connection** and releases it in a `finally`, so
  the controller never holds a DB connection across the GlobalTix network call —
  the same property §9.4 was fixed to get.
- **Response shape is untouched**: `data`, `totalRecords`, `ownTotalRecords`,
  `page`, `limit`, `cmd`.

### 15.2 Verification

- `scripts/smoke_bw_adapter.js` — drives the adapter end to end against a fake
  pool, no DB or network. `node --check` proves the file parses; it does NOT
  prove every identifier still resolves, because a variable the extraction
  dropped only throws when its line runs. This runs all six queries (routed by
  inspecting the SQL), the markup pipeline, the tagging, and the pagination
  branch. Stubs only what is genuinely not installed, so on a complete
  `node_modules` it exercises the real `moment`/`axios`/`ioredis`.
- `scripts/capture_activity_list_baseline.js` — the real safety net.
  `capture` before the change, `capture` after, `diff`. GlobalTix items and the
  combined `totalRecords` are excluded by default because a live third-party
  catalogue drifts between captures and would drown the signal; `--include-gt`
  compares them anyway. **Anything other than "NO DIFFERENCES" is a bug in the
  move.**

### 15.3 Not done in Phase 1

The `is_standalone` / `include_bw` flags, the `BW_SOURCE_ENABLED` /
`GT_SOURCE_ENABLED` env switches and the `sources` response block are **Phase
2** — deliberately separate, so that if the response shifts we know it was the
move and not the flags. GlobalTix still runs on every request, exactly as
before.

Phase 3, still open: point `getlActivityById` and `getActivityHolidaybyIds` at
the same adapter. Both still carry their own copies of this logic
(`createActivityProcessor` has three call sites).

## 16. Two-source refactor, Phase 2 — source flags (2026-08-26)

`/v1/activity_list` now decides per request which inventory sources to run.

### 16.1 The rules

| source | request param | default | env kill switch |
|---|---|---|---|
| BW (own inventory) | `include_bw` | **ON** — opt out with `0` | `BW_SOURCE_ENABLED` |
| GT (GlobalTix) | `is_standalone` | **OFF** — opt in with `1` | `GT_SOURCE_ENABLED` |

Accepted values on both: `1/0`, `true/false`, `yes/no`, `on/off`. Anything
unrecognised is **not** an opt-in and **not** an opt-out — it falls to the
default, so a typo can never silently enable third-party inventory.

**Env flags are kill switches only.** They can disable a source that was asked
for; they can never enable one that was not. That ordering is what makes
`BW_SOURCE_ENABLED=false` in `.env` sufficient to stop a source no matter what
any caller sends.

Both sources off is a legal, empty `200` — not an error. A caller that
explicitly asked for nothing gets nothing.

### 16.2 The behaviour change

**GlobalTix used to run on EVERY request. It now runs only when
`is_standalone = 1`.** Existing callers get BW-only until the frontend opts in.

That is the safer default — a third-party outage, a pricing surprise or a slow
`product/options` fan-out cannot reach a caller that never asked for third-party
inventory — but it is visible, and the frontend has to be told before this ships.

### 16.3 The `sources` block

```json
"sources": {
  "bw": { "enabled": true,  "reason": "ok",            "records": 42, "totalRecords": 42 },
  "gt": { "enabled": false, "reason": "not_requested", "records": 0 }
}
```

`reason` is one of `ok`, `not_requested`, `excluded_by_request`,
`disabled_by_env`. This exists so a caller can tell **"GlobalTix returned
nothing" from "GlobalTix was never asked"** — the single question that has cost
the most time on this integration. Everything else in the response
(`data`, `totalRecords`, `ownTotalRecords`, `page`, `limit`, `cmd`) is unchanged.

### 16.4 Contract alignment

`globalTixActivityAdapter.listActivities` now returns
`{ items, totalRecords }` like the BW adapter, instead of a bare array.
`totalRecords` is **always null** on the GT side: GAPI returns a page and a
`size` but no total we could page against, so the controller keeps BW's count
separate as `ownTotalRecords` rather than inventing a combined figure.

### 16.5 `config.boolEnv`

Added alongside `intEnv` (§14.5), and for the same reason. The idiom
`process.env.X !== 'false'` reads as "on unless disabled", but `X=0` is not the
string `'false'`, so it stays **ON** — the opposite of what setting `0` intends.
`GT_OPTIONS_ENRICH` and `GT_IMAGE_MIRROR` were both written that way and have
been migrated; setting either to `0` now actually disables it.

### 16.6 Verification

`services/sourceFlags.service.js` is deliberately a standalone pure module
rather than inline controller logic, so resolution is unit-testable with no
request, database or network. 37 assertions cover it and `boolEnv`, including
the properties that matter most: an env flag never forces a source on, an
unrecognised value is neither an opt-in nor an opt-out, and `is_standalone = 1`
keeps BW on (merged, not GlobalTix-only).

`scripts/capture_activity_list_baseline.js` gains three flag cases. **The key
Phase 2 regression check**: with no `is_standalone`, every BW field must still
be byte-identical to the Phase 1 capture, with GlobalTix simply absent.

## 17. Two-source refactor, Phase 3 — shared relation loading (2026-08-26)

The plan said "point `getlActivityById` and `getActivityHolidaybyIds` at the
same adapter". **Investigation changed the shape of that.**

### 17.1 Why they were NOT merged into `listActivities`

The relation loading is near-identical across the three, but the query that
selects the activities is genuinely different:

| | `getAllActivities` | `getlActivityById` | `getActivityHolidaybyIds` |
|---|---|---|---|
| base WHERE | `status IN (1) AND meal_activity = 0` | `status = 1` | `status = 1` + `id IN (…)` |
| id filter | none | `id = ?` (optional) | `id IN (…)` (required) |
| **bookable predicate** | **yes** | **no** | **no** |
| pagination | optional LIMIT/OFFSET | none | none |

Folding these together needs four behavioural switches, and one is a trap. The
bookable predicate (§12) drops any activity with no sellable inventory for the
date — correct for a listing, **wrong for a detail view**: `/v1/get_activity_by_id`
would return an EMPTY response for an activity that is merely closed on the
requested date, instead of showing it with `booking_closed: 1`. Same for the
holiday cross-sell endpoint. A shared function whose flags can silently empty a
live endpoint is worse than the duplication it removes.

So the **relation loading** was shared and the **queries** were left alone.

### 17.2 What was removed

**Dead code, ~85 lines × 3 copies.** Each block carried inline
`toNumberOrNull`, `round2`, `detectActivityCurrency`, `detectPackageCurrency`,
`detectContractCurrency`, `applyMarkupToPrice` and `convertValue` — leftovers
from before pricing moved into `activityMarkupPipeline.service.js`. Verified by
call graph before deleting: `applyMarkupToPrice`, `convertValue`,
`detectActivityCurrency` and `detectContractCurrency` had **no callers at all**,
and `toNumberOrNull` / `round2` / `detectPackageCurrency` were called **only**
from those. Only `firstIdFrom` was live (the transport section). Removing them
also made `GlobalService` unused in `bwActivityAdapter` — that require is gone.

**`services/activityRelations.service.js`** (new) — `loadActivityRelations()`:
categories, guides, date-wise inventory, time slots, cancellation policy items
and the transport map. All three call it.

Before replacing the two controller copies, every remaining difference was
diffed line by line. **All of it was logging** — no functional divergence:
`getlActivityById` lacked the gated `DEBUG_SQL` echo of the inventory query (it
gains it), and `getActivityHolidaybyIds` carried five extra `console.log`s, two
of them **ungated on a live endpoint** (`/v1/get_activity_by_ids` dumped the
full `activityIds` array on every call). Those are gone, along with a third
ungated `console.log('Config.DEBUG_SQL ---:', Config.DEBUG_SQL)` — which logged
the flag rather than being gated by it. §9.9's logging cleanup had missed all three.

The only parameter that genuinely varied is the date feeding the time-slot
query — `from_date` / `daydate` / `travel_date` — now the `slotDate` argument.

### 17.3 Net effect

| | before | after |
|---|---|---|
| `activityFrontController.js` | 1656 | 794 |
| `bwActivityAdapter.js` | 861 | 417 |
| `activityRelations.service.js` | — | 435 |
| **total** | **2517** | **1646** |

**~870 lines removed**, three copies of the relation loading collapsed to one.

`loadActivityRelations` does NOT acquire or release the DB connection — the
caller owns it — and it MUTATES `activities`, attaching time slots and
cancellation policy items to each, exactly as the inline code did.

### 17.4 Verification

`scripts/smoke_bw_adapter.js` still passes (it drives all six queries through
the new shared module). **But it only covers the listing path.** Before this
ships, `/v1/get_activity_by_id` and `/v1/get_activity_by_ids` need the same
before/after treatment `capture_activity_list_baseline.js` gives the listing —
those two endpoints have no golden-output coverage yet, and they are where the
two rewritten copies live.

### 17.5 Still duplicated

`webController.getlActivityById` (routed at `bw_activity_manager_api.js:439`) is
a fourth copy. Per §10 it is a deliberately simpler pipeline for a different
consumer, so it was left alone — but "the last of the duplication" is not quite
gone.

**Update 2026-09-03:** there is now a fifth. `bwActivityAdapter.getActivitiesByIds`
(§18.8, generalised to an id list in §18.11) carries the by-id **and** by-ids
query so `/xApi/get_activity_by_id` and `/xApi/get_activity_by_ids` have a data
layer, and neither `activityFrontController.getlActivityById` nor
`getActivityHolidaybyIds` was rewired to it — the point of that decision was that
no live `/v1` behaviour changes on the day the Out API routes ship. Both are now
one `excludeMealActivities` flag away from deleting their inline copies. It is the `getAllActivities` → adapter
move of §15 waiting to be repeated, and §17.4 names the missing prerequisite:
`/v1/get_activity_by_id` still has no golden-output coverage. Capture that
baseline first, then delete the controller copy.

Note that the new adapter function keeps the **detail** semantics §17.1
identified as the trap — no bookable predicate, no `meal_activity` exclusion —
so it is a drop-in for `getlActivityById` and NOT for `listActivities`.

---

## 18. Out API (`/xApi/*`) and its two admin modules (2026-08-30, rebuilt 2026-08-31)

A new partner-facing API surface plus the two admin CRUD modules that feed it.
Commits `9758455` (code) and `c734f2b` (Postman). Routes are appended at the
bottom of `bw_activity_manager_api.js` (lines 897–950), all `POST`.

| Route group | Controller / service | Auth |
|---|---|---|
| `/api_user/{register,update,list,details,status_update,delete}` | `controllers/activityUserController.js` | **none** |
| `/activity_markup/{create,update,list,details,status_update,delete}` | `controllers/activityMarkupController.js` | **none** |
| `/xApi/login` | `controllers/xApiController.js` → `xApiAuthService`, `xApiPasswordService`, `xApiMailService` | **none** (it *is* the auth endpoint) |
| `/xApi/verify_otp` | `controllers/xApiController.js` → `xApiAuthService` | 5-min `otp_token` from `/xApi/login` |
| `/xApi/activity_list` | `controllers/xApiController.js` → `xApiAuthService`, `xApiActivityList.service`, `xApiSourcePolicy.service`, `xApiMarkupService` | **optional** `Authorization: Bearer <jwt>` (§18.10) |
| `/xApi/get_activity_by_id` | `controllers/xApiController.js` → `xApiAuthService`, `xApiActivityDetail.service`, `xApiSourcePolicy.service`, `xApiMarkupService` | **optional** `Authorization: Bearer <jwt>` (§18.10) |
| `/xApi/get_activity_by_ids` | `controllers/xApiController.js` → `xApiAuthService`, `xApiActivityDetail.service`, `xApiSourcePolicy.service`, `xApiMarkupService` | **optional** Bearer; max 100 ids (§18.11) |
| `/xApi/get_time_slots` | `controllers/xApiController.js` → `xApiAuthService`, `xApiTimeSlot.service`, `xApiSourcePolicy.service`, `xApiMarkupService` | **optional** Bearer; `ref_id` required (§18.12) |
| `/xApi/{bestseller,popular_experiences}` | `controllers/xApiController.js` → `xApiAuthService`, `xApiMarkupService` | **optional** Bearer; marked up either way (§18.10) |
| `/xApi/create_booking` | `controllers/xApiController.js` → `xApiAuthService`, `xApiBooking.service`, `xApiBookingMail.service` | **mandatory** Bearer; the token is the AGENT (§18.17) |
| `/xApi/wallet_balance` | `controllers/xApiController.js` → `xApiAuthService`, `xApiBooking.service` | **mandatory** Bearer; the token picks the wallet (§18.18) |
| `/xApi/{bookings_list,booking_details,transactions_list}` | `controllers/xApiController.js` → `xApiAuthService`, `xApiBookingList.service` | **mandatory** Bearer; the token is the whole scope (§18.19) |
| `/xApi/{forgot_password,reset_password}` | `controllers/xApiController.js` → `xApiAuthService`, `xApiPasswordService`, `xApiMailService` | **none** — a partner who cannot log in has no token (§18.20) |
| `/xApi/change_password` | `controllers/xApiController.js` → `xApiAuthService`, `xApiPasswordService` | **mandatory** Bearer + the current password (§18.21) |
| `/xApi/{get_user_details,update_user_details}` | `controllers/xApiController.js` → `xApiAuthService`, `xApiProfile.service` | **mandatory** Bearer; the token is the whole addressing (§18.20) |
| `/xApi/{country_list,city_list,explore_destinations}` | `controllers/xApiController.js` | **none** — reference data, no prices |

Every `/xApi/*` route above additionally passes through the browser origin
allow-list in §18.9 — including the ones marked **none**, which are
unauthenticated by design but not embeddable by any page.

Postman collection: `docs/Activity_Out_API.postman_collection.json`. A
byte-identical copy is also committed at the repo root
(`Activity_Out_API.postman_collection.json`, same md5) — delete one of them.

All three new modules `require('../database/connection')`, i.e. the main pool
(§3), and use the `pool.promise().getConnection()` + `try/finally release()`
shape. They return `res.status().json()` directly rather than the
`(req, callback)` convention the rest of the service uses — acceptable, since
they are new leaf endpoints with no shared callers, but it is a second style in
the same file.

### 18.1 The `/xApi/activity_list` request path

**Rebuilt 2026-08-31.** The first version wrapped
`activityFrontController.getAllActivities`. That could never work: the core
resolves identity from a staff/website-user JWT and returns
`400 "Unauthorized Access"` (`activityFrontController.js:67`) for any caller
without one — which is every Out API partner. The symptom was a 400 whose body
still carried `api_user_ref`, proving the `X-API-KEY` check had passed and the
core, not the edge, was rejecting.

The Out API now has its own listing path:

```
POST /xApi/activity_list
  -> xApiAuthService.authenticateOptionalXApiToken   (Bearer JWT, or anonymous)
  -> xApiActivityList.service.listActivities    (own orchestration + contract)
       -> xApiSourcePolicy.service              (partner grant -> request -> env)
       -> bwActivityAdapter.listActivities      (shared data layer)
       -> globalTixActivityAdapter.listActivities
       -> shapeItem()                           (partner allow-list)
  -> xApiMarkupService.applyOutApiMarkup        (the ONLY markup layer)
```

It shares the **data layer** with `/v1` and nothing else. `bwActivityAdapter`
and `globalTixActivityAdapter` are exactly the seam §15/§16 built for this; the
BW SQL, pricing waterfall, bookable predicate, relation loading and GAPI
enrichment are needed identically by both paths, and forking them would
re-create the duplication §17 removed.

| | `/v1/activity_list` | `/xApi/activity_list` |
|---|---|---|
| identity | staff/website-user JWT | Bearer JWT -> `activity_users`, **or anonymous** (§18.10) |
| markup | BW group/country markup | none; `activity_markup` only, default rule when anonymous (§18.10) |
| sources | caller opts in per request | partner grant is the ceiling |
| contract | full internal item (~80 fields) | trimmed allow-list |

**The invariant that makes it work: `userId` is always `null` on this path.**
`globalService.calculateCountryGlobalMarkup` opens with

```js
if (!userId || !country_name || isNaN(Number(amount)) || Number(amount) === 0) {
    return { baseAmount: Number(amount) || 0, markup_value: 0, total_amount: Number(amount) || 0 };
}
```

so a null `userId` short-circuits **both** the user-group markup (step 1) *and*
the country-markup fallback (step 2) — it does not fall through to country
markup, which is the natural misreading. The adapters therefore return true net
rates. Currency conversion is a separate call (`convertValue` ->
`GlobalService.convertCurrency`) and still applies, so the partner gets net
prices in their own currency.

`countryForCalc` is deliberately **not** nulled alongside it. In
`bwActivityAdapter` that argument does two unrelated jobs: the
`open_for_countries` visibility filter (~line 111) and the markup call (~line
363). Nulling `userId` alone kills the markup and leaves the partner's country
visibility filtering intact.

If anyone ever passes a real `userId` here, partners get silently
double-marked-up — BW markup plus their own. That is the single most important
line in the module.

### 18.2 Auth: `POST /xApi/login` and a Bearer JWT (2026-08-31)

**This replaces the `X-API-KEY` scheme. It is a breaking change and it is
deliberate** (Darpan, 2026-08-31): the module is not yet reachable from outside,
so the blast radius is nil today and would not have been later.

The problem it solves: `user_reference_id` was simultaneously the bearer
credential, the partner's public identifier (echoed on every response as
`api_user_ref`), and a field returned in plaintext by `/api_user/list` and
`/api_user/details`. It is generated as `PREFIX_<16 hex chars>`
(`crypto.randomBytes(8)`, prefix from `role_id`: 1 ADM / 2 AGT / 3 DMC / 4 MBR,
else USR) — 64 bits is fine for a secret in isolation, but not for one that is
also the identifier and is listable.

The flow is password + emailed OTP, in three calls:

```
POST /xApi/login          { "username": "<email or user_reference_id>",
                            "password": "..." }
   -> 200 { status, replyCode: "otp_sent", otp_token, otp_expires_in_minutes: 5,
            email: "pa*****@globaldmc.com" }
   ... a 6-digit code is written to `activity_users.mail_otp` and mailed

POST /xApi/verify_otp     { "otp_token": "...", "otp": "482913" }
   -> 200 { status, token_type: "Bearer", token, expires_in: "24h", data{...} }
   ... `mail_otp` cleared, `last_login` stamped

POST /xApi/activity_list  Authorization: Bearer <token>
```

**`/xApi/login` does not log anyone in.** It returns no access token and no
profile — neither is owed to a caller who has cleared one of two factors. The
only thing it hands back is `otp_token`, which is useless anywhere except
`/xApi/verify_otp`.

| | before | after |
|---|---|---|
| credential | `X-API-KEY: <user_reference_id>` | `Authorization: Bearer <jwt>` |
| secret | the public identifier | `activity_users.password` |
| lifetime | forever | 24h (`XAPI_TOKEN_TTL`, no refresh token by design) |
| revocation | delete/rotate the row | `status`/`verified` flip, effective next call |

**The two tokens are different `typ`s and are not interchangeable.** An
`xapi_otp` token presented to `/xApi/activity_list` fails the same check a
website user's token fails; an `xapi` access token replayed into
`/xApi/verify_otp` fails the mirror of it. Without both directions the second
factor would be decorative — step 1 would already be a login, or a stolen
access token could mint fresh ones. Both are pinned by tests.

Five decisions worth keeping:

1. **`typ: 'xapi'` on every token, checked on every verify.** `Config.JWTSECRET`
   is **not** private to the Out API — `services/globalService.js` verifies
   staff and `website_users` tokens with the same secret. Without the claim
   check, a website user's token would pass `jwt.verify` here and its `id` would
   then be looked up in `activity_users`, a different table with an overlapping
   id space. `algorithms: ['HS256']` is pinned for the same class of reason, and
   `issuer` is checked. There is a test for it.
2. **The row is re-read from MySQL on every request**, not trusted from the
   token. Deactivating a partner, unverifying them, or changing
   `allowed_sources` therefore takes effect on their next call rather than
   whenever their token happens to expire. Both `id` **and**
   `user_reference_id` must match the row, so rotating the reference id also
   invalidates outstanding tokens.
3. **`SELECT *`, not a column list.** The table's DDL is not in this repo
   (§18.3), so an explicit list is a guess that fails closed — naming a column
   that does not exist 500s every Out API request. It had already failed the
   other way: the old explicit list omitted `country_name` and
   `allowed_sources`, both read downstream, so pricing ran without a country and
   **every partner silently fell back to the default `BW` grant**. Fixed as a
   side effect. `password` is stripped in `sanitizeUser` before the row leaves
   the service.
4. **One 401 and one message for every credential failure**, and account-state
   checks run *after* the password check. A login endpoint that distinguishes
   "no such partner" from "wrong password" from "account disabled" is a partner
   enumerator, which is what this section already criticises `/api_user/list`
   for. `/xApi/verify_otp` has the same single message for wrong / expired /
   already-used / never-issued.
5. **Account state is re-checked at step 2**, not trusted from step 1. A partner
   disabled during the five minutes between the two calls does not complete a
   login.

### 18.2.2 The OTP step

| | |
|---|---|
| code | 6 digits, `crypto.randomInt` (never `Math.random` — this is a credential), zero-padded |
| stored | `activity_users.mail_otp`, in clear, so support can read it when a partner says the mail never arrived |
| lifetime | 5 minutes, enforced by the `exp` of the step-1 token — **no column, and a caller cannot extend it** |
| attempts | 3, then the code is burned (`mail_otp = NULL`) and a fresh `/xApi/login` is required |
| recipient | `activity_users.email` and nothing else. No fallback, no caller-nominated address — that would be a free account takeover. A row with no email returns 403. |
| on success | `mail_otp = NULL, last_login = NOW()` in **one** statement, so a crash between them cannot leave a reusable code |

**Attempt counting is the one piece of OTP state that must be mutable
server-side.** A JWT cannot carry it — a caller would simply replay the copy
that says "0 attempts used" — and `activity_users` has no column for it. So the
counter lives in Redis (`xapi:otp_attempts:<id>`, TTL 5 min), the instance this
service already runs for markup caching.

Redis is best-effort everywhere else in this codebase; **here it is not allowed
to be.** If the counter cannot be read or written we cannot enforce three
strikes, so the code **fails closed**: one wrong guess burns the OTP. Degraded
UX during a Redis outage, never a brute-force window. If that trade is ever
unacceptable, the fix is a `mail_otp_attempts` column, not a softer fallback.

Two ordering rules that are load-bearing:

- **Write `mail_otp` before sending the mail.** The other order can deliver a
  code that was never stored, which reaches the partner as "the OTP is wrong".
- **A failed send clears the column and returns 502.** `sendBookingMail` is
  fire-and-forget and swallows its errors, correctly — a booking already in the
  database must not fail on a mail relay hiccup. `xApiMailService.sendOtpMail`
  **throws**, because here the mail *is* the login step: reporting success for a
  code the partner will never receive, and leaving that code live in the column,
  are both wrong.

`services/xApiMailService.js` takes its SMTP config, sender (`SMTP_FROM_BW`) and
house style (600px table, navy `#223250`, amber `#d97706`, auto-generated
footer) from `webController.sendBookingMail`. It sends a plain-text alternative
alongside the HTML — some partner gateways strip HTML, and a login code the
recipient cannot read is a support ticket. Partner names go from the database
straight into the template, so they are HTML-escaped. There is **no CC**:
`sendBookingMail` CCs an ops mailbox, which here would put every partner's live
OTP in a shared inbox.

`mail_otp` is stripped in `sanitizeUser` alongside `password` — `SELECT *` pulls
the live code, and it must never reach a response body, a log line, or a
downstream service. None of the OTP log lines contain the code.

### 18.2.1 `activity_users.password` holds **bcrypt**, written by another application

This was got wrong first time round and is the single most important fact in
this section, so it gets its own heading.

The reading of the code said clear text: `registerUser` inserted `password`
verbatim, `updateUser` passed it through an allow-list, `getUserDetails` deleted
it from the response and `listUsers` never selected it. Write-only, never read,
therefore clear text. The first version of `/xApi/login` was built on that
assumption and hashed with scrypt.

It failed on the first real partner. The actual stored value was:

```
password:      123456
row.password:  $2a$10$R1I2T35vNM.hWLMZQD.pm.0QGj5gMSjKkwVCgzVpDUZ2Wl9pyTGti
```

That is **bcrypt**, cost 10 — and `grep -rn bcrypt` over this repo returns
nothing, and `bcrypt`/`bcryptjs` were not in `package.json`. So the partner rows
that matter **were not created through `/api_user/register` at all**. Another
application writes this table. The code in this repo was never the authority on
the column's contents, which is the general lesson: `activity_users` has no DDL
in version control (§18.3) *and* no single writer.

Two rules follow, and `services/xApiPasswordService.js` is built around them:

1. **bcrypt is the canonical format for this column.** We verify bcrypt and we
   write bcrypt (`bcryptjs`, pure JS — no node-gyp, no rebuild when the
   server's node version moves). Cost 10 and the `$2a$` version tag, both
   matching what is already in the column: `bcryptjs` emits `$2b$` by default
   and `writeSalt()` pins it back to `$2a$`, because the sibling application's
   bcrypt library is unknown and a sufficiently old one may only accept `$2a$`.
   All of `$2a$`/`$2b$`/`$2y$` are *verified*; only `$2a$` is written.
2. **A working bcrypt row is never rewritten.** `needsUpgrade` is true only for
   a legacy clear-text row, which is ours to fix and nobody else's to read.
   Re-hashing a bcrypt row into a format of our own would break login in the
   application that wrote it.

`verifyPassword` therefore dispatches on the shape of the stored value and
returns the format it saw, which is also what gets logged on a failed login —
`(stored format=plain)` on a row that should be bcrypt is the fastest possible
diagnosis of the next version of this problem:

| stored value | verified as | `needsUpgrade` |
|---|---|---|
| `$2a$` / `$2b$` / `$2y$` | bcrypt | no |
| `scrypt$…` | scrypt | no |
| anything else | clear text, constant-time | **yes** → re-saved as bcrypt |

The `scrypt$` branch is a **read-compatibility shim** for hashes written by the
first version of this file (same day, 2026-08-31). Nothing writes that format
any more; delete the branch once the column is confirmed free of it.

Passwords over **72 bytes are refused**, not hashed: bcrypt truncates silently
past 72, which would let two different passwords open the same account.

`migrations/2026_08_31_activity_users_password_hash.sql` only widens the column
to `VARCHAR(255)` (a bcrypt hash is exactly 60 chars). Check
`SHOW COLUMNS FROM activity_users LIKE 'password'` first — if it already holds
`$2a$…` values it is wide enough and the migration is a no-op.

**Do not add debug logging to this path.** The first debugging pass added
`console.log("password", password)` and `console.log("row--user", row)` to
`loginXApiUser`, which put partner plaintext passwords and stored hashes into
stdout — and `index.js` already appends every request body to `postdata.txt`.
Both are removed; the failed-login line logs the format and nothing else.

Still open: `NULL`/empty `password` means "cannot log in", and any partner row
created here without one needs a password set before it can use the Out API — as
does an empty `email`, which now blocks login outright (§18.2.2).

And there is still no rate limiting on `/xApi/*`. `/xApi/login` is where that
now matters most: it is unauthenticated by definition, does a bcrypt comparison
per call, and **sends an email per call**. Nothing currently stops a caller from
using a valid username to mail-bomb a partner, or from burning CPU on bcrypt.
Per-IP and per-account throttling on `/xApi/login` is the next thing this module
needs.

### 18.3 The two tables exist on the DB, but their DDL is not in the repo

`scripts/apply_out_api_migrations.js` reads:

- `migrations/2026_08_30_create_activity_users_table.sql`
- `migrations/2026_08_30_create_activity_markup_table.sql`

**Neither file exists** — not on disk, not in git. `migrations/` holds only the
two 2026-08-25 GlobalTix files (§4), and neither `activity_users` nor
`activity_markup` appears in `fdk_activity_db.sql`. The script therefore throws
`ENOENT` on the first `readFileSync` and has never run successfully.

**Both tables were created by hand directly on the database (2026-08-30,
confirmed by Darpan), so the module works in the current environment.** The gap
is reproducibility: nothing in version control can recreate them, so a fresh
environment, a new developer's local DB, or a rebuilt staging box has no way to
stand the Out API up, and there is no record of what the columns actually are.
Backfill the two files from a `SHOW CREATE TABLE` on the live schema when
convenient — the script that reads them is already written and correct.

Until then the column list below is an **inference from the SQL in the
controllers**, not the real schema — treat it as the minimum the code requires,
not as authoritative:

- **`activity_users`** — `id`, `role_id`, `user_reference_id`, `name`, `email`,
  `mobile`, `image`, `credit_limit`, `address_2`, `phone`, `dialcode`,
  `address`, `city`, `state_id`, `state_name`, `country_id`, `country_name`,
  `currency_code`, `zipcode`, `company_name`, `website`, `gst_no`, `pan_no`,
  `gst_doc`, `pan_doc`, `password`, `prefix`, `verified`, `status`, plus
  `wallet`, `deposit`, `credit`, `otc_bal`, `otc_used`, `pan_verified`,
  `pan_data` (update-only), plus `createdAt`/`updatedAt`.
  Indexes the code needs: unique on `email` (`registerUser` checks it with a
  SELECT, which races without the constraint) and unique on
  `user_reference_id` (it is the auth lookup key, on the hot path of every Out
  API call).
- **`activity_markup`** — `id`, `title`, `activities_ids`, `cities`, `country`,
  `price_from`, `price_to`, `score`, `booking_date_from`, `booking_date_to`,
  `travel_date_from`, `travel_date_to`, `currency`, `markup_type`,
  `markup_amount_fix`, `markup_amount_percentage`, `discount_amount_fix`,
  `discount_amount_percentage`, `status`, `is_default`, `api_partners`.

Two things worth checking against the hand-created tables.
**`createdAt`/`updatedAt` are camelCase** in the `listUsers` SELECT, unlike
every other table in `fdk_activity` (snake_case) — if the live columns are
snake_case, `/api_user/list` fails outright, so one successful call to that
endpoint settles it either way. And `activities_ids`,
`cities`, `api_partners` are **comma-separated string columns** matched with
`String.split(',')` in JS, never `FIND_IN_SET` in SQL, so they cannot be
indexed and force the full-table read in §18.4.

### 18.4 How a markup rule is selected

> **SUPERSEDED 2026-09-05 — read §18.24 first.** Everything below describes the
> `api_partners` model. That column is gone
> (`migrations/2026_09_05_activity_markup_drop_legacy_columns.sql`), rule
> selection is now by markup GROUP, and every priced row carries a `markup`
> object stating which rule won. **Filters 1–5 and both shape problems at the
> end of this section are still current and still the only matcher** — that half
> was not touched. Only the eligibility rules above them were replaced.

`fetchActiveMarkupRules` runs
`SELECT * FROM activity_markup WHERE status = 1 ORDER BY score DESC, is_default ASC, id DESC`
— no partner predicate in SQL; the partner filter runs in JS afterwards. A rule
is kept for the calling partner when:

- `api_partners` is empty → kept for everyone; or
- `api_partners` contains the caller's `user_reference_id` **or** its numeric
  `id`; or
- ~~`is_default = 1` — kept even when `api_partners` names other partners.~~
  **Fixed 2026-08-31** (§18.5.6): a scoped rule is now scoped regardless of
  `is_default`, so only an unscoped rule can act as a fallback.

Then, per activity, the **first** surviving rule in that sort order that passes
all five filters wins (`rules.find`). `is_default ASC` puts non-default rules
ahead of default ones at equal `score`, which is the right precedence.

The five filters, in `isRuleMatchingActivity`:

1. `activities_ids` — CSV against `activity.id || activity.activity_id`.
2. `country` — against `activity.country_name || activity.country || req.body.country`.
3. `cities` — against `activity.city_name || activity.city || req.body.city`.
4. dates — `booking_date_*` and `travel_date_*` are both compared against the
   *same* value, `req.body.from_date || req.body.activityDate || today`. A list
   request carries no separate booking date, so the two ranges are
   indistinguishable in practice: a rule with `booking_date_from` set behaves
   exactly like one with `travel_date_from` set.
5. `price_from`/`price_to` against
   `adult_price || starting_price || price || minimum_price`. Per §11.1 that is
   the **post-markup, post-FX guest-currency selling price**, not the contract
   price — so the range is being matched against a number the rule's own
   currency has nothing to do with.

Two shape problems in filters 2 and 3:

- **A missing value still matches.** The guard is
  `if (actCountry && !countries.includes(actCountry)) return false` — an item
  with no country/city passes a country- or city-scoped rule rather than being
  excluded. Left permissive on purpose: the strict reading would leave such
  items matching *no* rule, and an unmatched item ships at net price (§18.5).
- **BW and GT disagree on what `country` holds.** BW items carry the country
  *name* (`"Thailand"` in `docs/samples/bw_activity_list_item.json`);
  `globalTixActivityAdapter.normalizeProduct` sets
  `country: product.countryCode || product.country` — a *code* (`"TH"`), and
  `city: product.cityName`. Same field name, two vocabularies — the same class
  of collision as `currency` in §11.1/§11.2. **Partly mitigated 2026-08-31**:
  `country` is now parsed as a comma-separated list like `cities`, so one rule
  can be written `"Thailand,TH"` and cover both sources. A rule naming only one
  vocabulary still silently misses the other; that is a rule-authoring hazard
  this code cannot resolve without a per-item mapping lookup, and there is a
  test pinning both behaviours.

### 18.5 Correctness issues — status

Found while reading the first implementation; fixed in the 2026-08-31 rebuild
except where noted.

1. **FIXED — the rule's `currency` was ignored.** `markup_amount_fix` was added
   straight onto a `guest_currency` price, so one rule meant a different amount
   to an INR partner and a THB one. `resolveFixedAmounts` now converts both
   fixed amounts from `activity_markup.currency` into the request's guest
   currency via `GlobalService.convertCurrency`, memoised per
   `(rule, from, to)`. A conversion that fails **throws** — an unconverted
   fixed amount is a wrong price, and a wrong price must not reach a partner.
   Percentages are ratios and need no conversion.
2. **FIXED — only top-level prices were marked up.** `categories[]` and
   `time_slots[]` kept net prices while the top-level moved, and per §11.1 the
   top-level price on a category activity *is a copy of*
   `categories[0].adult_price` — a partner booking against the category price
   would have paid net. Markup is now applied to the item and to every row of
   both nested collections. `org_*` is untouched but no longer reaches partners
   at all (§18.7).
3. **FIXED — the whole rule table was read per request.** Now cached in Redis
   for 60s under `xapi:markup_rules:active`, best-effort in the read direction
   (a Redis outage falls through to MySQL; a MySQL failure still throws).
   TTL-based rather than write-busted, so `/activity_markup/*` edits take up to
   a minute to take effect — deliberate, and the reason the TTL is short.
4. **STILL OPEN — `/api_user/*` and `/activity_markup/*` have no
   authentication.** No token check, no IP allow-list. `POST /api_user/register`
   from anywhere mints a working Out API partner, `/api_user/list` enumerates
   every existing one, `/api_user/delete` removes them. **This is the remaining
   blocker before the service is reachable from outside.** The §18.2 login work
   narrows it — registering a partner no longer hands out a usable credential by
   itself, since `/xApi/*` needs a password too and `user_reference_id` is no
   longer a credential — but `/api_user/update` can still *set* that password on
   any partner, so the hole is the same size. Fixing it means an admin auth
   layer on both modules, not more work inside `/xApi/*`.
5. **FIXED — markup failures were swallowed.** Both the rules-fetch failure and
   the per-item apply were caught and the activities returned unchanged, i.e.
   our net cost shipped to a reseller as their selling price. The module now
   fails closed: it throws, and the controller returns 500.
6. **FIXED — `is_default` leaked rules across partners.** The default flag was
   checked *after* the `api_partners` match failed, so a default rule scoped to
   partner A also applied to partner B. `filterRulesForPartner` now treats a
   scoped rule as scoped regardless of `is_default`; only an unscoped rule can
   act as a fallback.
7. **FIXED — the dead `if (err)` branch.** `getAllActivities` signals failure
   via `callback(status, null, body)` with `err` always null, so the branch
   never fired. Gone with the wrapper.

**Live risk that remains by design:** an item no rule matches ships at **net
price**. `applyOutApiMarkup` returns `{items, matched, unmatched}` and the
controller logs a loud warning when `unmatched > 0`, but it does not fail the
request — failing would take the endpoint down whenever rule coverage has a
gap. **A default rule (`is_default = 1`, empty `api_partners`) is what actually
prevents net-price leakage, and configuring one is an operational
prerequisite, not an optimisation.**

### 18.6 Test coverage

There are two suites.

`scripts/test_out_api_flow.js` — 153 assertions, **DB-free on purpose** (every
assertion is a pure function or stops before the DB round trip, so it runs in CI
or on a laptop with no MySQL and fails for exactly one reason: the logic
changed). Covers the Bearer-token guards (missing / non-Bearer / bad signature /
expired / wrong issuer / **missing `typ:'xapi'`** / a bare `X-API-KEY`, which
must now fail), the login input guards, the OTP step guards (input validation, expired/forged
`otp_token`, and **both** directions of token-type separation), the OTP mail
template (escaping, leading zeros, house style), bcrypt verification (the real `$2a$` row from
the sibling application, `$2b$`/`$2y$` tags, the never-rewrite rule, the `$2a$`
write tag, the >72-byte refusal), the clear-text→bcrypt upgrade flag, the scrypt
read-compat shim, malformed stored values, the source-policy precedence chain, the response
allow-list including the nested collections (guides and addons among them since
§18.14), partner rule scoping, all five rule filters, and the markup arithmetic. Run with
`node scripts/test_out_api_flow.js`.

`scripts/smoke_out_api_login.js` — the stateful half, with MySQL, Redis and the
mailer stubbed in-process via `require.cache` before the service loads. It talks
to nothing and is safe to run anywhere. Covers what only state can show: a wrong
password sends no mail and writes no OTP; the mailed code is the one in
`mail_otp`; step 1 returns no access token; the OTP is one-shot; two wrong
attempts survive and the third burns it (after which the *correct* code fails);
Redis down burns on the first wrong guess; an SMTP failure returns 502 and
leaves no live code; a row with no email is refused; deactivation between the
two steps blocks the login; `last_login` is stamped only at step 2; and the
bcrypt hash is byte-identical after a successful login.

Still not covered, and needing real infrastructure: an actual SMTP delivery, the
Redis cache path under contention, and `GlobalService.convertCurrency` against
real rates.

### 18.7 The partner response contract

`shapeItem` in `xApiActivityList.service.js` is an **allow-list**, not a
deny-list — a field added to the internal item shape later must not leak to
partners because nobody remembered to exclude it. Nested collections are shaped
too; a top-level allow-list would otherwise pass `categories` through whole,
`org_*` prices and all.

Excluded, and why:

| Field(s) | Reason |
|---|---|
| `org_*`, `org_mrp_*` | our pre-markup **net cost**. Never ship to a reseller. |
| `contract_price` | same, and double-encoded JSON (§11.1). |
| `supplier`, `supplier_id`, `supplier_contract_id`, `group_key` | buy-side identity. |
| `created_by`, `approved_by`, `created_at`, `updated_at` | internal audit; `created_by` alone can be 21 entries on one activity (§11.1, §9.8). |
| `open_for_countries` | the visibility rule itself, already applied. |
| `status`, `score`, `is_custom`, `priority_confirmation` | internal ops/merchandising flags. |
| `icon`, `images`, `other_images` | raw filenames, superseded by the absolute `*_url` fields both adapters emit (§14.6). |
| `transport_*`, `preffered_*`, `vehicles_price`, `sic_included` pricing | transport PRICING is a separate commercial conversation, and carries the known markup bug in §11.3. |
| ~~`activity_guides`~~, `sub_activity`, ~~`addon`~~, `restaurants` | each has its own pricing sub-shape with `org_*` fields; out of scope for v1. **`activity_guides` and `addon` left this row on 2026-09-03 — see §18.14.** |
| `sources` block | replaced by a server-side log line; support can still answer "why no GlobalTix?" without exposing source topology. |

**Added 2026-09-05: `conversion_charges`.** Attached after shaping by
`xApiConversionCharge.service.js`, on the same terms and for the same reason as
`markup` below. It discloses a charge that was already being taken (§18.25), so
it adds a field and changes no price. Its `source_currency` companion is new on
the time-slot, transfer and storefront rows only — `activity_list` has always
shipped the source currency as `currency`.

**Added 2026-09-05: `markup`.** The one field on a partner item that
`shapeItem` does *not* produce — `xApiMarkupService.applyOutApiMarkup` attaches
it after shaping, so it is outside the allow-list by construction. That is not a
hole in the allow-list: everything in it is sell-side (the partner's own
contracted percentages, and the price *before* those percentages), and the
rule's internal filters are deliberately excluded from it for exactly the
reasons the `score` / `is_custom` row above gives. Full shape in §18.24.

**Added 2026-09-03 for `/v1` field parity** on the detail routes (and therefore
on `activity_list` too — `shapeItem` is shared, which is the point of it):
`qty`, `is_pre_purchase`, `time_slot`, `cancellation_policy_id`,
`combo_activities`, `combo_activities_opt`, `combo_max_pick`,
`combo_max_pick_opt`, `sic_transporter`, `pvt_transporter`. Adding a field is
backward-compatible, so the three routes stay on one contract rather than
drifting into three.

Two clarifications the additions force onto the rows above. The `combo_*`
exclusion was about the **enriched** combo sub-shape with its `org_*` prices;
the four raw configuration columns (component ids and pick limits) carry no
price and are now in. The `sic_*` exclusion was about transport **pricing**;
`sic_transporter` / `pvt_transporter` are JSON id references, and
`preffered_transport_price` / `vehicles_price` remain excluded. `is_pre_purchase`
moved out of the ops-flags row because a consumer needs it to know how the
activity is sold.

**`contract_price` stays excluded** (decision 2026-09-03, Darpan), and this is
now a firmer line than it was: it is our pre-markup net cost, and since §18.10
these routes answer without a token, so allow-listing it would publish our
buy-side cost to anyone who can reach the service. It sits with `org_*`,
`supplier` and `supplier_id` — that quartet is where the net-cost boundary is.

**The one route that inverts all of this** is
`/xApi/get_activity_supplier_inventory_booking` (§18.15), which ships purchase
prices and supplier contact details on purpose to any authenticated partner. It
is bounded by its own allow-list rather than by this contract, and it is the
only exception on the surface.

**Not a contract question:** `available_quantity`, `total_quantity` and
`mrp_adult`/`mrp_child`/`mrp_infant` were allow-listed from the start. They are
not columns on `activity` — they come from `activity_inventory`, or are copied
up from the cheapest category by the pipeline — so an activity with no inventory
row for the requested date carries none of them and `pick` omits the absent
keys. `/v1` omits them on exactly the same rows. If one is missing from a
response, the question is whether that activity has inventory for that date.

Dropping `created_by` alone is also the largest single win against the `SELECT *`
payload problem in §9.8 — on this path, at least.

### 18.8 `/xApi/get_activity_by_id` — the detail route (2026-09-03)

The Out API port of `/v1/get_activity_by_id`. Same reason the listing route
could not wrap `getAllActivities` (§18.1): `activityFrontController.getlActivityById`
resolves identity through `GlobalService.getUserDetails(authorization)` against
`website_users` and returns `400 "Unauthorized Access"` for anyone it cannot
find there — which is every Out API partner.

```
POST /xApi/get_activity_by_id
  -> xApiAuthService.authenticateOptionalXApiToken   (Bearer JWT, or anonymous)
  -> xApiActivityDetail.service.getActivityById
       -> xApiSourcePolicy.service              (BW grant required, see below)
       -> bwActivityAdapter.getActivityById     (NEW — shared data layer)
       -> shapeItem()                           (the SAME allow-list as the list)
  -> xApiMarkupService.applyOutApiMarkup        (the ONLY markup layer)
```

Body: `activity_id` (**required**), plus optional `daydate` / `from_date`,
`guest_currency`, `country`, `city`, `keyword`, `is_combo`. Response is the
`/xApi/activity_list` envelope with `cmd: "x_get_activity_by_id"` and a `data`
array of 0 or 1 items — an array, not a bare object, so a partner can use one
parser for both routes.

`userId = null` holds here exactly as in §18.1, and for the same reason. It is
the single most important line in the module.

**`bwActivityAdapter.getActivityById` is new, and `/v1` was deliberately not
rewired to it.** `getlActivityById` keeps its own inline copy of the same SQL
for now, so the live `/v1` path is byte-for-byte unchanged by this work; the
by-id query now exists in two places until someone migrates `/v1`, which is the
`getAllActivities` → adapter move of §15 waiting to be repeated. The tail of
`listActivities` that stamps `source`/`source_id`/`*_url` was extracted to a
`tagBwItems` helper so both adapter functions emit identically-tagged items —
same block, moved, no behaviour change.

**Detail semantics, not list semantics** — this is the trap §17.1 named when
it decided the three by-id queries would not be folded into one. Three
differences from `bwActivityAdapter.listActivities`, all inherited from `/v1`:

1. no bookable-for-`checkDate` predicate in the WHERE (§12) — a detail lookup
   that returns nothing because the activity is closed *today* is not a useful
   answer; the row comes back carrying `expired` / `booking_closed` /
   `available_quantity`;
2. no `meal_activity = 0` exclusion — asking for one by id is an explicit
   request for that row;
3. no LIMIT/OFFSET.

**The caveat that matters:** the SQL predicate is gone but the *pipeline's* JS
drops are not. `activityMarkupPipeline.createActivityProcessor` still returns
`null` at the same three points, so an activity with no sellable inventory for
`checkDate` still vanishes and the partner gets `data: []` for an id that
exists. Identical to `/v1` today. The controller logs that case explicitly
(`matchedRows > 0 && returnedRows === 0`) because it is otherwise
indistinguishable from "no such activity" in a support ticket. Making detail
truly always-return means moving those three drops behind a flag in the
pipeline, which changes `/v1` too and was not in scope.

**BW only — a gap, not a design choice.** `globalTixActivityAdapter` exposes
`listActivities` and nothing else; there is no GAPI per-product detail path in
this repo, so a partner granted GT still cannot fetch a GT product by id, and
neither can `/v1`. The service therefore refuses rather than returning empty
when BW is unreachable for the caller:

| `sources.bw.reason` | Status | `replyCode` |
|---|---|---|
| `not_granted` (e.g. a GT-only reseller) | 403 | `source_not_granted` |
| `excluded_by_request` (`include_bw = 0`) | 409 | `source_not_granted` |
| `disabled_by_env` (`BW_SOURCE_ENABLED=false`) | 409 | `source_not_granted` |

An empty `data: []` for a GT-only partner would be indistinguishable from "that
id does not exist" and would burn a support cycle every time. When a GT detail
adapter is written, `xApiActivityDetail.service.js` is the seam it plugs into:
resolve `sources.gt.enabled`, dispatch on `body.source`, merge.

`activity_id` is **required and validated numeric**, which `/v1` does not do:
`getlActivityById` treats an empty `activity_id` as "no filter" and answers with
the entire catalogue. On a partner-facing route that is a full inventory dump
from a request that asked for one activity, so an absent or non-numeric id is a
`400 bad_request` here and the adapter carries a second guard behind it.

Tests: `scripts/test_out_api_flow.js` sections [7]–[9] (DB-free — every
assertion returns before the query).

### 18.9 Browser origin allow-list on `/xApi/*` (2026-09-03)

`services/xApiOriginPolicy.service.js`, mounted as the **first** middleware in
`bw_activity_manager_api.js` via `app.use('/xApi', guardXApiOrigin)`.

**What it is, stated before what it does, because the two get confused.**
`Origin` is set by the browser and cannot be changed by page script — that is
the entirety of its value. Anything that is not a browser writes whatever
`Origin` it likes, or omits it, in one line:

```
curl -H 'Origin: https://activity.insuremytravel.in' .../xApi/activity_list
```

So this is a **cross-site abuse control, not an access control**. It stops
another website from calling `/xApi/*` out of a visitor's browser and reading
the response, and it stops the unauthenticated routes (`country_list`,
`city_list` and the three storefront lists, which per §18 expose **net** adult
rates) from being embeddable in any page on the internet. It does not protect a
partner's data from a non-browser caller; `xApiAuthService`'s Bearer JWT (§18.2)
does that, and nothing here changes it. Do not describe this as securing the Out
API.

**Configuration.** `XAPI_ALLOWED_ORIGINS` in `.env`, comma-separated, parsed by
a new `listEnv` helper in `config.js` in the same refuse-loudly style as
`intEnv`/`boolEnv`. Defaults to `http://localhost:4200` and
`https://activity.insuremytravel.in` if unset — **set it per environment**, the
dev origin has no business being allow-listed in production.
`XAPI_ORIGIN_CHECK_ENABLED=false` is the kill switch. A list that is set but
parses to nothing logs an error rather than failing open or silently refusing
everything.

**Matching is exact**, on a normalised origin (`config.normaliseOrigin`:
lower-cased scheme+host, default port stripped, path and trailing slash dropped).
Deliberately no prefix/suffix matching — `startsWith('https://activity.insuremytravel.in')`
also matches `https://activity.insuremytravel.in.evil.example`, which is how an
origin allow-list becomes decorative. The literal `null` a sandboxed iframe or a
`file://` page sends is rejected rather than compared.

**Three outcomes:**

| `Origin` header | Result |
|---|---|
| absent | **allowed**, no `Access-Control-Allow-Origin` emitted, logged (sampled 1-in-50) |
| on the list | allowed; the exact origin is reflected, with `Allow-Credentials: true` |
| present, not on the list | `403 origin_not_allowed`, **no** ACAO header |

**Absent means allowed, and that is a decision** (Darpan, 2026-09-03). A request
with no `Origin` is Postman, curl, a health check or a server-to-server partner.
Blocking those breaks the Postman collection and every backend integration,
while stopping an attacker for as long as it takes to add one header. A
cross-site `fetch()` from a browser *always* carries `Origin`, so allowing the
header-less case does not reopen what this closes.

**It is also now the only CORS authority on `/xApi/*`,** which is why two
app-wide registrations are skipped for that prefix (`isXApiPath`):

- `app.use(cors())` answers `OPTIONS` itself with `*` and ends the response, so
  an /xApi preflight would never have reached the guard. Preflight is answered
  by the guard instead — which means a refused origin gets its **403 at
  preflight** and the browser never sends the real request.
- the hand-rolled header middleware sets `Access-Control-Allow-Origin: *`
  *together with* `Access-Control-Allow-Credentials: true`. That pair is invalid
  per the Fetch spec — a browser rejects a credentialed response whose ACAO is
  `*` — and, registered later, it would have overwritten the reflected origin.
  It still applies unchanged to every non-`/xApi` route.

`Vary: Origin` is set on every response including refusals, so a shared cache
cannot hand one origin's allowed response to another.

**Not done, and worth knowing:** the allow-list is global, not per partner.
`activity_users` has no `allowed_origins` column, so partner A's frontend origin
is accepted on partner B's token. Per-partner origins would need a migration on
a table whose DDL is not in the repo (§18.3) and could not cover the
unauthenticated routes at all — there is no partner to look up. If that matters,
the seam is `checkOrigin` plus the `apiUser` row the controller already holds.

Tests: `scripts/test_out_api_flow.js` sections [10]–[11] (pure functions) and
`scripts/test_xapi_origin_guard.js` (boots the real app on a spare port and
asserts the middleware ordering, the reflected headers and the preflight —
needs no MySQL or Redis).

### 18.10 Optional auth and the default-markup fallback (2026-09-03)

Four of the priced routes now serve the public **and** logged-in partners from
one code path:

| Route | Before | After |
|---|---|---|
| `/xApi/activity_list` | Bearer required | Bearer **optional** |
| `/xApi/get_activity_by_id` | Bearer required | Bearer **optional** |
| `/xApi/bestseller` | public, **net prices** | public, **marked up** |
| `/xApi/popular_experiences` | public, **net prices** | public, **marked up** |
| `/xApi/explore_destinations` | public | unchanged — it carries no prices |

**Pricing, in one rule.** The caller's own rules first; the global default rule
as the fallback; NET only if not even a default matches.

```
   token sent ──► rules for this partner (own + unscoped, priority order)
                       │ no match for this item
                       ▼
   no token ─────► GLOBAL DEFAULT rules  (is_default = 1 AND api_partners empty)
                       │ no match either
                       ▼
                  NET price + loud WARNING
```

**An anonymous caller is eligible for the global defaults ONLY** — not for every
unscoped rule. An unscoped non-default rule (a Dubai promotion, say) is written
for partners; letting it price public traffic means public pricing has two
sources and "why is this number wrong?" stops being answerable. `is_default = 1`
*with* `api_partners` set is **not** a global default: that row is a scoped rule
for the partners it names, which is the §18.5.6 fix restated.

**The fallback is the fix for the net-price leak.** Previously an item matching
no rule was returned unchanged and the `unmatched` counter warned about it —
i.e. our net cost shipped as a selling price, and all we did was log it. Now it
falls through to the defaults. Reaching NET requires every default rule's own
country/city/date/price filters to exclude the item; a default rule with **no
filters at all** makes that state unreachable, and configuring one is the
operational requirement this design leans on.

The default is not applied outside its own scope. Pricing an item by a rule
written for a different country is a different wrong price, not a safer one, so
the loud warning stands instead.

**A missing token is anonymous; a bad token is still 401.** Only an absent
`Authorization` header means anonymous
(`xApiAuthService.authenticateOptionalXApiToken`). Expired, malformed, wrong
`typ`, deactivated partner — all still 401. Downgrading a rejected token to
anonymous would quietly re-price a partner at DEFAULT rates while returning 200,
which surfaces as a billing dispute weeks later rather than an error in the
first second.

**Response shape is unchanged** except that `api_user_ref` is present only when
a token was accepted. Its absence is how an integrator detects that their token
never arrived — otherwise an anonymous response and an authenticated one are
indistinguishable, prices included.

**Per-item currency on the storefront lists.** `shapeStorefrontActivity` keeps a
row in its own currency when conversion fails (`price_converted: false`), so a
batch can be mixed. `applyOutApiMarkup` takes an optional
`resolveItemCurrency`, and the two storefront routes pass it: a fixed markup is
converted into whatever currency that row is actually quoted in. Adding an
INR-converted fixed amount to a price still in THB is wrong in both currencies.
Percentages need no such care.

**What going public actually costs, stated plainly.** `activity_list` and
`get_activity_by_id` were the two routes standing behind a token. They are not
any more, so the whole BW catalogue — names, descriptions, availability and
default-marked-up prices — is readable by anyone who can reach the service. The
origin allow-list (§18.9) is a browser control and does not change that; nor
does the markup, which sets the price but does not gate the read. Three
consequences worth deciding on deliberately rather than discovering:

- **Source grant.** An anonymous caller has no `allowed_sources`, so
  `parseGrant` returns the `DEFAULT_GRANT` of `'BW'` — own inventory only, never
  GlobalTix. A source grant is something a partner is given, not something the
  public inherits.
- **`open_for_countries`.** That filter is driven by the partner's country. An
  anonymous caller has none, so it does not narrow anything and country-locked
  inventory becomes publicly visible. If that inventory must stay hidden, the
  filter belongs in the adapter — do not invent a country here.
- **Rate limiting.** There is none on this service. An unauthenticated
  `activity_list` with no `limit` is an unbounded catalogue read plus a pricing
  pipeline pass, available to anyone. That was survivable behind a token.

**The `userId = null` invariant is untouched** and matters more now, not less:
the adapters still return true net rates and `xApiMarkupService` is still the
only markup layer. A real `userId` reaching the adapters would double-mark-up
every response, public ones included.

Tests: `scripts/test_out_api_flow.js` sections [12]–[15] (optional auth,
anonymous eligibility, the fallback arithmetic including nested prices, the
currency cache key) and `scripts/test_xapi_origin_guard.js` (all four routes
answer without a token and still 401 on a bad one).

### 18.11 `/xApi/get_activity_by_ids` — the batch route (2026-09-03)

The Out API port of `/v1/get_activity_by_ids`
(`activityFrontController.getActivityHolidaybyIds`, the holiday-package
cross-sell lookup). Same pipeline as §18.8, same optional auth and default-markup
rules as §18.10:

```
POST /xApi/get_activity_by_ids
  -> xApiAuthService.authenticateOptionalXApiToken   (Bearer JWT, or anonymous)
  -> xApiActivityDetail.service.getActivitiesByIds
       -> xApiSourcePolicy.service                   (BW grant required)
       -> bwActivityAdapter.getActivitiesByIds       (the generalised query)
       -> shapeItem()                                (the SAME allow-list)
  -> xApiMarkupService.applyOutApiMarkup             (own rules, then default)
```

**The adapter was generalised rather than copied.**
`bwActivityAdapter.getActivityById` (§18.8) became `getActivitiesByIds`, taking
an id list, and `getActivityById` is now a thin single-id wrapper over it. The
two `/v1` originals differ in exactly one respect — `get_activity_by_ids` carries
`meal_activity = 0` in its base WHERE and `get_activity_by_id` does not — so that
is an `excludeMealActivities` flag, not a second copy of the query. Everything
else (weekday filter, `open_for_countries`, the `expired` / `booking_closed` CASE
expressions, `ORDER BY score DESC`, the relation load, the pricing pipeline) is
byte-identical between them. `id IN (?)` with one id is the same primary-key plan
as `id = ?`, so the single-id caller loses nothing.

`listActivities` is untouched, and so are both `/v1` endpoints — §17.5's note
about migrating them still stands, and the adapter now covers both.

**The meal-activity divergence is preserved, not harmonised** (Darpan,
2026-09-03). The two `/v1` routes genuinely disagree, and this is a faithful port
of each; a caller passing a meal activity's id to the by-ids route gets that id
back in `missing_ids`, exactly as on `/v1`. Harmonising them is a `/v1` decision,
not one to make silently on the way through.

**Body:** `ids` (required — an array, a single value, or a comma-separated
string), plus optional `travel_date` / `daydate` / `from_date` (all three
spellings accepted, since the three source endpoints each named it differently),
`guest_currency`, `include_bw`.

**Three things this adds over `/v1`:**

1. **A cap of 100 ids**, over which the request is a `400 too_many_ids`. `/v1`
   has none, which was survivable behind a staff JWT; on an optional-auth route
   an unbounded `IN` list is a bulk catalogue read anyone can issue, and each id
   costs a relation load and a pricing-pipeline pass rather than just a row.
   Rejecting rather than truncating means a caller who sends 150 finds out,
   instead of silently losing 50 off the end of their list.
2. **Non-numeric ids are rejected and named**, not dropped. Answering
   `["12","abc"]` with only activity 12 hides that the second id was never
   looked up.
3. **`requested_ids` and `missing_ids`** on the response. Both are the caller's
   own data, not internal detail, so they do not widen the §18.7 contract. An id
   lands in `missing_ids` when: no such row, `status != 1`, it is a meal
   activity, the wrong weekday for the requested date, country-locked away from
   this partner, or dropped by the pricing pipeline for having no sellable
   inventory that day. The controller logs `matchedRows - returnedRows`
   separately, because that difference is precisely "the id exists but is not
   bookable that day" — the one case the caller cannot distinguish alone.

Ids are deduplicated (preserving the caller's order) **before** the cap is
applied, so a repeated id is not requested twice and returned once — which would
otherwise make `totalRecords` disagree with the id count for a reason that is not
a missing activity. Row ids are compared to requested ids **as strings**: mysql2
returns `id` as a number, and `[1].includes('1')` is false, so getting that wrong
would report every id as missing.

`cmd` is `x_get_activity_by_ids`. Note `/v1` returns `cmd: "get_all_activities"`
on this endpoint — a copy-paste left in the original, not followed here.

Tests: `scripts/test_out_api_flow.js` sections [16]–[18] and the two anonymous
`400` checks in `scripts/test_xapi_origin_guard.js`.

### 18.12 `/xApi/get_time_slots` (2026-09-03)

The Out API port of `webController.getAllTimeSlots` (`/web/get_time_slots`) —
the first port from `webController` rather than `activityFrontController`, but
the same reason applies: it resolves identity through
`GlobalService.getUserDetails(authorization)` against `website_users` and returns
`400 "Unauthorized Access"` to every Out API caller.

```
POST /xApi/get_time_slots
  -> xApiAuthService.authenticateOptionalXApiToken   (Bearer JWT, or anonymous)
  -> xApiTimeSlot.service.getTimeSlots
       -> xApiSourcePolicy.service                   (BW grant required)
       -> activity lookup (currency, country, city, open_for_countries)
       -> time_slot query -> convert to guest currency at NET
       -> shapeSlot()                                (slot allow-list)
  -> xApiMarkupService.applyOutApiMarkup             (own rules, then default)
```

Body: `ref_id` (**required** — the ACTIVITY id), optional `ref_date`
(`YYYY-MM-DD`) and `guest_currency`.

**The pricing path is one step shorter than `/web`'s, deliberately.** `/web`
applies BW group/country markup via `calculateCountryGlobalMarkupActivity` and
then converts currency. Only the conversion is reproduced. On this path `userId`
is null, so that markup call returns the amount untouched — it would be a
guaranteed no-op costing a round trip, and worse, it would sit in the code
inviting someone to "fix" it by passing a real id, which is exactly how partners
get double-marked-up (§18.1). Net price → guest currency → `xApiMarkupService`,
the only markup layer.

**A time slot has no currency of its own.** `time_slot` carries no currency
column; its prices are in the OWNING activity's currency, so that activity is
read first and a miss short-circuits — slots quoted in an unknown currency are
the one outcome worth refusing outright. `open_for_countries` is checked on that
activity too, so an activity a partner may not see cannot become visible through
its slots; the refusal is returned as the same empty answer a bad id gets, and
logged rather than explained.

**Rule matching needed a new seam, and this is the subtle part.**
`isRuleMatchingActivity` reads `id`, `country` and `city` off the row it is
pricing. A `time_slot` row has no country or city, and its `id` is the SLOT's id
— so a rule scoped by `activities_ids` would be compared against a slot id and
match the wrong thing, silently. `applyOutApiMarkup` therefore takes an optional
`resolveMatchContext(item)`, and this route passes the owning activity's
id/country/city plus the slot's own price (so the price band still evaluates
against what is actually being marked up). Prices are written back to the slot;
the context is read-only. Every other caller is unaffected — the default context
is the item itself.

Note `/web` has the same gap and does not notice it:
`applyMarkupToSinglePrice(row.country, ...)` reads `row.country` off a
`time_slot` row, which has no such column, so it passes `undefined` as the
activity country on every call.

**Three narrowings from `/web`** (decided 2026-09-03, Darpan):

| | `/web/get_time_slots` | `/xApi/get_time_slots` |
|---|---|---|
| `ref_id` | optional — empty body returns **every** active slot | **required**, numeric, else 400 |
| `ref_from` | `activity` or `sub-activity` | `activity` only; anything else is a 400 |
| date | exact `ref_date`, **no lower bound** | exact `ref_date`, else `inventory_date >= CURDATE()` |

The first two are about what an optional-auth route may expose: an unfiltered
call was a complete price list, and §18.7 keeps `sub_activity` out of the partner
contract entirely, so a partner has no route that yields a sub-activity id and
serving slots keyed by one leaks ids they cannot resolve. `ref_from` is
**refused** rather than ignored — returning activity slots to a caller who asked
for sub-activity slots is a wrong answer, not a filter. The third is about
usefulness: a partner asking for an activity's slots wants the ones they can
sell, not years of past dates.

`ORDER BY` gains `inventory_date, from_time` ahead of `/web`'s bare `id ASC`:
once the date filter spans a range, insertion order is not chronological order.

**The slot contract** (`SLOT_FIELDS` in the service) is an allow-list on the
same principle as `shapeItem`. Excluded: `created_by`, `updated_by`,
`approved_by`, `supplier`, `status`, `_conversion_issue` (`/web`'s internal
debug array), and — the important one — `org_adult_price` / `org_child_price` /
`org_infant_price`, which `/web` adds to every row. Those hold the price BEFORE
conversion, i.e. our net rate in the activity's own currency, which is the exact
number §18.7 exists to keep in. Each row carries `currency` (what its prices are
ACTUALLY in — the guest currency, or the activity's own when conversion failed),
`guest_currency`, `price_converted` and `activity_id`.

Tests: `scripts/test_out_api_flow.js` sections [20]–[22], including the
match-context trap tested from both sides, and two anonymous `400` checks in
`scripts/test_xapi_origin_guard.js`.

### 18.13 `/xApi/get_transfer_by_ids` — transport packages (2026-09-03)

The Out API port of `/v1/get_transfer_holiday_ids` in the **transport** service
(`booking_window_transport_packages`, `front_v1.getVendorVehiclePackagesHolidaybyIds`)
— the holiday-package transfer cross-sell lookup, and the transport twin of
§18.11. Optional auth, `activity_markup`, §18.7-style allow-list: structurally
the same route as the activity by-ids one, so the notes here are only what is
different.

**It reads another schema, on the same pool.** Transport packages are in
`fdk_transportation_package`, owned by a separate Node service. There is no HTTP
hop: both schemas sit on one MySQL instance under one credential, and this
codebase already crosses that line — `getCountryList` joins `fdk_hotels.countries`
to `fdk_transportation_package.currency` through this pool. So
`vendor_transportation_packages` and `transport_inventory` are addressed
schema-qualified (`${DB_PREFIX}fdk_transportation_package.<table>`), read-only,
with no second pool and no ORM. The one operational prerequisite is SELECT on
that schema for this service's MySQL user; it already holds it for `.currency`.

**Only conversion is reproduced from /v1's pricing, not markup.** /v1 runs
`calculateCountryGlobalMarkupTransport` and then converts. The first is skipped
for the reason §18.12 skips its activity twin: `userId` is null on this path, the
markup function returns the amount untouched without one, and `xApiMarkupService`
is the ONLY markup layer. A guaranteed no-op left in the code is an invitation to
"fix" it by passing a real id, which is how partners get double-marked-up.
Conversion stays, because a THB number shipped to an INR partner labelled INR is
a wrong price. Conversion is **all-or-nothing per row**: if any amount on a
package fails, the whole row keeps its own `currency` and carries
`price_converted: false` — a row half in THB and half in INR under one label is
undetectable by the caller.

**Three markup options the activity routes do not pass.** `xApiMarkupService`
gained `options.priceFields` and `options.nestedCollections` (additive; the
activity defaults are asserted unchanged in the tests). A transfer is priced on
`price` / `sale_price` / `sic_price` and carries per-vehicle rates in
`vehicles_price`; the activity defaults would mark up `price` alone and ship the
rest at NET. `PRICE_FIELDS` was **not** widened globally instead — `sale_price`
on an activity row is the already-marked-up selling price the pipeline wrote, and
marking it up again would double-charge every activity route. The third option is
`resolveMatchContext`, which withholds the package `id`: `activities_ids` holds
ACTIVITY ids, so transport package 12 would otherwise be priced by a rule written
for activity 12. Withholding it means such a rule cannot match a transfer and the
item falls through to the global default — the conservative outcome, and the same
trap §18.12 documents from the other direction.

**Nothing is dropped for being unbookable.** Unlike §18.11, a package that is
closed for the requested date comes back carrying `booking_closed: 1`. That is
/v1's behaviour and what a cross-sell block wants — render it greyed out, not
silently omit it. So `missing_ids` here means one of exactly three things: no
such package, `status != 1`, or country-locked away from this caller.

**`close_before_days` is in HOURS**, per the column comment on
`vendor_transportation_packages`. Cutoff = `travel_date 00:00 - N hours + 30
minutes`, ported unchanged. An inventory row's own `close_before_days` overrides
the package's.

**`travel_date` is optional**, unlike on the activity routes. Without one the
packages answer at base price with no inventory override and no
`available_time_slots` — /v1's behaviour, and what a catalogue-style render
wants. A date already past returns an empty list rather than a 400, with every
requested id in `missing_ids`. A date that cannot be PARSED is a 400: `moment(x)`
would read `10-09-2026` as invalid and silently default to today.

**One divergence from /v1, deliberate.** On the inventory path /v1 overwrites
`price` with the inventory price but leaves the stale base `sale_price` in place,
so one response carries two different numbers for the same transfer and a partner
cannot know which to book against. Here `sale_price` tracks `price` on both
paths.

**The contract** (`TRANSFER_FIELDS`, `VEHICLE_FIELDS`) excludes `purchase_price`
(net cost), `org_price` / `org_sale_price` (/v1 adds these; not reproduced),
`open_for_countries` (the visibility rule, already applied), `group_key` and
`vendor_id` (buy-side), and `status` / `score` / `is_custom` / `is_scheduler` /
`created`. `vehicles_price` **is** included, and that is a deliberate widening of
§18.7 rather than an oversight: §18.7 keeps it out of the ACTIVITY contract
because transport pricing on an activity is a separate commercial conversation —
this endpoint *is* that conversation, and the per-vehicle rate is the price the
transfer is sold at. Nested rows are allow-listed too, so a `purchase_price`
added to that admin-authored JSON later cannot ride out with them.

**Images** come from the transport service, not this one, so `publicUrl.bwUploadUrl`
is not used — it would build a 404 under this app's `/uploads`. The base is
configured as `TRANSPORT_IMG_URL` (set it to the transport service's own
`FRONT_IMG_URL`). Unset is supported, not a misconfiguration to guess around:
rows then carry the raw `package_image` only, `package_image_url` is null and the
response's `imgurl` is `""`.

Tests: `scripts/test_out_api_transfer_by_ids.js` (DB-free, 28 assertions).

### 18.14 `activity_guides` and `addon` in the partner contract (2026-09-03)

`/v1/activity_list` has returned both for a long time; the three Out API
activity routes — `/xApi/activity_list`, `/xApi/get_activity_by_id`,
`/xApi/get_activity_by_ids` — did not. This adds them to all three at once,
because all three shape their response through the one `shapeItem` (§18.7), and
that is the point of it.

**Why they were out, and what changed.** §18.7 excluded them for a real reason:
each carries its own pricing sub-shape with `org_*` fields, and passing the
arrays through whole would have shipped net cost. The fix is not to relax that —
it is to shape the arrays the way `categories` and `time_slots` are already
shaped. Two new allow-lists in `xApiActivityList.service.js` do it:

| | in the contract | dropped |
|---|---|---|
| `GUIDE_FIELDS` | `id`, `activity_id`, `title`, `min_pax`, `max_pax`, `guide_count`, `languages`, `selling_price` | `purchase_price`, `org_selling_price`, `org_purchase_price`, `supplier{}`, `supplier_id`, `status`, `created`/`created_by`/`updated`/`updated_by`, `currency` |
| `ADDON_FIELDS` | `name`, `adult_sale_price`, `child_sale_price`, `infant_sale_price` | `adult`/`child`/`infant_purchase_price`, `org_*_sale_price`, `org_*_purchase_price`, `supplier_id`, `supplier_name`, `supplier_email`, `supplier_mobile`, `supplier_company_name` |

Sell-side only (decision 2026-09-03, Darpan). The drops sit exactly where §18.7
already drew the net-cost boundary — `org_*`, `contract_price`, `supplier`,
`supplier_id` — and the nested supplier blocks are *worse* than the item's,
because they carry the supplier's email and mobile and these routes answer
without a token since §18.10.

**Why this closes a gap rather than adding a nicety.** `guide_mandatory` and
`is_addon` were already in the contract. A partner could be told a guide is
REQUIRED, or that addons exist, and had no way to see what they were or what
they cost.

**The `currency` drop on a guide is the one non-obvious call.**
`activityMarkupPipeline` converts `selling_price` into the guest currency but
leaves `guide.currency` holding the SOURCE currency — the assignment that would
fix it is commented out in that file. Emitting it would label a converted number
with the currency it was converted *from*. Guide prices are in the response's
`guest_currency`, like every other price in the payload. If the pipeline is ever
fixed, `currency` can be added back; until then it is a wrong number, not a
missing one.

**Both keys are always present and always arrays.** `activity.addon` is a
nullable JSON column and a BW row with no guides carries no `activity_guides`
key at all, so `pickAll` normalises absent/null/non-array to `[]`. A consumer
should not have to tell three states apart to learn there are no addons. GT
items already emitted `activity_guides: []` and `addon: []`
(`globalTixActivityAdapter`), so one shape covers both sources.

#### The markup half, and it is the half that matters

Shaping alone would have shipped these prices at **NET**. `userId` is null on
every Out API path, so `calculateCountryGlobalMarkup` short-circuits and nothing
marked them up upstream; `xApiMarkupService.PRICE_FIELDS` contains neither
`selling_price` nor `*_sale_price`, and `NESTED_COLLECTIONS` was `['categories',
'time_slots']`. Exposing the fields without pricing them would have published
our cost as a partner's selling price — precisely what that module's FAIL CLOSED
rule exists to prevent.

`NESTED_COLLECTIONS` entries may now be either a plain string (walked with the
item's `priceFields`, i.e. the original behaviour) or `{ name, priceFields }`
(walked with its own vocabulary). The default is now:

```js
['categories', 'time_slots',
 { name: 'activity_guides', priceFields: ['selling_price'] },
 { name: 'addon', priceFields: ['adult_sale_price', 'child_sale_price', 'infant_sale_price'] }]
```

**Why not just widen `PRICE_FIELDS`.** Smaller diff, wrong change, and for the
reason already recorded on `options.priceFields`: `PRICE_FIELDS` is applied to
the ITEM and to every nested row, so a name meaning "net, mark me up" on an
addon and "already marked up" elsewhere double-charges the day such a column
appears. Verified against the schema on 2026-09-03 — no `selling_price` or
`*_sale_price` column exists on `activity`, `activity_category` or `time_slot`
today — so scoping costs nothing now and is what keeps that true later.

**Why in the DEFAULT and not passed per route.** A route that shaped these into
its response and forgot the option would leak net cost. Being in the default
means the three activity routes get it by construction. The other callers are
unaffected: `/xApi/get_time_slots`, `bestseller` and `popular_experiences` use
the default but their rows carry neither array, and `applyOutApiMarkup` skips a
collection that is not an array on the row. `/xApi/get_transfer_by_ids` passes
its own `nestedCollections`, which replaces the default wholesale, as before.

**Backward compatibility.** Additive on all three routes — two new array fields
appear, nothing is removed or renamed. No SQL changed: `activity_guides` was
already loaded by `activityRelations.loadActivityRelations` and attached by the
pipeline for every one of these calls, and `addon` is a JSON column riding in on
`SELECT *`. Both were being fetched and then thrown away by `shapeItem`, so
there is **no additional query, row or round trip** — the only cost is a slightly
larger response and, for an activity that has guides or addons, one extra
`markUpPrice` call per nested price.

Tests: `scripts/test_out_api_flow.js` sections [24] and [25] — the allow-lists
strip cost/supplier/audit, the two keys normalise to `[]`, the nested prices are
marked up, and the guide/addon vocabularies stay scoped (a `selling_price` on
the item or on a category is *not* marked up). Suite is 153 assertions.

### 18.15 `/xApi/get_activity_supplier_inventory_booking` — the buy-side route (2026-09-03)

The Out API port of `/v1/get_activity_supplier_inventory_booking`
(`activityFrontController.getActivitySupplierInventoryForBooking`), and **the
only route on `/xApi/*` that ships buy-side data**. Everything else on this
surface is built around §18.7 — what we pay never travels with what we charge.
This route is the exception, so it is the one place where the access control,
not the response contract, is doing the work.

**What it returns:** `adult`/`child`/`infant_purchase_price` (our cost),
`supplier_name`, `supplier_company_name`, `supplier_email`, `supplier_mobile`,
`priority` (our sourcing order), `available_quantity` / `total_quantity`,
`group_key`, and the supplier's own cancellation policy with its items. Full
`/v1` parity (decision 2026-09-03, Darpan).

#### No markup, no currency conversion — both deliberate

`xApiMarkupService` is **not** called and must not be. A marked-up purchase
price is neither the cost nor the selling price, and the cost is the entire
reason a caller asks. Currency is likewise left alone: each row carries the
currency of its own supplier contract, so **one response can legitimately
contain rows in different currencies** — `activity_supplier_inventory.currency`
is per supplier contract, not per activity. Converting would round twice (here,
then again wherever the booking is actually priced) and would disagree with the
supplier's invoice. The response says `priced_in: "supplier_currency"` so no
integrator has to infer this from the absence of a `guest_currency` key.

The test suite asserts the negative directly: no field in
`SUPPLIER_INVENTORY_FIELDS` is named `adult_price`, `price`, `selling_price` or
any other member of `PRICE_FIELDS`, so wiring the markup service to this route
by accident would still price nothing.

#### One gate: a mandatory Bearer token

`resolveRequiredPartner` → `authenticateXApiToken`. A missing header is a
**401**, not the anonymous caller every other Out API route allows — a buy-side
route has no public answer to give. Expired, malformed, wrong `typ` and
deactivated partner all still 401, unchanged, because the optional path already
delegates to the same function.

**This is the first Out API route with MANDATORY auth**, and
`resolveRequiredPartner` is a shared helper rather than inline because more
`/v1` routes are being ported behind it.

**That token is the whole of the access control.** Every authenticated partner
may call this route, which means **any partner holding a valid token can read
our purchase prices and our suppliers' email addresses and mobile numbers.**
That is the accepted position (Darpan, 2026-09-03).

A per-account allow-list (`XAPI_SUPPLIER_INVENTORY_PARTNERS`, defaulting to
empty) was built and removed the same day as unnecessary friction. It is **gone,
not dormant** — the config key, the `hasPartnerGrant` / `grantDenied` helpers
and the 403 path were all deleted rather than left permanently empty, because a
allow-list that grants nobody reads as "this route is gated" to the next person
who greps for it. The suite asserts the absence
(`Config.XAPI_SUPPLIER_INVENTORY_PARTNERS === undefined`), so it cannot creep
back as dead code. Anything reintroducing per-account gating starts from
scratch.

**The consequence for the contract:** `SUPPLIER_INVENTORY_FIELDS` in the service
is now the *only* thing bounding what buy-side data leaves the building. It was
already an allow-list; it is now load-bearing. Adding a column to it is a
commercial decision, not a formatting one.

#### A finding, not a port: `/v1` has no authentication at all

`getActivitySupplierInventoryForBooking` reads `req.body`, validates two fields
and queries. It never calls `GlobalService.getUserDetails`, unlike
`getlActivityById` and `getActivityHolidaybyIds` beside it in the same
controller. **Anyone who can reach `/v1` today can read our supplier purchase
prices and our suppliers' email addresses and mobile numbers.** Nothing in this
change touches `/v1` — the route is untouched and still behaves exactly as it
did — but it should be closed separately, and it is why this port is gated
rather than merely copied.

#### Other divergences from /v1

- **A bad request is a 400.** `/v1` answers `200` with `replyCode: "error"` in
  the body. Consistency inside one API beats parity with the other.
- **Inputs are validated.** `/v1` passes `activity_id`, `supplier_id`,
  `category_id` and `travel_date` straight into the query. They are bound
  parameters, so this is not an injection — but MySQL casts `'12abc'` to `12`
  with a warning and casts a malformed date to `NULL`, which matches no row and
  is indistinguishable from "no inventory that day". Dates are parsed with
  **strict** moment: non-strict accepts `'2026'` and invents the missing parts,
  which means querying a day nobody asked for. An invalid *optional* filter is
  rejected rather than dropped, because dropping it silently widens the answer.
- **Grouping uses a `Map`, not a plain object.** `asi.id` is a bigint, and
  integer-like keys on a plain object are enumerated in numeric order ahead of
  string keys — which would silently undo the `ORDER BY asi.priority ASC,
  asi.adult_purchase_price ASC` the query just applied. On a route whose purpose
  is to *choose* a supplier, losing the cheapest-first ordering is the one bug
  that would look like working software. `Map` preserves insertion order for
  every key type.
- **The response is allow-listed** even though nothing is excluded today, for
  the §18.7 reason: `activity_supplier_inventory` is admin-written and has
  already grown a `supplier` JSON blob and `created_by`/`updated_by` columns. A
  column added later must not reach a caller by default — and on a route whose
  caller is already trusted with cost, the next column could be anything.

**A regression from removing the allow-list, and the guard added because of
it.** Deleting the gate removed a contiguous block of `xApiController.js` that
also contained `warnOnNetPriced` and `storefrontItemCurrency` — two helpers with
ten call sites across the priced routes — producing a `ReferenceError` and a 500
on `/xApi/activity_list`. Nothing caught it: `node --check` validates syntax,
and a call to an undefined name is valid syntax; the DB-free suites never invoke
the route handlers, and the ReferenceError is thrown at CALL time, not at module
load. `scripts/test_out_api_supplier_inventory.js` section [6] now resolves
every function call in the controller against the names defined or imported in
it, and is verified against a deliberate reintroduction of the same deletion.
Any edit that deletes a helper while a caller survives now fails there.

Tests: `scripts/test_out_api_supplier_inventory.js` (DB-free, 20 assertions) —
the route cannot be reached without a valid token, the removed allow-list has
left nothing behind, the guards all return before the query, the contract keeps
/v1 parity while stripping the blob and audit columns, and the wiring
assertions read the controller source to prove the action uses mandatory auth,
has no 403 path left, and calls neither `applyOutApiMarkup` nor any conversion.
Section [6] is controller-wide rather than specific to this route.

### 18.16 `/xApi/get_transport_supplier_inventory_booking` — the second buy-side route (2026-09-03)

The Out API port of the TRANSPORT service's
`/v1/get_transport_supplier_inventory_booking`
(`front_v1.getTransportSupplierInventoryForBooking`), and the transport twin of
§18.15. There are now **two** buy-side routes on `/xApi/*`, and they are
governed identically: mandatory Bearer token as the whole of the access
control, no markup, no currency conversion, response bounded by an allow-list.
§18.15 is the authority for all of that; this section covers only what is
different.

**What it returns:** `sic_price` and `vehicles_price` (the supplier's per-vehicle
rate card — our cost either way), `supplier_name`, `supplier_company_name`,
`supplier_email`, `supplier_mobile`, `group_key`, and the supplier's own
cancellation policy with its items. Full `/v1` parity.

**Where the data lives.** `transport_supplier_inventory` is in
`fdk_transportation_package`, so the base table is schema-qualified on this pool
— the same cross-schema arrangement §18.13 uses, read-only. The three supplier
tables it joins (`suppliers`, `supplier_cancellation_policies`,
`supplier_cancellation_policy_items`) are in `fdk_holidays`, which the activity
twin already reads, so no new grant is involved.

**Three columns the activity twin has and transport does not**, which is why the
shapes differ: no `category_id` (transport packages have no categories), no
`available_quantity` / `total_quantity`, and no `priority`. The sourcing sort is
therefore `sic_price ASC` alone — cheapest-first IS the supplier order here,
where the activity route puts `priority ASC` ahead of price.

**`vehicles_price` passes through unshaped**, and this is the one place the two
transport routes deliberately disagree. On the SELL side (§18.13) every vehicle
row is allow-listed, because a `purchase_price` inside that admin-authored JSON
must never reach a reseller. Here the caller is already trusted with cost by the
§18.15 decision, so an allow-list buys no protection it does not already have —
while filtering a drifting, admin-authored rate card risks dropping the exact
rate a booking needs, and a missing rate on a sourcing feed is the worse
failure. Top-level fields and policy items stay allow-listed either way.

#### `/v1` FINDING: THE FILTERS ARE CONCATENATED INTO THE SQL

Recorded here because it is not reproduced in the port, and because it is worse
than the equivalent finding in §18.15. `getTransportSupplierInventoryForBooking`
builds its WHERE by string append:

```js
whereCondition += `AND tsi.transport_id = ${transport_id} `;
whereCondition += `AND tsi.inventory_date = '${travel_date}' `;
whereCondition += `AND tsi.supplier_id = ${supplier_id} `;
```

Three request-body values straight into the statement, on a route with **no
authentication at all** (same as §18.15's finding against the activity route).
§18.15 could say of its own original that the values were at least bound and the
hazard was only MySQL's silent casting; here they are not bound. Note also that
the ACTIVITY sibling of this same endpoint DOES bind its parameters — so this is
a divergence between two services that were written from each other, not a house
pattern, which is what makes it likely to be an oversight rather than a decision.

The port binds and validates all three. The `/v1` route itself is untouched and
is in the transport repo.

**A fourth divergence, additive to §18.15's three:** `/v1` answers a failure with
`replyMsg: e.sqlMessage || e.message`, naming our schema to the caller on any
error. Logged here, never returned; the controller answers a flat 500.

Tests: `scripts/test_out_api_transport_supplier_inventory.js` (DB-free), which
mirrors the §18.15 suite including its controller-wide guard.

### 18.17 `/xApi/create_booking` — the first write route (2026-09-03)

The Out API port of `webController.create_booking` (§6.4), and the first route
on this surface that changes anything. Code:
`services/xApiBooking.service.js` (all the logic),
`services/xApiBookingMail.service.js` (confirmation mail + voucher),
`xApiController.createBooking` (the thin edge),
`scripts/test_out_api_create_booking.js` (30 DB-free tests).

**The token is identity, not just permission.** Mandatory Bearer, like the two
buy-side routes — but here the authenticated partner also *is* the customer:

```
fd_activities_booking.agent_id  = activity_users.id      (the partner)
fd_activities_booking.staff_id  = NULL                   (no staff involved)
fd_activities_booking.source    = 'ACT'                  (the Out API channel)
```

`agent_id` in the request body is **ignored**, so a partner cannot spend another
account's balance by naming it. `/web` trusts the body for this because it also
has a staff JWT; there is no staff here.

> **For whoever reports on `fd_activities_booking`:** `agent_id` now holds ids
> from two id spaces — `fdk_holidays.website_users.id` for `/web` bookings and
> `fdk_activity.activity_users.id` for these. `staff_id IS NULL` is what tells
> them apart. If that is not good enough, the answer is a dedicated
> partner/channel column, not a reinterpretation of `agent_id`.

**The wallet is ours now, and the debit is part of the transaction.** `/web`
reads `fdk_holidays.website_accounts` and then moves the money by POSTing to the
external FB Admin API (`accountService.insertAccountTransaction`). A partner has
no row in the holidays schema, so this route reads
`${DB_PREFIX}fdk_activity.website_accounts` keyed by the partner — and, as of
2026-09-03 (Darpan), **does not call FB Admin at all**. It writes the debit
itself, which fixes both of §6.4's money problems at once:

| | `/web/create_booking` | `/xApi/create_booking` |
|---|---|---|
| balance read | bare `SELECT`, no lock → two concurrent bookings by one agent can both pass (§6.4 pt 3) | newest row `SELECT … FOR UPDATE` **inside** the transaction → the second waits |
| the charge | HTTP POST **after** COMMIT; if it fails or the retries run out, the booking exists and nobody was charged, with nothing to roll back (§6.4 pt 7) | an `INSERT` in the **same** transaction as the booking and the decrement |
| failure mode | half-succeeded states are normal and have to be reconciled by hand | there are none: all three commit or none do |

`website_accounts` is a **ledger, not a balance**: every transaction is a row
and the current balance is the newest row's `available_balance` — which is how
every existing read of it works (`ORDER BY id DESC LIMIT 1`, in `wallet.js` and
in `/web/create_booking` alike). A booking therefore INSERTs a row with
`available_balance = previous - total_price`, a POSITIVE `amount`, and
`account_type = 'activity_booking'` naming the direction, which is the
convention the FB Admin payload already used (`wallet.js` does `Math.abs()` on
the amount for the same reason).

The columns are confirmed against the live table (Darpan, 2026-09-03).
`website_accounts` is forty columns wide and shared with flights, hotels, visas
and gateway settlement, so a wallet-funded activity booking sets only its own
fifteen and leaves the rest to their defaults — everything about payment
gateways, credit reversal, OTC balances and the cross-product ids
(`visa_id`, `po_id`, `transaction_id`, `cancelled_ticket_id`) is not ours to
fill. Three of them are not obvious:

- **there is no `type` column.** What the FB Admin payload called `type` is
  `booking_type` on the table, and it takes `'fd_activity'` here.
- **`drcr` carries the sign**, set to `'DR'`. `amount` (and `actual_amount`)
  stay POSITIVE — which is why `wallet.js` does `Math.abs()` before sending an
  amount anywhere.
- **`status = 1` is load-bearing.** The balance read filters on it, so a row
  written with any other status is invisible to the next booking's balance
  check: the partner would spend the same money twice.

`WALLET_TABLE` and `debitPartnerWallet` in `services/xApiBooking.service.js` are
the only two places that touch this table.

**Lock order is wallet, then inventory, always.** Two bookings by one partner on
two different activities would deadlock if either could take them the other way
round.

**Inventory is frozen, then deducted or released.** `/web/create_booking` never
touches inventory at all and can oversell a date without noticing. This route
does not.

*Which row moves* is decided by `reference_object.inventory_source` — the same
field the package-booking deduction branches on, and the same one
`/xApi/activity_list` already puts on every item. The three are **alternatives,
not layers**: exactly one row is decremented per booking.

| `inventory_source` | table | column | matched on |
|---|---|---|---|
| `time_slot` | `time_slot` | `available_quantity` | slot id, verified against `ref_from='activity'` + `ref_id` + `inventory_date` |
| `category` | `activity_inventory` | `available_quantity` | `(activity_id, category_id, activity_date)` — the `uk_activity_inventory` key |
| `inventory` | `activity_inventory` | `available_quantity` | same key with `category_id = 0` (the activity-level row, §6.1 tier 2) |
| `base` / `master` / `activity` | `activity` | `qty` | activity id |

The ids come out of `reference_object` the way the front end fills it in:
`selected_category_id` → the `categories[].selected` row → `default_category`;
`isSelectedTimeSlotObject.id` → `time_slots[0].id`. A top-level `category_id`,
`time_slot_id` or `time_slot` overrides both. `resolveInventoryTarget` is the
only place that decides this. Two traps it exists to avoid:

- **`reference_object.time_slot` is a 0/1 FLAG**, not a slot id — the top-level
  `time_slot` is the id. Reading the flag would decrement `time_slot#1`, a real
  row belonging to someone else.
- **A source that names a row without an id is refused**, not quietly redirected
  to a different row. `inventory_source: 'time_slot'` with no slot id anywhere
  is a `no_time_slot` 400. Only a MISSING or unrecognised source falls back, and
  then by specificity: slot → category → `activity.qty`.

| Phase | What happens |
|---|---|
| FREEZE | inside one transaction the target row is `SELECT … FOR UPDATE`d and decremented, with `<column> >= units` **inside the UPDATE**. The package-booking code writes `GREATEST(COALESCE(qty,0) - n, 0)`, which cannot fail and therefore cannot stop an oversell; this refuses instead. |
| DEDUCT | the decrement commits with the booking row — "booked" and "inventory taken" are one atomic fact, with no window between them. |
| RELEASE | anything that fails — the wallet debit, the INSERT, a lock timeout, the driver — rolls the decrement back with the transaction. Since the debit moved inside the transaction there is no step left that can fail after the commit, so `ROLLBACK` **is** the release and there is no compensation path to get wrong. |

Decisions taken 2026-09-03 (Darpan), each of which is a single place in the code
if it ever changes:
- **units = `no_of_travellers.travellers`, else adult + child** (`computeUnits`)
  — the package-booking rule verbatim. INFANTS DO NOT CONSUME A UNIT. This
  reversed the first draft of this route, which counted infants; matching the
  existing production code won.
- **inventory is mandatory.** No row for the resolved target is a `no_inventory`
  (or `no_time_slot`) 400, not a free booking; too few units is
  `insufficient_inventory`. `/web` would have written the booking in both cases,
  and the package-booking code would have clamped the quantity at 0 and carried
  on.
- **a NULL quantity means unavailable, not unlimited**, in all three tables.
  `<column> >= units` is NULL for such a row, the UPDATE matches nothing, and
  the booking is refused. A row that should sell without a cap needs a number.
- **a travel date is now REQUIRED** (`/web` tolerates its absence) — it is half
  of the inventory key. It is taken from `from_date`, falling back to
  `reference_object.activity_date`, so the unmodified `/web` payload still works.

**Two divergences from `/web` worth knowing.** The duplicate guard compares
`total_price` against `total_price` with `ABS(a - b) < 0.01`; `/web` compares
the `price` COLUMN against the incoming `total_price` VALUE with `=`, so its
guard only fires on unmarked-up bookings and only when float equality happens to
land. And `generateBookingRef` is bounded at 20 attempts rather than looping
forever — with ~2.2 billion candidates an unbounded loop is not collision
protection, it is a hang waiting for a query that keeps failing.

**Mail.** `xApiBookingMail.sendBookingConfirmationMail` is fire-and-forget from
`setImmediate`, swallows every error (the booking is committed and the wallet
already charged by the time it runs), and produces the SAME PDF voucher as `/web` via
`BookingPdfService`. It goes to the partner's own address only: there is no
staff recipient and no ops CC, and the sender is `Config.SMTP_FROM_BW`
unconditionally, because a partner's `Origin` matches neither of the two brands
`/web` switches on.

**Still open**
- **`drcr = 'DR'` and `actual_amount = amount` are the two values in the debit
  row that were inferred rather than confirmed** — the column names came from
  the live table but not their types or defaults. If `drcr` is an enum with
  other members, or `actual_amount` means something other than "the same figure
  before forex", `debitPartnerWallet` is a two-line fix.
- **The debit is `total_price`, in the booking currency** — the same figure
  `/web` sends the ledger. If a partner books in AED against an INR wallet,
  nothing on this path converts it. `total_price_inr` is carried on the booking
  row and would be the obvious alternative, but changing it is a commercial
  decision, not a code one.
- **Nothing writes to `booking_transactions`.** `/web` does not either on this
  path, but the wallet row is now the only record of the charge; if the
  financial ledger in §6.6 is meant to see partner bookings, that is a second
  INSERT in the same transaction.
- `activity_supplier_inventory` is NOT decremented — buy-side allocation is
  still whatever ops does by hand. Sell-side only, deliberately, for now.
- **`capacity` / `category_qty` are ignored.** A category like "VIP Seat For 2"
  has `capacity: 2`, and the front end sends `category_qty` per category, so a
  capacity product arguably consumes `category_qty` units rather than pax. The
  package-booking code deducts pax regardless and this route matches it; if
  capacity products are meant to be sold this way, `computeUnits` is the one
  place to change.
- Cancellation does not exist on this surface yet, so nothing gives inventory
  back after the booking succeeds. `releaseFrozen` is written to be reusable by
  it when it lands.

### 18.18 `/xApi/wallet_balance` — the read half of the wallet (2026-09-03)

`POST /xApi/wallet_balance`, mandatory Bearer. Answers "how much can I spend",
and with an optional `{ amount }` in the body, "can I afford this" —
`getWalletBalance` in `services/xApiBooking.service.js`,
`xApiController.getWalletBalance`.

It lives in the booking service on purpose: `WALLET_TABLE` and the
"newest row wins" rule are the two things about this table that are easy to get
wrong, and one owner beats two copies.

```
{ "status": true, "replyCode": "success", "cmd": "x_wallet_balance",
  "data": { "user_id": 7, "has_wallet": true, "available_balance": 98650.05,
            "as_of": "2026-09-03 18:10:00",
            "requested_amount": 4500, "sufficient": true, "shortfall": 0 } }
```

Four decisions worth keeping:

- **The token is the only thing that selects the wallet.** There is no `user_id`
  in the body and there must never be one — an id in a request body is not an
  authorisation, and this route would otherwise read any partner's balance.
- **No `FOR UPDATE`, no transaction.** `lockPartnerWallet` locks because it is
  about to spend; locking here would make a balance check block bookings, which
  is exactly backwards.
- **No wallet row is a zero balance, not a 400.** "You have no account row" and
  "your balance is 0" are the same fact to a caller deciding whether it can
  book, and an error pushes them to retry something that will never change.
- **The response is an allow-list of two columns.** `website_accounts` is forty
  columns of shared ledger — gateway signatures, gateway payloads,
  credit-reversal state, and this user's flight/hotel/visa booking ids. A
  "recent transactions" block means deciding what a partner may see of their
  other products, which is a product decision, not a formatting one.

`sufficient` uses the same `>=` the booking path uses, so an exact balance
passes both. If the two ever disagree this route is lying about bookability;
there is a test pinning them together.

### 18.19 The read side — `bookings_list`, `booking_details`, `transactions_list` (2026-09-03)

`services/xApiBookingList.service.js` + three thin handlers, all mandatory
Bearer. `xApiBooking.service.js` writes these two tables; this one only reads
them. Tests: `scripts/test_out_api_booking_read.js` (27, DB-free).

**The tenancy gate is two conditions and they are inseparable.**

```sql
FDA.agent_id = ? AND FDA.source = ? AND FDA.reference_type = 1   -- ? = partner id, 'ACT'
```

`agent_id` holds ids from two overlapping id spaces (§18.17):
`fdk_holidays.website_users.id` for `/web` bookings, `activity_users.id` for Out
API ones. **Partner #7 and BW agent #7 are different people**, so scoping on
`agent_id` alone would hand one the other's bookings — guest names, contact
numbers, prices. `source = 'ACT'` (commit 0fa1b93) is what separates them, which
is why the pair lives in ONE constant, `PARTNER_SCOPE`, and is never written out
at a call site. A future route that scopes on `agent_id` alone is a
cross-tenant leak, not a missing filter; there is a test that walks every query
on every path and asserts both halves are present and bound in order.

`booking_details` returns the SAME 404 for a booking that does not exist and one
belonging to another partner. Distinguishing them turns the route into an oracle
for guessing references.

**The responses are allow-lists** (`BOOKING_FIELDS`, `BOOKING_DETAIL_FIELDS`,
`TRANSACTION_FIELDS`), for the reason §18.7 exists. `/web/get_booking_list`
selects the whole row: `purchase_price`, `supplier_contract`,
`confirmed_supplier`, `ops_supplier_amount`, `supplier_doc` — our cost and our
suppliers' identities, shipped to the partner reselling the product. Also out:
`agent_id`/`staff_id`, the payout and ops-workflow flags, and

**`reference_object` is the exception, and the interesting one.** It is out of
the LIST contract (fifty of them is a wall of JSON) but `booking_details`
returns it — asked for 2026-09-03, Darpan — because a partner reconciling a
booking wants the activity snapshot they booked against.

It goes out through `stripBuySide`, which recursively removes every buy-side key
at any depth: `supplier_contract`/`supplier*` and the supplier's name, email and
mobile; anything matching `*purchase_price`; `org_*_price` (the net rate §18.7
keeps out of every contract); `contract_price`; the currency-conversion blocks.
Arrays keep their shape, sell-side fields (`adult_price`, `mrp_*`,
`available_quantity`, `price_breakdown`, `time_slots`) are untouched, and the
walk is depth-capped — at the cap the branch is DROPPED, never returned
unfiltered, so the failure mode is "less data", never "unfiltered data".

> The sanitiser is not there because of the partner's own input — they posted
> this object, so echoing it back tells them nothing new. It is there because
> **`reference_object` is writeable after the fact**: `/web/update_booking`
> takes it straight from its request body, so an ops edit can drop a supplier
> contract into a partner's booking row long after that partner last touched it.
> Sanitising on the way out means that can never become a leak.

`transactions_list` reads the same `website_accounts` rows `create_booking`
writes, `status = 1`, newest first, scoped by `user_id` from the token alone.
Ten columns: `id, booking_id, booking_type, reference_id, account_type, drcr,
amount, available_balance, narration, createdAt`. Everything gateway
(`payment_details`, `payment_signature`, `paytm_txt_token`), credit-reversal,
OTC and cross-product (`visa_id`, `po_id`, `cancelled_ticket_id`) stays out.

**Smaller decisions**
- **`status` is a word, not a flag.** `PENDING` / `CONFIRMED` / `CANCELLED` map
  onto `status` + `ops_confirmed` in `STATUS_FILTERS`, and every row carries a
  computed `booking_status`. The two-flag encoding (`status = 0` is a void row;
  `ops_confirmed` 0/1/2 is the ops workflow — which is how `/web`'s own
  analytics counts them) stays internal and can change without breaking callers.
- **`date_type` and `sort_order` are interpolated into the SQL**, so both are
  fixed maps with a default, never the caller's string. Every other filter is a
  bound parameter, `LIMIT`/`OFFSET` included. `date_type` takes two spellings of
  the same two choices (`DATE_COLUMNS`, 2026-09-08): `booking_date` / `booking`
  -> `FDA.created`, `travel_date` / `travel` -> `FDA.from_date`. The
  `*_date` pair is what `/web/get_booking_list`'s callers already send, so a
  caller moving between the surfaces does not translate; the bare pair is the
  published Out API contract and stays live. Anything unrecognised falls back to
  the booking date rather than erroring — the default this route always had, and
  the reason a `DROP TABLE` in `date_type` is inert. Travel matches on the START
  date only (`FDA.from_date`), the same as `/web`.
- **`limit` is capped at 100**, default 20; pagination is the
  `{ total, page, limit, total_pages }` block `/xApi/country_list` already uses.
- No agent join. On this surface the partner IS the agent, so `/web`'s four
  `AG.*` search terms have nothing to match and are dropped.

**Still open**
- No cancellation route, so nothing gives inventory or money back after a
  booking succeeds (§18.17). These three routes only observe.
- `voucher_pdf` is returned as stored. If it holds a path rather than an
  absolute URL, a partner cannot fetch it — worth checking against a real row,
  the same way `bwUploadUrl` fixes the image fields elsewhere.

### 18.20 Account self-service — password reset and profile (2026-09-03)

Four routes: `forgot_password` and `reset_password` (public, in
`xApiAuthService`), `get_user_details` and `update_user_details` (mandatory
Bearer, in the new `services/xApiProfile.service.js`). Reset mail lives in
`xApiMailService.sendPasswordResetMail`. Tests:
`scripts/test_out_api_account.js` (34, DB-free).

**The reset is the login flow's shape, reused.** A token that carries the
deadline, a code that carries the proof, a budget on guesses:

```
POST /xApi/forgot_password  { username }
      -> 200 { reset_token, expires_in_minutes: 15 }   + a 6-digit code by email
POST /xApi/reset_password   { reset_token, otp, new_password }
      -> 200. No access token: sign in through /xApi/login afterwards.
```

**`forgot_password` answers identically for an account that exists and one that
does not** — same 200, same message, same shape, a `reset_token` either way.
That last part is the awkward one, because a token can only be minted for a row
we found, so an unknown identifier gets a correctly-signed token whose subject
is nobody (`issueDecoyResetToken`: `unknown: true`, no id). `resetXApiPassword`
rejects it with the same message a wrong code gets, *before any query*. Without
this the route is a partner enumerator — the exact criticism §18.2 makes of
`/api_user/list`. Inactive accounts, unverified accounts, accounts with no email
on file and a failed SMTP send all take the same generic answer, and none of
them mails anything.

Everything else follows the login path deliberately: code persisted before it is
mailed, `checkAccountState` re-run at step 2 (an account disabled inside the
15-minute window cannot finish), the code burned on success and on a spent
budget, and **fail-closed when Redis is down** — one wrong guess burns the code
rather than leaving an uncounted brute-force window. The reset counter is its own
Redis key: sharing the login one would mean a fumbled login code eats a reset
attempt.

Three smaller decisions in `reset_password`:
- **No access token comes out of it.** Proving you can read the mailbox is not
  logging in, and a stolen reset token should not convert to a session in one
  call.
- **The 72-byte ceiling is not cosmetic** — bcrypt silently truncates past it,
  so without the check two different long passwords could open one account.
  Minimum is 8.
- **Reusing the current password is refused**, and that check runs only *after*
  the code matched, so it cannot be used to test a guess against an account the
  caller has no code for.

> **Known interaction:** the reset code lives in `activity_users.mail_otp`, the
> same column the login OTP uses. One column, one pending code — requesting a
> reset invalidates a login code already in flight, and vice versa. That also
> means an unauthenticated caller can clobber a partner's pending login OTP by
> hitting `forgot_password`. It is a nuisance, not an escalation (the reset code
> goes to the partner's mailbox, not the caller's), and fixing it properly means
> either a second column or moving reset codes into Redis. There is no rate
> limiter on this service at all, which is the more general version of the same
> gap.

**The profile routes are allow-lists in both directions.** `PROFILE_FIELDS`
decides what a partner may read of their own row — `password` and `mail_otp` are
already gone via `sanitizeUser`, and the wallet columns (`wallet`, `deposit`,
`credit`, `otc_*`), `allowed_sources` and `pan_data` are excluded too. The real
balance is `/xApi/wallet_balance`; a stale copy on a profile is worse than none.

`EDITABLE_FIELDS` is the half that matters. Without it `update_user_details` is
a self-service route to `status = 1`, `verified = 1`, `credit_limit = 999999`,
`role_id`, `allowed_sources` — or to `password`, written in whatever format the
caller fancied, bypassing bcrypt entirely. Also not writeable: `email` and
`user_reference_id`, because changing an email without proving control of the
new mailbox hands over the account (the login OTP goes there). Anything outside
the list is **ignored, not rejected** — a client that posts the whole profile
back still works — and the response names it in `ignored_fields`. The row is
addressed by the token id in the `WHERE`, never by anything in the body, and
the reply is the row read back rather than the input echoed.

`get_user_details` runs **no query**: `authenticateXApiToken` already re-read the
row for this request (that is how a deactivated partner is locked out
mid-token), so `apiUser` is the freshest copy there is.

### 18.21 `/xApi/change_password` (2026-09-03)

The authenticated sibling of §18.20's reset flow: for the partner who KNOWS
their password. `changeXApiPassword` in `xApiAuthService`, mandatory Bearer,
body `{ current_password, new_password, confirm_password? }`. The current
password is the proof, so there is no code and no email round trip — which is
exactly why the forgot/reset pair is public and this one is not.

Same policy as a reset (8 characters minimum, 72 bytes maximum because bcrypt
silently truncates past it, must differ from the current one), and the new value
is written as bcrypt through the same `xApiPasswordService`. A legacy clear-text
row verifies and comes out of this as bcrypt, which makes this one of the two
places such a row gets fixed.

Two things worth knowing:

- **The row is re-read; `apiUser.password` is not trusted.** The token was
  minted up to 24 hours ago, and the hash on that copy may have been changed
  since — by this route in another tab, by a reset, or by the sibling
  application that shares the table. Verifying against a stale hash would let a
  rotated-away password change the live one.
- **`mail_otp` is burned with the change.** A login or reset code in flight was
  issued against the old credential and must not outlive it.

> **Existing access tokens survive a password change.** These are stateless
> HS256 JWTs with no revocation list (§18.2), so a token minted before the
> change stays valid until it expires — up to 24 hours. Changing a password
> *because it leaked* therefore needs the account deactivated as well. The fix
> is a `password_changed_at` column checked against the token's `iat` in
> `authenticateXApiToken`; it is not in this change, and it would close the same
> gap for `reset_password`.

`scripts/test_out_api_account.js` now covers all five account routes (46 tests).
It stubs the Redis attempt counter — left real, every counter call waits out a
connection failure and the suite takes minutes — which also made it possible to
pin the two behaviours that matter about the budget: the code survives two wrong
guesses and burns on the third, and **with Redis down the first wrong guess
burns it**, i.e. the counter failing means fail-closed, never an unmetered
brute-force window.

### 18.22 `is_bookable` — showing what cannot be booked (2026-09-04)

`/xApi/activity_list` used to omit an activity that had no sellable inventory
for the requested travel date. It now **returns the row and tags it**, so a
consumer can render the activity and say "not available on this date" instead of
being unable to tell an unavailable activity from one that does not exist.

Two fields, on every item and always present:

| field | values |
|---|---|
| `is_bookable` | `1` \| `0` |
| `non_bookable_reason` | `null` when bookable, else `no_open_category_inventory` \| `inventory_closed` \| `booking_closed` \| `unavailable` |

**This is `/xApi/activity_list` only.** `/v1/activity_list`, the two by-id
routes and every other consumer are byte-identical to before — they do not even
gain the two fields. That is the point of the flag being a parameter rather than
a rewrite.

#### The exclusion lived in two places and they must move together

This is the thing to understand before touching any of it. §12's
bookable-for-checkDate predicate in `bwActivityAdapter`'s WHERE and the three
`return null` points in `activityMarkupPipeline.createActivityProcessor` are one
rule expressed twice, deliberately: SQL so that `totalRecords` and page sizes
are truthful, JS as the safety net that drops a row rather than serving one the
pipeline cannot price.

Relaxing only the SQL is the failure mode. The rows pass the query, the pipeline
deletes them anyway, and a page of 20 comes back with 3 while `totalRecords`
reports a number no amount of paging adds up to — the exact bug the predicate
was added to fix. So a single `includeNonBookable` flag is threaded through both:

```
xApiActivityList.listActivities   includeNonBookable: true   (the only caller)
  -> bwActivityAdapter.listActivities
       - omits the predicate from the WHERE (and its nine params with it)
       - passes the same flag on to:
  -> activityMarkupPipeline.createActivityProcessor
       - tags instead of returning null, at all three points
```

The nine params are pushed **inside** the same `if` as the clause. Omitting a
clause and keeping its params binds every later placeholder one position off,
silently, and only on this code path.

#### The three reasons map exactly to the three drops

| pipeline drop | condition | reason |
|---|---|---|
| A (line ~166) | has active categories, none with an open inventory row for `checkDate` | `no_open_category_inventory` |
| B (line ~192) | no categories; the category-0 row for `checkDate` exists but is past its cut-off | `inventory_closed` |
| C (line ~224) | no categories, no category-0 row at all, and the activity itself is `booking_closed` | `booking_closed` |

`unavailable` is the fourth value and appears only as `shapeItem`'s fallback for
an item tagged `is_bookable: 0` with no reason — it should never be produced by
the pipeline.

#### A tagged row carries NO prices

`adult_price`, `child_price`, `infant_price`, `mrp_adult`, `mrp_child`,
`mrp_infant`, `available_quantity` and `total_quantity` are set to **null**, and
`categories` to `[]`. Not an oversight, and not negotiable without a separate
decision:

- A category activity's sellable price **is** `categories[0]` (§11.1). With no
  open category there is no price to quote, and the `activity` columns are not a
  fallback — the pipeline has always nulled them for category activities.
- A base activity past its cut-off does have a number on the row, and it is a
  **NET** cost that no longer corresponds to anything sellable. Shipping it
  would be worse than shipping nothing: `xApiMarkupService` skips null and
  undefined, so nulls pass through untouched, whereas a stray number would be
  marked up and presented to a partner as a bookable rate for a date they cannot
  book.

Null rather than deleted, because `pick` copies keys by `hasOwnProperty`: a
deleted key vanishes from the contract and the consumer has to tell absent from
null from unavailable. This narrows §18.7's note on those five fields in one
direction — **absent** still means "this row never had one"; **null** now also
means "kept deliberately, nothing sellable for this date".

#### What else changed, and what deliberately did not

- **`totalRecords` / `ownTotalRecords` now count non-bookable rows.** They
  remain truthful counts of what the route returns, which is what pagination
  needs, but they are no longer a count of what a partner can book. A separate
  `bookableRecords` was considered and not added.
- **Ordering is unchanged** (`score DESC`). A non-bookable row can outrank a
  bookable one on page 1 — deliberate, decided 2026-09-04: the flag, not the
  position, is what says whether an item is sellable.
- **`shapeItem` normalises, it does not copy.** GlobalTix items and items from
  `xApiActivityDetail` never met the BW pipeline and carry neither key; `pick`
  would omit them and the field would be present on some items and absent on
  others. The default is `1`, and it is true rather than optimistic: every other
  path still applies the pipeline's drops, so a row that arrives untagged
  survived exactly the checks that would have set it to `0`. The reason is
  forced to `null` whenever `is_bookable` is `1`, so the two cannot disagree.
- **The weekday filter is NOT relaxed.** An activity that does not operate on
  `checkDate`'s weekday is still excluded from the listing entirely. That is a
  different exclusion from "operates, but has nothing sellable that day", and
  surfacing those would be a second decision with its own reason code — not a
  side effect of this one. **Open question for the product owner.**
- **The pipeline's JS drops are still there** for every other caller. Nothing
  about the safety net changed; it is now bypassed on one path, by request.

`scripts/test_activity_list_bookable_flag.js` covers it: 34 assertions against a
fake pool, no database and no network. It pins the default path (predicate still
in the SQL, rows still dropped, **no new fields on `/v1` items**), one activity
per drop branch with its reason and its nulled prices, the two bookable controls
either side, a placeholder-vs-parameter audit on every statement the adapter
builds, and `shapeItem`'s four normalisation cases.

### 18.23 `/xApi/autofill` — search typeahead (2026-09-04)

`POST /xApi/autofill`. Public, no token, no price on it. Feeds the front-end
search box: one request, three buckets — `countries`, `cities`, `activities`.

Files: `services/xApiAutofill.service.js` (queries + ranking + cache),
`getAutofillSuggestions` in `xApiController.js` (envelope only), route in
`bw_activity_manager_api.js`, knobs in `config.js`, tests in
`scripts/test_out_api_autofill.js`, indexes in
`migrations/2026_09_04_activity_autofill_indexes.sql` (**not applied**).

#### City-shaped and country-shaped input are the same query

The requirement reads like two cases — "Bangkok" should give a city, "Thailand"
should give a country *and all its cities* — but it is one. The city query
matches:

```sql
WHERE (a.city LIKE ? OR a.country LIKE ?)   -- both '%kw%'
```

so a country-shaped keyword pulls in that country's cities and a city-shaped one
pulls in the city. Nothing decides in advance which kind of word was typed, which
matters because it cannot be known: **Mexico, Singapore and Luxembourg are
both.** Each city row carries `matched_on: 'city' | 'country'` so the front end
can tell which half it came from.

`GROUP BY a.city, a.country`, not `GROUP BY a.city`. City names are not unique
across countries — grouping on the name alone merges Springfield/USA and
Springfield/Australia into one row whose `country` is whichever the server
picked. The pair is also exactly what the caller sends back to
`/xApi/activity_list`, so every suggestion maps to one unambiguous query.

#### The predicate is copied from the list route on purpose

Every query is fenced by `status = 1 AND meal_activity = 0` — character for
character what `bwActivityAdapter.listActivities` opens with. This is the most
important property of the file: **a suggestion that leads to an empty list is
worse than no suggestion at all**, and matching the list predicate exactly is
what makes that impossible.

Note the road not taken. `getExploreDestinations` (§ above) adds
`a.to_date >= CURDATE()`, and autofill deliberately does **not**:

1. `/xApi/activity_list` has no expiry filter — it returns expired rows and tags
   them per-date via `is_bookable` (§18.22). Filtering here would make autofill
   *narrower* than the list it feeds.
2. `to_date` is nullable and `NULL >= CURDATE()` is NULL, not true. So the
   filter as written in `getExploreDestinations` silently drops every activity
   with an open-ended run — **a real bug in that route, not just a scope
   difference.** If expiry filtering is ever wanted here it must be
   `(to_date IS NULL OR to_date >= CURDATE())`.

`test_out_api_autofill.js` asserts `to_date` does not appear in the predicate,
so copying the explore_destinations form in later fails the suite rather than
shipping.

#### LIKE wildcards are escaped, and nowhere else in this codebase does that

`escapeLike()` escapes `\`, `%` and `_` before the keyword becomes a pattern.
The value was always bound as a parameter, so this is not an injection fix — it
is a correctness fix, and in one case a availability one:

- `%` inside `%kw%` is a live wildcard, so a caller sending a bare `"%"` walks
  off with the **entire catalog from an unauthenticated route**.
- `_` matches any single character, so `"a_b"` matches `"aXb"`.
- A user typing `"100% Fun"` gets everything starting `"100"`.

Backslash is escaped first; escaping `%` first and backslashes after would
re-arm the wildcard. **Every other keyword filter in this repo** — `/xApi/country_list`,
`/xApi/city_list`, `/activity/activity_list`, `/activity/available_city_list`,
`webController`, `wallet` — interpolates the raw keyword into `%...%` with no
escaping and has all three problems. Not changed here (out of scope, and each
one is a behaviour change for existing callers), but worth a sweep.

#### Matching and ranking

`activities` matches `name`, `type`, `city`, `country` — the location columns are
in the match set so "activities in Bangkok" and "activities named Bangkok" fall
out of one query. They are **ranked below** name matches, because on a ten-row
dropdown ordering is the entire product: "Bangkok Night Market Tour" is a better
answer to `bangkok` than an unrelated tour that merely sits in the city.

`description`/`highlights` are deliberately excluded. They are `text` columns,
a keystroke-rate `LIKE '%kw%'` across them is expensive, and a tour whose blurb
mentions Bangkok in passing reads as a bug in the dropdown, not a result.

Prefix matches rank above mid-word ones everywhere, but matching is
contains-based — see the index note below for why the usual prefix-only trade
(`getCityList`) buys nothing here.

#### Short keywords are a 200, not a 400

Below `XAPI_AUTOFILL_MIN_KEYWORD` (default 2) the service returns empty buckets
and never touches the DB. The route is called on every keystroke and the first
keystroke of every search is one character long — that is normal traffic, and a
400 there fills the logs with failures that are nothing of the kind.

#### Indexes: measured, and the answer is add nothing

`activity` is **908 rows, 68.5% of them sellable** (production, 2026-09-04).
That is ~200KB — it lives in the buffer pool and a full scan of it is
sub-millisecond. Benchmarked at exactly that size and selectivity (MariaDB
10.11, real DDL, median of 25 runs):

| variant | countries | cities | activities | total |
|---|---|---|---|---|
| **no indexes (today)** | 0.7ms | 0.9ms | 0.9ms | **2.5ms** |
| the 4 indexes first proposed | 1.2ms | 1.3ms | 1.5ms | 3.9ms |
| composite `(status,meal,country,city)` +2 | 0.7ms | 0.7ms | 1.4ms | 2.8ms |
| `(country,city)` + `(city,country)` | 0.8ms | 0.9ms | 0.9ms | 2.5ms |

Nothing beats doing nothing and the whole spread is 1.4ms.
`migrations/2026_09_04_activity_autofill_indexes.sql` is therefore **inert** —
every ALTER commented out — and carries the measurements plus the two-step plan
(composite indexes, then FULLTEXT) for if the table ever grows.

The same benchmark at 120k rows made the four-index version **2.4x slower**
(561ms vs 235ms). Two reasons, both still true at 908 rows:

1. **The sell-side predicate is not selective** — 68.5% of rows pass it. The
   server walks the index then does random row lookups for columns it does not
   carry, where a scan reads sequentially. `EXPLAIN` goes `type: ALL` →
   `type: ref`, which looks like a win. **A prettier EXPLAIN is not a faster
   query.**
2. **Every match is a leading wildcard.** `LIKE '%kw%'` cannot seek a B-tree.

At 20% sellable and 120k rows indexes *did* win, so this is a property of the
data, not a law. Re-open only if the table grows an order of magnitude **and**
the sellable share drops.

#### There is no cache, and that is the second measured decision

This service shipped with a 60s Redis cache. It was removed. Two findings:

- **Nothing to cache.** Three queries, 2.5ms, on 200KB of data.
- **The cache was a liability.** `services/redisClient.js` leaves ioredis's
  offline queue on with a growing retry backoff, so with Redis *unreachable* a
  single `.get()` does not fail fast — it rejects after ~515ms, and successive
  calls degrade to a **20-second plateau**:

  | request | 1 | 3 | 6 | 12 |
  |---|---|---|---|---|
  | latency | 515ms | 4504ms | 10504ms | 20023ms |

  The `try/catch` around it does not help: the cost is in the `await`, not the
  rejection. On a service whose own config comments record that no Redis
  instance is provisioned yet, that cache turned a 2.5ms route into a
  20-second one on every keystroke.

`test_out_api_autofill.js` asserts the service does not require `redisClient`,
so the dependency cannot come back by accident.

**This exposure is not limited to autofill.** `services/globalService.js` uses
the same client and `await`s `redisGetJSON` on the markup and currency lookups
— i.e. on the pricing path of `/xApi/activity_list`, `/v1/activity_list` and
every priced route. The one-line fix is `enableOfflineQueue: false` in
`redisClient.js`, which makes a dead Redis reject in **0ms** and turns the
"best-effort" claim in that file's own header into the truth. Not applied here:
it touches shared infrastructure on the pricing path and deserves its own
change.

#### Verification

`node scripts/test_out_api_autofill.js` — 29 DB-free assertions covering the
escaping, keyword normalisation, limit bounding, cache keys and the predicate.

The SQL itself was run against a throwaway MySQL built from the real
`fdk_activity_db.sql` DDL and seeded with Thailand/UAE/India rows, including a
`status = 0` row, a `meal_activity = 1` row, an expired row, a `to_date IS NULL`
row, an empty-`city` row, a `100% Fun _ Water Park` row and Springfield in two
countries. Confirmed: `"Bangkok"` returns the city plus its 3 activities (not 5
— the inactive and meal rows are excluded); `"Thailand"` returns the country
plus all 5 Thai cities plus 8 activities; the expired and null-`to_date` rows are
present; `"%"` and `"_"` return nothing; `"100%"` returns exactly the one row;
Springfield comes back as two suggestions, not one. All three queries also run
clean under `ONLY_FULL_GROUP_BY`, so this route does not inherit the dependency
`getCityList` has on that mode being off.

## 19. Smaller changes, 2026-08-27 → 2026-08-30

- **`is_combo` filter on `/activity/get_all_activities`**
  (`activityController.getAllActivities`, `7321798`). `is_combo` is destructured
  off `req.body` and pushed as `activity.is_combo = ?`. The guard is
  `if (is_combo && is_combo !== undefined && ...)` — the leading truthiness test
  makes the rest redundant and means **`is_combo: 0` cannot be requested**; only
  `1` filters, `0` returns everything. If "combo only / non-combo only / both"
  is the requirement, this needs the `!== undefined && !== null && !== ''` shape
  the neighbouring `is_pre_purchase` check already uses. Note that
  `activityFrontController.getAllActivities` independently destructures
  `is_combo = 0` for its own purposes — same parameter name, two endpoints, two
  meanings.

- **`webController.getMealActivities` resolves the agent itself and filters on
  `open_for_countries`** (`80d32e0`). Before: always
  `GlobalService.getUserDetails(authorization)`, 400 if absent, then a dead
  `if (!agent_id && String(agent_id).trim() == '')` — a condition that cannot be
  true, since `!agent_id` and a non-empty trimmed string are mutually exclusive.
  Now: if `agent_id` is present in the body it goes to
  `GlobalService.getAgentDetailsById(agent_id)`; otherwise the token path as
  before. The resolved `agentCountry` (falling back to the `GuestCountry` header)
  drives both a new
  `FIND_IN_SET(?, REPLACE(open_for_countries, ', ', ','))` WHERE clause and the
  per-activity markup calculation, matching
  `activityFrontController.getAllActivities`.
  Two things to note: **passing `agent_id` bypasses authentication** on this
  endpoint entirely — `getAgentDetailsById` accepts any id and the token is
  never inspected on that branch; and the pricing loop (~L1400) still declares
  `const userId = agent_id;`, shadowing the outer `userId` assigned during
  resolution, which is now dead.

- **`db_connection/mysql_connection.js` no longer kills the process on a bad DB
  connection** (part of `9758455`). The startup `SELECT 1+1` probe was
  `if (err) throw err` — thrown from inside a callback, therefore unhandled,
  therefore process exit if MySQL was unreachable at boot. Now a `console.error`
  + `return`. This is the second, `trackerService`-only pool (§3);
  `database/connection.js` already handled its probe this way.

- **`services/globalService.js` whitespace-only reformat** (`7cb2f10`). No
  behavioural change — the diff is indentation and log-argument spacing.

## 20. Redis was best-effort in the error direction only (2026-09-04)

`services/redisClient.js` gains `enableOfflineQueue: false` and
`commandTimeout: 200`. Found while removing the autofill cache (§18.23); the
same client backs markup, currency, GlobalTix options and OTP attempt counting,
so this is the wider version of that finding.

Every caller of this client already wraps its reads in try/catch, so a Redis
outage never threw and the file's header called it best-effort. **It was
best-effort in the error direction only.** ioredis's offline queue holds
commands while the client reconnects, and `retryStrategy` backs off up to 10s,
so with Redis unreachable a command did not fail fast — it sat in the queue for
the whole backoff, and the backoff grew with every attempt. The try/catch cannot
help: the cost is in the `await`, not the rejection.

The worst call site is `fetchActiveMarkupRules()` in `xApiMarkupService.js`,
which has **no in-process cache** and whose own comment records that it "runs on
every Out API request". It awaits a GET, falls through to MySQL, then awaits a
SET — so it pays the stall twice. Measured against a dead port:

| request | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| before | 3024ms | 11010ms | 19013ms | 27019ms | 35030ms | **40037ms** |
| `commandTimeout` only | 402ms | 300ms | 401ms | 401ms | 400ms | 401ms |
| both (shipped) | 0ms | 0ms | 0ms | 0ms | 0ms | 0ms |

Forty seconds per request, on every priced `/xApi` route, for a cache — and
`config.js` records that no Redis instance is provisioned yet, so this is the
configured state, not a hypothetical outage.

Both options are set because they fix different failures.
`enableOfflineQueue: false` rejects immediately when the socket is known down
(the common case here). `commandTimeout` covers what that flag cannot: a socket
that is up but a server that is slow or hung, where the command is written and
the reply never comes.

**Verified** against a live Redis and a dead port using the real
`services/redisClient.js`: markup round trip 0.4–1.0ms up, 0.1–1.4ms down
(rejecting, so callers fall through to MySQL); set/get, incr/expire/del and a
rates round trip all correct; 200 sequential GETs in 24ms (0.12ms each), well
inside the 200ms bound. All 11 `scripts/test_*.js` suites pass.

**One behaviour change, in the safe direction.** A command issued while the
client is briefly reconnecting now rejects instead of queuing. For every cache
read that is correct — it falls through to MySQL, exactly as a cache miss does.
The one non-cache caller is `bumpAttempts` in `xApiAuthService.js`, whose null
return makes OTP verification fail closed (a wrong code burns the OTP instead of
allowing the remaining attempts). That path only runs when someone has **already
entered a wrong code** — a successful login never reaches it — so a reconnect
blip costs a user who mistyped their OTP a new code. Strictly better than that
same user waiting 40 seconds for it.

**Still worth doing separately:** give `fetchActiveMarkupRules()` an L1
in-process cache like `globalService`'s markup and currency lookups have. Redis
is now bounded, but that function is still one network round trip per request
for near-static admin data that every other hot lookup caches in-process first.

## 21. Banner / promo images (2026-09-04)

Home page and inner-page promotional images. One table, `activity_banners`
(`migrations/2026_09_04_activity_banners.sql`), one controller,
`controllers/bannerController.js`, routes under `/banner/*`.

**The row is not the image.** `activity_banners.image` stores the **bare upload
file name** returned by the existing `POST /activity/uploadImage` — the same
convention `activity.icon` and the activity image rows already use. Nothing here
touches multer, disk, or the uploads tree: the admin UI uploads first, gets back
`{ fileName }`, and sends that string as `image`. A value containing `/`, `\` or
`..` is rejected at write time, so a stored name can never build a URL escaping
`/uploads/` — the mirror of the defensive drop `publicUrl.service.bwUploadUrl`
already does at read time (§14.6). Every read returns `image_url` alongside
`image`, so callers do not have to know the uploads path.

**One generic table, not one table per surface.** A row is "this image, on this
`page`, in this `placement`, at this `display_order`". `banner_type` is an ENUM
(`banner` = hero/slider, `promo` = tile); `page` and `placement` are free-form
strings on purpose. A new page or a new slot on an existing page is a data
change, not a migration.

**Deletes are row-only.** The uploaded file stays on disk: the same file name may
be referenced by another row or a cached page, and no other module here deletes
files from a request handler.

**Routes.** Six admin endpoints — `create`, `update`, `list`, `details`,
`status_update`, `delete` — all `POST`, all taking JSON bodies, matching
`/activity_markup/*` field for field (paginated `list` with `totalRecords`,
partial `update` behind an allow-list, `status_update` for the inline grid
toggle, which also accepts `display_order` because re-ordering a slider is the
other one-field edit). `list` deliberately does **not** filter by status — the
admin has to see hidden rows.

`POST /banner/front_list` is the public read: `status = 1` only, one `page`,
ordered by `display_order ASC, id DESC`, returned both flat (`data`) and grouped
by placement (`grouped`) so the website can render each slot without filtering
client-side. Unpaginated on purpose — a page has a handful of banners and
`idx_banner_public (status, page, placement, banner_type, display_order)` covers
the whole lookup.

**The `page` collision.** `list` paginates with `page`, so the page-name filter
travels as `page_name` on both `list` and `front_list`. The column is still
`page`; only the request field differs.

**Unauthenticated**, like `/api_user/*` and `/activity_markup/*` before it. That
is the existing posture for admin modules on this service, not an endorsement of
it — see §18 for what the Out API does instead.


---

### 18.24 Group-scoped markup and the `markup` audit object (2026-09-05)

Two changes to the Out API pricing path, shipped together. The second is what
makes the first safe to deploy.

#### The rule set is chosen by identity, and by nothing else

Eligibility, in full, replacing every earlier version of this list:

| Caller | Rules eligible |
|---|---|
| Agent with `activity_users.markup_group_id` | that group's rules **only** |
| Agent without one | the default group's rules |
| Anonymous (no Bearer token) | the default group's rules |

The default group is the single `markup_group` row with `is_default = 1`;
`controllers/markupGroupController.js` keeps that flag exclusive
transactionally. Per-item fallback is **unchanged**: a rule *set* is chosen by
identity, but an *item* that no rule in that set fits still falls through to the
default group's rules rather than shipping at NET (§18.10).

**An ungrouped rule (`markup_group_id IS NULL`) now prices nobody.** It used to
apply to every partner as a layer under their scoped set. That layer is gone,
and this is the one change on this path that can move a live price without any
rule being edited: an ungrouped rule that was quietly pricing agents yesterday
stops, and those agents fall to the default group's percentage.

**Deployment step, not optional.** Before this ships, every active ungrouped
rule must be assigned to a group or retired:

```sql
SELECT id, title, markup_amount_percentage, discount_amount_percentage
FROM activity_markup
WHERE status = 1 AND markup_group_id IS NULL;
```

No schema change accompanies this — the columns all landed earlier the same day.

#### Every priced row carries a `markup` object

`applyOutApiMarkup` attaches it; no route shapes it on afterwards, because the
service is the one place that knows which rule won, and a route re-deriving the
amounts from a percentage is how the object and the price beside it start to
disagree.

```json
"markup": {
  "markup_id": 31,
  "title": "Dubai uplift",
  "markup_group_id": 4,
  "group_title": "Agent Tier A",
  "source": "group",
  "matched_on": "own_group",
  "markup_percentage": 15,
  "discount_percentage": 5,
  "currency": "AED",
  "price_field": "adult_price",
  "base_price": 1000,
  "final_price": 1100,
  "markup_amount": 150,
  "discount_amount": 50,
  "net_amount": 100,
  "applied_on": {
    "adult_price": { "base": 1000, "markup_amount": 150, "discount_amount": 50, "final": 1100 },
    "child_price": { "base": 600,  "markup_amount": 90,  "discount_amount": 30, "final": 660 }
  }
}
```

- `source` — `group` \| `default` \| `none`.
- `matched_on` — `own_group` \| `default_fallback` \| `none`. **This is the
  field to watch after the eligibility change above.** A crop of
  `default_fallback` on an agent who used to be priced by a rule is an ungrouped
  rule nobody assigned to a group.
- `price_field` / `base_price` / `final_price` — the *headline* price, i.e.
  `priceFields[0]` where present: `adult_price` on activities, `price` on
  transfers. `applied_on` carries the rest, in `priceFields` order.
- `applied_on` lists only fields that actually moved. A free infant at `0` is
  **absent**, not reported as `+0` — "not priced" and "priced at zero" are
  different claims.
- `clamped: true` appears on a field when a discount exceeded the price and
  `Math.max(0, …)` floored it. It is the only case where
  `base + markup_amount - discount_amount !== final`; the two amounts are still
  reported raw, because they are what the rule says.
- `matched_against` appears **only** when the caller passed
  `resolveMatchContext` — `/xApi/get_time_slots` is the case. Without it the
  object reads as a lie: a rule scoped to activity 5 sitting on a row whose own
  `id` is the *slot* id. It carries identity fields only (`id`, `activity_id`,
  `country`, `city`), never the price threaded through the context for the band
  check.
- Nested rows (`categories[]`, `time_slots[]`, `activity_guides[]`, `addon[]`,
  `vehicles_price[]`) are still marked up and carry **no object of their own**.
  The rule and both percentages are identical for every row under one item, so
  the item-level object already explains them.

**An item no rule matched still gets an object**, with `source: "none"` and
`reason: "no_matching_rule"`, quoting the NET price unchanged. A silent absence
would read as "this build has no markup feature"; `source: "none"` reads as
"this price is our cost", which is what it is. That state remains a revenue leak
and still logs a `WARNING` — the object just means someone finds it the same day
instead of when they next read the logs.

**It is sell-side only, and must stay that way (§18.7).** Percentages the
partner has contracted, and a `base_price` that is the price *before* those
percentages — not our purchase price from a supplier. The rule's own
`activities_ids` / `cities` / `country` / `score` filters are deliberately **not**
exposed: they describe rules written for other inventory and are internal
merchandising. There is a test asserting each of those five names is absent.

#### Route coverage

| Route | Markup | Note |
|---|---|---|
| `/xApi/activity_list` | ✅ | activity vocabulary, default nested collections |
| `/xApi/get_activity_by_id`, `/xApi/get_activity_by_ids` | ✅ | same, inherited |
| `/xApi/get_transfer_by_ids` | ✅ | `price`/`sale_price`/`sic_price` + `vehicles_price[]`; headline is `price` |
| `/xApi/get_time_slots` | ✅ | carries `matched_against` (owning activity) |
| `/xApi/bestseller`, `/xApi/popular_experiences` | ✅ | storefront shape |
| `/xApi/explore_destinations` | ➖ | **no price in the response** — `city`, `image`, `activity_count` only. Nothing to mark up; a markup pass would attach an all-null object for an event that did not happen. If a `starting_price` is ever added it arrives NET and must grow a markup pass in the same change. |
| `/xApi/get_activity_supplier_inventory_booking`, `/xApi/get_transport_supplier_inventory_booking` | ❌ | buy-side, §18.15/§18.16 — markup must never be pointed at these |
| `/xApi/autofill` | ❌ | carries no price at all (§18.23) |

#### Cache

`RULES_CACHE_KEY` moved to `xapi:markup_rules:active:v2`. The cached value is
the rule *row set*, and the row set grew `group_title`; reusing the old key
would serve rows without it for up to 60s after deploy, so every item priced in
that window would carry `group_title: null` — a wrong audit trail rather than a
missing one. Bump the suffix again whenever that SELECT changes shape.

#### Tests

`scripts/test_out_api_flow.js` — `[4]`, `[13]` and `[22]` were rewritten for the
narrowed eligibility (all three previously asserted that an ungrouped rule
reaches every partner), and `[26] The \`markup\` audit object` is new: the shape,
the arithmetic identity, `own_group` vs `default_fallback`, the `none` case, the
transfer headline field, and the time-slot `matched_against`.

---

### 18.25 The conversion charge, and disclosing it (2026-09-05)

#### The charge is not new. Only the disclosure is.

`GlobalService.convertCurrency` has always applied a **conversion charge** on
top of the FX rate, and has always returned only the total:

```js
const rate = toRate / fromRate;
const convertedPrecise = rate * amt;
const chargesPct = chargesMap.has(cTo) ? Number(chargesMap.get(cTo)) : 0;

const finalPrecise = (chargesPct && … && Number(chargesPct) !== 0)
  ? (convertedPrecise * (1 + (chargesPct / 100)))
  : convertedPrecise;

return Promise.resolve(round2(finalPrecise));
```

`chargesPct` comes from `fdk_transportation_package.vendor_currency_settings.charges_in_percentage`
— the same column `/xApi/GetConversionRate` already hands partners as
`convertesionCharges`. **It is keyed on the TARGET currency alone**, so it is a
property of the currency being sold in, not of the pair: one percentage covers
every row of a response, because `guest_currency` is fixed per request.

Because only the total came back, the charge was invisible everywhere. Note in
particular that `convertValue`'s `rateUsed: numeric / amtNum` (in
`activityMarkupPipeline.service.js`, and mirrored in `globalTixActivityAdapter`)
is the **effective** rate with the charge folded in — not the FX rate. Nothing
downstream could tell the two apart.

**Every priced Out API route reaches it**, via four different converters that
all bottom out in `convertCurrency`:

| Route | Converter |
|---|---|
| `activity_list`, `get_activity_by_id(s)` (BW) | `activityMarkupPipeline.convertValue` |
| the same, GlobalTix rows | `globalTixActivityAdapter` |
| `get_time_slots` | `xApiTimeSlot.convertAmount` |
| `get_transfer_by_ids` | `xApiTransferDetail.convertAmount` |
| `bestseller`, `popular_experiences` | `xApiController.convertAmount` |
| `explore_destinations` | — (no price; §18.24) |

**A same-currency row is never charged.** Both `convertValue` and
`convertCurrency` return early when source equals target, so an activity already
priced in `guest_currency` carries no charge at all.

#### The full price chain

```
net (source currency) → FX → CONVERSION CHARGE → MARKUP → sell price
                              └── §18.25 ──┘   └ §18.24 ┘
```

FX and the charge happen in each route's own service; markup happens afterwards
in the controller. So the disclosure runs **between** them, and the two objects
chain exactly:

```
item.conversion_charges.final_amount === item.markup.base_price
```

That equality is asserted per row in the tests and is the cheapest way to catch
the ordering being swapped — run after the markup pass, the disclosure would
report the markup as part of the conversion, which is both wrong and
unfalsifiable from the payload.

#### The object

Attached by `services/xApiConversionCharge.service.js` to every row on the seven
priced routes:

```json
"conversion_charges": {
  "from_currency": "THB",
  "to_currency": "INR",
  "converted": true,
  "resolved": true,
  "same_currency": false,
  "charges_percentage": 2.5,
  "fx_rate": 2.4,
  "price_field": "adult_price",
  "amount_before_charges": 2400.00,
  "charges_amount": 60.00,
  "final_amount": 2460.00,
  "applied_on": {
    "adult_price": { "amount_before_charges": 2400, "charges_amount": 60, "final_amount": 2460 },
    "child_price": { "amount_before_charges": 1200, "charges_amount": 30, "final_amount": 1230 }
  }
}
```

- `converted` vs `resolved` are **different claims**, and conflating them would
  hide a broken currency table. `converted: false` says no charge was taken
  (same currency, or the conversion failed and the row is quoted in its own
  currency). `resolved: false` says the rate/charge tables could not answer for
  this pair, so we cannot state what was taken. Both give a zero charge; only
  the second means the disclosure is incomplete, and only the second is counted
  and logged as a warning.
- `same_currency: true` is the ordinary, correct reason for a zero charge.
- `price_field` / the three summary amounts describe the **headline** field —
  `priceFields[0]` where present, same convention as `markup`, so the two
  summaries describe the same field and can be compared directly.

#### The amounts are derived, and why

The price on the row is already post-FX and post-charge, so:

```
amount_before_charges = price / (1 + pct / 100)
charges_amount        = price − amount_before_charges
```

The exact alternative — reading the pre-conversion `org_*` amounts the pipeline
stores — was rejected deliberately. `org_*` is our **net cost** (§18.7 excludes
it from the partner contract for exactly that reason), `shapeItem` drops it, and
recovering it would mean building this audit inside four route services *before*
shaping, including the pipeline shared with every `/v1` route. One derivation in
one place, run after shaping like the markup pass, cannot put a cost field on
the wire by accident and cannot break `/v1` at all.

**The cost of that choice, stated so nobody rediscovers it as a bug:**
`convertCurrency` rounds the total to 2dp, so splitting that rounded number back
into two parts can land ±0.01 from the unrounded intermediate. The identity
`amount_before_charges + charges_amount === final_amount` still holds **to the
cent** — `charges_amount` is computed as the remainder and never rounded
independently, because a cent a reader cannot account for looks like a third
undisclosed fee. It is *to the cent* rather than bit-exact because both parts
are 2dp decimals and IEEE doubles cannot hold most of those (`964.31 + 35.68`
evaluates to `999.9899999999999`); compare through `round2`, never `===` on the
raw sum.

#### `getCurrencyConversionMeta` — additive, `convertCurrency` untouched

`GlobalService.getCurrencyConversionMeta(from, to)` reports `fx_rate` and
`charges_percentage` separately. It re-reads the **same cached maps**
(`getRatesAndChargesMap`) that `convertCurrency` uses, so the numbers it reports
are by construction the ones the conversion actually used, and it costs no extra
DB round trip on a list request. `convertCurrency` itself is **not modified** —
every `/v1` route and the whole markup pipeline depend on it, and this change
must not be able to reach them.

It **never throws**: `convertCurrency` rejects on an unknown currency because it
cannot invent a price; this one only annotates a price that already exists, so
an unknown pair comes back `resolved: false` rather than turning a working
response into a 500.

#### FAIL OPEN — the opposite of `xApiMarkupService`

`xApiMarkupService` fails **closed** because a missing markup ships our cost as a
selling price: real money, so a throw is better than a response. This module
ships no price and changes none. A failure costs a disclosure block, not
revenue, so every failure path attaches `resolved: false` and moves on, and
`discloseConversionCharges` in the controller swallows anything the service
somehow lets through.

#### `source_currency` — a new field on three routes

The routes disagree about what `currency` means after conversion, and that is
what `options.resolveSourceCurrency` exists for:

| Route | `currency` after conversion | Source from |
|---|---|---|
| `activity_list`, `get_activity_by_id(s)` | the **SOURCE** currency (BW and GT both) | `currency` — default resolver |
| `get_time_slots` | the guest currency on success | new `source_currency` (+ `meta.activity.currency`) |
| `get_transfer_by_ids` | the guest currency on success | new `source_currency` |
| `bestseller`, `popular_experiences` | the guest currency on success | new `source_currency` |

Those three now retain `source_currency` (added to `SLOT_FIELDS`,
`TRANSFER_FIELDS` and `shapeStorefrontActivity`, and set on **both** branches).
Without it every converted row on those routes would report a conversion from
the guest currency to itself. It is sell-side safe on the same terms as
`currency`, which `activity_list` has always shipped.

#### Tests

`scripts/test_out_api_flow.js` → `[27] The \`conversion_charges\` disclosure`:
the split at five rates, the to-the-cent identity, all three "was this
converted?" vocabularies, the same-currency and unresolvable cases, the
`resolveSourceCurrency` override (and what goes wrong without it), the
conversion→markup chain on every row, and fail-open under a dead currency table.

---

### 18.26 `share_status` — the Out API share gate (2026-09-09)

`activity.share_status` is the operator's per-activity switch for "this may be
sold through the Out API". `1` (or `NULL`) = visible, `0` = hidden. The column
and its writer already existed — `POST /activity/change_activity_share_status`
→ `activityController.changeActivityShareStatus` — with **no reader anywhere**.
This section is the reader.

**It gates exactly three routes**, and nothing else:

| route | gated |
|---|---|
| `/xApi/activity_list` | yes |
| `/xApi/get_activity_by_id` | yes |
| `/xApi/get_activity_by_ids` | yes |
| `/v1/activity_list`, `/v1/get_activity_by_id`, `/v1/get_activity_by_ids` | **no** |
| `/xApi/autofill`, `/xApi/bestseller`, `/xApi/get_time_slots`, the supplier-inventory and booking routes | **no** |

#### It is a flag, not a WHERE clause

`bwActivityAdapter` has two listing callers: `activityFrontController`
(`/v1/activity_list`, line ~152) and `xApiActivityList.service.js`. Only the
second passes `sharedOnly: true`. Making the clause unconditional in the adapter
would hide un-shared rows **from the admin UI as well**, which is the opposite of
what the switch is for — staff must still be able to see and manage an activity
they have un-shared. Same shape as `includeNonBookable` (§18.22): a behaviour
the Out API opts into, not a change to the shared data layer.

`getActivityById` forwards the flag through `...rest`, so the single-id route
needs no separate wiring; the two `/v1` by-id endpoints keep their own inline SQL
in `activityFrontController` and are untouched.

#### `COALESCE(share_status, 1) = 1`, not `share_status = 1`

The column was added to an already-populated `activity` table, so rows predating
it — and any INSERT path that does not set it — carry `NULL`. Treating `NULL` as
**shared** means the gate hides only what an operator has explicitly turned off:
deploying this cannot make the partner catalogue go dark, and no backfill is
required. (Decision 2026-09-09, Darpan.) If the policy ever inverts to
private-by-default, the clause and the backfill change **in the same release** —
one without the other is an outage in one direction or a leak in the other.

The clause carries **no `?` placeholder**, deliberately. In `listActivities` it
sits ahead of the nine bookable-predicate params, so a placeholder smuggled in
there would bind every later value one position off — a silent wrong-data bug,
not a crash. `scripts/test_share_status_gate.js` asserts the param vectors are
value-for-value identical with the flag on and off.

#### An un-shared id is a MISSING id, not a forbidden one

On the two detail routes the row is filtered in the **WHERE**, so:

- `/xApi/get_activity_by_id` → `data: []`, `replyMsg: "No activity found for the given id."`
- `/xApi/get_activity_by_ids` → the id appears in `missing_ids`

Indistinguishable from an id that does not exist. There is no `not_shared` reply
code on purpose: one would confirm the activity exists to anyone who can guess an
id, and since §18.10 these routes answer **without a token**. It also means
`meta.matchedRows` is `0` for an un-shared id, same as for a bad one — when
support asks "the id exists but they got nothing", `activity.share_status` is the
column to check, not the response.

`share_status = 0` is now one more entry on the list of reasons an id lands in
`missing_ids`, alongside: no such row, `status != 1`, meal activity, wrong
weekday for `travel_date`, country-locked away from this partner, and dropped by
the pricing pipeline for having no sellable inventory that day.

#### Counts, GlobalTix, and the field itself

- The gate is in the WHERE, so `totalRecords` / `ownTotalRecords` count only
  shared activities. They stay truthful counts of what the route returns, which
  is what pagination needs.
- **GlobalTix rows are unaffected.** `share_status` is a column on our own
  `activity` table; GT products are not in it. The gate is a statement about own
  inventory only.
- `share_status` is **not exposed to partners.** `ITEM_FIELDS` in
  `xApiActivityList.service.js` is an allow-list and does not name it, so
  `SELECT *` picking it up in the adapter goes no further than the process.

#### Known gap, stated rather than fixed

`/xApi/autofill` (§18.23) mirrors the `listActivities` base predicate
character-for-character but is **not** gated, per the scope of this change. An
un-shared activity's name and city can therefore still surface as a typeahead
suggestion, whose `activity_list` / `get_activity_by_id` follow-up then returns
nothing. Gating it is a one-line change in `xApiAutofill.service.js` if that is
the wanted behaviour; it was left alone because narrowing autofill was not asked
for and is a separate decision.

#### Tests

`scripts/test_share_status_gate.js` — no DB, pool stubbed at the module loader.
Asserts the clause is present in COUNT **and** SELECT on all three routes with
the flag, absent on all of them without it (the `/v1` non-regression), that
param vectors do not shift, that the three services actually pass
`sharedOnly: true` (a correct flag nobody sets being the likelier failure), and
the caller-visible not-found shape on both detail routes.

---

### 22. The agent join follows `source` (2026-09-09)

`fd_activities_booking.agent_id` holds ids from **two id spaces**, and §18.17
already warned about this from the write side. This is the read side finally
honouring it:

```
source = 'ACT'   ->  fdk_activity.activity_users.id    (Out API partner)
otherwise        ->  fdk_holidays.website_users.id     (/web agent)
```

`get_booking_list` joined `fdk_holidays.website_users` unconditionally. **The two
id spaces overlap**, so that was not a "partner bookings show blank agent" bug —
it was "partner #7's booking shows BW agent #7's name, email and mobile", a real
row belonging to a different person, with nothing anywhere reporting an error.

#### Two joins, not a dynamic table name

`source` varies row by row, so one query has to serve both kinds:

```sql
LEFT JOIN fdk_activity.activity_users AG_ACT
    ON FDA.source = 'ACT'
   AND FDA.agent_id = AG_ACT.id
LEFT JOIN fdk_holidays.website_users AG_HOL
    ON (FDA.source IS NULL OR FDA.source <> 'ACT')
   AND FDA.agent_id = AG_HOL.id
```

The ON conditions are mutually exclusive and both sides join on a primary key, so
**no row can multiply** — `COUNT(*)` and the analytics `SUM`s return exactly the
numbers they did before.

`FDA.source IS NULL` is in the second ON because `<>` is NULL-valued against a
NULL source, which would silently drop the agent for such a row. The column
defaults to `'BW'` so it should never fire; it costs nothing and fails safe.

`'ACT'` is a **literal** in the SQL, not a placeholder. In the list query the
CASE expressions sit ahead of the WHERE params, so a `?` there would shift every
later value one position — silent wrong data rather than a crash.

#### One definition, three queries, and the search filter

`agentJoin` and `agentCol(col)` are built once at the top of the function and
used by **all three** queries — `countQuery`, `analyticsQuery`, `listQuery` — and
by the `search` filter, which matches on the agent's name / company_name / email
/ mobile.

Fixing only the SELECT would have left two visible bugs: searching a partner's
name would not find their bookings, and `pagination.total_records` would disagree
with the rows on the page. `agentCol` exists so the searched expression and the
returned expression cannot drift — a search matching one table while the output
came from the other returns rows whose agent columns do not contain the term.

`activity_users` and `fdk_holidays.website_users` both carry all four columns, so
none of them needs a fallback and the output aliases are unchanged.

#### Where it is applied

| function | queries carrying the join | applied |
|---|---|---|
| `get_booking_list` | count, analytics, list | yes |
| `get_operations_list` | count, list | yes |
| `get_booking_details` | 1 | **no** |
| `addBookingTransaction` | 1 | **no** |

`get_operations_list` holds a **deliberate second copy** of `agentJoin` /
`agentCol` rather than sharing one with `get_booking_list`. The two functions are
independent and already duplicate their WHERE builders and SELECT lists; hoisting
a helper to module scope to serve both is a wider refactor than either change
asked for. **If a third caller needs it, that is the moment to extract it** — and
this section is the definition to extract to. Until then
`scripts/test_agent_join_by_source.js` §[9] asserts the two copies emit an
identical join block, because nothing else stops one being fixed and the other
forgotten.

The two remaining functions carry the same latent wrong-agent bug and were left
alone as out of scope. When one is next touched, copy the pattern above.

#### Tests

`scripts/test_agent_join_by_source.js` — no DB, pool stubbed at the module
loader, every `(sql, params)` pair captured, run against **both** endpoints.
Asserts both joins in every query, that no bare `AG.` reference or unconditional
`website_users AG` join survives, the four source-aware output columns under
their original aliases, that search matches through the same expressions it
outputs, that placeholders still equal bound params with and without `search`,
and that the two copies have not drifted.

---

### 23. Request decryption — why `/xApi/login` saw a blank body (2026-09-09)

An encrypted POST to `/xApi/login` reached the route handler with
`req.body = {}`, status 200, and nothing logged. Three separate defects fed one
symptom.

#### 1. One flag drove both directions

```js
if (config.ENCRYPTION == 'true') { app.use(encryptResponses); }
if (config.ENCRYPTION == 'true') { app.use(decryptRequests); }
```

`.env` carried `ENCRYPTION = false`, so **`decryptRequests` was never registered
at all**. It could not be turned on in isolation either: the same value also
enables `encryptResponses`, which base64-encrypts the `data` field of every
successful response on every route, so switching it on to fix one partner would
have broken every client already reading `data`.

The two are now separate. `config.decryptRequestsEnabled()` reads
`DECRYPT_REQUESTS`, **or** `ENCRYPTION` — so a deployment that already had
encryption on keeps exactly the behaviour it had, while accepting encrypted
requests no longer implies encrypting responses. Accepting an encrypted body is
backward-compatible (a plain-JSON caller is untouched, because only something
that both looks like base64 and decrypts is treated as ciphertext); encrypting
responses is a coordinated release. They should never have shared a switch.

#### 2. The Content-Type gate was silent

```js
if (ct !== 'application/json') return next();   // body stays {}
```

Nothing else claims `text/plain` either — not `express.json()`, not
`bodyParser.urlencoded()` — so a blob posted under any other content-type fell
through the entire chain and arrived empty. No error, no log.

`DECRYPTABLE_CONTENT_TYPES` now covers `application/json`, `text/plain`,
`application/octet-stream` and the empty string (no header, which is what several
clients send with a raw string body). Widening it cannot regress anything,
because **no request on those types worked before** — they all produced an empty
body — and the base64 sniff plus the decrypt still decide.

Form encodings are deliberately excluded: `application/x-www-form-urlencoded` and
`multipart/form-data` belong to `bodyParser` and the upload handlers, and a
raw-body reader in front of a file upload would consume the stream they need.

A body-presence guard (`content-length` / `transfer-encoding`) applies to the
newly-accepted types only, because `''` matches every bodyless GET in the
service. The `application/json` path is left byte-for-byte as it was.

#### 3. The error handler never sent, and sat in the wrong place

```js
return res.status(err.status || 500);   // sets the code, sends nothing
```

`res.status()` returns `res`; it does not end the response. So the one case that
*did* raise an error — a base64 blob posted as `application/json` with decryption
off — **hung until the client timed out** rather than returning a 400. A silent
timeout is why this was hard to see.

It was also registered *before* every route, so it only ever caught errors from
earlier middleware (the body parsers). Anything thrown inside a route handler
fell through to Express's default HTML error page. It is now the last
registration in `bw_activity_manager_api.js`, and it responds: 4xx echoes the
error message (the caller's own malformed request — the most useful thing a
partner debugging can be told), 5xx logs and returns a generic message.

#### Sending an encrypted request

Raw base64 from `services/encryption.js` (AES-256-ECB) as the **entire** body —
not wrapped as `{"data":"..."}`, which parses as ordinary JSON and leaves the
route with no fields. `Content-Type: application/json` remains the documented
choice; the others are accepted so a client you do not control still works.

#### Tests

`scripts/test_request_decryption.js` — drives the real middleware in the real
registration order over a real socket, no DB. Covers every accepted
content-type, plain JSON still parsing, urlencoded and bodyless GETs staying
untouched, `excludePaths`, both original symptoms reproduced with decryption
off, and the flag-split truth table including the backward-compatible
`ENCRYPTION = true` case.

### 24. `ACT` / `GT` — grant tokens and partner-facing source labels (2026-09-16)

**Decision (Darpan).** Partner rows are written with
`activity_users.allowed_sources = 'ACT,GT'`. `ACT` means our own activity
inventory, and `GT` means GlobalTix.

**Problem.** `parseGrant` in `services/xApiSourcePolicy.service.js` recognised
only `BW` and `GT`, and it dropped every other token without logging anything.
The results were:

- `'ACT,GT'` gave GT only.
- `'ACT'` gave an empty grant, so the partner saw nothing. The code falls back
  to `DEFAULT_GRANT` only when the value is empty or NULL, not when every token
  is unknown.

**Change.**

- `SOURCE_ALIASES` maps both `act` and `bw` to the internal key `bw`, and `gt`
  to `gt`. Nothing downstream changes: `sources.bw`, `include_bw` and
  `BW_SOURCE_ENABLED` still control own inventory for either token.
- `BW` is still accepted, so existing rows and the column default `'BW'` keep
  their current meaning. The DDL is unchanged. Only the migration's header
  comment was edited.
- Unknown tokens still grant nothing. A misspelled value does not fall back to
  own inventory.
- **Blank grant fix (same day).** A partner row with `allowed_sources` = `''`,
  only whitespace, or NULL still saw own inventory, because `parseGrant`
  treated blank the same as missing and fell back to `DEFAULT_GRANT`. It now
  grants nothing. Only `undefined` (no partner row, i.e. anonymous `/xApi`, or
  a row read before the column existed) falls back to `DEFAULT_GRANT`.
  - `activity_list`: a blank partner gets `data: []`, and neither adapter is
    called.
  - Detail, time slots and transfers: a blank partner gets 403
    `source_not_granted`.
- **`create_booking` gate (same day).** `xApiBooking.service.createBooking`
  never consulted the grant, so a blank or GT-only partner could book own
  inventory it could not list. It now calls `resolvePartnerSources(apiUser, {},
  Config)` before validation:
  - Not granted: 403 `source_not_granted`.
  - `BW_SOURCE_ENABLED=false`: 409.
  - `include_bw` in the booking body is ignored.
  - `cancel_booking`, `cancellation_charges`, `bookings_list`,
    `booking_details` and `transactions_list` are deliberately not gated, so a
    partner whose grant is removed can still manage the bookings it already
    has. They are now logged with `console.warn`, once for each
  distinct raw value per process (the set of logged values is capped at 500).

**Partner-facing labels (same day, Darpan).** `shapeItem` in
`services/xApiActivityList.service.js` now maps the item `source` through
`PARTNER_SOURCE_LABELS`:

- `BW` becomes `ACT`, and `globaltix` becomes `GT`. The mapping is
  case-insensitive, and applying it twice gives the same result.
- An unknown value is passed through unchanged. An absent or null value is
  left as it is.

Details:

- Only the partner boundary relabels. The adapters, `/v1` and the booking
  tables keep `BW` / `globaltix`.
- Because `shapeItem` is shared, this covers `/xApi` and `/outApi`
  `activity_list` and `get_activity_by_id`, and it changes the `/xApi`
  contract too.
- `create_booking` never reads `reference_object.source`. A partner echoing
  `"ACT"` back is stored as-is and does not affect booking.
- The `replyMsg` text of the 403 `source_not_granted` responses in the detail,
  time-slot and transfer services now says "(ACT)". `replyCode` and the status
  codes are unchanged.

**Still unchanged.** There is no `include_act` alias; the request switches
are still `include_bw` / `include_gt`.

**Effect.** `/xApi/activity_list` and `/outApi/activity_list` already run GT
when it is granted (§18.1), so `'ACT,GT'` returns both sources. Detail, time
slots, transfers and booking still serve own inventory only.

**Tests.** `scripts/test_out_api_flow.js` adds:

- 9 grant cases in §[2].
- 5 label cases in §[3].
- A new §[G], which stubs both adapters and checks that `allowed_sources`
  decides which adapter is called and how items are labelled. It covers
  `ACT,GT`, `ACT`, `GT`, `BW,GT`, empty, a misspelled value, request opt-out,
  and an anonymous caller.

The blank-grant cases add to §[2], §[7], §[G] and the time-slot guards. After
them, the suite has 227 passing. `test_out_api_transfer_by_ids.js` has 29
passing. `test_out_api_create_booking.js` has 63 passing, including the grant
gate.

### 25. GlobalTix on `get_activity_by_id` (2026-09-17)

**Trigger.** A GT-only partner calling `get_activity_by_id` got
`[xApi] detail refused ... reason=not_granted grant=[gt]`. That was the designed
behaviour, because the route served own inventory only (§18.8). This section
adds GT.

**Adapter.** `globalTixActivityAdapter.getActivityById({ productId, ... })`:

- It calls `product/info`, then `normalizeProduct`, then `applyListPrice`,
  then `enrichWithOptions` (cached), then `applyMirroredImages`.
- `applyListPrice` was extracted from the list's first pass, so the list and
  detail price the headline the same way. The list's behaviour is unchanged.
- It returns 0 or 1 item and never throws.
- The id must be digits only. It is checked before any GlobalTix call.
- A product that is unknown, returns `success:false`, or makes GlobalTix error
  reads as not found (`totalRecords: 0`).
- A product that is found but still has no adult price after enrichment is
  dropped (`totalRecords: 1`, no items).
- `GT_OPTIONS_MAX_PRODUCTS` does not apply to this path.
  `GT_OPTIONS_ENRICH` still does.
- **Unconfirmed:** the field names `product/info` uses for images and country
  or city. Only name, country, city, currency, originalPrice and fromPrice are
  known. A differently named field comes back null.

**Choosing the source.** The request can send an optional `source`: `ACT`,
`GT`, `BW` or `globaltix`, in any case. `sourceKeyOf` in
`xApiSourcePolicy.service.js` reads it. It is separate from the grant aliases,
so `allowed_sources` still accepts only ACT/BW/GT.

| `source` | Result |
|---|---|
| sent | That source. Refused with 403 if the partner is not granted it, or 409 if the request opted it out or it is switched off. |
| absent, partner has ACT | ACT. This is what every existing caller gets. |
| absent, partner has only GT | GT. This fixes the report. |
| unrecognised | 400 |

Other details:

- An anonymous `/xApi` caller gets the default grant (ACT only), so
  `source: GT` is a 403 for them.
- `meta.source` is new. The controller's log lines now print it.
- The share gate and the country/city/keyword/is_combo filters apply to ACT
  only.
- `get_activity_by_ids` (the batch route) is still ACT only.

**Booking safety** (superseded by §26 on the same day: the refusal now comes
from the shared check as `source_not_supported`). A GT product id is not an
`activity.id`. Once detail
returned GT items, a partner echoing one into `create_booking` would have
booked whichever of our activities shares that number. So
`validateBookingRequest` now checks `body.source` and
`reference_object.source`:

- GT or globaltix: 400 `source_not_bookable`.
- Anything unrecognised: 400 `bad_request`.
- ACT, BW or absent: accepted as before.

This stays until GT booking exists (plan step 5).

**Tests.**

- `test_out_api_flow.js` §[H] adds 16 checks: adapter behaviour on the real
  `docs/samples/gapi_product_options.json`, which source is chosen, and each
  refusal. The old §[7] case "GT-only means 403" now sends `source: ACT`.
  The suite has 244 passing.
- `test_out_api_create_booking.js` has 66 passing.

**Found, not caused here.** `test_gt_options_mapping.js` has 4 failures. Its
`*_url` cases still expect `/uploads/` in BW URLs, which commit 182dca7
removed. The test needs updating to match that commit, or the commit needs
revisiting.

### 26. One source registry and one grant check for every endpoint (2026-09-17)

**Trigger.** After §25, a GT-only partner booking a GT item got
`create_booking refused ... reason=not_granted grant=[gt]`. That message was
wrong: the partner is granted GT, but GT booking does not exist.

**Root cause.** Every endpoint hard-coded `sources.bw` (and sometimes
`sources.gt`). A supplier the endpoint could not serve was reported as "not
granted", and each new supplier would have meant editing the grant logic in
every service.

**Decision (Darpan).** `allowed_sources` is the only thing that decides access.
Adding a supplier must not require new grant code in each endpoint.

**Design.** Everything lives in `services/xApiSourcePolicy.service.js`.

- **`SOURCES`** is the registry, one entry per source:

  | Field | Meaning |
  |---|---|
  | `key` | Internal key |
  | `code` | Name partners see (ACT, GT) |
  | `label` | Readable name for messages |
  | `grantTokens` | Values accepted in `allowed_sources` |
  | `aliases` | Values accepted in a request `source` or an item `source` |
  | `optOutParam` | Request parameter that opts the source out |
  | `envFlag` | Config kill switch |

  Registry order is the default preference and the merge order. `bw` keeps
  its key, so `include_bw`, `BW_SOURCE_ENABLED` and the booking tables are
  unchanged.
- **`resolvePartnerSources`** now covers every registry entry. Its return
  shape is unchanged: `{ bw, gt, ..., grant }`.
- **`resolveEndpointSource({ apiUser, body, cfg, supported, requested,
  endpoint, honourOptOut })`** is the single check for single-source
  endpoints. It returns `{ source, def, sources }` or `{ error }`, with checks
  in this order:

  | Condition | Result |
  |---|---|
  | `source` not recognised | 400 `bad_request` |
  | Source not granted | 403 `source_not_granted` |
  | Opted out, or env switch off | 409 `source_not_granted` |
  | Granted, but not in `supported` | 400 `source_not_supported` |
  | Endpoint declares nothing (a bug) | 500 |

  When no `source` is sent, it uses the first supported source that is
  granted. Otherwise it falls back to `supported[0]`, so the refusal names it.
- **`sourceKeyOf` and `partnerSourceCode`** replace the hard-coded label map
  in `xApiActivityList`.

**Endpoints.** Each declares what it can serve and nothing more:

| Endpoint | What it declares | Reads `source` from |
|---|---|---|
| `activity_list` | `LIST_ADAPTERS` (`bw`, `gt`), run in parallel over the registry | — |
| `get_activity_by_id` | `DETAIL_HANDLERS` (`bw`, `gt`) | `body.source` |
| `get_activity_by_ids` | `BATCH_SOURCES = ['bw']` | `body.source` |
| `get_time_slots` | `SERVED_SOURCES = ['bw']` | `body.source` |
| `get_transfer_by_ids` | `SERVED_SOURCES = ['bw']` | nothing (transport is not an activity catalogue) |
| `create_booking` | `BOOKABLE_SOURCES = ['bw']` | `requestedBookingSource`, see below |

Details:

- **`activity_list`.** An external source that fails degrades to empty; a
  failure in own inventory still surfaces as a 500. `totalRecords` adds each
  source's count, or its item count when the source cannot count, which is
  the same arithmetic as before. The meta now also carries `records` and
  `unsupported` (sources that are granted but have no list adapter).
- **`create_booking`.**
  - `requestedBookingSource` reads `body.source` and
    `reference_object.source`. If the two disagree, the request is a 400.
  - It runs before validation and before any wallet work.
  - `honourOptOut` is false for bookings.
  - `validateBookingRequest` repeats the "not bookable" refusal so the pure
    validator is safe on its own.
  - `source_not_bookable` from §25 was renamed to the shared
    `source_not_supported`.
- **Logs.** The controller logs `sourceRefusalLog` for refusals and
  `sourceSummaryLog` for the list. Neither reads `sources.bw` or `sources.gt`
  directly any more.

**Not changed.** `/v1` (`sourceFlags.service.js`,
`activityFrontController`) has its own source flags and is out of scope.
Cancel, cancellation charges and the booking lists are still not gated by
source.

**Adding a supplier (checklist).**

1. Add a `SOURCES` entry. Give it a unique key, code, grant tokens and
   aliases; the registry test enforces uniqueness.
2. Add `<ENVFLAG>` to `config.js`. If it is missing, the source counts as
   enabled.
3. Write the adapter(s). Each returns `{ items, totalRecords }` in the shared
   item shape, with `source` set to one of the entry's aliases.
4. Register the adapter where the supplier should be served:
   - `LIST_ADAPTERS` for `activity_list`;
   - `DETAIL_HANDLERS` for `get_activity_by_id`;
   - `BOOKABLE_SOURCES` plus a booking flow for `create_booking`;
   - `SERVED_SOURCES` for time slots.
5. Grant it to partners through `allowed_sources`. Endpoints that don't list
   it answer `source_not_supported`, with no further code.
6. Markup rules match on `country`. Check which country vocabulary the new
   supplier uses (§18.4).

**Tests.**

- `test_out_api_flow.js` §[I] adds 13 checks. They cover registry integrity,
  every branch of `resolveEndpointSource`, a check that every single-source
  service uses the shared function (and none reads `sources.bw` or
  `sources.gt` directly), and a check that every list adapter belongs to a
  registered source. The suite has 257 passing.
- `test_out_api_create_booking.js` covers the reported case (GT-only
  partner, GT item → 400 `source_not_supported`), ACT from a GT-only partner
  (→ 403), GT from an ACT-only partner (→ 403), an unknown source, and body
  vs `reference_object` disagreement. It has 71 passing.


### 27. `vehicles_price` on the private-transport branch (2026-09-18)

**Symptom:** on `/v1/activity_list`, an activity with `preffered_is_sic = 0`
came back with no vehicles.

`preffered_is_sic` is set at the TOP of the private branch, before any vehicle
work — so seeing `0` with nothing in `vehicles_price` proves the branch ran and
the vehicles were lost inside it, not that transport was misconfigured. Three
ways that happens, and the fix addresses two of them:

#### 1. The two tables disagree on field names (fixed)

`vehicles_price` exists on both tables and they are not the same shape:

| source | names |
|---|---|
| `vendor_transportation_packages` | `name`, `type`, `sale_price`, `purchase_price` |
| `transport_inventory` | `vehicle_name`, `vehicle_id`, `price` |

This is not inferred from nothing — `VEHICLE_FIELDS` in
`xApiTransferDetail.service.js` allow-lists **both** vocabularies side by side
(`vehicle_id, vehicle_name, name, type, …, price, sale_price`), because that
route reads both sources and never normalised them.

The private branch in `activityMarkupPipeline` reads `sale_price` /
`purchase_price` only. When 2026-09-18 pointed `/v1/activity_list` at
`transport_inventory`, every vehicle started arriving priced under `price` — so
`vSale` and `vPurchase` were both `null`, neither conversion ran, and the rows
shipped unpriced (and, to a frontend reading `sale_price`, empty).

`normaliseInventoryVehicle` now renames inventory rows into the base vocabulary
before anything reads a price. **Rename, not duplicate** (decision 2026-09-18):
the alias is moved onto the canonical key and deleted, so `vehicles_price` keeps
the one shape consumers already read and no row carries two names for one
number. `vehicle_id` is kept — it is identity, not price, and has no counterpart
in the base vocabulary. A row carrying **both** names is left completely alone:
two different numbers under two names is a data question, and quietly picking
one destroys the evidence needed to answer it. Base-package rows are not touched
at all, so every caller that does not load transport inventory is unchanged.

#### 2. Double-encoded JSON (fixed)

The branch parsed a string `vehicles_price` exactly once. §11.1 records
`contract_price` arriving **double-encoded** from this family of tables, and one
parse of a double-encoded value yields another string — `Array.isArray` is then
false and every vehicle is dropped silently. The parse now loops until the value
stops being a string, bounded at three attempts.

#### 3. No `transport_inventory` row for the date — NOW FALLS BACK TO BASE

**This reverses the original 2026-09-18 rule.** That rule said a transport with no
dated row has no rate: `0` for SIC, `null` vehicles for private, on the reasoning
that the base columns are what the package charged before per-date rates existed
and are no longer quotable.

`/xApi/get_transfer_by_ids` never worked that way — it takes the dated value when
the row carries one and the base column otherwise — and an activity answering
empty while the transfer route quotes the same transport happily is not a
defensible pair of behaviours. So the activity route now mirrors it (decision
2026-09-18, Darpan), for **both** transport types.

**The fallback is per FIELD, not per row**, which is the part to get right:

| state | SIC | private |
|---|---|---|
| dated row, value present | `inventory.sic_price` | `inventory.vehicles_price` |
| dated row, value blank / empty array | base `sale_price` | base `vehicles_price` |
| dated row, unparseable JSON | — | base `vehicles_price` |
| no dated row | base `sale_price` | base `vehicles_price` |

A dated row that carries no vehicles means "no vehicle rates loaded for this
date", **not** "this transport has no vehicles", so the base rate card stands and
the activity stays bookable. Blank counts as absent on the SIC side too —
treating `''` as a rate quotes `NaN`.

**The trade, stated plainly:** a base rate the transport team believed retired can
now be quoted. `preffered_transport_rate_source` (`'inventory'` | `'base'`) ships
alongside so that is visible in the response rather than silent, and the currency
label follows the source actually used — an inventory currency on a base price is
a wrong number, not a cosmetic mismatch. The field is emitted only when
`useTransportInventory` is on, so no other route's shape changes.

One consequence for the multi-id selection below: its second tier (an id with a
package row but no dated rate) now produces a base price instead of an empty
result, so falling through the list degrades rather than failing.
`scripts/diagnose_pvt_vehicles_price.js <activity_id> <YYYY-MM-DD>` tells the two
apart — it walks `pvt_transporter` → package row → inventory row → JSON → field
names and names the hop that broke. Read-only, and it must run **on the app
server**: `mysql_host` is `127.0.0.1`.

#### 4. Only `pvt_transporter[0]` was ever used (fixed)

`pvt_transporter` is a **list** — `[23, 45, 55]` — and both the loader and the
pipeline read index 0 and nothing else. That was harmless while transport priced
off the base package columns. Once it prices per date, index 0 having no
`transport_inventory` row for the travel date empties `vehicles_price` even when
45 or 55 have one, and the other two ids are dead weight in the column.

**The first candidate that actually answers for the date now wins** (decision
2026-09-18). Array order is still preference — `find` returns the *earliest*
match, never the cheapest — this only skips transporters with nothing to sell
that day. Two tiers, because "no package row" and "package but no rate for the
date" are different failures: prefer an id with both, then one with a package at
least (so `preffered_is_sic` is still `0` and the response says private transport
exists), then fall back to index 0 and answer empty exactly as before.

`preffered_transport_id` is emitted alongside, naming the transporter that priced
the activity — otherwise "which of the three did this come from?" is unanswerable
from the response.

Two things this deliberately does NOT do:

- **SIC is untouched.** `sic_transporter` still resolves to index 0 only.
- **Gated on `useTransportInventory`**, so it is `/v1/activity_list` only. Every
  other caller keeps index-0-unconditionally, and `allIdsFrom(x)[0]` is exactly
  `firstIdFrom(x)` by construction, so those paths are byte-identical.

`loadActivityRelations` had to widen with it: it now collects **every** id in a
`pvt_transporter` list into `transportIds`, because selecting from maps that only
ever held index 0 would find nothing to fall back to. Safe for the callers that
do not select — `transportMap` is only ever read by key, so extra entries are
invisible to them, and the cost is a longer IN list on two queries already keyed
by primary key.

#### One more thing the diagnostic checks

`firstIdFrom` is implemented **twice** with a difference:
`activityRelations.service.js` wraps the id in `Number(...)`, the pipeline does
not. Both maps are keyed by that id, and JS object keys are strings either way,
so the two agree today — but they are one edit apart from not agreeing, and the
diagnostic prints both.

**Scope:** `/v1/activity_list` only. `/v1/get_activity_by_id` and
`get_activity_by_ids` still price transport off the base columns (they do not
pass `useTransportInventory`), which stays deliberate pending the same decision
there.

Tests: `scripts/test_pvt_vehicles_price.js` (DB-free, 51 assertions) — the base
path unchanged, the rename, single- and double-encoded JSON, the no-row-for-date
answer, a vehicle with no price at all, a row carrying both names, and
unparseable JSON, and the whole multi-id selection including the base path that
must not select.

## 27. Out API per-partner activity access rules (2026-09-20)

"Partner B may not sell activity 5512, but partner A may." Three access layers
now stack on `/outApi/*`, each able only to narrow the one above it:

| Layer | Where | Granularity | Scope |
|---|---|---|---|
| Source grant | `activity_users.allowed_sources` → `xApiSourcePolicy.service.js` (§26) | supplier (ACT / GT) | per partner |
| Share gate | `activity.share_status` → `bwActivityAdapter` `sharedOnly` (§18.26) | one activity | every partner |
| **Access rule** | `activity_api_access_rule` → `xApiActivityAccess.service.js` | one activity | per partner, or all |

### Block-list, and why

A row REMOVES access; no row means access. So an empty table is byte-identical
to the behaviour before this existed and nothing needed backfilling on deploy.
An allow-list would have taken every existing partner's catalogue dark until
every activity had been granted to every partner — the same asymmetry §16 and
§26 reason about, resolved the same way.

`api_user_id = 0` is the global block ("no API user may sell this"), stored as a
normal row in the same table rather than as a second switch beside
`share_status`. The two are independent and are allowed to disagree:
`share_status = 0` is the operator saying nobody sells this, a rule here is
commercial saying this partner doesn't. Either one blocking is enough.

`api_user_id` is `NOT NULL DEFAULT 0`, not nullable, because MySQL permits
unlimited NULLs in a UNIQUE index — a nullable "all partners" column would let
the same global block be inserted any number of times. It also makes every
lookup one clause, `api_user_id IN (0, ?)`.

Rules key on `activity_users.id`, NOT `user_reference_id`: the reference id is
the API key and is rotatable, so keying on it would silently drop every rule the
day a partner's key was reissued.

`activity_ref` is VARCHAR so a GlobalTix product id — which is not an
`activity.id` and never will be — fits the same column. **Phase 1 enforces ACT
only.** A GT row is stored and returned by the admin endpoints and changes
nothing; enforcing it is a code change in the GT adapters, not a migration.

### Where it is enforced

| Path | How |
|---|---|
| `activity_list` | `NOT EXISTS` clause in `bwActivityAdapter.listActivities` |
| `get_activity_by_id` / `_by_ids` | same clause in `getActivitiesByIds` (`getActivityById` forwards through `...rest`) |
| `get_time_slots` | explicit `isActivityBlocked`; this route reads `activity` directly and goes nowhere near the adapter |
| `reserve_booking` | explicit gate after validation, before the quote |
| `create_booking` | explicit gate, one-step path |
| confirm half of `create_booking` | re-checked; on a block the hold is RELEASED and the units returned |

Not gated, deliberately: `bookings_list`, `booking_details`,
`cancellation_charges`, `cancel_booking`, `transactions_list`. Blocking is
forward-looking — a partner must still be able to service what they legitimately
sold. `get_transfer_by_ids` is out of scope: transport packages are not
`activity` rows (see `xApiTransferDetail.service.js`).

### The booking gate is explicit, and that is the whole point

It CANNOT be left to the listing filter or to the re-price.
`xApiBookingQuote.quoteBooking` returns `resolved: false` when the detail lookup
comes back empty — exactly what a blocked activity produces — and
`reserveBooking` then does:

    const heldTotal = quote.resolved ? Number(quote.total) : Number(v.total_price);

i.e. falls back to the partner's own figure and books anyway.

**This is a pre-existing hole in `share_status`, found while writing this and
NOT fixed here.** An activity un-shared through
`/activity/change_activity_share_status` is invisible in the catalogue and still
bookable by anyone who knows its id. Closing it is a production behaviour change
of its own — it makes long-un-shared activities unbookable the day it deploys —
and wants a check of live traffic first. The new gate exists so that access
rules do not repeat it.

### Two enforcement styles

`blockedActivityClause` is SQL, for the listing. It has to be in the WHERE
rather than a filter over the results, because the adapter applies its WHERE to
BOTH the COUNT and the SELECT precisely so `totalRecords` counts what the caller
can see — a post-filter re-creates the bug §12 and the bookable predicate exist
to prevent.

`isActivityBlocked` is a single-row lookup, for the paths that already hold one
id. It **fails closed**: a rule that cannot be read is treated as a block. Of
the two failure modes only one is recoverable by the partner retrying.

No caching. These are equality lookups on a small, fully-indexed table and no
bottleneck has been measured (rule 29). A block a cache makes effective 60
seconds from now is a block the operator believes is already in force.

### Two traps worth remembering

1. **Placeholder ordering.** The `sharedOnly` clause carries a comment saying it
   deliberately has no `?`, because a placeholder ahead of the nine
   bookable-predicate params binds every one of them a position off — the query
   still runs, it just prices the wrong dates. The access clause DOES carry
   placeholders, so it is pushed together with its params, among the other
   parameterised clauses. `scripts/test_out_api_activity_access.js` §3 is the
   test that catches a regression here.
2. **Collation.** `activity_ref` is VARCHAR and `activity.id` is INT. Comparing
   them directly makes MySQL coerce the varchar to a number and lose the index;
   `CAST(activity.id AS CHAR)` keeps it, but the cast takes the CONNECTION
   collation (often `utf8mb4_0900_ai_ci`) while the column is
   `utf8mb4_general_ci`, and an unpinned mix is error 1267 at runtime. Hence the
   explicit `COLLATE utf8mb4_general_ci`.

### What the partner sees

Catalogue reads answer exactly as they do for an un-shared or non-existent id —
"No activity found for the given id.", `missing_ids`, an empty slot list — so a
partner cannot map the catalogue by probing ids. That is the rule
`xApiTimeSlot` already applies to `open_for_countries`. Booking answers `403
activity_not_permitted`, because there the partner is acting on an id they were
legitimately given, money is involved, and a silent empty answer produces a
support ticket nobody can answer. The reason is in `meta` either way, which is
logged and never returned.

### Admin

`controllers/activityApiAccessController.js`, routes `/activity_api_access/
{block,bulk_block,unblock,bulk_unblock,list}`. `bulk_block` is ONE multi-row
`INSERT ... ON DUPLICATE KEY UPDATE` and `bulk_unblock` ONE
`DELETE ... WHERE ... IN (...)`, both capped at 500. ACT refs are validated
against `activity` and canonicalised to the exact string
`CAST(activity.id AS CHAR)` produces, because a rule stored as ' 5512' reads
correctly in an admin UI and blocks nothing.

**Unblocking HARD-DELETES the row** (2026-09-20, Darpan). So this table holds
only the blocks currently in force and is NOT an audit log — "when did partner B
lose access to 5512, and when did it come back" is answerable only from whatever
the admin surface logs, not from here. Do not build a history screen on these
rows. A second unblock of the same rule is an honest 404.

Both unblock endpoints echo the removed row(s) back in the response, so a caller
can log them or undo by re-POSTing to `/block` without a prior read.

`status` survives as a column and the enforcement clause still filters
`status = 1`, but nothing in the API writes 0 any more — it is there for an
operator disabling a rule by hand in SQL, and for the `status` filter on the
admin list. If that is not wanted, drop the column AND the enforcement filter
together; dropping one without the other makes every rule stop matching. The
migration has not been run anywhere yet, so this is still a free decision.

`activity_ref` holds ONE id. Blocking n activities for a partner is n rows, not
a comma-separated column — which would lose the index (`FIND_IN_SET` on the
column defeats `idx_api_access_rule_lookup` inside a per-row correlated
subquery), let `'4,517'` and `'517,4'` both exist under the unique key, inherit
the `', '` whitespace bug `open_for_countries` already has, and turn lifting one
id into a read-modify-write race that destroys the per-id `reason` and dates.

`bulk_unblock` identifies rules by `ids` (rule ids) or by `api_user_id` +
`activity_ids`; `ids` wins when both are sent, matching the single-rule
endpoint. It reports `deleted` / `not_found` as two buckets rather than an
affectedRows count, because that count says how many ids were missing but never
WHICH — and a mistyped id is the thing an operator has to act on. (Before the
switch to hard delete there was a third bucket, `already_lifted`; with rows
removed rather than flagged, that state cannot exist.) The SELECT that builds
the report runs `FOR UPDATE` in a transaction with the DELETE, so a concurrent
removal cannot make the response name something it did not delete; `bulk_block`
needs none of that, being a single self-contained statement.

The two bulk endpoints treat a partially-unknown batch differently, on purpose.
`bulk_block` refuses the whole call if any id is not an `activity` row: writing
a rule that can never match is a silent mistake worth stopping. `bulk_unblock`
reports unknown ids and lifts the rest, because refusing there would leave real
restrictions in force over a typo.

These routes carry no auth middleware, matching `/markup_group/*` and
`/activity/*` in this project. That is a gap worth closing — they decide what
partners can sell — but it is the existing project-wide pattern and was not
changed unilaterally here.

Migration: `migrations/2026_09_20_activity_api_access_rule.sql` — run BEFORE
deploy. Tests: `scripts/test_out_api_activity_access.js` (49 assertions, enforcement)
and `scripts/test_activity_api_access_admin.js` (56 assertions, the write path).
Docs: OUT_API_AGENT_DOCUMENTATION.md §1.5 and the §4 error table.

## 28. The BW Out API — a second partner channel (`/bwOutApi/*`, 2026-09-22)

"The same Out API, for our own BW agents, charged to their BW wallet." Fourteen
routes mirroring `/outApi/*`, with the key resolving against
`fdk_holidays.website_users` instead of `fdk_activity.activity_users`.

Everything about the catalogue is SHARED code, not copied: `activity_list`,
`get_activity_by_id`, `get_time_slots`, `get_transfer_by_ids`, `country_list`
and `city_list` are the `/xApi` handlers, called with a BW agent in the
partner's place. They read the authenticated row and nothing else — markup from
`markup_group_id`, the source grant from `allowed_sources`, the access rules by
asking the row which channel it is on — so none of them needed a notion of a
channel to be correct.

What is NOT shared is money and tenancy, and the reason is the same for both.

### `agent_id` now comes from two id spaces, and they overlap

§18.17 already said this about `/web` versus `/outApi`. This channel makes it
sharp: ACT partner #7 and BW agent #7 are different companies with the same
number, and `fd_activities_booking.source` is the only column that tells them
apart. So the tag is a THIRD value, `'BWAPI'`:

| source | agent_id points at | who books it |
|---|---|---|
| `'BW'` (or NULL) | `fdk_holidays.website_users` | `/web` |
| `'ACT'` | `fdk_activity.activity_users` | `/outApi` |
| `'BWAPI'` | `fdk_holidays.website_users` | `/bwOutApi` |

`'BWAPI'` rather than reusing `'BW'` because a BW agent's API bookings must not
merge with the same agent's `/web` bookings: `bookings_list` would return them
and `cancel_booking` could cancel them, neither of which this surface is
entitled to do. And it lands on the right side of every existing branch that
tests for `'ACT'` — the `/web` agent join resolves it against `website_users`
(correct), and the ops cancellation takes its non-ACT path (correct: that is
this channel's ledger).

### The debit is an HTTP call, so it cannot be in the transaction

`/outApi` writes the wallet debit as a row in the same transaction as the
booking, which is what lets §18.17 say "there is nothing left that can
half-succeed". A BW agent's money lives in `fdk_holidays` and only the FB Admin
API writes that ledger (`services/accountService.js`), so this channel is back
to `/web`'s shape — with the compensation `/web` never had:

    confirm:   commit -> debit -> (fail) -> back to RESERVED, nothing charged
                      -> GT confirm -> (fail) -> refund, back to RESERVED
    one-step:  commit -> debit -> (fail) -> units back, booking voided (status 0)
    cancel:    commit -> refund -> (fail) -> `refund_pending: true`, money owed

The order is always "the survivable failure is the one that happens". Money
moves BEFORE the supplier is asked to confirm (the 2026-09-18 decision, §18.x):
tickets bought with nobody charged is what no compensation can undo. A
cancellation commits BEFORE the refund is posted: paying out for a cancellation
that then failed to commit is money gone on a live booking.

The balance check is a plain read, not `SELECT … FOR UPDATE`. Locking a row we
are not going to write buys nothing and would hold a lock across the wire, so
two concurrent bookings by one agent can both pass it — exactly the race §6.4
describes for `/web`. It is inherited with the ledger, not introduced here; FB
Admin is the authority and refuses the second debit, which is then compensated.

### The one-step void restores the row the freeze took units from

`voidUnpaidBooking` passes the frozen `{mode, id}` to `restoreInventory` rather
than letting it re-derive the target. With no hold and an empty
`reference_object` that resolver falls back to the activity's BASE `qty` row, so
a booking that took its units from a time slot would have them handed back to
the wrong row and the slot would stay short for ever. Caught by
`scripts/test_bw_out_api.js` §5, which asserts the UPDATE names `time_slot`.

### `channelSource` is a parameter, not a patch

`buildBookingInsert` gained `channelSource = 'ACT'` (one default-preserving
parameter) and `bwBooking` passes `'BWAPI'`. The first attempt re-tagged the
params array by finding the string `'ACT'` in it — which is wrong, because
`supplier` is ALSO `'ACT'` on an own-inventory booking. Two columns, two
different questions, one shared value: the channel is who sold it, the supplier
is who fulfils it. Picking the wrong one writes a BW booking onto the ACT
channel, where the ACT partner with the same agent id can read it.

### The channel mark is a Symbol

`bwOutApiAuth.channelOf(apiUser)` is the one way anything asks. It reads a
module-private Symbol set on the authenticated row, and answers `'ACT'` for any
row without it — so every existing caller keeps its behaviour and code that has
never heard of this channel stays correct. A Symbol because it cannot arrive in
a JSON body (a caller cannot claim to be a BW agent), `JSON.stringify` ignores
it (it cannot leak into a response), and object spread copies it (it survives
`{...apiUser}` down the call chain).

### Access rules key on (id, channel) now

`activity_api_access_rule.api_user_channel` (DEFAULT `'ACT'`) joins the unique
key and every lookup, because `api_user_id` alone stopped being an identity.
The GLOBAL block stays channel-blind: `api_user_id = 0` is a statement about the
activity, not about anyone's id space, so it is matched on `api_user_id = 0`
alone. The audit tables gained a `channel` column for the same reason, read off
the authenticated row by `outApiLog` so `agent_id` and `channel` can never be
recorded out of step.

### What is a deliberate copy, and how it is kept honest

`bwBookingList` and `bwCancelBooking` are copies of their ACT counterparts
(decision 2026-09-22, Darpan: keep the channels' money and tenancy code
separate). The read side differs from the ACT original in five lines — the
ledger table, the channel tag and three `cmd` constants — and
`scripts/test_bw_out_api.js` §3 asserts that the partner-visible field
allow-lists remain IDENTICAL on both, so the contract cannot fork quietly.
Pricing is not copied at all: both channels call
`xApiCancellation.computeChargesForBooking`, which gained a `scope` parameter
(defaulting to the ACT scope) so that WHOSE booking may be priced is channel-
specific while HOW it is priced is not.

Migration: `migrations/2026_09_22_bw_out_api.sql` — run BEFORE deploy
(`website_users.markup_group_id`, the access-rule channel, the audit channel).
Tests: `scripts/test_bw_out_api.js` (50 assertions).
Docs: `docs/BW_OUT_API_DOCUMENTATION.md`,
`docs/Activity_BW_Out_API.postman_collection.json`.

## 29. Logging every GlobalTix call (`globaltix_logs`, 2026-09-22)

"What did we send GlobalTix, and what did they send back?" One row per HTTP call
to GAPI, in `globaltix_logs` (migration `2026_09_22_globaltix_logs.sql`).

### One seam, because there already was one

Every GlobalTix call in this codebase is made by `services/globalTixService.js`
-- fifteen of them -- including the image fetches, which reach it through
`globalTixImageMirror` calling `getProductImageStream`. Nothing else talks to
GAPI. So the logging is an axios INSTANCE with interceptors (`this.http`,
created in the constructor) rather than fifteen log statements: a call is
recorded because it happened, not because somebody remembered, and a method
added next year inherits it.

**An instance, never the global `axios`.** Interceptors on the global would also
capture `accountService` (FB Admin), `bookingPdfService`, `trackerService` and
`wallet.js`, writing other systems' traffic -- and their credentials -- into a
table named for GlobalTix.

Each call site tags itself `gtOperation: '<method name>'`. axios passes an
unknown config key straight through to the interceptors, so this costs one
argument per call and no wrapper functions. `scripts/test_globaltix_logs.js` §6
asserts that the count of `this.http.*` calls equals the count of tags, that no
`axios.*` call survives in the file, and that every tag names a method that
exists -- which is how the log is kept from drifting from the code.

A cached options read makes NO GAPI call (`globalTixOptionsService` holds a
300-second Redis/in-process cache), so it produces no row. The table records
traffic, not intent.

### What is NOT stored, and why each one is deliberate

**Headers.** Not redacted -- ABSENT, with no column to put them in and nothing
passing them. Every GAPI call carries `Authorization: Bearer <jwt>` and
`x-api-agent`, and the auth call carries `x-api-key`. A header column is a
credential store nobody means to create, and it cannot be un-leaked once a
support engineer has pasted a row into a ticket.

**The token.** `getAccessToken` is forced to metadata whatever else is
configured, including when it FAILS: its request body names the account and its
response IS the credential, and a failed auth response can echo the key back.

**Image fetches, entirely.** `getProductImageStream` writes no row
(`SKIP_OPERATIONS`). It is a binary GET with no body that could be stored, it is
the highest-volume call on the integration -- a cold mirror fetches up to 25 per
request -- and a row per image would out-number every row worth reading. Image
problems are diagnosed from the mirror's own `[GT Mirror]` logging, which names
the cause. The interceptor still refuses to touch a `responseType === 'stream'`
body, so a future streaming call cannot be consumed by the logger.

**Anything secret-shaped inside a body**, at any depth, via
`outApiLog.service.js`'s redactor, required rather than copied so the two audit
tables can never disagree about what a secret looks like.

### Bodies are stored for every call

The first cut kept bodies only for the money-bearing calls and recorded the
reads as metadata, so a catalogue search could not bury the booking rows. In
practice that made the table look empty: reads ARE the traffic, so nearly every
row had a null body and the log answered none of the questions it was built for.
Revised the same day -- `detail_level` is 'full' on every row except
`getAccessToken`, which stays metadata always, including when it fails, because
a failed auth response can echo the credential back.

Size is managed by the knobs rather than by dropping bodies:
`GT_LOG_MAX_BODY_BYTES` (default 65536) cuts each stored body while
`request_bytes` / `response_bytes` always record the FULL size and
`is_truncated` says a cut happened; pruning on `created_at` keeps the table's
size a choice. A product/options response runs to tens of KB, so this table now
grows faster than the /outApi audit tables in both row count and row size --
watch it for the first week.

### The request behind the call

`services/requestContext.service.js` is an `AsyncLocalStorage` set once by
`outApiRoute` / `bwOutApiRoute`, so `globaltix_logs.request_id` joins straight to
`activity_out_api_logs.request_id`. The alternative was threading a request id
through controller -> list service -> adapter -> options service ->
globalTixService, touching dozens of signatures to carry a value almost none of
them use.

It is for AUDIT ONLY. It must not become a way to pass identity or permissions:
the authenticated row is already handed explicitly to the services that need it,
and a second implicit copy is a second thing to keep in step.

NULL is normal -- `/web`, `/v1`, the image-mirror warmer and the diagnostic
scripts all make GAPI calls with no partner request behind them. The context is
read SYNCHRONOUSLY when the call completes, not inside the `setImmediate` that
writes the row, because the store is gone by then.

### It cannot break a GlobalTix call

The writer swallows its own errors and the interceptors wrap it in their own
try/catch on top -- an exception thrown from an interceptor would surface to the
caller as if GlobalTix itself had failed. The INSERT is scheduled with
`setImmediate` after the response has been handed back, so no partner waits on
it. A missing table costs a log line per call and nothing else. `GT_LOG_ENABLED=0`
turns it off entirely, because a log table is not worth an incident.

Tests: `scripts/test_globaltix_logs.js` (26). Two existing suites were updated
where they stubbed the global `axios` and now have to stub the service's own
instance -- `test_out_api_flow` §32 and `test_gt_options_mapping`'s module stub,
which needed an `axios.create`.

## 30. Out API country / city block-list (`rule_type = 'LOCATION'`, 2026-09-25)

Asked for: "don't take Pattaya activities from GlobalTix" and "don't give Dubai
ACT activities to the API". The §27 per-activity rules cannot say either, and
GT activity-id rules are still deferred. Migration:
`migrations/2026_09_25_activity_api_access_rule_location.sql` -- **run before
deploy** (every enforcement query now names `rule_type`).

### One table, two kinds of row

Kept in `activity_api_access_rule` itself (Darpan, 2026-09-25) so operators see
every block in one place:

| rule_type | key columns | the others |
|---|---|---|
| `ACTIVITY` (default; every pre-existing row) | `activity_ref` | `country` = `city` = `''` |
| `LOCATION` | `country` and/or `city` (`''` = any) | `activity_ref` = `''` |

Every query names its `rule_type`, so a LOCATION row is never read as an
activity rule or the reverse. The unique key gained `rule_type`, `country`,
`city`; `idx_api_access_rule_location` serves the location lookups. Everything
else is §27 unchanged: block-list, `api_user_id = 0` = every partner (stored
with channel `ACT` so the unique key holds), `api_user_channel` (§28),
hard-delete on unblock.

### Names, not ids

Rules store names only. `product/list` and `product/info` both send
`"country":"Australia","city":"Brisbane"` (checked in `globaltix_logs`), so a GT
product is compared with the rule by name, case-insensitive and trimmed. On
write a GT name is checked against `globaltix_countries` / `globaltix_cities`
and stored in GlobalTix's spelling; a country sent as a code (`TH`) is stored as
its name. An ACT name is stored as typed and compared with
`TRIM(activity.city) COLLATE utf8mb4_general_ci` (`activity` is 0900_ai_ci,
NO PAD; unpinned is error 1267). `activity.city` is free text: 'Dubai' does not
match 'Dubai City', so the endpoint reports `matched_activities` and refuses a
rule matching none unless `allow_unmatched: true`.

### Enforcement -- every existing gate, no new ones

| Path | ACT | GT |
|---|---|---|
| activity_list | `blockedGeoClause` in the WHERE, next to the §27 clause, in COUNT and SELECT | `gtGeoExcluder` predicate on the RAW product/list page, before pricing and option enrichment |
| get_activity_by_id / _by_ids | same WHERE (rides through the adapter) | predicate after product/info -> "No activity found" |
| get_time_slots | `isActivityBlocked` | n/a (ACT only) |
| reserve / create / confirm, both channels | `isActivityBlocked` | `isActivityBlocked` |

`isActivityBlocked` answers for both kinds. ACT: one `UNION ALL` statement
(ACTIVITY row, then LOCATION row joined through `activity` for its location).
GT: LOCATION rows only; the DB is read first and product/info is fetched only
if a GT location rule applies to this partner.

### Fail-closed points

- Rule table unreadable: ACT gate blocked (as §27); GT list/detail exclude every
  GT product; GT gate blocked.
- GT product states no city while a city rule applies (or no country for a
  country rule): excluded and logged as "location unreadable".
- product/info fails at a GT gate while a rule applies: blocked.

### Known limits

- GT list pages can come back short (no GlobalTix exclude filter). Not a
  `totalRecords` lie: GT contributes the items it returned, never a count.
- A GT confirm with a rule in force makes one product/info call inside the
  confirm transaction.
- If GlobalTix renames a city, a rule on the old name stops matching.
- `blockedRefsAmong` reads ACTIVITY rows only (no callers today).

### Admin -- the existing four endpoints, no new ones

| Endpoint | Activity rule (unchanged) | Location rule |
|---|---|---|
| `/activity_api_access/block` | `activity_id` | `country` and/or `city` |
| `/activity_api_access/bulk_block` | `activity_ids: []` | `locations: [{country, city}]` |
| `/activity_api_access/unblock` | `id`, or `activity_id` | `id`, or `country` / `city` |
| `/activity_api_access/bulk_unblock` | `ids`, or `activity_ids` | `ids`, or `locations` |

Sending an activity id AND a location is a 400. Bulk writes are one multi-row
INSERT; ACT matches are counted in one query and GT names checked in two,
whatever the list size (cap 500). One location that can never match refuses the
whole call, like an unknown activity id. Unblock by location tolerates a GT name
no longer in `globaltix_cities`, so an old rule can always be lifted.
`bulk_unblock` reports locations as `{country, city}`. `/list` returns both
kinds; `rule_type` filters it.

Tests: `scripts/test_out_api_geo_access.js`, `scripts/test_activity_api_geo_admin.js`.
`test_out_api_activity_access`, `test_activity_api_access_admin` and
`test_bw_out_api` were updated for the extra bound params, the `rule_type`
filter and the wider bulk_unblock SELECT.
