Files
myep/docs/bpn-xml-api-reference.md

383 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 —
> `<ausstiege>` (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 `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
```
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
`<buchungsart>` decides what the call does:
| Value | Effect |
|---|---|
| `Anfrage` | Enquiry. Validates and **returns the full price breakdown**. Nothing is stored. |
| `Buchung` | Stores the booking. |
`<status>` 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
<unterbringungen>
<unterbringung idleistung="22341" anzahl="1" zuordnung="1,2"/>
</unterbringungen>
```
For the **Ferienziel** domain — what this app books — `unterbringungen` is *replaced* by
`ferienzielunterbringungen`, **and** a root `<idpartner>` added:
```xml
<ferienzielunterbringungen>
<ferienzielunterbringung idzimmer="2" kategorie="Ik" idverpflegung="2"
anreise="30.01.2018" abreise="06.02.2018"
anzahl="1" zuordnung="1,2"/>
</ferienzielunterbringungen>
<idpartner>78687</idpartner>
```
> `<idpartner>` 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 `<idpartner>222925</idpartner>`
> 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>
<user/><key/><satz typ="BUCHUNG"/>
<buchungsart>Anfrage|Buchung</buchungsart>
<status>F|O|A</status>
<idagentur/> <idcrm/> <idreise/> <idpartner/>
<fremdvorgang/> <!-- your own reference, stored as Buchung.FremdVorgang -->
<datenschutztfmp/> <!-- ad consent, combinable: T Telefon, F Telefax, M EMail, P Post -->
<anmelder>
name, vorname, anrede, titel, geschlecht, nationalitaet, geburtsdatum,
<anschrift>strasse, plz, ort, ortsteil, land</anschrift>
<kommunikation>telefonmobil, email, telefonprivat</kommunikation>
<bemerkung/>
</anmelder>
<teilnehmerliste>
<teilnehmer id="1">
…same personal fields…
<wünsche><unterbringungswunsch/><beförderungswunsch/></wünsche>
<aktionscode/>
</teilnehmer>
</teilnehmerliste>
<beförderungen><beförderung idleistung anzahl zuordnung/></beförderungen>
<ferienzielunterbringungen></ferienzielunterbringungen>
<zusatzleistungen><zusatzleistung idleistung anzahl zuordnung/></zusatzleistungen>
<zustiege><zustieg idzustieg anzahl zuordnung/></zustiege>
<sitzpläne><sitzplan idleistung idsitzplan sitzplätze="1B;1A"/></sitzpläne>
<versicherungen><versicherung idversicherung anzahl zuordnung/></versicherungen>
<gutscheine><gutschein gutscheinnr einloesecode/></gutscheine>
<zuschlaege>
<zuschlag text betrag anzahl zuordnung
provision altersrabatt gruppenrabatt frühbucherrabatt bzberechnung/>
</zuschlaege>
<zahlung idzahlungsart bezeichnung art>
<bankverbindung kreditinstitut bic iban kontoinhaber/>
</zahlung>
</anfrage>
```
`zuordnung` is a comma-joined list of 1-based `teilnehmer/@id` values throughout.
### Responses
`buchungsart=Anfrage``<buchung>möglich</buchung>` plus `<preise>`: one `<preis>` per line with
`position`, `art`, `unterart`, `bezeichnung`, `terminvon`/`terminbis`, `anzahl`, `zuordnung`,
`preis`, `gesamtpreis`, `id`; then `<gesamtpreis>` and `<zahlungsbedingungen>` with `<anzahlung>`
and `<restzahlung>` (each `betrag` + `termin`). `art` values seen: `BEF` transport, `UNT`
accommodation, `SON` other, `ERM` discount, `VER` insurance.
`buchungsart=Buchung``<buchung>erfolgt</buchung>`, `<vorgang>`, `<rechnung>`, `<idbuchung>`,
`<email>`, `<pdfpasswort>`.
### 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 `<idbuchung>`, and the responses follow `buchungsart`
the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), 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"/> <!-- vendor's typo -->
<änderung art="unterbringung" möglich="False"/>
<änderung art="zusatzleistung" möglich="True" möglichbiszum="23.02.2020"/>
<änderung art="teilnehmerdaten" möglich="False"/>
</änderungen>
```
## 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 |
|---|---|---|
| `<ausstiege><ausstieg idzustieg …/></ausstiege>` | create and edit | Return boarding points. Confirmed as a recent vendor addition; the PDF documents only `<zustiege>`. |
| `<status>` inside `<teilnehmer>` | create (hardcoded `F`) and edit | Not in the PDF in either direction, though `Vorgang_Details` returns `status_teilnehmer`. |
| `<login>J</login>` 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 `<status>` (`BookingParser.php:38`). But `Buchung` has **no status
column** — the root value is an aggregate over `BuchungTN.Status`, the only real store
(`<status>F</status>` beside `<status_teilnehmer>F/F/…/F</status_teilnehmer>` 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
<satz typ="SITZPLATZ"/>
<idreise/><idleistung_hin/><idleistung_rueck/><anzahl_personen/>
<wunsch_vornemittehinten>V|M|H</wunsch_vornemittehinten>
<wunsch_linksrechts>L|R</wunsch_linksrechts>
<wunsch_fenstergang>F|G</wunsch_fenstergang>
```
Response: one `<sitzplan>` 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 `<zuschläge>` (`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 `<sitzpläne>`, 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
`<art>`: `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
`<personen>` list with `<ausweis>` data and answers `änderung`. `Versicherungspolice` returns
per-policy document URLs.
## AGENTURKONTO
`<art>`: `Vorgänge`, `Vorgangdruck`, `Rechnungdruck`, `Hotelgutscheine`, `Hotelgutscheindruck`,
`OptionInFest` — the last converts a held option into a firm booking, answering
`<optioninfestumgewandelt>true</optioninfestumgewandelt>`. `Vorgangdruck` takes `idbuchung`,
`Rechnungdruck` takes `idrechnung`, `Hotelgutscheindruck` takes `idgutschein`; print arts return
base64 PDFs.
## Availability and payment
`VERFUEGBARKEIT` takes `<idreise>` plus a `<leistungen><leistung id="…"/></leistungen>` filter and
answers `<reise id status>` 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 `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#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:
- `<idpartner>` **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 `<anschrift/>`.
- `<wünsche><beförderungswunsch>` carries the role label — "Reiseleitung + Busbegleitung".
- Per participant, `<optionsdatum>` sets the option expiry.
- The total is driven to zero with a **manual `<zuschlag>`**: Ortstaxe 44,00 + Keycard-Pfand 10,80
+ Abfahrtsortzuschlag 25,80 = 80,60, offset by an unnamed 80,60 line. `<zuschlaege>` is part of
the documented `BUCHUNG` payload, so this part *is* expressible over XML.
See `.ddev/plans/teamer-placeholder-booking-plan.md`.