config = $this->resolveOptions($options); } /** * @throws ApiClientException */ public function getPersonalData(string $email, string $password): Notification|PersonalData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adressdaten', 'email' => $email, 'passwort' => $password, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function register(RegistrationDto $registrationData): Notification|RegistrationResponse { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adresse_Neu', 'adressdaten' => [ 'geschlecht' => $registrationData->gender, 'vorname' => $registrationData->firstName, 'name' => $registrationData->name, 'kommunikation' => [ 'email' => $registrationData->email, ], ], ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function resetPassword(string $email): Notification { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Passwort_Anfrage', 'email' => $email, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function updatePersonalData( string $email, string $password, PersonalData $personalData, bool $debug = false, ): Notification|PersonalData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adressdaten_Ändern', 'email' => $email, 'passwort' => $password, 'idadresse' => $personalData->addressId, 'adressdaten' => $personalData->toPayload(), ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); } /** * @throws ApiClientException */ public function createAddress( PersonalData $personalData, bool $debug = false, ): RegistrationResponse|Notification { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adresse_Neu', 'adressdaten' => $personalData->toPayload(), 'ohnemailversand' => 'True', ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); } /** * @throws ApiClientException */ public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Newsletter', 'email' => $email, 'passwort' => $password, 'idadresse' => $personalData->addressId, 'newsletter' => [ 'email' => $email, 'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False', ], ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getBookings(string $email, string $password): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Vorgänge', 'email' => $email, 'passwort' => $password, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getBooking(string $email, string $password, int $id): Notification|Booking { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Vorgang_Details', 'email' => $email, 'passwort' => $password, 'idbuchung' => $id, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate { $payload = $this->bookingDataProcessor->createUpdateRequestPayload($formData); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING_UPDATE), 'satz' => ['@typ' => static::TYPE_BOOKING_UPDATE], 'buchungsart' => Constants::BOOKING_TYPE_BOOKING, ...$payload, ]; return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug); } /** * Submits a booking inquiry for validation. * * First phase of the two-phase booking process. Validates all booking data * and returns pricing information without creating an actual booking. * * @param BookingDto $bookingDto The booking creation form data * @param bool $debug Enable debug mode (XML dumps) * * @return Notification|BookingResponse Notification on error, BookingResponse on success * * @throws ApiClientException If the API request fails */ public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse { $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_INQUIRY); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), 'satz' => ['@typ' => static::TYPE_BOOKING], ...$payload, ]; return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); } /** * Submits the final booking request. * * Second phase of the two-phase booking process. Creates the actual booking * after successful inquiry validation. * * @param BookingDto $bookingDto The booking creation form data * @param bool $debug Enable debug mode (XML dumps) * * @return Notification|BookingResponse Notification on error, BookingResponse with booking number on success * * @throws ApiClientException If the API request fails */ public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse { $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_BOOKING); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), 'satz' => ['@typ' => static::TYPE_BOOKING], ...$payload, ]; return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); } /** * @throws ApiClientException */ public function getMutableData(int $dateId): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_DATA), 'satz' => ['@typ' => static::TYPE_MUTABLE_DATA], 'idreise' => $dateId, ]; return $this->sendRequest(static::TYPE_MUTABLE_DATA, $data); } /** * @throws ApiClientException */ public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY), 'satz' => ['@typ' => static::TYPE_AVAILABILITY], 'idreise' => $dateId, ]; return $this->sendRequest(static::TYPE_AVAILABILITY, $data); } /** * @throws ApiClientException */ public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_HOTEL), 'satz' => ['@typ' => static::TYPE_AVAILABILITY_HOTEL], 'idreise' => $dateId, 'idpartner' => $hotelId, 'terminbis' => $dateTo->format('d.m.Y'), ]; return $this->sendRequest(static::TYPE_AVAILABILITY_HOTEL, $data); } /** * @throws ApiClientException */ public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'SelektionCRM', 'email' => $email, 'passwort' => $password, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getBaseData(string $type): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], $type), 'satz' => ['@typ' => $type], ]; return $this->sendRequest($type, $data); } /** * @throws ApiClientException */ public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => $type, 'email' => $email, 'passwort' => $password, 'idbuchung' => $bookingId, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA), 'satz' => ['@typ' => static::TYPE_PRODUCT_DATA], 'idprodukt' => $travelId, ]; return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]); } /** * @throws ApiClientException */ public function getProducts(): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCTS), 'satz' => ['@typ' => static::TYPE_PRODUCTS], ]; return $this->sendRequest(static::TYPE_PRODUCTS, $data); } /** * Fetches all available agencies from the BusProNet API. * * Returns a list of all agencies with their contact information. * This data is typically cached for long periods as it changes infrequently. * * @return Agency[]|Notification Array of Agency objects on success, Notification on error * * @throws ApiClientException If the API request fails */ public function getAgencies(): array|Notification { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AGENCIES), 'satz' => ['@typ' => static::TYPE_AGENCIES], ]; return $this->sendRequest(static::TYPE_AGENCIES, $data); } /** * Validates a purchase voucher by redemption code. * * Purchase vouchers (Gutscheine) have a remaining balance that reduces with each redemption. * They can be regular purchased vouchers or goodwill vouchers (Kulanz). * Returns the voucher with remaining balance, or a Notification if not found or invalid. * * @param string $redemptionCode The voucher redemption code (einloesecode) * * @return Notification|PurchaseVoucher Notification on error, PurchaseVoucher on success * * @throws ApiClientException If the API request fails */ public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PURCHASE_VOUCHER), 'satz' => ['@typ' => static::TYPE_PURCHASE_VOUCHER], 'einloesecode' => $redemptionCode, ]; return $this->sendRequest(static::TYPE_PURCHASE_VOUCHER, $data); } /** * Validates a promo voucher for a specific travel and participant price. * * Promo vouchers (Aktionsgutscheine) provide absolute discounts per person or per booking. * Applicability is indicated by the pro_buchung_person field: "P" = per person, "B" = per booking. * * @param string $promoCode The promo voucher code * @param int $travelId The travel ID this voucher applies to * @param float $participantPrice Current participant price for validation * * @return Notification|PromoVoucher Notification on error, PromoVoucher on success * * @throws ApiClientException If the API request fails */ public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher { // Format price to German format (comma decimal separator) $priceFormatted = number_format($participantPrice, 2, ',', ''); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PROMO_VOUCHER), 'satz' => ['@typ' => static::TYPE_PROMO_VOUCHER], 'aktionsgutschein' => $promoCode, 'idreise' => $travelId, 'preis' => $priceFormatted, ]; return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data); } /** * Sends raw XML to the BPN API with key regeneration and automatic retry. * * Parses the XML to extract the request type, regenerates the authentication key * with the current date, and sends the request. Returns the raw XML response. * Automatically retries on immediate connection close (server busy). * * @param string $xml The raw XML request body * @param bool $debug Enable debug mode (XML dumps) * * @return string The raw XML response * * @throws ApiClientException If the request fails or XML is invalid */ public function sendRawXml(string $xml, bool $debug = false): string { $maxAttempts = $this->config['busy_retry_attempts']; $retryDelay = $this->config['busy_retry_delay']; $lastException = null; for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { try { return $this->doSendRawXml($xml, $debug); } catch (ImmediateConnectionCloseException $e) { $lastException = $e; if ($attempt < $maxAttempts) { $this->logger->warning('BPN server busy, retrying raw XML request', [ 'attempt' => $attempt, 'maxAttempts' => $maxAttempts, 'retryDelay' => $retryDelay, ]); sleep($retryDelay); } } } $this->logger->error('BPN server busy after all retry attempts (raw XML)', [ 'attempts' => $maxAttempts, ]); throw $lastException; } /** * Performs the actual raw XML request to the BPN API. * * @throws ApiClientException * @throws ImmediateConnectionCloseException */ private function doSendRawXml(string $xml, bool $debug = false): string { $requestId = date(DATE_ATOM).uniqid(); $doc = new \DOMDocument(); if (false === @$doc->loadXML($xml)) { throw new ApiClientException('Invalid XML provided'); } $satzNode = $doc->getElementsByTagName('satz')->item(0); if (null === $satzNode) { throw new ApiClientException('Missing element in XML'); } $type = $satzNode->getAttribute('typ'); if ('' === $type) { throw new ApiClientException('Missing typ attribute on element'); } $keyNode = $doc->getElementsByTagName('key')->item(0); if (null === $keyNode) { throw new ApiClientException('Missing element in XML'); } $newKey = $this->createKey( $this->config['bpn_username'], $this->config['bpn_password'], $type ); $keyNode->nodeValue = $newKey; $body = $doc->saveXML(); $this->logger->info('Sending raw XML request to BPN API', [ 'requestId' => $requestId, 'type' => $type, ]); if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('request', $requestId, $body); } $socket = $this->connect( $this->config['bpn_api_ip'], $this->config['bpn_api_port'], $this->config['max_retries'], $this->config['connection_timeout'], $this->config['stream_timeout'], $this->config['total_timeout'] ); $this->send($socket, $body, $this->config['total_timeout']); $response = $this->receive($socket, $this->config['total_timeout']); $this->disconnect($socket); $responseXml = substr($response, 10); if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('response', $requestId, $responseXml); } return $responseXml; } /** * Sends a request to the BPN API with automatic retry on immediate connection close. * * When the BPN server is busy, it may close connections immediately without responding. * This method detects such conditions and automatically retries after a short delay. * * @throws ApiClientException */ private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed { $maxAttempts = $this->config['busy_retry_attempts']; $retryDelay = $this->config['busy_retry_delay']; $lastException = null; for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { try { return $this->doSendRequest($type, $data, $additionalArgs, $debug); } catch (ImmediateConnectionCloseException $e) { $lastException = $e; if ($attempt < $maxAttempts) { $this->logger->warning('BPN server busy, retrying request', [ 'attempt' => $attempt, 'maxAttempts' => $maxAttempts, 'retryDelay' => $retryDelay, 'type' => $type, ]); sleep($retryDelay); } } } $this->logger->error('BPN server busy after all retry attempts', [ 'attempts' => $maxAttempts, 'type' => $type, ]); throw $lastException; } /** * Performs the actual request to the BPN API. * * @throws ApiClientException * @throws ImmediateConnectionCloseException */ private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed { $requestId = date(DATE_ATOM).uniqid(); $body = $this ->serializer ->serialize($data, 'xml', [ XmlEncoder::ROOT_NODE_NAME => 'anfrage', XmlEncoder::ENCODING => 'UTF-8', ]) ; $this->logger->info('Sending request to BPN API', [ 'requestId' => $requestId, 'type' => $data['satz']['@typ'], ]); if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('request', $requestId, $body); } $socket = $this->connect( $this->config['bpn_api_ip'], $this->config['bpn_api_port'], $this->config['max_retries'], $this->config['connection_timeout'], $this->config['stream_timeout'], $this->config['total_timeout'] ); $this->send($socket, $body, $this->config['total_timeout']); $response = $this->receive($socket, $this->config['total_timeout']); $this->disconnect($socket); // message length (10 bytes) is prepended to actual message $xml = substr($response, 10); if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('response', $requestId, $xml); } try { return $this->responseParser->parseXmlString($type, $xml, $additionalArgs); } catch (ResponseParserException $e) { $this->dumpXmlToFile('response', $requestId, $xml); } $this->logger->error('Unexpected response received from API', [ 'request_id' => $requestId, ]); throw new ApiClientException('Unexpected response received from API'); } private function dumpXmlToFile(string $type, string $requestId, string $body): void { try { $this->xmlDump->write($requestId.'_'.$type.'.xml', $body); } catch (FilesystemException $e) { } } private function createKey(string $username, string $password, string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); return md5($username.$password.$date.$type); } private function resolveOptions(array $options): array { $optionsResolver = new OptionsResolver(); $optionsResolver->setRequired([ 'bpn_username', 'bpn_password', 'bpn_api_ip', 'bpn_api_port', ]); $optionsResolver->setDefaults([ 'max_retries' => 25, 'debug' => false, 'connection_timeout' => 5, 'stream_timeout' => 30, 'total_timeout' => 45, 'busy_retry_attempts' => 3, 'busy_retry_delay' => 1, ]); return $optionsResolver->resolve($options); } }