# ACT booking flow — reserve → confirm → release

Own inventory (`source: "ACT"`) on `/outApi/*`. Every request carries
`x-api-key: <Agent API Key>`; every response carries `X-Request-Id`.

`create_booking` still works on its own, exactly as before — a partner that does
not reserve first gets the old one-step booking. A `booking_ref_no` in the body
turns it into the confirm step instead.

## The flow

```mermaid
sequenceDiagram
    autonumber
    participant P as Partner
    participant API as /outApi
    participant DB as MySQL
    participant W as Wallet (website_accounts)

    P->>API: activity_list / get_activity_by_id
    API-->>P: activity + time_slots + prices (travel date only)

    rect rgb(238, 245, 255)
    note over P,W: 1. RESERVE — nothing is charged
    P->>API: reserve_booking (activity, date, slot, pax, total_price)
    API->>DB: release holds that have expired
    API->>API: re-price from the catalogue (+ markup)
    API->>W: lock + read balance (check only)
    API->>DB: freeze units on the slot / category / activity
    API->>DB: INSERT booking RESERVED, hold_expires_at = now + hold window
    API-->>P: booking_ref_no, hold_expires_at, total_price, price_changed
    end

    rect rgb(238, 255, 240)
    note over P,W: 2a. CONFIRM — the money moves
    P->>API: create_booking (booking_ref_no)
    API->>W: lock wallet
    API->>DB: lock booking row, re-check state + expiry
    API->>W: DR total_price
    API->>DB: reservation_status = CONFIRMED
    API-->>P: booking_id, booking_ref_no, wallet_balance
    end

    rect rgb(255, 243, 238)
    note over P,DB: 2b. RELEASE — or give it back
    P->>API: release_booking (booking_ref_no)
    API->>DB: units restored, reservation_status = RELEASED
    API-->>P: units_released
    end

    note over API,DB: 2c. EXPIRY — a hold nobody confirmed<br/>units restored, reservation_status = EXPIRED
```

## Reservation states

```mermaid
stateDiagram-v2
    [*] --> RESERVED: reserve_booking
    [*] --> CONFIRMED: create_booking (one step, no hold)
    RESERVED --> CONFIRMED: create_booking (booking_ref_no) — wallet charged
    RESERVED --> RELEASED: release_booking
    RESERVED --> EXPIRED: hold_expires_at passed (sweep, or a late confirm)
    CONFIRMED --> CANCELLED: cancel_booking — charge by policy, refund to wallet
    RELEASED --> [*]
    EXPIRED --> [*]
    CANCELLED --> [*]
```

`reservation_status` lives on `fd_activities_booking`
(migration `2026_09_17_activity_booking_reservation.sql`). Rows written before
it existed, and every `/web` booking, are `CONFIRMED`. **Reports that count ACT
sales must filter `reservation_status = 'CONFIRMED'`** — a `RESERVED` row has no
wallet debit behind it. `CANCELLED` above is the existing `status = 2` /
`ops_confirmed = 2` pair, not a new value.

## Endpoints

### `POST /outApi/reserve_booking`

Holds the units, checks the wallet without charging, re-prices the activity.

| Param | Type | Req. | Notes |
|---|---|---|---|
| `reference_type` | int | **Yes** | Always `1` (activity) |
| `reference_id` | int | **Yes** | Activity id. `reference_object.id` also accepted |
| `source` | `ACT` | No | Must agree with `reference_object.source`. GT is not bookable yet |
| `travel_date` | date | **Yes** | Aliases `from_date`, or `reference_object.activity_date` |
| `to_date` | date | No | Defaults to the travel date |
| `no_of_travellers` | object | **Yes** | `{adult, child, infant, travellers}`. Units = `travellers`, else adult + child; infants consume no unit |
| `total_price` | number | **Yes** | What you expect to pay. Compared with our current price |
| `currency` | string | No | Stored on the booking; also the quote currency |
| `guest_info` | object | **Yes** | `{first_name, last_name, email, contact_no}` |
| `reference_object` | object | **Yes** | The item as listed. `inventory_source`, `time_slots`, `isSelectedTimeSlotObject`, `no_of_travellers` |
| `time_slot_id` / `time_slot` | int | No | The selected slot, if not in `reference_object` |
| `category_id` | int | No | The selected category, for category inventory |
| `title`, `city`, `country`, `time_from`, `time_to`, `cancellation_policy_id`, `pickup_location`, `drop_location`, `transfer_mode`, `image`, `description` | — | No | Stored on the booking as sent |

**Which stock is held:** a selected time slot wins; otherwise `inventory_source`
decides (`time_slot` → `time_slot`, `category` → `activity_inventory`,
`inventory` → the activity-level row, `base`/`master` → `activity.qty`).

**Returns:** `booking_ref_no`, `booking_id`, `reservation_status: "RESERVED"`,
`hold_expires_at`, `hold_minutes`, `reference_id`, `inventory_source`,
`category_id`, `time_slot_id`, `from_date`, `to_date`, `units`,
`requested_total_price`, `total_price` (what confirm charges), `price_changed`,
`price_check`, `currency`, `wallet_balance`.

`price_check: "quoted"` means the price was recomputed from the catalogue
(`adult_price × adults + child_price × children + infant_price × infants` on the
held row, after markup; guides and add-ons are not included).
`price_check: "unavailable"` means the quote could not be produced, your own
`total_price` is held, and `price_changed` is `null`.

**Refusals:** `unauthorized` (401), `source_not_granted` (403),
`source_not_supported` (400), `bad_request` (400), `insufficient_balance` (400),
`no_time_slot` / `no_inventory` (400), `insufficient_inventory` (400),
`duplicate_booking` (400).

### `POST /outApi/create_booking`

**As confirm** — send the reservation reference:

| Param | Type | Req. | Notes |
|---|---|---|---|
| `booking_ref_no` | string | **Yes** | From `reserve_booking` (alias `reservation_ref`) |
| `total_price` | number | No | Must equal the held price when sent |

Everything else in the body is ignored: the booking row already holds it.

**Returns:** `booking_id`, `booking_ref_no`, `reservation_status: "CONFIRMED"`,
`reference_id`, `inventory_source`, `category_id`, `time_slot_id`, `from_date`,
`to_date`, `units`, `total_price`, `currency`, `wallet_balance`.

**Refusals:** `not_found` (404), `already_confirmed` (400),
`reservation_released` (409), `reservation_expired` (409 — the hold lapsed and
its units were put back), `price_mismatch` (409, carries the held
`total_price`), `booking_cancelled` (400), `insufficient_balance` (400).

**As a one-step booking** — no `booking_ref_no`: same parameters as
`reserve_booking`, and it freezes the units, writes the booking and charges the
wallet in one call. Unchanged.

### `POST /outApi/release_booking`

| Param | Type | Req. | Notes |
|---|---|---|---|
| `booking_ref_no` | string | **Yes** | The reservation to release (alias `reservation_ref`) |

**Returns:** `booking_ref_no`, `reservation_status`, `units_released`,
`inventory_restored`.

Releasing an already released or expired hold is a 200 with
`units_released: 0`, so a retry after a timeout is safe. A confirmed booking is
refused with `already_confirmed` (409) — use `cancel_booking`, which prices the
cancellation and refunds the wallet.

### After the booking

`booking_details`, `bookings_list`, `cancellation_charges`, `cancel_booking`,
`wallet_balance` — unchanged.

## Timings and settings

| Setting | Default | Meaning |
|---|---|---|
| `ACT_BOOKING_HOLD_MINUTES` | 15 | How long a hold survives without a confirm |

Expired holds are released three ways, all running the same code
(`releaseExpiredHolds`): when any partner calls `reserve_booking`, when a late
`create_booking` finds the hold lapsed, and from `scripts/release_expired_holds.js`
on the system cron, so a quiet API still frees stock.

```
*/5 * * * * cd /path/to/booking_window_activity_node && \
  /usr/bin/node scripts/release_expired_holds.js --quiet >> /var/log/act_holds.log 2>&1
```

`--limit=N` caps one run (default 200); `--quiet` prints nothing when there was
nothing to do. Exit code 0 means it ran, 1 means it could not start at all.

**Or as a URL**, for a scheduler that can only fetch one:

```
GET http://127.0.0.1:<PORT>/internal/release_expired_holds[?limit=200]
*/5 * * * * curl -fsS "http://127.0.0.1:4080/internal/release_expired_holds" >> /var/log/act_holds.log 2>&1
```

Answers `{released, skipped, failed, limit, took_ms}`. It is **local only**: the
call must come straight from this server, and a request carrying
`X-Forwarded-For`, `X-Real-IP` or `Forwarded` (i.e. proxied from somewhere else)
gets a 404. Use the server's own address and port, not the public domain —
going through nginx or a load balancer will be refused.

## Postman

`docs/Activity_ACT_Booking_API.postman_collection.json` — set `baseUrl` and
`api_key`, then run **Reserve Booking** (it stores `booking_ref_no` and the held
price) followed by **Create Booking (confirm the hold)** or **Release Booking**.
