# Calendar availability and prepayment API

The CRM is the source of venue availability for mobile apps and external services.
The [OpenAPI 3.0 contract](calendar-availability.openapi.json) can be imported into
Swagger or Postman. All routes use the club host: `https://{club}.laserwar.club/api/v1`.

## Enable and connect

The owner opens **Settings → Calendar API**, verifies working hours, breaks and
calendar step in the general settings, selects the club time zone, and enables the API.
Optional player limits apply per party and venue. An unspecified limit is not invented
from equipment counts. Create a separate integration key for each backend. Keys can
read availability; prepayment permission must be enabled separately.

The key is shown once and stored as a SHA-256 hash. Keep it on your backend, never in
an APK/IPA, public JavaScript, URLs or Git. Use HTTPS and the header
`Authorization: Bearer <key>`. CRM login cookies cannot replace the key. Keys work
only for their club host, and an intent is visible only to its creating integration.
Revoking a key does not cancel an existing bank payment or prevent its verified webhook.
Owners can check outstanding payments directly in the Calendar API settings.

## Read available dates and intervals

| Method and path | Purpose |
| --- | --- |
| `GET /availability/venues` | Active venues, translations, parallel event capacity and optional player limit |
| `GET /availability` | Available calendar days and full intervals |
| `POST /availability/check` | Check one interval without reserving capacity |
| `POST /prepayment-intents` | Atomically check and hold capacity |
| `GET /prepayment-intents/{id}` | Read own intent and authoritative payment state |
| `DELETE /prepayment-intents/{id}` | Cancel a hold before bank checkout starts |
| `POST /prepayment-intents/{id}/payment` | Start checkout and obtain the bank URL |
| `POST /prepayment-intents/{id}/sync` | Query the bank for the current payment status |

Availability queries accept `from` and optional inclusive `to` (YYYY-MM-DD),
`venue_id` (0 means any active venue), `duration_minutes` (default 60), `players`
(default 1), and optional `game_type_id`. A game type is validated and saved as
metadata; it does not imply a venue mapping. Pass an explicit venue when selling
a specific arena. Request at most 31 days, no more than 365 days ahead.

The result is `{ "success": true, "data": { ... } }`. `data.days` contains `date`,
`available`, `slot_count`; `data.slots` contains `venue_id`, `date`, `time`, `start`,
`end`, `starts_at`, `ends_at`, `remaining_capacity` and nullable `player_limit`.
ISO timestamps include the UTC offset; the result also reports the IANA `timezone`.
Parallel capacity is a number of simultaneous events, not a number of players.

The calculation includes work days/hours, breaks, the entire event duration,
turnaround, occupied orders, separate games and payment holds. It handles overnight
events and peak concurrency for parallel venues. Cancelled, finished and explicitly
out-of-schedule statuses release capacity. An unassigned legacy order conservatively
consumes one event place on each venue. Instructor shifts and unassigned equipment
are not guaranteed; the `constraints` field states the applied rules.

`POST /availability/check` takes `date`, `time` (HH:MM) and the same event parameters.
It returns `available`, `timezone`, and either `slot` or `reason`. The full duration
must fit; selecting a free start minute alone is insufficient.

## Hold and pay

Availability reads/checks are advisory. Another guest may choose the same interval.
On the trusted backend, calculate the price and create an intent with `date`, `time`,
optional venue/type, duration, player count, `amount_rubles`, optional `total_rubles`,
`customer: {name, phone, email?}`, and optional `comment`.

Send `Idempotency-Key` (8–128 letters/digits or `_ . : -`). Retry the exact request
with the same key after a timeout. It returns the original intent; changed inputs
return `409 idempotency_conflict`. Use a new key for a fresh attempt after expiry.
Current acquiring supports whole RUB amounts; the amount must be 1–10,000,000 rubles,
and the full event total must be at least the prepayment. Never trust a guest-supplied
amount without validating it on your backend.

Creating an intent only holds one event place, for 2–30 minutes configured by the
owner. It creates no order/customer and sends no message. If no venue is provided,
the CRM selects a free active venue and returns its ID. Call `/payment` when the
guest chooses to pay. The CRM rechecks the interval, creates a technical request,
reuses a matching club customer or creates one without marketing consent, and uses
the saved amount and the club's default bank. A services cash register must be configured.
Redirect the guest to `payment_url`.
Retries reuse the same payment. Concurrent initialization returns `409 payment_in_progress`.

The existing `/book/callback` verifies the signature and fetches an authenticated
bank status. The bank payment ID and merchant reference must belong to this checkout;
an early callback is bound only by bank-fetched metadata. A mismatching currency is
rejected. T-Bank `AUTHORIZED` is a hold, not a captured payment; final confirmation
requires `CONFIRMED`. See the [T-Bank status API](https://developer.tbank.ru/eacq/api/get-state)
and [YooKassa payment process](https://yookassa.ru/developers/payment-acceptance/getting-started/payment-process).
A matching paid amount confirms the intent, request and cash entry
under one club lock. Repeated notifications do not create additional cash entries.
A browser success redirect is not proof of payment. Poll the intent or call `/sync`
every 30–60 seconds while awaiting a result.

## States and recovery

| State | Capacity |
| --- | --- |
| `held` | Held until `expires_at`, before checkout |
| `expired` | Released automatically without cron |
| `payment_pending` | Held while initialization/payment is in progress |
| `payment_review` | Held after an uncertain bank response or timeout |
| `confirmed` | Occupied by the ordinary paid CRM request |
| `cancelled` | Released before checkout or after verified bank cancellation |

Closing a bank tab or reaching the initial hold expiry never cancels an issued
payment. Cancel it at the bank, then synchronize. Otherwise a late successful charge
could pay for an already-resold interval. `DELETE` returns `409 bank_state_required`
after checkout starts. A missing bank payment identifier requires operator review;
do not clear such holds with a timer or start a second payment. Refunds and cancellation
of already paid events use the existing CRM workflow. Owners can synchronize pending
payments in settings even after revoking the original key.

Errors are `{ "success": false, "error": { "code": "...", "message": "...", "details": [] } }`.
Use stable `code` values for your UI. HTTP 401 means a missing/revoked key, 403 means
disabled API/wrong host/missing scope, 404 means an unavailable own resource, 409 is
a conflict or payment state requiring review, 422 is invalid input, 429 is rate limiting,
and 502/503 indicate bank/configuration problems. Bank failure keeps capacity held.
The limit is 120 requests/minute/key (`Retry-After: 60`); JSON bodies are at most 16 KiB.
Responses use `Cache-Control: no-store` and never expose other guests' details.

## Deployment and tests

Apply `m261020_160000_calendar_availability_api` and verify both prefixed tables:
`calendar_api_key`, `calendar_prepayment_intent`. Existing club data is not rewritten.
Use the standard GitLab CI deployment. The current `deploy_production` job runs
`php console/yii migrate/up --interactive=0`; verify its success and both new
tables. Do not deploy PHP through root SFTP. Do not remove tables or remove
calendar guards while bank payments remain active.

Run `python scripts/test_calendar_availability_smoke.py` for offline ORM/HTTP/race
tests and syntax checks. Optional `--base-url https://lasertag-test.laserwar.club`
performs read-only post-deploy checks, using `LW_CALENDAR_API_KEY` when provided.
Tests use local synthetic data and a fake bank network adapter, not real payments.
External SQL importers must participate in `CalendarLock` and capacity checks;
direct database writes cannot be protected by PHP model guards.
