750 lines
39 KiB
Markdown
750 lines
39 KiB
Markdown
# API Consumer Guide
|
||
|
||
This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it.
|
||
|
||
## Overview
|
||
|
||
A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes.
|
||
|
||
## Authentication
|
||
|
||
- Token endpoint: `POST /token` (unauthenticated).
|
||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||
- Access token TTL is **10 minutes** for every grant.
|
||
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
|
||
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
|
||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||
- Send the token as `Authorization: Bearer <access_token>`.
|
||
|
||
Scope required per endpoint:
|
||
|
||
| Scope needed | Endpoints |
|
||
|---|---|
|
||
| `api` | everything except the two below |
|
||
| `profile` | `GET /api/crm-attributes` |
|
||
| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) |
|
||
|
||
`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials.
|
||
|
||
### Machine-to-machine example
|
||
|
||
```
|
||
POST /token
|
||
Content-Type: application/x-www-form-urlencoded
|
||
|
||
grant_type=client_credentials&client_id=...&client_secret=...&scope=api
|
||
```
|
||
|
||
```
|
||
GET /api/hotels
|
||
Authorization: Bearer eyJ0eXAi...
|
||
```
|
||
|
||
## Error conventions
|
||
|
||
There is no single error envelope; three shapes occur:
|
||
|
||
- `{"message": "..."}` — commonly with 404 / 400.
|
||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||
|
||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||
|
||
`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
|
||
|
||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||
|
||
---
|
||
|
||
## Endpoints
|
||
|
||
### Reference data (BusProNet master data)
|
||
|
||
#### `GET /api/hotels` — scope `api`
|
||
|
||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
|
||
`costUnit`, `countryCode`.
|
||
|
||
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
|
||
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
|
||
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
|
||
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
|
||
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
|
||
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
|
||
the mapping table is extended.
|
||
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
|
||
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
|
||
|
||
#### `GET /api/hotels/{id}` — scope `api`
|
||
|
||
Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
|
||
`404 {"message":"Not found"}` if unknown.
|
||
|
||
#### `GET /api/countries` — scope `api`
|
||
|
||
Array of `{id, name, token, nationality}`.
|
||
|
||
#### `GET /api/products` — scope `api`
|
||
|
||
Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**.
|
||
|
||
#### `GET /api/last-update` — scope `api`
|
||
|
||
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
|
||
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
|
||
status 200 — treat null as "unknown", not as "changed".
|
||
|
||
---
|
||
|
||
### Pickups (bus boarding points)
|
||
|
||
#### `GET /api/pickups` — scope `api`
|
||
|
||
Array of `{id, code, city, postalCode, street}`.
|
||
|
||
#### `GET /api/pickups/{id}` — scope `api`
|
||
|
||
Single pickup by numeric id, same fields as the list.
|
||
|
||
#### `GET /api/pickups-planning/{travelCode}` — scope `api`
|
||
|
||
Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted:
|
||
|
||
```json
|
||
[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}]
|
||
```
|
||
|
||
`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors.
|
||
|
||
#### `POST /api/pickups-planning` — scope `api`
|
||
|
||
Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"PlanungsArt": "HIN",
|
||
"Reisecode": "ABC123",
|
||
"Zustieg/Ausstieg Ort": "Köln",
|
||
"Zustieg/Ausstieg Strasse": "Hauptbahnhof",
|
||
"Zeit": "24.12.2026 07:30",
|
||
"BusNummer": "1"
|
||
}
|
||
]
|
||
```
|
||
|
||
Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`.
|
||
|
||
This is a full overwrite, not a merge.
|
||
|
||
---
|
||
|
||
### Bulk exports
|
||
|
||
#### `GET /api/exports/travels` — scope `api`
|
||
|
||
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
|
||
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
|
||
|
||
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
|
||
|
||
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
|
||
arbitrary string, so an export hung off that collection would share a namespace with every date code.
|
||
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
|
||
|
||
Query parameters:
|
||
|
||
| Parameter | Type | Default | Notes |
|
||
|---|---|---|---|
|
||
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
|
||
| `perPage` | int 1–1000 | `500` | |
|
||
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
|
||
|
||
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
|
||
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
|
||
an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||
|
||
```json
|
||
{
|
||
"data": [
|
||
{
|
||
"busProId": 12794,
|
||
"code": "PCJSER261226",
|
||
"productCode": "PCJSER",
|
||
"product": "Semesterende - Portes du Soleil - Joliment",
|
||
"dateFrom": "2026-12-26",
|
||
"dateTo": "2026-12-30",
|
||
"hotel": {
|
||
"busProId": 225444,
|
||
"code": "PCJSER1",
|
||
"name": "NEU SER Jolimont 1",
|
||
"costUnit": "863",
|
||
"country": "CH",
|
||
"countryCode": "ch"
|
||
},
|
||
"pickups": [
|
||
{
|
||
"busProId": 18,
|
||
"code": "ESS-Route",
|
||
"city": "Essen",
|
||
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
||
"time": "20:15",
|
||
"datetime": "2026-12-25T20:15:00"
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"meta": {
|
||
"page": 1,
|
||
"perPage": 500,
|
||
"total": 415,
|
||
"totalPages": 1,
|
||
"skipped": 0,
|
||
"pickupsWithoutMasterData": 0,
|
||
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
|
||
"generatedAt": "2026-09-29T12:10:06+02:00"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
|
||
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
|
||
empty, never null.
|
||
|
||
**`productCode`** is the code of the travel (product) the date belongs to, e.g. `DKIWE`. Key on it to
|
||
recognise dates of the same product. Do not derive it from the date `code` by cutting off the date: some
|
||
products run dates under a different code (`DWWMP` runs `DWWMW…`), and group travels carry each group's own
|
||
code on the date. It is `null` for the few travels that have no code upstream.
|
||
|
||
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
|
||
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
|
||
|
||
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
|
||
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
|
||
lost rows you previously had.
|
||
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
|
||
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
|
||
with no name is more useful to a disposition view than one that silently vanished.
|
||
|
||
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
|
||
|
||
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
|
||
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
|
||
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
|
||
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
|
||
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
|
||
an offset to compare or sort them; they already sort correctly as strings.
|
||
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
|
||
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
|
||
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
|
||
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
|
||
`datetime` carries that resolution for bare-time sources too.
|
||
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
|
||
null together.
|
||
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
|
||
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
|
||
day earlier than the running order implies. This is upstream data, not a transformation applied here.
|
||
|
||
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
|
||
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
|
||
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
|
||
|
||
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
|
||
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
|
||
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
|
||
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
|
||
|
||
#### `GET /api/exports/products` — scope `api`
|
||
|
||
Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product
|
||
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
|
||
boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and
|
||
page-consistency rule as `GET /api/exports/travels` above; only the rows differ.
|
||
|
||
**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services,
|
||
`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of
|
||
them a website shows, and how, is the client's rule, not this endpoint's.
|
||
|
||
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500`
|
||
returns it in one page.
|
||
|
||
```json
|
||
{
|
||
"data": [
|
||
{
|
||
"product": { "code": "DKIWE", "busProId": 1127 },
|
||
"date": {
|
||
"code": "DKIWE041226",
|
||
"busProId": 12437,
|
||
"from": "2026-12-04",
|
||
"to": "2026-12-06",
|
||
"type": "F"
|
||
},
|
||
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
|
||
"pseudoPrice": null,
|
||
"selectionGroups": [
|
||
{
|
||
"busProId": 7,
|
||
"label": "Leistungen Skipass",
|
||
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
|
||
}
|
||
],
|
||
"transportServices": [
|
||
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
|
||
],
|
||
"dateServices": [
|
||
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||
],
|
||
"hotelServices": [
|
||
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
|
||
],
|
||
"rooms": [
|
||
{
|
||
"busProId": 74,
|
||
"code": "MBZET",
|
||
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
|
||
"category": "GR",
|
||
"minPax": 1,
|
||
"maxPax": 1,
|
||
"nights": 2,
|
||
"price": 229,
|
||
"available": 24,
|
||
"status": "Frei",
|
||
"boardCode": "Lt. Auss.",
|
||
"boardBusProId": 2
|
||
}
|
||
],
|
||
"pickups": [
|
||
{
|
||
"busProId": 4,
|
||
"code": "FRA-Route",
|
||
"city": "Frankfurt",
|
||
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
||
"price": 5.9,
|
||
"returnLeg": true,
|
||
"returnPrice": 5.9,
|
||
"datetime": "2026-12-03T23:30:00"
|
||
}
|
||
],
|
||
"guide": null
|
||
}
|
||
],
|
||
"meta": {
|
||
"page": 1,
|
||
"perPage": 500,
|
||
"total": 419,
|
||
"totalPages": 1,
|
||
"skipped": 0,
|
||
"pickupsWithoutMasterData": 0,
|
||
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
|
||
"generatedAt": "2026-09-30T14:05:00+02:00"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair.
|
||
`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on
|
||
hotel master data, so `skipped` counts only travel dates whose dates could not be parsed.
|
||
|
||
**Types.**
|
||
|
||
- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does
|
||
not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike.
|
||
- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are
|
||
always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable.
|
||
- All arrays are in **source order**. Sort for display yourself; do not depend on the order.
|
||
|
||
**Rooms.** One entry per room offered on the date at that hotel.
|
||
|
||
- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`.
|
||
- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`.
|
||
- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them
|
||
empty.
|
||
|
||
**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code
|
||
`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows.
|
||
|
||
**Services.** Three lists, deliberately kept apart:
|
||
|
||
- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions.
|
||
- `dateServices` — the date's general services.
|
||
- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services
|
||
here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by
|
||
`busProId`: it identifies a service across both lists.
|
||
|
||
`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through.
|
||
`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount,
|
||
such as for arriving by car.
|
||
|
||
**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by
|
||
`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
|
||
|
||
| `busProId` | Label |
|
||
|---|---|
|
||
| 7 | Leistungen Skipass |
|
||
| 8 | Leistungen Sonstige |
|
||
| 71 | Leistungen Verpflegung |
|
||
| 85 | Leistungen Übernachtung |
|
||
| 86 | Leistungen Programm |
|
||
| 87 | Leistungen Kurse |
|
||
| 89 | Leistungen Events |
|
||
| 90 | Leistungen Transport |
|
||
|
||
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
|
||
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
|
||
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
|
||
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
|
||
none. The surcharge is charged **per leg**: `returnLeg` says whether the point is also served on the way back, and
|
||
`returnPrice` is its return surcharge, again verbatim and `null` where upstream states none. Neither price is levelled
|
||
against the other. A point served on the return leg only is not emitted. An id absent from
|
||
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
|
||
of `0` is emitted as-is but not counted.
|
||
|
||
**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`.
|
||
|
||
---
|
||
|
||
### Travels
|
||
|
||
#### `GET /api/travels` — scope `api`
|
||
|
||
Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`.
|
||
|
||
#### `GET /api/travels/{dateId}/{hotelId}` — scope `api`
|
||
|
||
Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits.
|
||
|
||
#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api`
|
||
|
||
Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists.
|
||
|
||
Both variants accept optional query parameters:
|
||
|
||
- `source=local` — force the local XML/snapshot data.
|
||
- `source=remote` — force a live upstream call.
|
||
- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback.
|
||
|
||
Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`.
|
||
|
||
#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api`
|
||
|
||
Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**.
|
||
|
||
---
|
||
|
||
### Contingents & accommodation prices
|
||
|
||
#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api`
|
||
|
||
Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`.
|
||
|
||
- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code).
|
||
- `year`: required int, 1000–9999.
|
||
|
||
Each row:
|
||
|
||
```json
|
||
{
|
||
"dateFrom": "2026-06-01",
|
||
"dateTo": "2026-06-14",
|
||
"season": "high|low|...|null",
|
||
"includedPax": 2,
|
||
"pricePerNight": 89.5,
|
||
"priceAdditionalPerson": 25.0,
|
||
"defaultPricePerNight": 99.0,
|
||
"defaultPriceAdditionalPerson": 30.0,
|
||
"currency": "EUR",
|
||
"type": "discount|null"
|
||
}
|
||
```
|
||
|
||
Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters.
|
||
|
||
#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||
|
||
Per-day availability enriched with prices.
|
||
|
||
`dateFrom` and `dateTo` are **optional, and must be supplied together** — one without the other is a `400`. Both are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); when supplied, the range may not exceed 366 days and `dateTo` must not precede `dateFrom`.
|
||
|
||
**Omit both to get the full priced span.** The range is then derived from the accommodation's persisted prices, `MIN(dateFrom)` to `MAX(dateTo)`, and never starts before today. This is the way to fetch everything a hotel sells in one call, and it is not subject to the 366-day cap — a hotel priced over two seasons returns well over a year of entries.
|
||
|
||
Full-span responses are contiguous, exactly like ranged ones: every day between the derived bounds gets an entry, including days no price covers. Those come back `BLOCKED` with `null` price fields — a hotel closed over winter reports the whole closure day by day rather than skipping it, so a consumer keying off `status` always finds one.
|
||
|
||
The response is `[]` — a `200`, not a `502` — when the hotel has no prices at all, or when every priced period has already ended.
|
||
|
||
Availability is served from a **local snapshot** refreshed over a 24-month horizon — every 15 minutes during the day, hourly overnight — not fetched from the upstream contingent service per request. The endpoint therefore responds in single-digit milliseconds and stays available during an upstream outage, at the cost of being at most one sync interval stale.
|
||
|
||
The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint returns **one entry per day of the range**, whether that range came from `dateFrom`/`dateTo` or was derived from the prices. Previously days the upstream service did not mention were simply absent, so a response may contain days it would not have contained before. It is a superset, never a different shape.
|
||
|
||
Two kinds of day resolve to `BLOCKED` regardless of the underlying contingent:
|
||
|
||
- **days in the past** — the snapshot is maintained from today forward only (`prices` likewise refuses past ranges)
|
||
- **days beyond the synced horizon** — currently 24 months out. Prices are often maintained further ahead than the contingent is synced, so a full-span response can end in a run of `BLOCKED` days that simply have no availability data yet.
|
||
|
||
Each entry:
|
||
|
||
```json
|
||
{
|
||
"date": "2026-06-01",
|
||
"status": "Free|Blocked|...",
|
||
"type": "discount|null",
|
||
"pricePerNight": 89.5,
|
||
"defaultPricePerNight": 99.0,
|
||
"priceAdditionalPerson": 25.0,
|
||
"defaultPriceAdditionalPerson": 30.0,
|
||
"currency": "EUR",
|
||
"includedPax": 2,
|
||
"minNights": 2
|
||
}
|
||
```
|
||
|
||
Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. This applies to both modes.
|
||
|
||
Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters.
|
||
|
||
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
|
||
|
||
#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
|
||
|
||
The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable.
|
||
|
||
Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span.
|
||
|
||
Each entry, ordered by date:
|
||
|
||
```json
|
||
{"date": "2027-01-30", "status": "BLOCKED"}
|
||
```
|
||
|
||
`status` is `OK`, `BLOCKED` or `ON_REQUEST`.
|
||
|
||
Two differences from `calendar`:
|
||
|
||
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
|
||
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
|
||
|
||
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
|
||
|
||
---
|
||
|
||
### Operating the snapshot (deployment)
|
||
|
||
The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
|
||
|
||
Rollout order:
|
||
|
||
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`.
|
||
2. Deploy; `database:migrate` creates the empty tables.
|
||
3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
|
||
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response.
|
||
|
||
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
|
||
|
||
### Freshness and propagation
|
||
|
||
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
|
||
|
||
That leaves two levers, and consumers should know both:
|
||
|
||
- **Their own cache TTL** bounds how stale a rendered calendar can be. A one-hour TTL is the current agreed budget.
|
||
- **Direct cache invalidation** is the only fast path when something must propagate sooner — a hotel closing, or bad availability that got cached. Consumers should keep this targeted rather than flushing everything; the TYPO3 site, for example, tags its cached calendar and price entries per hotel, so a single hotel can be flushed and re-warmed on its own.
|
||
|
||
On this side, staleness is bounded by the sync cadence and hard-capped by the 6-hour guard: past that, the endpoint returns `502` rather than presenting stale availability as current.
|
||
|
||
---
|
||
|
||
### Accommodation bookings (group bookings)
|
||
|
||
#### `GET /api/accommodation-bookings/{uuid}` — scope `api`
|
||
|
||
Single booking by UUID. `404 {"message":"Not found"}` if unknown.
|
||
|
||
```json
|
||
{
|
||
"uuid": "...",
|
||
"status": "...",
|
||
"type": "...",
|
||
"dateFrom": "2026-06-01",
|
||
"dateTo": "2026-06-08",
|
||
"nights": 7,
|
||
"paxCount": 40,
|
||
"minorsCount": 12,
|
||
"childrenCount": 3,
|
||
"groupName": "...",
|
||
"acceptedAt": "2026-05-01T10:00:00+00:00",
|
||
"personalData": {},
|
||
"accommodation": {},
|
||
"boardService": {},
|
||
"additionalServices": [],
|
||
"accommodationDiscount": null,
|
||
"boardServiceDiscount": null,
|
||
"additionalServicesDiscount": null,
|
||
"totalDiscountAmount": null,
|
||
"totalDiscountLabel": null,
|
||
"totalPrice": 123456,
|
||
"pricingCurrency": "EUR",
|
||
"pricingVersion": 2,
|
||
"priceBreakdown": {}
|
||
}
|
||
```
|
||
|
||
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
|
||
|
||
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges, running costs and the lines of split-off periods sit in `total` but in no section, so the section discounts never reach them; only `totalDiscountAmount` does.
|
||
|
||
`dateFrom`/`dateTo`/`nights` span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. `paxCount`, `minorsCount`, `childrenCount`, `boardService` and `additionalServices` describe the **group**, which stays for what remains between the split-off periods. The split-off periods are listed in `priceBreakdown.periods`, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
|
||
|
||
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
|
||
|
||
`priceBreakdown` carries the resulting rows, all in minor units:
|
||
|
||
| field | meaning |
|
||
| --- | --- |
|
||
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
|
||
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
|
||
| `periodsTotal` | the sum of all `periods[].total` |
|
||
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
|
||
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
|
||
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
|
||
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
|
||
| `discountedTotal` | the final price, equal to the booking's `totalPrice` |
|
||
|
||
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
|
||
|
||
Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once.
|
||
|
||
---
|
||
|
||
### Contact form
|
||
|
||
#### `POST /api/contactform` — scope `api`
|
||
|
||
Creates or matches an address record in the upstream CRM.
|
||
|
||
Request body (all fields optional strings; the upstream system decides what is required):
|
||
|
||
```json
|
||
{
|
||
"lastName": "...", "firstName": "...", "gender": "...",
|
||
"street": "...", "zipCode": "...", "city": "...",
|
||
"email": "...", "phone": "..."
|
||
}
|
||
```
|
||
|
||
- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}`
|
||
- `409` — `{"success": false, "message": "<upstream notification>"}` (upstream rejected/conflicted)
|
||
- `400` — `{"success": false, "message": "<error>"}` (upstream call failed)
|
||
- `422` — malformed payload
|
||
|
||
---
|
||
|
||
### Newsletter
|
||
|
||
#### `GET /api/newsletters` — scope `api`
|
||
|
||
The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below.
|
||
|
||
#### `POST /api/newsletter-subscriptions` — scope `api`
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"email": "[email protected]",
|
||
"firstName": "Ada",
|
||
"lastName": "Lovelace",
|
||
"listIds": [1, 2]
|
||
}
|
||
```
|
||
|
||
- `email`: required, strict email validation.
|
||
- `firstName` / `lastName`: optional, max 255 chars.
|
||
- `listIds`: required, at least one positive integer; every id must be one of the configured lists.
|
||
|
||
Responses:
|
||
|
||
- `200` — subscription completed, `state: "subscribed"`.
|
||
- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`.
|
||
- Body in both cases:
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"email": "[email protected]",
|
||
"lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}],
|
||
"state": "subscribed|pending_confirmation|confirmation_requested",
|
||
"confirmationRequested": true
|
||
}
|
||
```
|
||
|
||
- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations.
|
||
- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry.
|
||
|
||
---
|
||
|
||
### End-user endpoints (require a user-bound token)
|
||
|
||
#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`)
|
||
|
||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||
|
||
- always: `sub` (UUID string, see below), `email`
|
||
- scope `id`: `person_id`, `address_id`
|
||
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
|
||
- scope `profile`: `profile` object:
|
||
|
||
```json
|
||
{
|
||
"first_name": "...", "last_name": "...", "title": "...", "salutation": "...",
|
||
"gender": "...", "date_of_birth": "1990-01-01", "nationality": "...",
|
||
"address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."},
|
||
"communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true},
|
||
"hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..."
|
||
}
|
||
```
|
||
|
||
##### Identifying the account: match on `sub`
|
||
|
||
A BusPro address record holds several email addresses (private and business, say), and BusPro
|
||
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles** —
|
||
that is intended, not a duplicate: administrative roles are reserved for the company address, so
|
||
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
|
||
`@ep-reisen.de` address is an administrator.
|
||
|
||
All of those accounts report the **same** `person_id` and `address_id`, and the same
|
||
`profile.communication.email` — that value is the first contact address on the BusPro record and
|
||
is **not necessarily** the one signed in with. So:
|
||
|
||
- **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
|
||
unique across accounts. The only claim that identifies an account. Match your local user on it
|
||
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
|
||
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
|
||
- **`email`** — the address this session actually authenticated with. Distinct per account, but
|
||
treat it as a display and contact value; it is not the account key.
|
||
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
|
||
person's accounts, so they identify a *human*, never an account. Do not match on them.
|
||
|
||
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
|
||
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
|
||
`profile.communication.email` will disagree with MyE&P in both directions.
|
||
|
||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||
|
||
#### `GET /api/crm-attributes` — scope `profile`
|
||
|
||
CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure.
|
||
|
||
---
|
||
|
||
## Client implementation notes
|
||
|
||
1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
|
||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||
5. **Server-side caching** (1 h on products) means upstream changes may not be visible immediately. The contingent calendar is snapshot-backed instead: it is at most one sync interval behind upstream. There is no change-notification endpoint — clients are expected to cache on their own TTL and, when something must propagate sooner, invalidate their cache directly.
|
||
6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.
|