# Booking-sync API: brafa operations (internal)

Internal notes for running the booking-sync API. Not for partners: this file names
our tables, commands and deploy steps.

The partner-facing docs, each complete on its own and safe to send:

* [booking-sync-entrify.md](booking-sync-entrify.md): for Entrify.
* [booking-sync-stundagatt.md](booking-sync-stundagatt.md): for Stundagátt.
* [booking-sync-api.md](booking-sync-api.md): the generic version, for any future
  system.

The three docs share their signing, lock-status, error and limit sections word for
word. When the API changes, change all three. The signature test vector in them is
checked by `BookingLockStatusTest`.

## Pieces

| | |
|---|---|
| Admin page | Superadmin → **Booking Systems** (`/superadmin/booking-systems`, `BookingSystemManagement`) |
| Request log | Superadmin → **Booking API Log** (`/superadmin/booking-sync-requests`, `BookingSyncRequestLog`). Every API request and our reply, accepted or rejected, is written by the `LogBookingSyncRequest` middleware after the reply has gone out, to `booking_sync_requests`. Door codes are masked; an empty code (a cancellation) stays visible. Entries are kept 30 days, pruned by `booking-sync:run`. You can filter by system, venue, booking id, result and call. |
| Routes | `POST /api/booking-sync/{provider}/{tenant}/bookings` and `/lock-status` |
| Auth | `VerifyBookingSourceSignature`, against `booking_providers` (one encrypted secret per system; `previous_secret` while rotating) |
| Venues | `booking_sources`: provider + tenant → property. Also `replaces_api`, `live_since` (first push) and `last_push_at`. |
| Units | `booking_source_units` maps the provider's unit id to our room (`RoomID`), via `BookingSourceUnits`. `locks_inherited` flags a unit that got the property's earlier locks. |
| Bookings | `bookings.booking_source_id` (which venue pushed it) and `source_modified_at` (the provider's `modifiedAt`; an older push is `stale`) |
| Code sync | `BookingCodeSyncService`: today-only codes, change in place, multi-unit (`booking_units`), errors on `booking_codes.lock_error*` |
| Status / arrival | `BookingLockStatus`; `BookingArrivalService` writes `bookings.checked_in_at` and `booking_codes.lock_first_used_at` |
| Schedule | `booking-sync:run`, a global `scheduled_tasks` row running every 10 minutes |
| Cleanup | Once a day, at the first run after 03:00, `booking-sync:run` runs `DeleteExpiredBookingCodes::CodeDeletion` for each live venue's property. It removes expired codes and cards from the locks and drops code rows more than 2 days old, the same as the per-property `app:delete-*` scripts. It is tracked on `booking_sources.last_cleanup_at`. |
| Feed | `BookingFeedPoller`, run by `booking-sync:run` before the push. It reads the system's own feed (`booking_providers.feed_url` with `{tenant}`, `feed_token` as a Bearer token) for each live venue, for today and tomorrow. Bookings missing from the feed are never cancelled. The result is on `booking_sources.last_poll_at` / `poll_error`. |

## First setup: Entrify and Stundagátt

Do this on the **Booking Systems** page once the code and migrations are deployed.

**The URL names must be exactly `entrify` and `stundagatt`.** Billing treats those
two as golf (flat 4,900 ISK per venue), and venues Entrify onboarded before are found
by them.

1. **Entrify**
   1. Add a system named "Entrify", with URL name `entrify`. Copy the secret.
   2. Link venue `golfsvitan` to the Golfsvítan property. Do the same for any other
      tenant we already read through Entrify's feed.
   3. Send Entrify `booking-sync-entrify.md`, and the secret separately.

   Entrify's feed poll keeps running alongside the pushes. Both go through one engine
   and one per-property lock (`booking-code-sync:{PropertyID}`).
2. **Stundagátt**
   1. Add a system named "Stundagátt", with URL name `stundagatt`. Copy the secret.
   2. Get Stundagátt's tenant id for each venue moving from Bookly. Link each one on
      the page; the **Moving from Bookly** list shows which still need it.
   3. Send Stundagátt `booking-sync-stundagatt.md`, and the secret separately. When
      they've built their bookings feed, enter its address and token under **Bookings
      feed** on the page. Ask them to:
      - push every booking from today on, per venue, with `SG-`-style ids
      - use unit ids, with names matching our room names on the first push

New venues of either system that we don't serve yet need nothing beforehand: their
first push creates the property. Then add its lock account in property details and
map its locks in Assigned Locks.

## Moving Bookly venues (golf_simulator) to Stundagátt

The **Moving from Bookly** list on the page shows every `golf_simulator` property:
its linked venue, its status, whether it has a lock account, and how many locks it
has.

* **Linking** a Stundagátt venue to a Bookly property records `replaces_api =
  golf_simulator`. This is detected automatically, because the property's API isn't
  the provider. Nothing else changes yet: Bookly's `app:automate-*` keeps placing
  codes, and `booking-sync:run` leaves the property alone.
* **Stundagátt's first push** for the venue is the switch-over (`live_since`):
  - The Bookly path stands down for the property. `compareGolfBookingsWithCodesTable`
    returns early, so `app:automate-<venue>` becomes a no-op. You can leave the task
    on, which also keeps lock billing's "automation" flag on.
  - Only Stundagátt's own bookings (`booking_source_id` set) get codes. Bookly's
    leftover bookings never reach a lock again, even for later days.
  - Codes Bookly already put on a lock are adopted, not duplicated (-3007).
* **Lock account and locks** stay on the same property, so they carry over.
  - A Stundagátt unit whose name equals an existing unit uses its `AssignedLock` row,
    since golf rows have `RoomID` = the unit name.
  - A unit whose name matches nothing gets **all** the locks the property had assigned
    before: usually the one shared entrance; if units had different locks, all of
    them. Such units are flagged on the page ("got the earlier locks: check"). Fix the
    mapping in Assigned Locks if it's too wide.
* **Property API stays `golf_simulator`**, so billing is unchanged.

**Known effect:** if a Stundagátt booking that took over a Bookly code is cancelled on
switch-over day, the code stays on the lock until that slot ends. Bookly's own row
still holds the entry. `app:delete-<venue>` cleans it up afterwards.

## Running it

* **Scheduled run:** `booking-sync:run` runs every 10 minutes for every **live** venue
  (switched on, and has pushed). There is no per-venue script: one global
  `scheduled_tasks` row covers them all, each property handled separately. For each
  property it reads the system's bookings feed (if it has one), places codes when a
  booking's day comes, retries failures, finishes removals that failed, and checks
  for arrivals. Once a day it also removes expired codes (see Cleanup above). Pushes for
  same-day bookings run right after the response under PHP-FPM; there is no queue
  worker.
* **On/off switches**, all on the page:
  - a venue's switch stops its pushes and its syncing
  - a system's switch refuses its requests
  - "New venues set up on first push" lets unknown venues create a property

  The dashboard automation toggle doesn't apply to pushed venues.
* **Rotate a secret:** use **New secret**. The old secret works until you press
  **Finish rotation**.
* **Arrival:** only codes placed by this sync are matched (rows with
  `lock_attempted_at`). Hotel auto check-in (`tagArrivalCheckins`) is unaffected.
  Nothing in the dashboard shows `checked_in_at` yet.
* **Looking into a partner's problem:** start in **Booking API Log**. Filter by the
  booking id or the venue to see exactly what they sent and what we answered,
  including requests we rejected and why.
* **Logs:**
  - rejected requests: `Booking sync request rejected`, with the reason and IP
  - engine messages start with `Lock sync:`
  - switch-overs, provisioning and inherited locks are logged with `Booking sync:`
* **Shell:** `booking-provider:add`, `booking-provider:rotate-secret` and
  `booking-source:add` do the same as the page.
* **Deploy:** run the `2026_10_12_*` and `2026_10_13_*` migrations **before** the code
  is live. They:
  - add the `booking_codes` lock-result columns and two `bookings` columns
    (`booking_source_id`, `source_modified_at`)
  - create the booking-sync tables
  - seed the `booking-sync:run` task
  - create `booking_sync_requests` (the request log)
