From 715fae8e33aa39ebd42e49b38dba9fb2e360f21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 2 Sep 2026 10:33:47 +0200 Subject: [PATCH] chore: add bpn xml api documentation extract for agents --- docs/bpn-xml-api-reference.md | 382 ++++++++++++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 docs/bpn-xml-api-reference.md diff --git a/docs/bpn-xml-api-reference.md b/docs/bpn-xml-api-reference.md new file mode 100644 index 0000000..c7dcacf --- /dev/null +++ b/docs/bpn-xml-api-reference.md @@ -0,0 +1,382 @@ +# BusProNet XML API — working reference + +Condensed from the vendor PDF `docs/bpn_docs.pdf` (41 pages, German), keeping what this project +uses or plausibly could. The PDF stays authoritative; a few of its sentences are mangled by a +broken font encoding, so anything unclear there is flagged rather than guessed at. + +> **The PDF lags the live API.** The vendor has shipped elements since it was written — +> `` (return boarding points) is one confirmed example. So *absent from the PDF* means +> "not in this snapshot", never "unsupported". Treat the document as a floor on what the API +> accepts, not a ceiling, and prefer a live `buchungsart=Anfrage` probe over an argument from +> silence. + +Read this alongside `buspro-database-reference.md` (what BusPro actually stores) — the two answer +different questions, and where they disagree the schema is the record of what happened. + +**Use it for:** what an element is called and what it may contain; which codes a failure can +return; which documented capabilities we are not using; and which elements we send that the +vendor never documented — those are the ones that can break without warning. + +## Envelope + +Every request is `` with ``, `` and ``. + +``` +key = md5( user + password + date("YYYYMMDD") + satz-typ ) +``` + +Per satz type and per day. `ApiClient::createKey()` implements it, and `sendRawXml()` regenerates +the key before sending, so a hand-built payload can carry a placeholder. + +## Satz types + +**Documented:** `ADRESSFELDER`, `ADRESSFELDERGUTSCHEIN`, `ADRESSFELDERKATALOGANFRAGE`, +`AGENTUREN`, `AGENTURKONTO`, `AKTIONSGUTSCHEIN`, `BUCHUNG`, `BUCHUNGAENDERUNG`, `CRM`, +`CRMKATALOGANFRAGE`, `GUTSCHEINBUCHUNG`, `GUTSCHEINDRUCK`, `GUTSCHEINPRUEFUNGEINLOESUNG`, +`GUTSCHEINVORLAGEN`, `GUTSCHEINVORSCHAU`, `GUTSCHEINZAHLUNGSARTEN`, `KATALOGANFRAGE`, +`KUNDENKONTO`, `KUNDENLISTE`, `MOEGLICHEAENDERUNGEN`, `PRODUKTDATEN`, `PRODUKTE`, `SITZPLATZ`, +`STAMMHOTELS`, `STAMMLAENDER`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `VERFUEGBARKEIT`, +`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`. +`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section. + +**Used here but undocumented:** `VERFUEGBARKEIT2` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — the +extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`. +Its contract is observed, not specified. + +**Documented but unused here:** `SITZPLATZ`/`SITZPLATZAUSWAHL`, `KUNDENLISTE`, `ZAHLUNG2`, +`ADRESSFELDER`, `STAMMHOTELS`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `KATALOGANFRAGE`. + +## BUCHUNG — the create contract + +`` decides what the call does: + +| Value | Effect | +|---|---| +| `Anfrage` | Enquiry. Validates and **returns the full price breakdown**. Nothing is stored. | +| `Buchung` | Stores the booking. | + +`` at the root: `F` Festbuchung · `O` Optionsbuchung · `A` Anfragebuchung. Gated by +error `962` ("Buchungsstatus #1# kann nicht verarbeitet werden, nur Status #2# möglich"), and by +`982`/`983` when the travel forbids options or enquiries outright. + +### Two accommodation models + +The base payload books accommodation as an ordinary service: + +```xml + + + +``` + +For the **Ferienziel** domain — what this app books — `unterbringungen` is *replaced* by +`ferienzielunterbringungen`, **and** a root `` added: + +```xml + + + +… +78687 +``` + +> `` is introduced as the element you add when switching to the Ferienziel model, and +> `914 Partner-ID fehlt` guards it. Note it is **not** confined to bookings that have a room: a +> teamer booking with zero `BuchungTNFerienziel` rows still returns `222925` +> from `Vorgang_Details`. Where that comes from is **unresolved** — `Buchung` has no partner FK, so +> either it is stored on `Buchung.IDAdresse_FS_ExtraFerienzielPartner`, or it is derived from the +> travel. That booking's product has exactly one hotel, so the response cannot distinguish the two; +> one read-only query settles it. + +Accommodation is **mandatory** — `913 Unterbringungen fehlen` / `916 Ferienziel-Unterbringungen +fehlen`. Confirmed empirically on the test system 2026-09-02, not just documented: see +"Validation gate order" below. Room dates are cross-checked against the transport legs (`921`, +`923`). + +### Full documented request shape + +```xml + + + Anfrage|Buchung + F|O|A + + + + + name, vorname, anrede, titel, geschlecht, nationalitaet, geburtsdatum, + strasse, plz, ort, ortsteil, land + telefonmobil, email, telefonprivat + + + + + …same personal fields… + + + + + + + + + + + + + + + + + + +``` + +`zuordnung` is a comma-joined list of 1-based `teilnehmer/@id` values throughout. + +### Responses + +`buchungsart=Anfrage` → `möglich` plus ``: one `` per line with +`position`, `art`, `unterart`, `bezeichnung`, `terminvon`/`terminbis`, `anzahl`, `zuordnung`, +`preis`, `gesamtpreis`, `id`; then `` and `` with `` +and `` (each `betrag` + `termin`). `art` values seen: `BEF` transport, `UNT` +accommodation, `SON` other, `ERM` discount, `VER` insurance. + +`buchungsart=Buchung` → `erfolgt`, ``, ``, ``, +``, ``. + +### Validation gate order (measured) + +Established with `buchungsart=Anfrage` against the test system on 2026-09-02, by mirroring a real +room-less teamer booking (Vorgang 110628) element for element: + +1. **Accommodation.** No `ferienzielunterbringungen` → `916`, regardless of everything else being + valid. Nothing further is evaluated. +2. **Personal data**, once a room is present → `650` with the field list *"Fehlende + Anmelder/Teilnehmer Daten … Nachname, Vorname, Strasse, Plz, Ort, Telefon-Mobil, Mail-Adresse, + Geburtsdatum, Nationalität"*, for the Anmelder **and every participant**. + +So **sending `idadresse`/`idadresseperson` alone is not enough on create** — BusPro validates the +personal fields whether or not it could resolve them from the ids. Echoing the record back +(`idadresse` + `idadresseperson` + full data) narrows the complaint to whatever that record is +genuinely missing, which is a useful way to audit an address: the placeholder account 19465:41003 +fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only +`telefonprivat` and no `nationalitaet`. + +Note `650` again: both of those are hard rejections delivered under the code +`Notification::isError()` treats as success. + +## BUCHUNGAENDERUNG — the edit contract + +Structurally identical to `BUCHUNG` plus ``, and the responses follow `buchungsart` +the same way. Ask `MOEGLICHEAENDERUNGEN` first (``), which answers: + +```xml +<änderungen> + <änderung art="anzahl_teilnehmer" möglich="False"/> + <änderung art="beförderung" möglich="False"/> + <änderung art="zustieg" möglich="False"/> + <änderung art="sitztplatz" möglich="False"/> + <änderung art="unterbringung" möglich="False"/> + <änderung art="zusatzleistung" möglich="True" möglichbiszum="23.02.2020"/> + <änderung art="teilnehmerdaten" möglich="False"/> + +``` + +## How this app maps onto the contract + +`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the +`addTo…Payload()` helpers build edit; `ApiClient::createBooking()`/`updateBooking()` wrap them. + +| Documented element | Sent on create | Sent on edit | Notes | +|---|---|---|---| +| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) | +| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below | +| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room | +| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()` | +| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` | +| `teilnehmerliste` | yes | yes | | +| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | | +| `aktionscode` | yes | yes | Promo code, paired with `AKTIONSGUTSCHEIN` | +| `beförderungen`, `zusatzleistungen`, `zustiege` | yes | yes | Built by `ServiceMappingCollector` | +| `ferienzielunterbringungen` | yes | yes | Create omits the node entirely when no room maps; edit always emits the empty skeleton | +| `versicherungen` | yes | yes | | +| `gutscheine` | yes | yes | Paired with `GUTSCHEINPRUEFUNGEINLOESUNG` | +| `zahlung` + `bankverbindung` | yes | yes | | +| `idcrm` | no | no | | +| `fremdvorgang` | no | no | Would give our own bookings a stable external reference | +| `datenschutztfmp` | no | no | Advertising consent — we track newsletter consent locally instead | +| `sitzpläne` | no | no | See Seats | +| `zuschlaege` | no | no | Free-text manual surcharge or discount with its own amount | + +### Newer than the PDF + +These are absent from the document but work against the live API — vendor additions that +post-date it. Listed so nobody mistakes the PDF's silence for a contract violation, and so the +gaps in this reference are known. + +| Element | Where | Note | +|---|---|---| +| `` | create and edit | Return boarding points. Confirmed as a recent vendor addition; the PDF documents only ``. | +| `` inside `` | create (hardcoded `F`) and edit | Not in the PDF in either direction, though `Vorgang_Details` returns `status_teilnehmer`. | +| `J` on `KUNDENKONTO` | probe command | Passwordless read escape hatch; see `bpn-login-j-passwordless-reads`. | +| `VERFUEGBARKEIT2` | availability path | Whole satz type post-dates the PDF. Returns `buchungstatusmoeglich` and per-service `pflicht`. | + +When one of these needs pinning down, the authoritative move is a live `Anfrage` call, not a +reading of this file. + +### Caveat: the edit flow replays a derived status + +`buildBasePayload()` sends `'status' => $bookingData->status`, and `Booking::$status` is parsed +from the response's root `` (`BookingParser.php:38`). But `Buchung` has **no status +column** — the root value is an aggregate over `BuchungTN.Status`, the only real store +(`F` beside `F/F/…/F` in +`tests/Resources/booking_data.xml`). So on a booking whose participants do *not* all share one +status, the edit flow sends an aggregate back as a directive. Worth confirming against a mixed +booking before relying on it. + +### Gap: edit never dry-runs + +Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends +`buchungsart=Buchung` directly (`ApiClient.php:233`), even though `BUCHUNGAENDERUNG` supports +`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated +price *before* mutating a live booking. + +## Seats + +`SITZPLATZ` asks BusPro to pick seats: + +```xml + + +V|M|H +L|R +F|G +``` + +Response: one `` per direction with `sitzplaetze="0D;1D;1C"`, `idsitzplan`, +`richtung="H|R"`, `bezeichnung`, `idleistung`, and `grafik` (base64 PNG of the seat map). Where +seats carry surcharges the response adds `` (`id`, `bezeichnung`, `preis`, colours) and +the request must then carry `idzuschlag`. `SITZPLATZAUSWAHL` returns the raw plan structure +instead of a picture. + +Chosen seats go back into a booking via ``, landing in +`BuchungTNLeistungZusatz.Sitzplatz` / `IDSitzplan_FS`. Guarded by `933 Sitzplan mit ID #1# bei +Leistung #2# nicht freigegeben` and `304 Es konnte nicht für alle Teilnehmer ein Sitzplatz +ermittelt werden`. + +## KUNDENKONTO + +``: `Adressdaten`, `Adressdaten_Ändern`, `Adresse_Neu`, `SelektionCRM`, +`SelektionCRM_Ändern`, `Vorgänge`, `Vorgangdruck`, `Voucherdruck`, `Dokumentdruck`, +`Passwort_Anfrage`, `Newsletter`, `Adressdokument`, `Vorgang_Details`, `Prüfung`, `PrüfungEmail`, +`Ausweisdaten_Ändern`, `Stornoanfrage`, `Storno`, `Versicherungspolice`. + +`Stornoanfrage` answers `stornierung_moeglich` + `stornopreis`; `Storno` answers `storniert` — +the documented way to cancel, including cleaning up a test booking. `Ausweisdaten_Ändern` takes a +`` list with `` data and answers `änderung`. `Versicherungspolice` returns +per-policy document URLs. + +## AGENTURKONTO + +``: `Vorgänge`, `Vorgangdruck`, `Rechnungdruck`, `Hotelgutscheine`, `Hotelgutscheindruck`, +`OptionInFest` — the last converts a held option into a firm booking, answering +`true`. `Vorgangdruck` takes `idbuchung`, +`Rechnungdruck` takes `idrechnung`, `Hotelgutscheindruck` takes `idgutschein`; print arts return +base64 PDFs. + +## Availability and payment + +`VERFUEGBARKEIT` takes `` plus a `` filter and +answers `` plus per-service `status` ("buchbar"), `frei`, `preis`, +`preisfaellig`. `VERFUEGBARKEIT2`, which we actually use, additionally reports +`buchungstatusmoeglich` and `pflicht` but is undocumented. + +`ZAHLUNGSARTEN2` lists payment methods; `ZAHLUNG2` records a payment against a booking +(`vorgang`, `idbuchung`, `datum`, `betrag`, `idzahlungsart`, `zahlungstext` — all mandatory). +`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed +`anzahlung` or errors `1011`-`1018` follow. + +## Error codes + +Returned as ``. `#1#`/`#2#` are substituted at +runtime. + +> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message +> carrier whose entire content is substituted. `Notification::isError()` +> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on +> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`, where the same code delivers +> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*. +> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO. + +**Envelope / auth** — 100 Anfrageknoten fehlt · 101 Satzknoten fehlt · 102 Ungültiger Satztyp · +103/104 User fehlt/fehlerhaft · 105/106 Key fehlt/fehlerhaft · 999 Systemfehler + +**Lookup** — 200 Keine Einträge · 201/202 Partner · 203 Ungültiger Termin · 300 IDReise fehlt · +301 Reise nicht gefunden · 302 IDLeistung (Hin & Rück) fehlt · 303 IDLeistung passt nicht zu +IDReise · 304 Kein Sitzplatz für alle Teilnehmer ermittelbar · 305 Anzahl Personen fehlt · +306/307 Leistungen · 308 Keine Zahlungsarten · 400 IDPartner fehlt · 401 Partner nicht gefunden · +402 Bis-Termin fehlt · 403 Keine Unterbringungsleistungen gefunden + +**Customer / address** — 500 Art fehlt · 502 Keine Kunden · 840-842 Personen · 850 IDAdresse und +EMail fehlen · 851 Passwort fehlt · 852 Art fehlt · 853 ID, EMail oder Passwort falsch · 855 Keine +Daten für Adresse · 860 Adressdaten fehlen · 862-867 Selektionsmerkmale / CRM-Aktionen · +870-875 Newsletter · 880-882 Dokumente · 885-891 Pflichtfelder (Name, Vorname, Geschlecht, EMail, +PLZ, Straße) · 889 Kunde bereits angelegt (ID #1#) + +**Booking (create and edit)** — 900 Buchungsart fehlt · 901 Buchungsart falsch · 902 Status +fehlt · 903 Status falsch · 904/905 Agentur · 906 Reise-ID fehlt · 907 Reise nicht gefunden · +908 Reise ist storniert · **909 Beförderungen fehlen** · 910 Leistung gehört nicht zur Reise · +911 Leistung gehört nicht zum Produkt · 912 Beförderungsleistung fehlt · **913 Unterbringungen +fehlen** · **914 Partner-ID fehlt** · 915 Partner nicht gefunden · **916 Ferienziel-Unterbringungen +fehlen** · 917 Zimmer-IDZ fehlt · 918 Kategorie fehlt · 919 Verpflegungs-ID fehlt · 920 Anreise +fehlt · **921 Anreise passt nicht zur Beförderungsleistung (Hinfahrt)** · 922 Abreise fehlt · +**923 Abreise passt nicht zur Beförderungsleistung (Rückfahrt)** · **924 Ferienziel: Keine Preise +zu den Daten gefunden** · 925 Preis zu den Daten nicht gefunden · 930 Zustiege fehlen · +931 Zustieg nicht gefunden · 932 Zustieg bei Leistung nicht freigegeben · 933 Sitzplan bei +Leistung nicht freigegeben · 935-938 Versicherungen · 940-942 Zahlungsart · 950 Anmelder fehlt · +951 Teilnehmerliste fehlt · 952 Teilnehmer-ID fehlt · 953 Keine Teilnehmer übergeben · +960 Preisfehler · 961 Buchung konnte nicht gespeichert werden · **962 Buchungsstatus #1# kann +nicht verarbeitet werden, nur Status #2# möglich** · 970 CRM-Merkmal nicht gültig · 980 Reise ist +fürs Internet gesperrt · 981 Reise ist nicht mehr buchbar (#1#) · **982 Optionsbuchungen nicht +zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs Internet buchbar · +985 Zustieg nicht fürs Internet buchbar · 986/987 Rechnung · 990-994 Agenturkonto · +995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum +fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich + +**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm / +Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein + +## Applying this: the teamer placeholder booking + +**Answered 2026-09-02: `BUCHUNG` cannot create it.** A payload mirroring the real teamer booking +(travel 12429, hotel 222925, agency 77876, both bus legs, ski pass, Ortstaxe, Keycard-Pfand, +pickup and drop-off, root and participant status `O`, ids-only identity, no room) returns +`916 Ferienziel-Unterbringungen fehlen`. Adding a room to the identical payload clears that gate +and moves on to `650`, which proves accommodation was the only structural objection. + +Two blockers that were *not* the problem: + +- **Status `O` is fine here.** Travel 12429 reports `buchungstatusmoeglich="F, O, A"`. The `982` + refusal seen earlier is per travel, not a global setting. +- **`NurFahrt` never came up** — the request never got far enough for it to matter. + +What the real booking shows about how the office builds one, worth reproducing if the work moves +to a direct write: + +- `` **is** present despite there being no accommodation — 222925 is the ordinary + hotel of the travel (`Adresse.IDAdresse`, "NEU Sportclub Weissfluh Midweeks und Wochenreisen", + the sole hotel of product 1429, with real bookable rooms), not a special no-accommodation + partner. Whether the booking stores it or the response derives it is still open. +- Each teamer seat is its **own `AdressePerson`** (407725, 407726) under the shared address 41003, + with an empty ``. +- `` carries the role label — "Reiseleitung + Busbegleitung". +- Per participant, `` sets the option expiry. +- The total is driven to zero with a **manual ``**: Ortstaxe 44,00 + Keycard-Pfand 10,80 + + Abfahrtsortzuschlag 25,80 = 80,60, offset by an unnamed −80,60 line. `` is part of + the documented `BUCHUNG` payload, so this part *is* expressible over XML. + +See `.ddev/plans/teamer-placeholder-booking-plan.md`.