Compare commits

..
Author SHA1 Message Date
fromme 2d876152aa fix: drop unreachable null check 2026-10-08 11:10:13 +02:00
fromme 24498e6fd1 chore: update project dependencies 2026-10-08 11:09:41 +02:00
fromme de5d80c2df feat: replace hardcoded dummy api-key with env variable 2026-10-08 11:05:42 +02:00
fromme 18d3edcd53 chore: list the active unprefixed legacy promo codes 2026-10-06 11:34:33 +02:00
fromme 56af0f921b fix: stop caching rejected voucher checks, document response codes 2026-10-06 11:29:14 +02:00
fromme a14c41de4e fix: keep icon-only buttons at least one line tall 2026-10-06 11:22:54 +02:00
fromme 9a8879dfbe fix: give buttons the input box model so they align with fields 2026-10-06 10:50:30 +02:00
fromme 21d756a2e3 feat: fold purchase and promo voucher fields into one code field 2026-10-06 10:41:30 +02:00
fromme 93512b8345 feat: add employee-only supervisor role nominated by crm selection 1487 2026-10-05 17:06:37 +02:00
fromme 5f95492a7f feat: add flag to disable hx-boost locally 2026-10-05 11:51:25 +02:00
fromme 627b190fcb feat: update voucher purchase urls to match established patterns 2026-10-05 09:10:19 +02:00
fromme e42f836d9b chore: higher resolution voucher thumbnails, drop source pdfs 2026-10-05 09:09:17 +02:00
fromme b755a6f43e refactor: split buspro ApiClient into domain clients 2026-10-02 13:55:30 +02:00
fromme 0a55544ca8 refactor: move buspro XML protocol into XmlChannel 2026-10-02 13:55:16 +02:00
fromme 3c81c79819 refactor: move buspro socket framing into SocketTransport 2026-10-02 13:55:04 +02:00
fromme e51664df6c refactor: deduplicate request building in buspro api client 2026-10-02 13:54:50 +02:00
fromme f3dd122678 feat: voucher purchase flow 2026-10-01 15:03:27 +02:00
fromme a628e96d06 chore: re-add buspro documentation 2026-10-01 12:25:18 +02:00
fromme 2bb0148e49 chore: replace nelexa/zip with maennchen/zipstream-php 2026-10-01 10:48:49 +02:00
fromme 289741d5d4 chore: bump php platform requirement to 8.5 2026-10-01 10:48:44 +02:00
fromme f070f4117c chore: update project dependencies 2026-10-01 09:50:19 +02:00
fromme 72ef3ff105 chore: bump project to php 8.5 2026-10-01 09:48:37 +02:00
fromme 1301df80c2 chore: update company address in email footer 2026-10-01 08:54:29 +02:00
fromme 56deb1e24b feat: name the accommodation booking PDF after its record label 2026-10-01 08:46:12 +02:00
fromme ec10ca3623 feat: offer the accommodation booking PDF download in every status 2026-10-01 08:46:06 +02:00
fromme 67c0c2fb0c fix: date group heading in offer summary 2026-10-01 08:45:59 +02:00
fromme eb45693257 feat: panel layout for the accommodation booking edit and detail pages 2026-09-30 18:06:45 +02:00
fromme 5fdee68ccd fix: show each admin money field's own currency 2026-09-30 18:06:39 +02:00
fromme 7001bc0cf2 feat: split nights off an accommodation booking as separate periods
addresses #869f6z36v
2026-09-30 18:06:20 +02:00
fromme 972839286c fix: never reuse a collection entry index after a removal 2026-09-30 18:05:23 +02:00
fromme ede8fd840a fix: emit export pickup times as clock readings without an offset 2026-09-30 14:52:04 +02:00
fromme 6c700ea18b chore: update project dependencies 2026-09-30 14:12:35 +02:00
fromme fe18ebf479 fix: give the product export builder the tag-aware bpn.cache pool 2026-09-30 14:09:50 +02:00
fromme 1d42565a84 feat: unfiltered product export for the website's catalogue import 2026-09-30 12:19:10 +02:00
fromme 272c811f4f feat: report the source export timestamp on every travel export page 2026-09-30 12:19:04 +02:00
fromme c2c55bc8b2 fix: answer null instead of 500 when the export marker is missing 2026-09-30 12:18:59 +02:00
fromme 8a6ce7606f feat: add a cleanup command for accounts without recent activity 2026-09-29 16:23:32 +02:00
fromme 7ec21c1cac chore: update project dependencies 2026-09-29 16:18:17 +02:00
fromme f951242465 chore: document the travel export endpoint and the new hotel fields 2026-09-29 15:16:47 +02:00
fromme 7dadb6806a feat: add a paginated travel export for the teamer app 2026-09-29 14:46:36 +02:00
fromme 6b539f43f3 fix: skip travels whose hotel is missing from the master data 2026-09-29 14:46:27 +02:00
fromme 1845441bc2 feat: expose hotel cost unit and iso country code via the api 2026-09-29 14:46:20 +02:00
fromme 33c31d088c feat: enable client credentials grant for machine-to-machine api clients 2026-09-29 14:46:13 +02:00
214 changed files with 10781 additions and 2682 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
name: myep name: myep
type: php type: php
docroot: public docroot: public
php_version: "8.4" php_version: "8.5"
webserver_type: apache-fpm webserver_type: apache-fpm
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: additional_hostnames:
+7
View File
@@ -82,6 +82,8 @@ APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireisever
APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/" APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/"
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/" APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/" APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
# TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client; the real value per stage goes in .env.local
APP_CMS_API_KEY=
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/" APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
APP_BASE_URL="https://my.ep-reisen.de" APP_BASE_URL="https://my.ep-reisen.de"
APP_CUSTOMER_SERVICE_EMAIL="[email protected]" APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
@@ -101,6 +103,11 @@ APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# because the cached travels and TravelSnapshot rows carry the parsed price. # because the cached travels and TravelSnapshot rows carry the parsed price.
APP_BPN_LEVEL_BOARDING_POINT_PRICES=true APP_BPN_LEVEL_BOARDING_POINT_PRICES=true
# Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
# script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
# Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
APP_HTMX_BOOST=true
# Emails # Emails
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
@@ -0,0 +1,22 @@
import {Controller} from '@hotwired/stimulus'
/*
* Keeps buttons disabled until the given fields pass the browser's constraint validation
* (required, min, max, step, …), which the form types render from the same rules the server
* validates. A text field holding only whitespace counts as empty, as NotBlank does.
*
* Works on forms with novalidate too: that only stops the browser from blocking the submit.
*/
export default class extends Controller {
static targets = ['field', 'button']
connect() {
this.update()
}
update() {
const invalid = this.fieldTargets.some(field => !field.checkValidity()
|| ('radio' !== field.type && 'checkbox' !== field.type && '' === field.value.trim()))
this.buttonTargets.forEach(button => button.disabled = invalid)
}
}
@@ -11,10 +11,13 @@ export default class extends Controller {
connect() { connect() {
this.itemsCountValue = this.fieldTargets.length this.itemsCountValue = this.fieldTargets.length
// A running counter rather than the current count: after a remove, count + 1 can be an
// index a remaining entry still holds, and the two would submit into the same slot.
this.nextIndex = this.fieldTargets.length
} }
addItem() { addItem() {
const index = this.fieldTargets.length + 1 const index = this.nextIndex++
const prototype = JSON.parse(this.prototypeValue) const prototype = JSON.parse(this.prototypeValue)
const newField = prototype.replace(/__name__/g, index) const newField = prototype.replace(/__name__/g, index)
this.fieldsTarget.insertAdjacentHTML('beforeend', newField) this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
@@ -0,0 +1,26 @@
import {Controller} from '@hotwired/stimulus'
/*
* Shows an indicator over an iframe until its content has loaded.
*
* The controller sets the frame's src itself, after the load listener is attached, so a fast
* response cannot finish before anybody listens.
*/
export default class extends Controller {
static targets = ['frame', 'indicator']
static values = {src: String}
connect() {
this.loaded = this.loaded.bind(this)
this.frameTarget.addEventListener('load', this.loaded)
this.frameTarget.src = this.srcValue
}
disconnect() {
this.frameTarget.removeEventListener('load', this.loaded)
}
loaded() {
this.indicatorTarget.classList.add('hidden')
}
}
+10 -4
View File
@@ -1,16 +1,22 @@
.button { .button {
@apply inline-flex items-center justify-center space-x-2 h-8 leading-8 md:h-10 md:leading-10 px-4 md:px-8 cursor-pointer; /* Same box as the @tailwindcss/forms inputs (1px border, py-2, 24px line), so a button lines up with a field beside it.
@apply uppercase leading-none text-center text-sm md:text-base; The line height is set with each font size (text-sm/6), because text-sm's own line height would override a plain leading-6. */
@apply inline-flex items-center justify-center space-x-2 px-4 md:px-8 py-2 border border-transparent bg-origin-border cursor-pointer;
@apply uppercase text-center text-sm/6 md:text-base/6;
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark; @apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
@apply rounded-md; @apply rounded-md;
/* At least one line tall even when the content is only a smaller icon: line + padding + border */
min-height: calc(1lh + 2 * theme('spacing.2') + 2px);
} }
.button--small { .button--small {
@apply px-2 py-1 md:px-4 text-xs sm:text-sm; @apply px-2 py-1 md:px-4 text-xs/6 sm:text-sm/6;
min-height: calc(1lh + 2 * theme('spacing.1') + 2px);
} }
.button--primary { .button--primary {
background: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%); /* Only the image: the background shorthand would reset .button's background-origin */
background-image: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
@apply text-white; @apply text-white;
@apply hover:bg-none hover:bg-pink/80; @apply hover:bg-none hover:bg-pink/80;
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+3 -3
View File
@@ -4,7 +4,7 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.4", "php": ">=8.5",
"ext-ctype": "*", "ext-ctype": "*",
"ext-dom": "*", "ext-dom": "*",
"ext-iconv": "*", "ext-iconv": "*",
@@ -22,7 +22,7 @@
"league/flysystem-bundle": "^3.4", "league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^1.0", "league/oauth2-server-bundle": "^1.0",
"nelexa/zip": "^4.0", "maennchen/zipstream-php": "^3.2",
"nesbot/carbon": "^3.8", "nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4", "phpoffice/phpspreadsheet": "^5.4",
@@ -77,7 +77,7 @@
}, },
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "8.4.24" "php": "8.5.0"
} }
}, },
"autoload": { "autoload": {
Generated
+343 -392
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -22,6 +22,7 @@ framework:
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%' base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
headers: headers:
accept: 'text/json' accept: 'text/json'
X-Api-Key: '%env(APP_CMS_API_KEY)%'
when@test: when@test:
framework: framework:
+1
View File
@@ -5,6 +5,7 @@ twig:
'%kernel.project_dir%/assets/images': images '%kernel.project_dir%/assets/images': images
globals: globals:
htmx_change_trigger: 'change delay:100ms' htmx_change_trigger: 'change delay:100ms'
htmx_boost: '%env(bool:APP_HTMX_BOOST)%'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%' website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%' customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%' legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
+50 -11
View File
@@ -16,6 +16,15 @@ parameters:
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%' role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
# Promo codes issued before the "R-" prefix was introduced, with the last day each is recognised
# as a promo code (VoucherCodeClassifier). Remove this parameter and the classifier branch
# reading it once the last date has passed.
legacy_promo_voucher_codes:
BEWERTUNG25: '2026-10-31'
ANNA50: '2026-12-20'
SIDNEY50: '2026-12-20'
LENA20: '2027-03-04'
BOTSCHAFTER2627: '2027-04-30'
# MailJet list ids and their labels # MailJet list ids and their labels
mailjet_lists: mailjet_lists:
@@ -128,18 +137,22 @@ services:
- '../src/Model' - '../src/Model'
- '../src/Kernel.php' - '../src/Kernel.php'
App\BusProNet\ApiClient: App\BusProNet\Transport\XmlChannel:
arguments: arguments:
$logger: '@monolog.logger.bpn' $logger: '@monolog.logger.bpn'
$options: $options:
bpn_username: '%env(APP_BPN_USER)%' bpn_username: '%env(APP_BPN_USER)%'
bpn_password: '%env(APP_BPN_PASSWORD)%' bpn_password: '%env(APP_BPN_PASSWORD)%'
bpn_api_ip: '%env(APP_BPN_IP)%'
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
debug: '%env(bool:APP_BPN_DEBUG)%' debug: '%env(bool:APP_BPN_DEBUG)%'
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%' App\BusProNet\Transport\SocketTransport:
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%' arguments:
$logger: '@monolog.logger.bpn'
$host: '%env(APP_BPN_IP)%'
$ports: '%env(csv:APP_BPN_PORTS)%'
$connectionTimeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
$streamTimeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
$totalTimeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
App\Twig\AppRuntime: App\Twig\AppRuntime:
arguments: arguments:
@@ -228,17 +241,44 @@ services:
$cache: '@bpn.cache' $cache: '@bpn.cache'
$logger: '@monolog.logger.bpn' $logger: '@monolog.logger.bpn'
App\Service\ProductExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator: App\Service\VoucherValidator:
arguments: arguments:
$cache: '@cache.app' $cache: '@cache.app'
$logger: '@monolog.logger.bpn' $logger: '@monolog.logger.bpn'
App\Service\VoucherCodeClassifier:
arguments:
$legacyPromoCodes: '%legacy_promo_voucher_codes%'
# Voucher purchase flow (/gutschein)
App\Service\VoucherTemplateProvider:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherThumbnailResolver:
arguments:
$manifestPath: '%kernel.project_dir%/public/build/manifest.json'
$logger: '@monolog.logger.bpn'
App\Controller\Voucher\Step1Controller:
arguments:
$logger: '@monolog.logger.bpn'
App\Controller\Voucher\Step3Controller:
arguments:
$logger: '@monolog.logger.bpn'
# Field Handlers with dependencies # Field Handlers with dependencies
App\Form\Service\ParticipantInsuranceFieldHandler: ~ App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~ App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~ App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~ App\Form\Service\ParticipantVoucherFieldHandler: ~
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services # Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry: App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -264,8 +304,7 @@ services:
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler' - '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler' - '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler' - '@App\Form\Service\ParticipantInsuranceFieldHandler'
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler' - '@App\Form\Service\ParticipantVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
- '@App\Form\Service\ParticipantParkingFieldHandler' - '@App\Form\Service\ParticipantParkingFieldHandler'
# Booking Status Rules # Booking Status Rules
@@ -297,7 +336,7 @@ services:
App\Service\CmsDataProvider: App\Service\CmsDataProvider:
arguments: arguments:
$httpClient: '@typo3.client' $httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented $apiKey: '%env(APP_CMS_API_KEY)%'
App\Logger\RequestIdProcessor: App\Logger\RequestIdProcessor:
tags: tags:
+179 -16
View File
@@ -12,8 +12,8 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. - 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. - 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. 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/travels`; re-request one on 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` the whole export is a single page today. 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`. - 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`). - 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>`. - Send the token as `Authorization: Bearer <access_token>`.
@@ -52,7 +52,7 @@ There is no single error envelope; three shapes occur:
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**. 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/travels` 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. `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. 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.
@@ -92,7 +92,9 @@ Product master data from the upstream BusProNet system, cached server-side for 1
#### `GET /api/last-update` — scope `api` #### `GET /api/last-update` — scope `api`
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown". 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".
--- ---
@@ -146,7 +148,7 @@ This is a full overwrite, not a merge.
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the 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. disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
This is the only paginated endpoint and the only one with a `meta` envelope. 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 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. arbitrary string, so an export hung off that collection would share a namespace with every date code.
@@ -188,7 +190,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"city": "Essen", "city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)", "street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15", "time": "20:15",
"datetime": "2026-12-25T20:15:00+01:00" "datetime": "2026-12-25T20:15:00"
} }
] ]
} }
@@ -200,6 +202,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"totalPages": 1, "totalPages": 1,
"skipped": 0, "skipped": 0,
"pickupsWithoutMasterData": 0, "pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
"generatedAt": "2026-09-29T12:10:06+02:00" "generatedAt": "2026-09-29T12:10:06+02:00"
} }
} }
@@ -209,8 +212,8 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
empty, never null. empty, never null.
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData` **`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the `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)`. 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 - `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
@@ -222,12 +225,17 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time. **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 moment as a full ISO-8601 string with a - `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.** `YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved - Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an `02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for `Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
bare-time sources too, and carries that resolution. 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 - A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together. null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some - Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
@@ -238,6 +246,155 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls. 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. 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,
"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. It is not levelled against the return leg. 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 ### Travels
@@ -398,14 +555,16 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
"totalDiscountLabel": null, "totalDiscountLabel": null,
"totalPrice": 123456, "totalPrice": 123456,
"pricingCurrency": "EUR", "pricingCurrency": "EUR",
"pricingVersion": 3, "pricingVersion": 2,
"priceBreakdown": {} "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. `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 and running costs sit in `total` but in no section, so they are never discounted. 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. `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.
@@ -413,6 +572,10 @@ The first three discounts each apply to one section of the breakdown: `accommoda
| field | meaning | | 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 | | `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` | | `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 | | `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
+82 -8
View File
@@ -25,7 +25,7 @@ Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
key = md5( user + password + date("YYYYMMDD") + satz-typ ) key = md5( user + password + date("YYYYMMDD") + satz-typ )
``` ```
Per satz type and per day. `ApiClient::createKey()` implements it, and `sendRawXml()` regenerates Per satz type and per day. `XmlChannel::createKey()` implements it, and `XmlChannel::replay()` regenerates
the key before sending, so a hand-built payload can carry a placeholder. the key before sending, so a hand-built payload can carry a placeholder.
## Satz types ## Satz types
@@ -39,7 +39,7 @@ the key before sending, so a hand-built payload can carry a placeholder.
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`. `VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section. `SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
**Used here but undocumented:** `VERFUEGBARKEIT2` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — the **Used here but undocumented:** `VERFUEGBARKEIT2` (`MessageType::AvailabilityExtended`) — the
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`. extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
Its contract is observed, not specified. Its contract is observed, not specified.
@@ -201,11 +201,11 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
## How this app maps onto the contract ## How this app maps onto the contract
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the `BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
`addTo…Payload()` helpers build edit; `ApiClient::createBooking()`/`updateBooking()` wrap them. `addTo…Payload()` helpers build edit; `BookingClient::createBooking()`/`updateBooking()` wrap them.
| Documented element | Sent on create | Sent on edit | Notes | | Documented element | Sent on create | Sent on edit | Notes |
|---|---|---|---| |---|---|---|---|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) | | `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below | | `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 | | `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()`. BusPro accepts an Anmelder who is not a participant | | `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
@@ -253,7 +253,7 @@ booking before relying on it.
### Gap: edit never dry-runs ### Gap: edit never dry-runs
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
`buchungsart=Buchung` directly (`ApiClient.php:233`), even though `BUCHUNGAENDERUNG` supports `buchungsart=Buchung` directly (`BookingClient::updateBooking()`), even though `BUCHUNGAENDERUNG` supports
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated `Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
price *before* mutating a live booking. price *before* mutating a live booking.
@@ -312,6 +312,67 @@ answers `<reise id status>` plus per-service `status` ("buchbar"), `frei`, `prei
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed `ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
`anzahlung` or errors `1011`-`1018` follow. `anzahlung` or errors `1011`-`1018` follow.
## Selling vouchers (Kaufgutschein)
Used by the `/gutschein` flow, which replaces the BusProNet-hosted shop
(`maske_gutschein.php`). `VoucherClient`, `MessageType::Voucher*`; not to be confused with
`MessageType::PurchaseVoucher` (`GUTSCHEINPRUEFUNGEINLOESUNG`), which redeems a voucher in a booking.
| Satz | Request | Response |
|---|---|---|
| `GUTSCHEINVORLAGEN` | — | `gutscheinminbetrag`, `gutscheinmaxbetrag` (**undocumented**, measured), then `<gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis/>` |
| `GUTSCHEINZAHLUNGSARTEN` | — | `<zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"/>` (measured; the attributes are echoed as `<zahlung>` below) |
| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `<gutscheinpdf>data:application/pdf;base64,…` (measured, about 430 KB) |
| `ADRESSFELDERGUTSCHEIN` | — | which address fields to show and which are mandatory; not used, the flow fixes the live shop's fields |
| `GUTSCHEINBUCHUNG` | see below | `buchung=erfolgt`, `idgutschein`, `gutscheinnummer`, `einloesecode`, `preis`, `rechnungsnummer`, `email` |
| `GUTSCHEINDRUCK` | `idgutschein`, `einloesecode` | the voucher PDF; not used, BusPro mails the voucher itself once the payment has arrived |
```xml
<satz typ="GUTSCHEINBUCHUNG"/>
<buchungsart>Anfrage|Buchung</buchungsart>
<gutscheinart>K</gutscheinart>
<idgutscheinvorlage>3</idgutscheinvorlage>
<kaeufer>…</kaeufer> <!-- "von", printed on the voucher -->
<empfaenger>…</empfaenger> <!-- "für", printed on the voucher -->
<kaufpreis>25</kaufpreis>
<text/> <!-- Zusatztext; the live shop sends it empty -->
<rechnungsempfänger>
<name/><vorname/><anrede/><titel/><geschlecht/><geburtsdatum/>
<anschrift><strasse/><plz/><ort/><ortsteil/><land>D</land></anschrift>
<kommunikation><telefonprivat/><email/><telefonmobil/></kommunikation>
</rechnungsempfänger>
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>
```
`<preiszusaetze>` is documented but not sent; the total is the voucher value. `telefonmobil` is not
in the vendor's example, its name is the one `BUCHUNG` uses, and the test system accepts it. The
live shop also collects a business phone, whose element name is not documented anywhere, so the
flow does not collect it.
### Measured on the test system (2026-10-01)
`buchungsart=Anfrage` is a full dry run: a valid request answers `<buchung>möglich</buchung>` and
nothing else (no `idgutschein`, `gutscheinnummer`, `einloesecode`, `rechnungsnummer`), so nothing is
issued. `idagentur` is not required (619/620 never fired).
| Variant | Answer |
|---|---|
| Valid (control), 25 € | `möglich` |
| `kaufpreis` 6000 | `608 Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein` |
| `kaufpreis` 5 | `607 Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein` |
| `kaufpreis` 5000 | `möglich` (bounds inclusive) |
| `kaufpreis` 25,50 | `möglich` (cents accepted; the flow offers whole euros only) |
| `empfaenger` missing | `605 Empfängername fehlt` |
| `idgutscheinvorlage` 999 | `602 Gutscheine mit ID 999 nicht gefunden` (not 615 as documented) |
| `email` empty | `650 Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse` |
| `strasse` empty | `650 … Strasse` |
| `anrede` Herr, `telefonmobil` set | `möglich` |
Missing address data is a `650` and a hard rejection, as on `BUCHUNG`. The flow therefore treats
every `HINWEIS` on this path as a rejection and sends it back to the step that owns it
(`VoucherSaleRejectionMapper`). The design ids (3, 4, 5) match the thumbnail file names of the old
shop, which suggests they are shared with production; confirm on the first production read.
## Error codes ## Error codes
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
@@ -320,7 +381,7 @@ runtime.
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message > **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
> carrier whose entire content is substituted. `Notification::isError()` > carrier whose entire content is substituted. `Notification::isError()`
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on > (`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 > the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`/`GUTSCHEINBUCHUNG`, where the same code delivers
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*. > real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO. > `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
@@ -359,8 +420,21 @@ zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum 995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm / **Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein 605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) ·
610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht
ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID ·
621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630 Aktionsgutschein fehlt ·
631 Aktionsgutschein mit Code #1# nicht gefunden · 632 Aktionsgutschein für geprüfte Reise ungültig ·
**633 Aktionsgutschein vom Buchungszeitraum ungültig** · **634 Aktionsgutschein vom Reisezeitraum
ungültig**
633 and 634 are not in the PDF; both were measured on `AKTIONSGUTSCHEIN` on 2026-10-06. 633 means
the request falls outside the promo's booking period (`GutscheinAktion.BuchungDtVon`/`BuchungDtBis`):
a test promo whose booking period was being edited answered 633 and was accepted once the period
covered the request date. 634 refers to the travel period (`ReiseDtVon`/`ReiseDtBis`). It was
returned on prod for a promo whose stored travel period did contain the travel's `TerminVon`/
`TerminBis`. What it actually compares has not been established.
## Applying this: the teamer placeholder booking ## Applying this: the teamer placeholder booking
BIN
View File
Binary file not shown.
@@ -9,7 +9,7 @@ Companion to `travel-queries.md` (static catalog) and `live-api-queries.md` (liv
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response — socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
the document that drives the portal's entire authorization model. the document that drives the portal's entire authorization model.
Request side: `ApiClient::getCrmAttributes()` (`src/BusProNet/ApiClient.php`) sends Request side: `AccountClient::getCrmAttributes()` (`src/BusProNet/Client/AccountClient.php`) sends
`satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password. `satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
Response side: `CrmAttributesResponseParser` Response side: `CrmAttributesResponseParser`
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into (`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
@@ -24,6 +24,7 @@ Symfony roles:
| `1478` | `ROLE_GROUPS_ADMIN` | | `1478` | `ROLE_GROUPS_ADMIN` |
| `1483` | `ROLE_CUSTOMER_EXPERT` | | `1483` | `ROLE_CUSTOMER_EXPERT` |
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) | | `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
| `1487` | `ROLE_SUPERVISOR` (not consumed here — exported through `/api/userinfo` for ephub) |
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` | | label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
| nothing matched | `ROLE_CUSTOMER` | | nothing matched | `ROLE_CUSTOMER` |
@@ -45,7 +46,7 @@ filtered before anything is stored:
| --- | --- | --- | | --- | --- | --- |
| `ROLE_TEAMER` | granted | granted | | `ROLE_TEAMER` | granted | granted |
| `ROLE_HOUSE_MANAGER` | nominated | nominated | | `ROLE_HOUSE_MANAGER` | nominated | nominated |
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated | | `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT`, `ROLE_SUPERVISOR` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
- **Staff address**: the login email's domain is listed in `employee_email_domains` - **Staff address**: the login email's domain is listed in `employee_email_domains`
(`config/services.yaml`, exact match, never a suffix). Such an account also gets (`config/services.yaml`, exact match, never a suffix). Such an account also gets
@@ -229,7 +230,7 @@ Three conclusions:
`dbo.AgenturSelektionenKunde` is a bare two-column link table, `dbo.AgenturSelektionenKunde` is a bare two-column link table,
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The `(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
portal authenticates against BusPro as an **agency** account (`bpn_username` in portal authenticates against BusPro as an **agency** account (`bpn_username` in
`ApiClient`), so a per-agency whitelist of which customer selections that agency may see `XmlChannel`), so a per-agency whitelist of which customer selections that agency may see
and/or edit is exactly the right shape. It is the only candidate found so far that could and/or edit is exactly the right shape. It is the only candidate found so far that could
explain the `1478` omission **and** `aenderbar` through one mechanism. explain the `1478` omission **and** `aenderbar` through one mechanism.
@@ -461,7 +462,7 @@ and they are long shots. Prefer the escalation path below.
Escalation path, cheapest first: Escalation path, cheapest first:
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the 1. **Re-fetch and confirm staleness.** Our side does not cache this call — the
`xml-sync` cache tag covers the XML *export* sync, and `ApiClient::getCrmAttributes()` `xml-sync` cache tag covers the XML *export* sync, and `AccountClient::getCrmAttributes()`
has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent
from a response captured after the insert, the staleness is on the BusProNet host and from a response captured after the insert, the staleness is on the BusProNet host and
an application-pool/service restart there is the test. an application-pool/service restart there is the test.
+2 -2
View File
@@ -5,8 +5,8 @@
> per-endpoint migration verdicts and the `frei` discovery SQL. > per-endpoint migration verdicts and the `frei` discovery SQL.
This complements `travel-queries.md`. That doc replaced the **XML export** (static This complements `travel-queries.md`. That doc replaced the **XML export** (static
catalog). This one covers the **live socket API** methods on catalog). This one covers the **live socket API** methods of the clients in
`src/BusProNet/ApiClient.php` and classifies how replicable each is by direct MSSQL `src/BusProNet/Client/` and classifies how replicable each is by direct MSSQL
query in the Go proxy. query in the Go proxy.
**Verdict in one line:** the *structural* parts replicate cleanly; the *live "free" **Verdict in one line:** the *structural* parts replicate cleanly; the *live "free"
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Split-off periods of an accommodation booking. No backfill: a booking without periods is
* priced exactly as before.
*/
final class Version20260930150445 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds accommodation_booking_period for nights split off the start or end of a stay.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE accommodation_booking_period (id INT AUTO_INCREMENT NOT NULL, booking_id INT NOT NULL, position VARCHAR(10) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', label VARCHAR(255) DEFAULT NULL, price_lines JSON NOT NULL COMMENT \'(DC2Type:json)\', INDEX IDX_ABFCF6B33301C60 (booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE accommodation_booking_period ADD CONSTRAINT FK_ABFCF6B33301C60 FOREIGN KEY (booking_id) REFERENCES accommodation_booking (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking_period DROP FOREIGN KEY FK_ABFCF6B33301C60');
$this->addSql('DROP TABLE accommodation_booking_period');
}
}
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PersonalDataUpdateResponse;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\RegistrationDto;
/**
* A customer's BusPro account: login, address data, newsletter, CRM selections.
*/
class AccountClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @throws ApiClientException
*/
public function getPersonalData(string $email, string $password): Notification|PersonalData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adressdaten',
'email' => $email,
'passwort' => $password,
]);
}
/**
* @throws ApiClientException
*/
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adresse_Neu',
'adressdaten' => $registrationData->toPayload(),
]);
}
/**
* @throws ApiClientException
*/
public function resetPassword(string $email): Notification
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Passwort_Anfrage',
'email' => $email,
]);
}
/**
* @throws ApiClientException
*/
public function updatePersonalData(
string $email,
string $password,
PersonalData $personalData,
bool $debug = false,
): Notification|PersonalDataUpdateResponse {
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adressdaten_Ändern',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'adressdaten' => $personalData->toPayload(),
], [], $debug);
}
/**
* @throws ApiClientException
*/
public function createAddress(
PersonalData $personalData,
bool $debug = false,
): RegistrationResponse|Notification {
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adresse_Neu',
'adressdaten' => $personalData->toPayload(false),
'ohnemailversand' => 'True',
], [], $debug);
}
/**
* @throws ApiClientException
*/
public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Newsletter',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'newsletter' => [
'email' => $email,
'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False',
],
]);
}
/**
* @throws ApiClientException
*/
public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'SelektionCRM',
'email' => $email,
'passwort' => $password,
]);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\Notification;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\BookingDto;
/**
* A customer's bookings: listing, details and documents, creating and changing them.
*/
class BookingClient
{
public function __construct(
private readonly XmlChannel $channel,
private readonly BookingDataProcessor $bookingDataProcessor,
) {
}
/**
* @throws ApiClientException
*/
public function getBookings(string $email, string $password): Notification|BaseData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Vorgänge',
'email' => $email,
'passwort' => $password,
]);
}
/**
* @throws ApiClientException
*/
public function getBooking(string $email, string $password, int $id): Notification|Booking
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Vorgang_Details',
'email' => $email,
'passwort' => $password,
'idbuchung' => $id,
]);
}
/**
* @throws ApiClientException
*/
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
{
return $this->channel->request(MessageType::CustomerData, [
'art' => $type,
'email' => $email,
'passwort' => $password,
'idbuchung' => $bookingId,
]);
}
/**
* @throws ApiClientException
*/
public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate
{
return $this->channel->request(MessageType::BookingUpdate, [
'buchungsart' => Constants::BOOKING_TYPE_BOOKING,
...$this->bookingDataProcessor->createUpdateRequestPayload($formData),
], [], $debug);
}
/**
* First phase of the two-phase booking process: validates the booking and returns the
* prices without creating it.
*
* @throws ApiClientException
*/
public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_INQUIRY, $debug);
}
/**
* Second phase of the two-phase booking process: creates the booking after a successful
* inquiry.
*
* @throws ApiClientException
*/
public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_BOOKING, $debug);
}
/**
* @throws ApiClientException
*/
private function sendBooking(BookingDto $bookingDto, string $bookingType, bool $debug): Notification|BookingResponse
{
return $this->channel->request(MessageType::Booking, $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, $bookingType), [], $debug);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
use App\BusProNet\Transport\XmlChannel;
/**
* Lists that change rarely and that callers cache for long periods.
*/
class ReferenceDataClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @return Agency[]|Notification
*
* @throws ApiClientException
*/
public function getAgencies(): array|Notification
{
return $this->channel->request(MessageType::Agencies);
}
/**
* @throws ApiClientException
*/
public function getBaseData(MessageType $type): Notification|BaseData
{
return $this->channel->request($type);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\Transport\XmlChannel;
/**
* Travels from the live API. Most reads belong in TravelDataProvider, which arbitrates
* between this, the XML export and the snapshots.
*/
class TravelClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @throws ApiClientException
*/
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
{
return $this->channel->request(MessageType::ProductData, ['idprodukt' => $travelId], ['hotelId' => $hotelId]);
}
/**
* Returns the unparsed response, which has the structure of BusPro's XML export and is
* stored in the export directory as is.
*
* @throws ApiClientException
*/
public function getTravelDataXml(int $travelId): string
{
return $this->channel->requestRaw(MessageType::ProductData, ['idprodukt' => $travelId]);
}
/**
* @throws ApiClientException
*/
public function getProducts(): Notification|BaseData
{
return $this->channel->request(MessageType::Products);
}
/**
* @throws ApiClientException
*/
public function getMutableData(int $dateId): Notification|BaseData
{
return $this->channel->request(MessageType::MutableData, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse
{
return $this->channel->request(MessageType::Availability, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
{
return $this->channel->request(MessageType::AvailabilityExtended, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
{
return $this->channel->request(MessageType::AvailabilityHotel, [
'idreise' => $dateId,
'idpartner' => $hotelId,
'terminbis' => $dateTo->format('d.m.Y'),
]);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherPreview;
use App\BusProNet\Model\VoucherSaleResponse;
use App\BusProNet\Model\VoucherTemplates;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\VoucherPurchaseDto;
/**
* Redeeming vouchers in a booking, and selling purchase vouchers (Kaufgutscheine).
*/
class VoucherClient
{
public function __construct(
private readonly XmlChannel $channel,
private readonly VoucherSalePayloadBuilder $voucherSalePayloadBuilder,
) {
}
/**
* Looks up a purchase voucher (Gutschein) by its redemption code. Purchase vouchers, regular
* or goodwill (Kulanz), carry a balance that each redemption reduces.
*
* @throws ApiClientException
*/
public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher
{
return $this->channel->request(MessageType::PurchaseVoucher, ['einloesecode' => $redemptionCode]);
}
/**
* Validates a promo voucher (Aktionsgutschein) for a travel and participant price. Promo
* vouchers are absolute discounts, per person ("P") or per booking ("B") as pro_buchung_person
* says.
*
* @throws ApiClientException
*/
public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher
{
return $this->channel->request(MessageType::PromoVoucher, [
'aktionsgutschein' => $promoCode,
'idreise' => $travelId,
'preis' => number_format($participantPrice, 2, ',', ''),
]);
}
/**
* Fetches the voucher designs on sale and the amount bounds.
*
* @throws ApiClientException
*/
public function getVoucherTemplates(): Notification|VoucherTemplates
{
return $this->channel->request(MessageType::VoucherTemplates);
}
/**
* Fetches the payment methods offered for voucher purchases.
*
* @return list<VoucherPaymentMethod>|Notification
*
* @throws ApiClientException
*/
public function getVoucherPaymentMethods(): array|Notification
{
return $this->channel->request(MessageType::VoucherPaymentMethods);
}
/**
* Renders the PDF preview of a voucher design with the entered data.
*
* @throws ApiClientException
*/
public function getVoucherPreview(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherPreview
{
return $this->channel->request(MessageType::VoucherPreview, $this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto));
}
/**
* Dry run of a voucher purchase: full validation, nothing is issued.
*
* Every HINWEIS is a rejection on this path, including 650 (missing address data), so the
* result must not be judged by Notification::isError().
*
* @throws ApiClientException
*/
public function createVoucherSaleInquiry(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_INQUIRY);
}
/**
* Buys the voucher. BusPro mails it to the buyer once the payment has arrived.
*
* Every HINWEIS is a rejection on this path, including 650.
*
* @throws ApiClientException
*/
public function createVoucherSale(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_BOOKING);
}
/**
* @throws ApiClientException
*/
private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse
{
return $this->channel->request(MessageType::VoucherSale, $this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType));
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\Form\Model\VoucherPurchaseDto;
/**
* Builds the GUTSCHEINBUCHUNG and GUTSCHEINVORSCHAU payloads from the voucher purchase DTO.
*
* The buyer goes into <rechnungsempfänger> and nothing else: linking the purchase to a customer
* record is BusPro's business. The <zahlung> attributes are echoed from GUTSCHEINZAHLUNGSARTEN.
*/
class VoucherSalePayloadBuilder
{
/**
* @param string $saleType Constants::BOOKING_TYPE_INQUIRY for the dry run, Constants::BOOKING_TYPE_BOOKING for the purchase
*
* @return array<string, mixed>
*/
public function buildSalePayload(VoucherPurchaseDto $dto, string $saleType): array
{
if (null === $dto->template || null === $dto->paymentMethod) {
throw new \LogicException('A voucher sale needs a template and a payment method.');
}
$communication = [
'telefonprivat' => $dto->phone,
'email' => $dto->email,
];
// Not in the vendor's GUTSCHEINBUCHUNG example; the element name is the one BUCHUNG uses.
if (null !== $dto->mobile && '' !== trim($dto->mobile)) {
$communication['telefonmobil'] = $dto->mobile;
}
return [
'buchungsart' => $saleType,
'gutscheinart' => $dto->template->type,
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
'rechnungsempfänger' => [
'name' => $dto->lastName,
'vorname' => $dto->firstName,
'anrede' => $dto->salutation,
'titel' => $dto->title,
'geschlecht' => '',
'geburtsdatum' => '',
'anschrift' => $dto->address->toPayload(),
'kommunikation' => $communication,
],
'zahlung' => [
'@idzahlungsart' => $dto->paymentMethod->id,
'@bezeichnung' => $dto->paymentMethod->label,
'@art' => $dto->paymentMethod->type,
],
];
}
/**
* @return array<string, mixed>
*/
public function buildPreviewPayload(VoucherPurchaseDto $dto): array
{
if (null === $dto->template) {
throw new \LogicException('A voucher preview needs a template.');
}
return [
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
];
}
}
@@ -2,7 +2,8 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient; use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Country; use App\BusProNet\Model\Country;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@@ -10,7 +11,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class CountryDataProvider class CountryDataProvider
{ {
public function __construct(private readonly ApiClient $apiClient, private readonly CacheInterface $cache) public function __construct(private readonly ReferenceDataClient $referenceDataClient, private readonly CacheInterface $cache)
{ {
} }
@@ -22,8 +23,8 @@ class CountryDataProvider
$item->expiresAfter(6 * 60 * 60); $item->expiresAfter(6 * 60 * 60);
return $this return $this
->apiClient ->referenceDataClient
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES) ->getBaseData(MessageType::Countries)
->getItems() ->getItems()
; ;
}); });
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\BusProNet;
/**
* The `typ` of BusPro's `<satz>`: the command on a request, the kind of answer on a response.
*/
enum MessageType: string
{
// Only ever a response: BusPro's refusal or remark, whatever was asked.
case Notification = 'HINWEIS';
case CustomerData = 'KUNDENKONTO';
case Countries = 'STAMMLAENDER';
case MutableData = 'MOEGLICHEAENDERUNGEN';
case Availability = 'VERFUEGBARKEIT';
case AvailabilityExtended = 'VERFUEGBARKEIT2';
case AvailabilityHotel = 'VERFUEGBARKEITHOTEL';
case BookingUpdate = 'BUCHUNGAENDERUNG';
case Booking = 'BUCHUNG';
case Products = 'PRODUKTE';
case ProductData = 'PRODUKTDATEN';
case Agencies = 'AGENTUREN';
case PurchaseVoucher = 'GUTSCHEINPRUEFUNGEINLOESUNG';
case PromoVoucher = 'AKTIONSGUTSCHEIN';
// Selling Kaufgutscheine. Not to be confused with PurchaseVoucher, which redeems one in a booking.
case VoucherTemplates = 'GUTSCHEINVORLAGEN';
case VoucherPaymentMethods = 'GUTSCHEINZAHLUNGSARTEN';
case VoucherPreview = 'GUTSCHEINVORSCHAU';
case VoucherSale = 'GUTSCHEINBUCHUNG';
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A payment method for voucher purchases (GUTSCHEINZAHLUNGSARTEN).
*
* Echoed unchanged as the <zahlung> attributes of GUTSCHEINBUCHUNG.
*/
final readonly class VoucherPaymentMethod
{
public const string TYPE_TRANSFER = 'UEBERW';
public function __construct(
public int $id,
public string $label,
public string $type,
) {
}
public function isTransfer(): bool
{
return self::TYPE_TRANSFER === $this->type;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORSCHAU response, decoded from its data URI to the raw PDF bytes.
*/
final readonly class VoucherPreview
{
public function __construct(
public string $pdf,
) {
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A GUTSCHEINBUCHUNG response that is not a HINWEIS.
*
* The dry run (buchungsart Anfrage) answers <buchung>möglich</buchung> and nothing else, so every
* other field is only set by the actual purchase (buchungsart Buchung, <buchung>erfolgt</buchung>).
*
* The redemption code is deliberately not exposed to the customer before payment: BusPro mails the
* voucher itself once the transfer has arrived.
*/
final readonly class VoucherSaleResponse
{
public const string SALE_POSSIBLE = 'möglich';
public const string SALE_PROCESSED = 'erfolgt';
public function __construct(
public string $status,
public ?int $voucherId = null,
public ?string $voucherNumber = null,
public ?string $redemptionCode = null,
public ?float $price = null,
public ?string $invoiceNumber = null,
public ?string $email = null,
) {
}
public function isInquiryValid(): bool
{
return self::SALE_POSSIBLE === $this->status;
}
public function isSaleSuccessful(): bool
{
return self::SALE_PROCESSED === $this->status;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A voucher design offered for online sale (GUTSCHEINVORLAGEN).
*
* The id doubles as the key for the local thumbnail, see VoucherThumbnailResolver.
*/
final readonly class VoucherTemplate
{
public function __construct(
public int $id,
public string $type,
public string $label,
public ?string $note = null,
) {
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORLAGEN response: the designs on sale plus BusPro's amount bounds.
*
* The bounds (gutscheinminbetrag/gutscheinmaxbetrag) are not in the vendor documentation,
* they were measured on the test system (10 and 5,000 €, both inclusive).
*/
final readonly class VoucherTemplates
{
/**
* @param list<VoucherTemplate> $templates
*/
public function __construct(
public int $minAmount,
public int $maxAmount,
public array $templates,
) {
}
public function find(int $id): ?VoucherTemplate
{
foreach ($this->templates as $template) {
if ($id === $template->id) {
return $template;
}
}
return null;
}
}
+23
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class XmlExportInfo final readonly class XmlExportInfo
{ {
private const INFO_FILENAME = 'uebertragung.info'; private const INFO_FILENAME = 'uebertragung.info';
@@ -20,6 +23,26 @@ final readonly class XmlExportInfo
return self::INFO_FILENAME; return self::INFO_FILENAME;
} }
/**
* Reads the marker from a storage, or null when it is missing or unreadable.
*
* A marker caught mid-sync or truncated is treated as absent rather than as an error:
* callers only ever want to know which export they are looking at, and "unknown" is
* an answer they can act on.
*/
public static function fromStorage(FilesystemOperator $storage): ?self
{
try {
if (false === $storage->fileExists(self::INFO_FILENAME)) {
return null;
}
return self::fromString($storage->read(self::INFO_FILENAME));
} catch (FilesystemException|\InvalidArgumentException) {
return null;
}
}
public static function fromString(string $content): self public static function fromString(string $content): self
{ {
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252'); $content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
+5 -2
View File
@@ -79,6 +79,9 @@ trait TypeConversionTrait
* This method is used primarily to parse pickup-times since data is * This method is used primarily to parse pickup-times since data is
* either provided with date and time or time only. In this case a * either provided with date and time or time only. In this case a
* date has to be provided which defaults to a travel's start date. * date has to be provided which defaults to a travel's start date.
*
* Both shapes are read in the default date's zone, so a caller that needs plain clock
* readings can pass a UTC date and no daylight saving gap shifts the time.
*/ */
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
{ {
@@ -89,12 +92,12 @@ trait TypeConversionTrait
try { try {
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) { if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
// time only provided // time only provided
$dateTime = Carbon::createFromFormat('H:i', $string); $dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
// assign default date // assign default date
$dateTime->setDateFrom($defaultDate); $dateTime->setDateFrom($defaultDate);
} else { } else {
// date and time provided // date and time provided
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16)); $dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16), $defaultDate->getTimezone());
} }
return $dateTime->toDateTimeImmutable(); return $dateTime->toDateTimeImmutable();
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Transport;
use App\Service\RequestIdGenerator;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Ids for BPN requests: the id of the current HTTP request, or a fresh one on the CLI, with a
* running number, so the log entries and dumps of one page load can be found together.
*/
class RequestIdSequence
{
private int $counter = 0;
public function __construct(
private readonly RequestStack $requestStack,
private readonly RequestIdGenerator $requestIdGenerator,
) {
}
public function next(): string
{
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
?? $this->requestIdGenerator->generateBaseId();
return $baseId.'_'.++$this->counter;
}
}
+265
View File
@@ -0,0 +1,265 @@
<?php
namespace App\BusProNet\Transport;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\TimeoutException;
use Psr\Log\LoggerInterface;
/**
* BusPro's socket framing: one connection per request, each message prefixed with its length
* as a 10 byte header. Knows nothing about what the messages contain.
*/
class SocketTransport
{
private float $operationStartTime;
private int $selectedPort;
/** @param list<int|string> $ports */
public function __construct(
private readonly LoggerInterface $logger,
private readonly string $host,
private readonly array $ports,
private readonly int $connectionTimeout = 5,
private readonly int $streamTimeout = 30,
private readonly int $totalTimeout = 45,
private readonly int $maxRetries = 25,
) {
}
/**
* Sends $body over a fresh connection and returns the response payload. $logMessage is
* logged with $context and the chosen port once the connection is open.
*
* @param array<string, mixed> $context
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
public function exchange(string $body, string $logMessage, array $context = []): string
{
$socket = $this->connect();
$this->logger->info($logMessage, [...$context, 'port' => $this->selectedPort]);
$this->send($socket, $body);
try {
// the length header is consumed and verified by receive(), this is the payload
return $this->receive($socket);
} finally {
$this->disconnect($socket);
}
}
/**
* @return resource
*
* @throws ApiClientException
* @throws TimeoutException
*/
private function connect()
{
// Randomly select a port from the available ports for load balancing
$this->selectedPort = $this->ports[array_rand($this->ports)];
$this->operationStartTime = microtime(true);
$tries = 1;
$errNo = $errStr = '';
$errorCodesForRetry = [
SOCKET_ECONNREFUSED,
SOCKET_EBADF,
];
$openSocket = function (&$errNo, &$errStr) {
return @fsockopen(
$this->host,
$this->selectedPort,
$errNo,
$errStr,
$this->connectionTimeout
);
};
$socket = $openSocket($errNo, $errStr);
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->maxRetries > $tries) {
// Check if we've exceeded total timeout during retries
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
$this->logger->error('Connection retry timeout exceeded', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'total_timeout' => $this->totalTimeout,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Connection timeout exceeded during retries');
}
$this->logger->warning('Could not connect to socket, retrying', [
'error_message' => $errStr,
'error_number' => $errNo,
'attempt' => $tries,
'port' => $this->selectedPort,
]);
++$tries;
sleep(1);
$socket = $openSocket($errNo, $errStr);
}
if (false !== $socket) {
stream_set_timeout($socket, $this->streamTimeout);
} else {
$this->logger->error('Unable to open socket', [
'error_message' => $errStr,
'error_number' => $errNo,
'elapsed_time' => microtime(true) - $this->operationStartTime,
'port' => $this->selectedPort,
]);
throw new ApiClientException('Unable to open socket');
}
return $socket;
}
/**
* @param resource $socket
*
* @throws TimeoutException
*/
private function send($socket, string $data): void
{
// Check total timeout before sending
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
$this->logger->error('Total timeout exceeded before send', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Total operation timeout exceeded before send');
}
// message length is prepended to actual message
$send = sprintf('%010s', strlen($data)).$data;
fwrite($socket, $send);
}
/**
* Reads a single response message and returns its payload. The protocol prepends the
* payload length as a 10 byte header, so the announced length is compared against what
* actually arrived: a peer closing mid-stream would otherwise yield a silently
* truncated body that only surfaces later as an unexplained parse failure.
*
* @param resource $socket
*
* @throws ApiClientException
* @throws TimeoutException
* @throws ImmediateConnectionCloseException
*/
private function receive($socket): string
{
$response = '';
$readAttempts = 0;
$totalTimeout = $this->totalTimeout;
while (false === feof($socket)) {
// Check total timeout before each read
$elapsedTime = microtime(true) - $this->operationStartTime;
if ($elapsedTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded during receive', [
'elapsed_time' => $elapsedTime,
'total_timeout' => $totalTimeout,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Total operation timeout exceeded while receiving data');
}
$chunk = fread($socket, 4096);
++$readAttempts;
// Check if stream timed out on this specific read
$metadata = stream_get_meta_data($socket);
if (true === $metadata['timed_out']) {
// Check if this is an immediate rejection (0 bytes, < 1 second)
// This indicates the server is busy rather than a true timeout
if (0 === strlen($response) && $elapsedTime < 1.0) {
$this->logger->warning('Server closed connection immediately', [
'elapsed_time' => $elapsedTime,
'bytes_received' => 0,
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy');
}
$this->logger->error('Stream read timeout detected', [
'elapsed_time' => $elapsedTime,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Stream timeout while reading from socket');
}
$response .= $chunk;
}
if (0 === strlen($response)) {
$elapsedTime = microtime(true) - $this->operationStartTime;
$this->logger->warning('Server closed connection without sending data', [
'elapsed_time' => $elapsedTime,
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
}
return $this->extractPayload($response);
}
/**
* @throws ApiClientException
*/
private function extractPayload(string $response): string
{
if (10 > strlen($response)) {
$this->logger->error('Incomplete response header received from API', [
'bytes_received' => strlen($response),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
}
$header = substr($response, 0, 10);
$announcedLength = (int) trim($header);
$payload = substr($response, 10);
if (1 > $announcedLength) {
$this->logger->error('Empty response body announced by API', [
'header' => trim($header),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
if (strlen($payload) !== $announcedLength) {
$this->logger->error('Truncated response received from API', [
'bytes_received' => strlen($payload),
'announced_length' => $announcedLength,
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
}
return $payload;
}
/** @param resource $socket */
private function disconnect($socket): void
{
@fclose($socket);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
namespace App\BusProNet\Transport;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Service\XmlDumpWriter;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;
/**
* The XML protocol around one BusPro command: authentication, serialization, dumps, the
* busy-retry and parsing. What a command contains is up to the caller.
*/
class XmlChannel
{
/** @var array<string, mixed> */
private array $config;
/** @param array<string, mixed> $options */
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ApiResponseParser $responseParser,
private readonly XmlDumpWriter $dumpWriter,
private readonly LoggerInterface $logger,
private readonly RequestIdSequence $requestIds,
private readonly SocketTransport $transport,
array $options,
) {
$this->config = $this->resolveOptions($options);
}
/**
* Sends command $type with $fields and returns the parsed response.
*
* @param array<string, mixed> $fields
* @param array<string, mixed> $parserArgs
*
* @throws ApiClientException
*/
public function request(MessageType $type, array $fields = [], array $parserArgs = [], bool $debug = false): mixed
{
$data = $this->envelope($type, $fields);
return $this->executeWithRetry(function () use ($type, $data, $parserArgs, $debug) {
$requestId = $this->requestIds->next();
$dump = $debug || true === $this->config['debug'];
$xml = $this->exchange($requestId, $this->serializeXml($data), $type->value, 'Sending request to BPN API', $dump);
return $this->parseResponse($type, $xml, $parserArgs, $requestId);
}, $type->value);
}
/**
* Like request(), but returns the unparsed response.
*
* @param array<string, mixed> $fields
*
* @throws ApiClientException
*/
public function requestRaw(MessageType $type, array $fields = []): string
{
$data = $this->envelope($type, $fields);
return $this->executeWithRetry(function () use ($type, $data) {
$requestId = $this->requestIds->next();
$body = $this->serializeXml($data);
return $this->exchange($requestId, $body, $type->value, 'Sending raw request to BPN API', true === $this->config['debug']);
});
}
/**
* Sends a recorded request as is, except for the key, which is regenerated for the current
* date. Returns the unparsed response.
*
* @throws ApiClientException
*/
public function replay(string $xml, bool $debug = false): string
{
return $this->executeWithRetry(fn () => $this->doReplay($xml, $debug));
}
/**
* The authentication every request starts with, followed by the command's own fields.
*
* @param array<string, mixed> $fields
*
* @return array<string, mixed>
*/
private function envelope(MessageType $type, array $fields): array
{
return [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($type->value),
'satz' => ['@typ' => $type->value],
...$fields,
];
}
private function createKey(string $type): string
{
$date = (new \DateTimeImmutable())->format('Ymd');
return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type);
}
/**
* Executes an operation 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 ImmediateConnectionCloseException If all retry attempts fail
*/
private function executeWithRetry(callable $operation, ?string $type = null): mixed
{
$maxAttempts = $this->config['busy_retry_attempts'];
$retryDelay = $this->config['busy_retry_delay'];
$typeContext = null !== $type ? ['type' => $type] : [];
$lastException = null;
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
try {
return $operation();
} catch (ImmediateConnectionCloseException $e) {
$lastException = $e;
if ($attempt < $maxAttempts) {
$this->logger->warning('BPN server busy, retrying request', [
'attempt' => $attempt,
'maxAttempts' => $maxAttempts,
'retryDelay' => $retryDelay,
...$typeContext,
]);
sleep($retryDelay);
}
}
}
$this->logger->error('BPN server busy after all retry attempts', ['attempts' => $maxAttempts, ...$typeContext]);
throw $lastException;
}
/**
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function doReplay(string $xml, bool $debug): string
{
$requestId = $this->requestIds->next();
$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 <satz> element in XML');
}
$type = $satzNode->getAttribute('typ');
if ('' === $type) {
throw new ApiClientException('Missing typ attribute on <satz> element');
}
$keyNode = $doc->getElementsByTagName('key')->item(0);
if (null === $keyNode) {
throw new ApiClientException('Missing <key> element in XML');
}
$keyNode->nodeValue = $this->createKey($type);
return $this->exchange($requestId, $doc->saveXML(), $type, 'Sending raw XML request to BPN API', $debug || true === $this->config['debug']);
}
/**
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function exchange(string $requestId, string $body, string $type, string $logMessage, bool $dump): string
{
if ($dump) {
$this->dumpWriter->write($requestId, XmlDumpWriter::REQUEST, $body);
}
$response = $this->transport->exchange($body, $logMessage, ['requestId' => $requestId, 'type' => $type]);
if ($dump) {
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $response);
}
return $response;
}
/**
* @param array<string, mixed> $parserArgs
*
* @throws ApiClientException
*/
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
{
try {
return $this->responseParser->parseXmlString($type, $xml, $parserArgs);
} catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
if ($dumpOnFailure) {
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $xml);
}
$this->logger->error('Unable to parse response received from API', [
'request_id' => $requestId,
'type' => $type->value,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
}
}
/** @param array<string, mixed> $data */
private function serializeXml(array $data): string
{
return $this->serializer->serialize($data, 'xml', [
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
XmlEncoder::ENCODING => 'UTF-8',
]);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired([
'bpn_username',
'bpn_password',
]);
$optionsResolver->setDefaults([
'debug' => false,
'busy_retry_attempts' => 3,
'busy_retry_delay' => 1,
]);
return $optionsResolver->resolve($options);
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\XmlLoader; namespace App\BusProNet\XmlLoader;
use App\BusProNet\ApiClient; use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\Model\Agency; use App\BusProNet\Model\Agency;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
@@ -19,7 +19,7 @@ class AgencyLoader
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly ApiClient $apiClient, private readonly ReferenceDataClient $referenceDataClient,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
@@ -34,7 +34,7 @@ class AgencyLoader
// Cache for 24 hours (agencies change infrequently) // Cache for 24 hours (agencies change infrequently)
$item->expiresAfter(24 * 60 * 60); $item->expiresAfter(24 * 60 * 60);
$result = $this->apiClient->getAgencies(); $result = $this->referenceDataClient->getAgencies();
if ($result instanceof Notification) { if ($result instanceof Notification) {
$this->logger->error('Failed to fetch agencies from BPN API', [ $this->logger->error('Failed to fetch agencies from BPN API', [
+32 -22
View File
@@ -2,8 +2,8 @@
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException; use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\XmlCrawlerFactory; use App\BusProNet\XmlCrawlerFactory;
class ApiResponseParser extends AbstractParser class ApiResponseParser extends AbstractParser
@@ -14,10 +14,11 @@ class ApiResponseParser extends AbstractParser
} }
/** /**
* @param array<string, mixed> $additionalArgs
*
* @throws ResponseParserException * @throws ResponseParserException
*/ */
/** @param array<string, mixed> $additionalArgs */ public function parseXmlString(MessageType $type, string $xml, array $additionalArgs = []): mixed
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
{ {
$crawler = XmlCrawlerFactory::create($xml); $crawler = XmlCrawlerFactory::create($xml);
@@ -25,17 +26,20 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml)); throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
} }
// Override type unless present in XML to catch error responses // The type in the XML wins, to catch error responses. BusPro may send one we don't know.
$responseType = $this $typeAttribute = $this->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ'));
->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ')) ?? $type; $responseType = null === $typeAttribute ? $type : MessageType::tryFrom($typeAttribute);
if (null === $responseType) {
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $typeAttribute));
}
$resultNode = $crawler->filterXPath('//ergebnis'); $resultNode = $crawler->filterXPath('//ergebnis');
try { try {
switch ($responseType) { switch ($responseType) {
case ApiClient::TYPE_NOTIFICATION: case MessageType::Notification:
return (new NotificationParser())->parse($crawler); return (new NotificationParser())->parse($crawler);
case ApiClient::TYPE_CUSTOMER_DATA: case MessageType::CustomerData:
$subType = $this->getStringOrNullValue($resultNode->filterXPath('//art')); $subType = $this->getStringOrNullValue($resultNode->filterXPath('//art'));
if (null === $subType || '' === $subType) { if (null === $subType || '' === $subType) {
throw new ResponseParserException('Unable to determine customer data subtype from XML response'); throw new ResponseParserException('Unable to determine customer data subtype from XML response');
@@ -63,23 +67,23 @@ class ApiResponseParser extends AbstractParser
} }
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType)); throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
case ApiClient::TYPE_BASE_DATA_COUNTRIES: case MessageType::Countries:
return (new CountriesParser())->parse($resultNode); return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA: case MessageType::MutableData:
return (new MutableDataParser())->parse($resultNode); return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY: case MessageType::Availability:
return (new AvailabilitiesParser())->parseServices($resultNode); return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_EXTENDED: case MessageType::AvailabilityExtended:
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode); return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL: case MessageType::AvailabilityHotel:
return (new AvailabilitiesParser())->parseRooms($resultNode); return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE: case MessageType::BookingUpdate:
return (new BookingUpdateParser())->parse($resultNode); return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_BOOKING: case MessageType::Booking:
return (new BookingResponseParser())->parse($resultNode); return (new BookingResponseParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS: case MessageType::Products:
return (new ProductsParser())->parse($resultNode); return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA: case MessageType::ProductData:
$travelNode = $crawler->filterXPath('//reise/termin'); $travelNode = $crawler->filterXPath('//reise/termin');
if (0 === $travelNode->count()) { if (0 === $travelNode->count()) {
@@ -87,17 +91,23 @@ class ApiResponseParser extends AbstractParser
} }
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs); return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES: case MessageType::Agencies:
return (new AgencyParser())->parse($resultNode); return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER: case MessageType::PurchaseVoucher:
return (new PurchaseVoucherParser())->parse($resultNode); return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER: case MessageType::PromoVoucher:
return (new PromoVoucherParser())->parse($resultNode); return (new PromoVoucherParser())->parse($resultNode);
case MessageType::VoucherTemplates:
return (new VoucherTemplatesParser())->parse($resultNode);
case MessageType::VoucherPaymentMethods:
return (new VoucherPaymentMethodsParser())->parse($resultNode);
case MessageType::VoucherPreview:
return (new VoucherPreviewParser())->parse($resultNode);
case MessageType::VoucherSale:
return (new VoucherSaleResponseParser())->parse($resultNode);
} }
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException $e) {
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e); throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
} }
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
} }
} }
@@ -41,6 +41,7 @@ class CrmAttributesResponseParser
1478 => Role::GROUPS_ADMIN, 1478 => Role::GROUPS_ADMIN,
1483 => Role::CUSTOMER_EXPERT, 1483 => Role::CUSTOMER_EXPERT,
1484 => Role::TEAM_ADMIN, 1484 => Role::TEAM_ADMIN,
1487 => Role::SUPERVISOR,
]; ];
/** /**
+17 -6
View File
@@ -3,9 +3,9 @@
namespace App\BusProNet\XmlParser; namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\File; use App\BusProNet\Model\File;
use PhpZip\Exception\ZipException;
use PhpZip\ZipFile;
use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\DomCrawler\Crawler;
use ZipStream\Exception as ZipException;
use ZipStream\ZipStream;
class DocumentsParser class DocumentsParser
{ {
@@ -28,17 +28,28 @@ class DocumentsParser
} }
// In case of multiple documents create ZIP file // In case of multiple documents create ZIP file
$stream = fopen('php://temp', 'w+b');
try { try {
$zip = new ZipFile(); // Zero headers trip up some unzip clients, and the data is in memory anyway
$zip = new ZipStream(
outputStream: $stream,
defaultEnableZeroHeader: false,
sendHttpHeaders: false,
);
$documents->each(function (Crawler $node) use (&$zip) { $documents->each(function (Crawler $node) use ($zip) {
[$filename, $pdfData] = $this->getFileInfo($node); [$filename, $pdfData] = $this->getFileInfo($node);
$zip->addFromString($filename.'.pdf', $pdfData); $zip->addFile($filename.'.pdf', $pdfData);
}); });
return new File('reisedokumente.zip', $zip->outputAsString(), 'application/zip'); $zip->finish();
rewind($stream);
return new File('reisedokumente.zip', stream_get_contents($stream), 'application/zip');
} catch (ZipException $e) { } catch (ZipException $e) {
return null; return null;
} finally {
fclose($stream);
} }
} }
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Constants;
use Symfony\Component\DomCrawler\Crawler;
/**
* Reads the parts of a `Ziel_*.xml` travel date that the product export carries, verbatim.
*
* Deliberately separate from TravelParser, which builds the booking path's models and
* applies the booking path's rules: it drops the PDGS pseudo room, excludes the
* hotel-nested services and levels boarding-point prices. The product export filters and
* rewrites nothing -- its consumer applies its own rules -- so it reads the nodes directly
* and returns plain arrays in source order.
*
* @see \App\Service\ProductExportBuilder
*/
class ProductExportParser extends AbstractParser
{
/**
* Every selection group on the date, not only the `Leistungen *` ones.
*
* @return list<array{busProId: int, label: ?string, selections: list<array{busProId: int, label: ?string}>}>
*/
public function getSelectionGroups(Crawler $dateNode): array
{
return $this->children($dateNode, 'selektiongruppe')->each(fn (Crawler $group): array => [
'busProId' => (int) $group->attr('idbuspro'),
'label' => $group->attr('bezeichnung'),
'selections' => $this->children($group, 'selektion')->each(fn (Crawler $selection): array => [
'busProId' => (int) $selection->attr('idbuspro'),
'label' => $selection->attr('bezeichnung'),
]),
]);
}
/**
* Services of one `lei_*` list, keyed by nothing -- the consumer keys them by `busProId`.
*
* The per-list `id` attribute is not read: it restarts at 1 in every list, so it
* identifies nothing once a date-level and a hotel-level list are merged.
*
* @return list<array{busProId: int, subType: ?string, label: ?string, price: ?float, status: ?string, direction: ?string}>
*/
public function getServices(Crawler $serviceNodes): array
{
return $serviceNodes->each(fn (Crawler $service): array => [
'busProId' => (int) $service->attr('idbuspro'),
'subType' => $service->attr('unterart'),
'label' => $this->getStringOrNullValue($this->children($service, 'text')),
'price' => $this->getFloatOrNullValue($this->children($service, 'preis')),
'status' => $this->getStringOrNullValue($this->children($service, 'status')),
'direction' => $this->getStringOrNullValue($this->children($service, 'richtung')),
]);
}
/**
* The hotel's rooms, without the PDGS pseudo room.
*
* `available` is a count of rooms, not persons. Occupancy is read case-insensitively:
* the travel export writes `MinPax`/`MaxPax`.
*
* @return list<array<string, mixed>>
*/
public function getRooms(Crawler $hotelNode): array
{
$rooms = [];
$this->children($hotelNode, 'zimmer/preis')->each(function (Crawler $room) use (&$rooms) {
$code = $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'));
if (Constants::PSEUDO_PRICE_ROOM_CODE === $code) {
return;
}
$rooms[] = [
'busProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_zimmer'),
'code' => $code,
'label' => $this->getAttrOrNullValue($room, 'zimmertext'),
'category' => $this->getAttrOrNullValue($room, 'kat'),
'minPax' => $this->getIntAttrOrNullValue($room, 'minpax'),
'maxPax' => $this->getIntAttrOrNullValue($room, 'maxpax'),
'nights' => $this->getIntAttrOrNullValue($room, 'naechte'),
'price' => $this->stringToFloat($this->getAttrOrNullValue($room, 'preis')),
'available' => $this->getIntAttrOrNullValue($room, 'verfuegbar'),
'status' => $this->getAttrOrNullValue($room, 'status'),
'boardCode' => $this->getAttrOrNullValue($room, 'vpcode'),
'boardBusProId' => $this->getIntAttrOrNullValue($room, 'idbuspro_vp'),
];
});
return $rooms;
}
/**
* The struck-through comparison price, carried by the PDGS pseudo room.
*/
public function getPseudoPrice(Crawler $hotelNode): ?float
{
foreach ($this->children($hotelNode, 'zimmer/preis') as $room) {
$room = new Crawler($room);
if (Constants::PSEUDO_PRICE_ROOM_CODE === $this->trimOrNull($this->getAttrOrNullValue($room, 'zimmercode'))) {
return $this->stringToFloat($this->getAttrOrNullValue($room, 'preis'));
}
}
return null;
}
/**
* The guide of the first outbound bus that has one.
*
* Not TravelParser::getGuide(), which takes the first guide of any bus, return leg
* included.
*
* @return array{name: ?string, phone: ?string}|null
*/
public function getGuide(Crawler $dateNode): ?array
{
$guides = $this->children($dateNode, 'lei_befoerderung/leistung[@unterart="BUS"][not(richtung="RUECK")]/zustiegsplanung/reiseleiter');
if (0 === $guides->count()) {
return null;
}
$guide = $guides->first();
return [
'name' => $this->getStringOrNullValue($this->children($guide, 'name')),
'phone' => $this->getStringOrNullValue($this->children($guide, 'telefon')),
];
}
/**
* A path below the node's own children, never deeper.
*
* DomCrawler evaluates filterXPath() against a fake parent of the node, so `./x` finds
* nothing and `.//x` searches every descendant -- which would pull a hotel's
* `lei_sonstiges` into the date's list. A leading `*` step anchors the path at the
* node itself, whatever its name.
*/
public function children(Crawler $node, string $path): Crawler
{
return $node->filterXPath('*/'.$path);
}
private function trimOrNull(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPaymentMethod;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINZAHLUNGSARTEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINZAHLUNGSARTEN"></satz>
* <zahlungsarten>
* <zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
* </zahlungsarten>
* </ergebnis>
*/
final class VoucherPaymentMethodsParser extends AbstractParser
{
/**
* @return list<VoucherPaymentMethod>
*/
public function parse(Crawler $node): array
{
$paymentMethods = [];
$node->filterXPath('//zahlungsarten/zahlungsart')->each(function (Crawler $methodNode) use (&$paymentMethods): void {
$paymentMethods[] = new VoucherPaymentMethod(
id: (int) $this->getRequiredAttrValue($methodNode, 'id', 'voucher payment method'),
label: $this->getRequiredAttrValue($methodNode, 'bezeichnung', 'voucher payment method'),
type: $this->getRequiredAttrValue($methodNode, 'art', 'voucher payment method'),
);
});
return $paymentMethods;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPreview;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORSCHAU responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORSCHAU"/>
* <gutscheinpdf>data:application/pdf;base64,JVBERi0xLjQ…</gutscheinpdf>
* </ergebnis>
*/
final class VoucherPreviewParser extends AbstractParser
{
private const string DATA_URI_PREFIX = 'data:application/pdf;base64,';
public function parse(Crawler $node): VoucherPreview
{
$dataUri = trim($this->getStringOrNullValue($node->filterXPath('//gutscheinpdf')) ?? '');
if (!str_starts_with($dataUri, self::DATA_URI_PREFIX)) {
throw new \InvalidArgumentException('Missing or malformed PDF data URI in voucher preview response.');
}
$pdf = base64_decode(substr($dataUri, \strlen(self::DATA_URI_PREFIX)), true);
if (false === $pdf || '' === $pdf) {
throw new \InvalidArgumentException('Undecodable PDF in voucher preview response.');
}
return new VoucherPreview($pdf);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherSaleResponse;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINBUCHUNG responses that are not a HINWEIS.
*
* Dry run (buchungsart Anfrage, measured on the test system 2026-10-01):
* <ergebnis><satz typ="GUTSCHEINBUCHUNG"></satz><buchung>möglich</buchung></ergebnis>
*
* Purchase (buchungsart Buchung, as documented by the vendor):
* <ergebnis>
* <satz typ="GUTSCHEINBUCHUNG"/>
* <buchung>erfolgt</buchung>
* <idgutschein/><gutscheinnummer/><einloesecode/><preis/><rechnungsnummer/><email/>
* </ergebnis>
*/
final class VoucherSaleResponseParser extends AbstractParser
{
public function parse(Crawler $node): VoucherSaleResponse
{
$status = trim($this->getStringOrNullValue($node->filterXPath('//buchung')) ?? '');
if ('' === $status) {
throw new \InvalidArgumentException('Missing required status in voucher sale response.');
}
return new VoucherSaleResponse(
status: $status,
voucherId: $this->getIntOrNullValue($node->filterXPath('//idgutschein')),
voucherNumber: $this->trimmedOrNull($node->filterXPath('//gutscheinnummer')),
redemptionCode: $this->trimmedOrNull($node->filterXPath('//einloesecode')),
price: $this->getFloatOrNullValue($node->filterXPath('//preis')),
invoiceNumber: $this->trimmedOrNull($node->filterXPath('//rechnungsnummer')),
email: $this->trimmedOrNull($node->filterXPath('//email')),
);
}
private function trimmedOrNull(Crawler $node): ?string
{
$value = trim($this->getStringOrNullValue($node) ?? '');
return '' !== $value ? $value : null;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherTemplate;
use App\BusProNet\Model\VoucherTemplates;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORLAGEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORLAGEN"></satz>
* <gutscheinminbetrag>10</gutscheinminbetrag>
* <gutscheinmaxbetrag>5000</gutscheinmaxbetrag>
* <gutscheine>
* <gutschein idgutscheinvorlage="3">
* <gutscheinart>K</gutscheinart>
* <bezeichnung>Allgemein</bezeichnung>
* <hinweis></hinweis>
* </gutschein>
* </gutscheine>
* </ergebnis>
*/
final class VoucherTemplatesParser extends AbstractParser
{
public function parse(Crawler $node): VoucherTemplates
{
$minAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinminbetrag'));
$maxAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinmaxbetrag'));
if (null === $minAmount || '' === trim($minAmount) || null === $maxAmount || '' === trim($maxAmount)) {
throw new \InvalidArgumentException('Missing voucher amount bounds in voucher templates response.');
}
$templates = [];
$node->filterXPath('//gutscheine/gutschein')->each(function (Crawler $templateNode) use (&$templates): void {
$note = $this->getStringOrNullValue($templateNode->filterXPath('//hinweis'));
$templates[] = new VoucherTemplate(
id: (int) $this->getRequiredAttrValue($templateNode, 'idgutscheinvorlage', 'voucher template'),
type: trim($this->getStringOrNullValue($templateNode->filterXPath('//gutscheinart')) ?? ''),
label: trim($this->getStringOrNullValue($templateNode->filterXPath('//bezeichnung')) ?? ''),
note: null !== $note && '' !== trim($note) ? trim($note) : null,
);
});
return new VoucherTemplates(
// whole euros: the flow only offers whole-euro amounts, so the bounds are kept as such
minAmount: (int) ceil($this->stringToFloat($minAmount)),
maxAmount: (int) floor($this->stringToFloat($maxAmount)),
templates: $templates,
);
}
}
+6 -4
View File
@@ -4,7 +4,8 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\BusProNet\ApiClient; use App\BusProNet\Client\BookingClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor; use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking; use App\BusProNet\Model\Booking;
@@ -78,7 +79,8 @@ class BookingRepairDraftCommand extends Command
public function __construct( public function __construct(
private readonly BookingEditDraftRepository $draftRepository, private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ApiClient $apiClient, private readonly BookingClient $bookingClient,
private readonly TravelClient $travelClient,
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly TravelDataProvider $travelDataProvider, private readonly TravelDataProvider $travelDataProvider,
private readonly BookingDataProcessor $bookingDataProcessor, private readonly BookingDataProcessor $bookingDataProcessor,
@@ -214,7 +216,7 @@ class BookingRepairDraftCommand extends Command
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
{ {
$result = $this->apiClient->getBooking( $result = $this->bookingClient->getBooking(
(string) $user->getEmail(), (string) $user->getEmail(),
$this->crypt->decrypt((string) $user->getPassword()), $this->crypt->decrypt((string) $user->getPassword()),
$bookingId, $bookingId,
@@ -300,7 +302,7 @@ class BookingRepairDraftCommand extends Command
} }
} }
$live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id); $live = $this->travelClient->getAvailabilitiesExtended((int) $travel->id);
if ($live instanceof Notification) { if ($live instanceof Notification) {
$io->warning(sprintf( $io->warning(sprintf(
'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.', 'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.',
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\BusProNet\ApiClient; use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
@@ -29,7 +29,7 @@ class BpnFetchTravelCommand extends Command
]; ];
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly TravelClient $travelClient,
private readonly FilesystemOperator $xmlExport, private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
@@ -56,7 +56,7 @@ class BpnFetchTravelCommand extends Command
$io->text(sprintf('Fetching travel data for product ID %d...', $travelId)); $io->text(sprintf('Fetching travel data for product ID %d...', $travelId));
try { try {
$xml = $this->apiClient->getTravelDataXml($travelId); $xml = $this->travelClient->getTravelDataXml($travelId);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$io->error(sprintf('API request failed: %s', $e->getMessage())); $io->error(sprintf('API request failed: %s', $e->getMessage()));
$this->logger->error('Failed to fetch travel data from API', [ $this->logger->error('Failed to fetch travel data from API', [
+3 -3
View File
@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Transport\XmlChannel;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@@ -21,7 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class BpnReplayCommand extends Command class BpnReplayCommand extends Command
{ {
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly XmlChannel $channel,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -77,7 +77,7 @@ class BpnReplayCommand extends Command
$io->info('Sending request to BPN API...'); $io->info('Sending request to BPN API...');
try { try {
$response = $this->apiClient->sendRawXml($xml); $response = $this->channel->replay($xml);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$io->error(sprintf('API request failed: %s', $e->getMessage())); $io->error(sprintf('API request failed: %s', $e->getMessage()));
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Removes local accounts that carry nothing worth keeping.
*
* Nothing here destroys customer data: BusPro owns the person, the bookings and the roles, and
* BpnAuthenticator rebuilds the local row on the next login (`findOneBy(email) ?? new User()`),
* re-deriving the roles, the hotel codes and the profile completeness from the CRM as it goes.
* An account is a cache of what BusPro says, so dropping one costs its owner nothing.
*
* One thing is not rebuilt, and it is the reason every removal gets a log line of its own: the
* uuid, minted in the constructor and exported as the OAuth2 `sub` claim (see UserinfoController).
* A returning account gets a fresh one, so the old subject cannot be tied to the new row by
* anything except that log entry. The SSO clients that gate on a role — E&P Team, E&P Hub — never
* see these accounts anyway, since the roles they require are exactly the ones excluded below.
*
* An account is removed when all of the following hold:
*
* - it holds no effective role beyond ROLE_CUSTOMER, that role being the fallback for an account
* with no privileges at all (see Role::withCustomerFallback()). An account left with no roles
* at all is the same state, spelled the way it was before the fallback landed;
* - it carries no pending nomination, which would otherwise disappear from /admin/user before
* anybody had ruled on it;
* - it holds no booking edit draft, and nothing in the Groups subsystem names it.
*
* --inactive-for adds a "has not signed in for N months" condition on top. It is off by default:
* a returning customer is recreated on sight, so age says nothing about whether a row may go.
*
* Deleting requires --force. The removal cannot be undone and the first run clears thousands of
* rows, so a bare invocation reports what it would do and stops. The guard is a flag rather than
* an $io->confirm() because the scheduler hands the command a StringInput, which reports itself
* as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag
* behaves the same way whether a person or cron is running it, and it makes the schedule entry
* say out loud that the task destroys data.
*/
#[AsCommand(
name: 'app:cleanup:users',
description: 'Removes accounts that hold no role, no draft and no group booking reference',
)]
class CleanupUsersCommand extends Command
{
/**
* How many accounts are removed between two flushes. The unit of work holds every scheduled
* removal until then, and the first run has thousands of rows to get through, so it is a
* memory bound rather than a policy one.
*/
private const BATCH_SIZE = 500;
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'inactive-for',
null,
InputOption::VALUE_REQUIRED,
'Only consider accounts without a login for this many months (default: no such limit)',
)
->addOption(
'limit',
null,
InputOption::VALUE_REQUIRED,
'Maximum number of accounts to delete in one run (default: no limit)',
)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting')
->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null;
$limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null;
$dryRun = (bool) $input->getOption('dry-run');
$force = (bool) $input->getOption('force');
if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) {
$io->error('--inactive-for and --limit have to be at least 1 when given.');
return Command::INVALID;
}
$inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null;
$candidates = array_values(array_filter(
$this->userRepository->findCleanupCandidates($inactiveSince),
static fn (User $user): bool => self::isDisposable($user),
));
// After the role filter, never before it: --limit caps the accounts actually deleted, and
// the candidate query cannot tell which ones those are.
if (null !== $limit) {
$candidates = \array_slice($candidates, 0, $limit);
}
if ([] === $candidates) {
$io->success('No accounts to remove.');
return Command::SUCCESS;
}
if (true === $dryRun || false === $force) {
$io->table(
['E-Mail', 'Letzter Login', 'Rollen'],
array_map(static fn (User $user): array => [
$user->getEmail(),
$user->getLastLoginAt()?->format('Y-m-d') ?? '-',
implode(', ', Role::assignedOnly($user->getRoles())) ?: '-',
], $candidates),
);
$io->note(sprintf('Would delete %d account(s).', \count($candidates)));
if (true === $dryRun) {
return Command::SUCCESS;
}
// Not a dry run, just an invocation that forgot to say so. Reported as a failure so a
// schedule entry missing --force turns into a mail rather than a task that quietly
// does nothing every week.
$io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.');
return Command::INVALID;
}
foreach ($candidates as $index => $user) {
// One line per account, before the removal rather than after: once the row is gone
// this is the only place left that records which account the uuid belonged to. Worth
// a log_entry row per deletion, including the several thousand the first run writes —
// they age out with the six-month log retention.
$this->logger->info('Deleting account without roles', [
'uuid' => $user->getUuid(),
'email' => $user->getEmail(),
'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM),
'roles' => Role::assignedOnly($user->getRoles()),
]);
$this->entityManager->remove($user);
if (0 === ($index + 1) % self::BATCH_SIZE) {
$this->entityManager->flush();
}
}
$this->entityManager->flush();
$count = \count($candidates);
$this->logger->info('Deleted accounts without roles', [
'count' => $count,
'inactiveSince' => $inactiveSince?->format('Y-m-d'),
]);
$io->success(sprintf('Deleted %d account(s).', $count));
return Command::SUCCESS;
}
/**
* Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a
* baseline, so "customer only" and "nothing at all" are the same state — the second being the
* spelling accounts kept that have not signed in since the fallback landed.
*
* Pending markers grant nothing, but they are a nomination waiting for an administrator, so an
* account carrying one stays until somebody has ruled on it.
*/
private static function isDisposable(User $user): bool
{
$roles = $user->getRoles();
return [] === Role::pendingOnly($roles)
&& [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]);
}
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Account; namespace App\Controller\Account;
use App\BusProNet\ApiClient; use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
@@ -39,7 +39,7 @@ class PersonalDataController extends AbstractController
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect'; public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
/** /**
* @param ApiClient $apiClient BusProNet API client for data operations * @param AccountClient $accountClient BusProNet account client
* @param Crypt $crypt Encryption service for password handling * @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation * @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
* @param ProfileCompletenessChecker $completenessChecker Profile validation service * @param ProfileCompletenessChecker $completenessChecker Profile validation service
@@ -48,7 +48,7 @@ class PersonalDataController extends AbstractController
* @param LoggerInterface $logger Logger for audit trails and debugging * @param LoggerInterface $logger Logger for audit trails and debugging
*/ */
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly AccountClient $accountClient,
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly BookingEditDataLoader $dataLoader, private readonly BookingEditDataLoader $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker, private readonly ProfileCompletenessChecker $completenessChecker,
@@ -100,7 +100,7 @@ class PersonalDataController extends AbstractController
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) { if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
try { try {
$updateResult = $this->apiClient->updatePersonalData($email, $password, $personalData); $updateResult = $this->accountClient->updatePersonalData($email, $password, $personalData);
if ($updateResult instanceof Notification) { if ($updateResult instanceof Notification) {
$this->logger->error('Personal data update rejected by BusProNet', [ $this->logger->error('Personal data update rejected by BusProNet', [
@@ -223,7 +223,7 @@ class PersonalDataController extends AbstractController
try { try {
$personalData = $this $personalData = $this
->apiClient ->accountClient
->getPersonalData($email, $password); ->getPersonalData($email, $password);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden'); $this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
@@ -12,6 +12,7 @@ use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository; use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -33,6 +34,7 @@ class EditController extends AbstractController
private readonly AdditionalServiceRepository $additionalServiceRepo, private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly UserRepository $userRepo, private readonly UserRepository $userRepo,
private readonly AccommodationBookingService $bookingService, private readonly AccommodationBookingService $bookingService,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
) { ) {
} }
@@ -128,10 +130,7 @@ class EditController extends AbstractController
// The submitted changes stay on the entity but are never flushed, so the form // The submitted changes stay on the entity but are never flushed, so the form
// comes back filled in with the error on it. Logging is safe here: the database // comes back filled in with the error on it. Logging is safe here: the database
// handler writes through DBAL, not the EntityManager. // handler writes through DBAL, not the EntityManager.
return $this->render('admin/accommodation_booking/edit.html.twig', [ return $this->renderForm($booking, $form);
'booking' => $booking,
'form' => $form,
]);
} }
// Editing never moves the booking on and never mails the customer: every status // Editing never moves the booking on and never mails the customer: every status
@@ -160,9 +159,22 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_admin_accommodationbooking_show', $parameters); return $this->redirectToRoute('app_admin_accommodationbooking_show', $parameters);
} }
return $this->renderForm($booking, $form);
}
/**
* The sidebar shows the stored price, not one computed from the submitted values: after a
* refused save the entity carries the unsaved changes, and the price there is labelled as the
* state of the last save.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function renderForm(AccommodationBooking $booking, FormInterface $form): Response
{
return $this->render('admin/accommodation_booking/edit.html.twig', [ return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking, 'booking' => $booking,
'form' => $form, 'form' => $form,
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
]); ]);
} }
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/** Changes the length and the lines of a split-off period; the main period absorbs the difference. */
#[IsGranted('ROLE_GROUPS_MANAGER')]
class EditPeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking-period/{id}/edit', name: 'app_admin_accommodationbooking_period_edit')]
public function index(AccommodationBookingPeriod $period, Request $request): Response
{
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
$dto = AccommodationBookingPeriodDto::fromPeriod($period);
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
'hx_post' => $request->getRequestUri(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$violations = $this->bookingService->updatePeriod($booking, $period, $dto);
foreach ($violations as $violation) {
$form->addError(new FormError((string) $violation->getMessage()));
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde aktualisiert.');
$this->logger->info('Updated a period of accommodation booking', [
'id' => $booking->getId(),
'periodId' => $period->getId(),
'nights' => $dto->nights,
]);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to update a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'periodId' => $period->getId(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
}
}
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
'booking' => $booking,
'form' => $form,
'title' => 'Zeitraum bearbeiten',
]);
}
}
@@ -21,12 +21,8 @@ class PdfController extends AbstractController
#[Route('/admin/accommodation-booking/{id}/pdf', name: 'app_admin_accommodationbooking_pdf')] #[Route('/admin/accommodation-booking/{id}/pdf', name: 'app_admin_accommodationbooking_pdf')]
public function index(AccommodationBooking $booking): Response public function index(AccommodationBooking $booking): Response
{ {
if (false === $booking->isConfirmed()) { // Offered in every status, so the office can check the document before it goes out
$this->addFlash('warning', 'Ein PDF kann nur für bestätigte Buchungen erstellt werden.'); // with the confirmation email; it labels itself Angebot or Buchung accordingly.
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
try { try {
return $this->pdfGenerator->createDownloadResponse($booking); return $this->pdfGenerator->createDownloadResponse($booking);
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use Symfony\Component\HttpFoundation\Request;
/**
* For the modals opened from the edit form: they save on their own and send the browser back to
* it, rebuilt with the new price.
*/
trait RedirectsToEditFormTrait
{
/**
* The return url is forwarded still encoded, the way return_url() handed it over, so that
* the edit page keeps leading back to the list the booking was opened from.
*/
private function editUrl(AccommodationBooking $booking, Request $request): string
{
$parameters = ['id' => $booking->getId()];
$returnUrl = $request->query->getString('r');
if ('' !== $returnUrl) {
$parameters['r'] = $returnUrl;
}
return $this->generateUrl('app_admin_accommodationbooking_edit', $parameters);
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use App\Exception\ImplausibleBookingPriceException;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/** Gives a split-off period's nights back to the main period, which reprices the booking. */
#[IsGranted('ROLE_GROUPS_MANAGER')]
class RemovePeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking-period/{id}/remove', name: 'app_admin_accommodationbooking_period_remove')]
public function index(AccommodationBookingPeriod $period, Request $request): Response
{
$booking = $period->getBooking() ?? throw $this->createNotFoundException();
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('remove_accommodation_booking_period_'.$period->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$periodId = $period->getId();
try {
$violations = $this->bookingService->removePeriod($booking, $period);
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to remove a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
'periodId' => $periodId,
]);
$this->addFlash('error', ImplausibleBookingPriceException::FORM_ERROR_MESSAGE);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
// Removing only ever lengthens the main period, so this is reached only when the
// stay's dates were already inconsistent with the periods.
foreach ($violations as $violation) {
$this->addFlash('error', (string) $violation->getMessage());
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde entfernt.');
$this->logger->info('Removed a period of accommodation booking', [
'id' => $booking->getId(),
'periodId' => $periodId,
]);
}
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
return $this->render('admin/accommodation_booking/modal_remove_period.html.twig', [
'booking' => $booking,
'period' => $period,
'csrf_token_id' => 'remove_accommodation_booking_period_'.$period->getId(),
]);
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Exception\ImplausibleBookingPriceException;
use App\Form\Admin\Groups\AccommodationBookingPeriodType;
use App\Form\Model\AccommodationBookingPeriodDto;
use App\Form\Model\AccommodationBookingPeriodLineDto;
use App\Htmx\HxTrait;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Splits nights off the start or the end of the stay, for a part of the group that arrives
* early or leaves late and is quoted as free-form lines instead of the booking's parameters.
* Each side can be split once; the period can be resized afterwards through its edit modal.
*/
#[IsGranted('ROLE_GROUPS_MANAGER')]
class SplitPeriodController extends AbstractController
{
use HxTrait;
use RedirectsToEditFormTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route(
'/admin/accommodation-booking/{id}/period/split/{position}',
name: 'app_admin_accommodationbooking_period_split',
requirements: ['position' => 'leading|trailing'],
)]
public function index(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, Request $request): Response
{
// One period per side. The edit page only offers the free sides, so this catches a page
// that was open while somebody else split the same side.
if (!$booking->canSplitOffPeriod($position)) {
$this->addFlash('error', AccommodationBookingPeriodPosition::Leading === $position
? 'Am Anfang wurde bereits ein Zeitraum abgeteilt.'
: 'Am Ende wurde bereits ein Zeitraum abgeteilt.');
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
$dto = new AccommodationBookingPeriodDto(1, [new AccommodationBookingPeriodLineDto()]);
$form = $this->createForm(AccommodationBookingPeriodType::class, $dto, [
'currency' => $booking->getPricingCurrency() ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR',
'hx_post' => $request->getRequestUri(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$violations = $this->bookingService->splitOffPeriod($booking, $position, $dto);
foreach ($violations as $violation) {
$form->addError(new FormError((string) $violation->getMessage()));
}
if (0 === count($violations)) {
$this->addFlash('success', 'Der Zeitraum wurde abgeteilt.');
$this->logger->info('Split off a period of accommodation booking', [
'id' => $booking->getId(),
'position' => $position->value,
'nights' => $dto->nights,
]);
return $this->htmxRedirect($request, $this->editUrl($booking, $request));
}
} catch (ImplausibleBookingPriceException $e) {
$this->logger->error('Refused to split off a period over an implausible accommodation booking total', [
'exception' => $e,
'id' => $booking->getId(),
]);
$form->addError(new FormError(ImplausibleBookingPriceException::FORM_ERROR_MESSAGE));
}
}
return $this->renderModal($booking, $position, $form);
}
/** @param FormInterface<AccommodationBookingPeriodDto> $form */
private function renderModal(AccommodationBooking $booking, AccommodationBookingPeriodPosition $position, FormInterface $form): Response
{
return $this->render('admin/accommodation_booking/modal_period.html.twig', [
'booking' => $booking,
'form' => $form,
'title' => AccommodationBookingPeriodPosition::Leading === $position
? 'Zeitraum am Anfang abteilen'
: 'Zeitraum am Ende abteilen',
]);
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Api; namespace App\Controller\Api;
use App\BusProNet\ApiClient; use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\ContactFormSubmission; use App\BusProNet\Model\ContactFormSubmission;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
@@ -21,7 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_API')] #[IsGranted('ROLE_OAUTH2_API')]
class ContactFormController extends AbstractController class ContactFormController extends AbstractController
{ {
public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger) public function __construct(private readonly AccountClient $accountClient, private readonly LoggerInterface $logger)
{ {
} }
@@ -31,7 +31,7 @@ class ContactFormController extends AbstractController
$personalData = PersonalData::fromContactFormSubmission($dto); $personalData = PersonalData::fromContactFormSubmission($dto);
try { try {
$result = $this->apiClient->createAddress($personalData); $result = $this->accountClient->createAddress($personalData);
if ($result instanceof Notification) { if ($result instanceof Notification) {
$this->logger->info('Unable to submit contact form', [ $this->logger->info('Unable to submit contact form', [
@@ -2,7 +2,7 @@
namespace App\Controller\Api; namespace App\Controller\Api;
use App\BusProNet\ApiClient; use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Entity\User; use App\Entity\User;
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_PROFILE')] #[IsGranted('ROLE_OAUTH2_PROFILE')]
class CrmAttributeController extends AbstractController class CrmAttributeController extends AbstractController
{ {
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt) public function __construct(private readonly AccountClient $accountClient, private readonly Crypt $crypt)
{ {
} }
@@ -30,7 +30,7 @@ class CrmAttributeController extends AbstractController
$password = $this->crypt->decrypt($user->getPassword()); $password = $this->crypt->decrypt($user->getPassword());
try { try {
$data = $this->apiClient->getCrmAttributes($email, $password); $data = $this->accountClient->getCrmAttributes($email, $password);
if ($data instanceof Notification) { if ($data instanceof Notification) {
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST); return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
+25 -3
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Api; namespace App\Controller\Api;
use App\Model\TravelExportQuery; use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Service\TravelExportBuilder; use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -29,6 +31,7 @@ class ExportController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly TravelExportBuilder $travelExportBuilder, private readonly TravelExportBuilder $travelExportBuilder,
private readonly ProductExportBuilder $productExportBuilder,
) { ) {
} }
@@ -41,13 +44,31 @@ class ExportController extends AbstractController
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])] #[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
public function travels( public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(), ExportQuery $query = new ExportQuery(),
): JsonResponse { ): JsonResponse {
// Deliberately not wrapped in a catch that answers 200 with an error body, the way // Deliberately not wrapped in a catch that answers 200 with an error body, the way
// TravelController::hotelAvailability() does: the consumer's retry logic keys on // TravelController::hotelAvailability() does: the consumer's retry logic keys on
// status codes, so a failure here has to surface as a 5xx. // status codes, so a failure here has to surface as a 5xx.
$export = $this->travelExportBuilder->build(); return $this->paginate($this->travelExportBuilder->build(), $query);
}
/**
* Every travel date x hotel pair with its whole record, unfiltered, paginated.
*
* The website's product import. Same grain, envelope and failure behaviour as travels();
* the rows carry rooms, prices, services and selection groups instead of the
* disposition subset, and nothing is dropped for the consumer.
*/
#[Route(path: '/products', name: 'api_exports_products', methods: ['GET'])]
public function products(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ExportQuery $query = new ExportQuery(),
): JsonResponse {
return $this->paginate($this->productExportBuilder->build(), $query);
}
private function paginate(Export $export, ExportQuery $query): JsonResponse
{
$total = count($export->rows); $total = count($export->rows);
return $this->json([ return $this->json([
@@ -59,6 +80,7 @@ class ExportController extends AbstractController
'totalPages' => (int) ceil($total / $query->perPage), 'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped, 'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData, 'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
'generatedAt' => $export->generatedAt->format('c'), 'generatedAt' => $export->generatedAt->format('c'),
], ],
]); ]);
+7 -14
View File
@@ -2,6 +2,7 @@
namespace App\Controller\Api; namespace App\Controller\Api;
use App\BusProNet\Model\XmlExportInfo;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@@ -16,23 +17,15 @@ class LastUpdateController extends AbstractController
{ {
} }
/**
* A missing or unreadable marker answers `{"timestamp": null}`, never a 500: consumers
* poll this for change detection and treat null as "unknown".
*/
#[Route(path: '/last-update', name: 'api_last_update', methods: ['GET'])] #[Route(path: '/last-update', name: 'api_last_update', methods: ['GET'])]
public function index(): JsonResponse public function index(): JsonResponse
{ {
if (false === $this->xmlExport->has('uebertragung.info')) { $info = XmlExportInfo::fromStorage($this->xmlExport);
$lastUploadAt = null;
} else {
$file = $this->xmlExport->read('uebertragung.info');
$lines = explode("\n", $file);
$line = trim($lines[0]);
if (true === empty($line)) { return $this->json(['timestamp' => $info?->lastTransfer->format('Y-m-d H:i:s')]);
$lastUploadAt = null;
} else {
$lastUploadAt = \DateTime::createFromFormat('d.m.Y H:i:s', $line);
}
}
return $this->json(['timestamp' => $lastUploadAt->format('Y-m-d H:i:s')]);
} }
} }
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api; namespace App\Controller\Api;
use App\BusProNet\ApiClient; use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -17,7 +17,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class ProductController extends AbstractController class ProductController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly TravelClient $travelClient,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
) { ) {
} }
@@ -29,7 +29,7 @@ class ProductController extends AbstractController
$products = $this->cache->get('api_products', function (ItemInterface $item) { $products = $this->cache->get('api_products', function (ItemInterface $item) {
$item->expiresAfter(3600); // 1 hour cache $item->expiresAfter(3600); // 1 hour cache
return $this->apiClient->getProducts(); return $this->travelClient->getProducts();
}); });
return $this->json($products); return $this->json($products);
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api; namespace App\Controller\Api;
use App\BusProNet\ApiClient; use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DateCodeUtility; use App\BusProNet\Utility\DateCodeUtility;
@@ -20,7 +20,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class TravelController extends AbstractController class TravelController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly TravelClient $travelClient,
private readonly TravelDataProvider $travelDataService, private readonly TravelDataProvider $travelDataService,
) { ) {
} }
@@ -89,7 +89,7 @@ class TravelController extends AbstractController
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo, #[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo,
): JsonResponse { ): JsonResponse {
try { try {
$result = $this->apiClient->getHotelAvailability($dateId, $hotelId, $dateTo); $result = $this->travelClient->getHotelAvailability($dateId, $hotelId, $dateTo);
return $this->json($result); return $this->json($result);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api; namespace App\Controller\Api;
use App\BusProNet\ApiClient; use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_EMAIL')] #[IsGranted('ROLE_OAUTH2_EMAIL')]
class UserinfoController extends AbstractController class UserinfoController extends AbstractController
{ {
public function __construct(private readonly ApiClient $apiClient, private readonly Crypt $crypt) public function __construct(private readonly AccountClient $accountClient, private readonly Crypt $crypt)
{ {
} }
@@ -48,7 +48,7 @@ class UserinfoController extends AbstractController
$password = $this->crypt->decrypt($user->getPassword()); $password = $this->crypt->decrypt($user->getPassword());
try { try {
$data = $this->apiClient->getPersonalData($email, $password); $data = $this->accountClient->getPersonalData($email, $password);
if ($data instanceof Notification) { if ($data instanceof Notification) {
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST); return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
@@ -11,9 +11,11 @@ use App\Exception\NoRoomsAvailableException;
use App\Exception\TravelNotFoundException; use App\Exception\TravelNotFoundException;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Model\BookingQueryParams; use App\Model\BookingQueryParams;
use App\Model\PurchaseFlow;
use App\Service\BookingConfigurator; use App\Service\BookingConfigurator;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler; use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -36,6 +38,7 @@ class IndexController extends AbstractController
private readonly BookingSessionManager $bookingSessionService, private readonly BookingSessionManager $bookingSessionService,
private readonly AgencyLoader $agencyLoader, private readonly AgencyLoader $agencyLoader,
private readonly BookingSummaryAssembler $summaryDataService, private readonly BookingSummaryAssembler $summaryDataService,
private readonly PurchaseFlowResolver $purchaseFlowResolver,
) { ) {
} }
@@ -103,6 +106,7 @@ class IndexController extends AbstractController
// Create fresh booking session with the provided parameters // Create fresh booking session with the provided parameters
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId); $bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
$this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Booking);
// Warm the CMS cache early so data is available on the login page // Warm the CMS cache early so data is available on the login page
$this->summaryDataService->getCmsDataForProduct( $this->summaryDataService->getCmsDataForProduct(
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create; namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient; use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\BookingResponse; use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
@@ -40,7 +40,7 @@ class Step3Controller extends AbstractBookingCreateController
private readonly BookingCreateContextFactory $createContextFactory, private readonly BookingCreateContextFactory $createContextFactory,
private readonly BookingPriceCalculator $priceCalculator, private readonly BookingPriceCalculator $priceCalculator,
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics, private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
private readonly ApiClient $apiClient, private readonly BookingClient $bookingClient,
private readonly ParticipantFormSupport $participantFormSupportService, private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@@ -76,7 +76,7 @@ class Step3Controller extends AbstractBookingCreateController
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto); $this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
// Validate booking data with API by submitting an inquiry booking // Validate booking data with API by submitting an inquiry booking
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); $inquiryResponse = $this->bookingClient->createBookingInquiry($bookingCreateDto);
// Surface any insurance corrections made during payload building (e.g. a // Surface any insurance corrections made during payload building (e.g. a
// stale price tier reassigned/cleared right before submission) // stale price tier reassigned/cleared right before submission)
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create; namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient; use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Entity\User; use App\Entity\User;
@@ -37,7 +37,7 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingConfigurator $bookingService, private readonly BookingConfigurator $bookingService,
private readonly BookingSessionManager $bookingSessionService, private readonly BookingSessionManager $bookingSessionService,
private readonly BookingCreateContextFactory $createContextFactory, private readonly BookingCreateContextFactory $createContextFactory,
private readonly ApiClient $apiClient, private readonly BookingClient $bookingClient,
private readonly CacheInterface $cache, private readonly CacheInterface $cache,
private readonly BookingNewsletterOptIn $bookingNewsletterOptIn, private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
private readonly ParticipantFormSupport $participantFormSupportService, private readonly ParticipantFormSupport $participantFormSupportService,
@@ -80,7 +80,7 @@ class Step4Controller extends AbstractBookingCreateController
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto); $this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
// Submit final booking (already validated in Step 3) // Submit final booking (already validated in Step 3)
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto); $bookingResponse = $this->bookingClient->createBooking($bookingCreateDto);
// Surface any insurance corrections made during payload building (e.g. a // Surface any insurance corrections made during payload building (e.g. a
// stale price tier reassigned/cleared right before submission) // stale price tier reassigned/cleared right before submission)
@@ -2,7 +2,7 @@
namespace App\Controller\Booking; namespace App\Controller\Booking;
use App\BusProNet\ApiClient; use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Entity\User; use App\Entity\User;
@@ -20,7 +20,7 @@ use function Symfony\Component\String\u;
class DownloadController extends AbstractController class DownloadController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly BookingClient $bookingClient,
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@@ -60,7 +60,7 @@ class DownloadController extends AbstractController
try { try {
$file = $this $file = $this
->apiClient ->bookingClient
->getDocuments($email, $password, $id, $type); ->getDocuments($email, $password, $id, $type);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$file = null; $file = null;
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Booking; namespace App\Controller\Booking;
use App\BusProNet\ApiClient; use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\BaseData; use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly BookingClient $bookingClient,
private readonly TravelLoader $travelDataLoader, private readonly TravelLoader $travelDataLoader,
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
@@ -36,7 +36,7 @@ class IndexController extends AbstractController
$password = $this->crypt->decrypt($user->getPassword()); $password = $this->crypt->decrypt($user->getPassword());
try { try {
$bookings = $this->apiClient->getBookings($email, $password); $bookings = $this->bookingClient->getBookings($email, $password);
} catch (ApiClientException $e) { } catch (ApiClientException $e) {
$this->addFlash('error', 'Buchungen nicht abrufbar'); $this->addFlash('error', 'Buchungen nicht abrufbar');
$bookings = new BaseData([]); $bookings = new BaseData([]);
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller; namespace App\Controller;
use App\BusProNet\ApiClient; use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification; use App\BusProNet\Model\Notification;
use App\Form\Model\RegistrationDto; use App\Form\Model\RegistrationDto;
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class RegistrationController extends AbstractController class RegistrationController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ApiClient $apiClient, private readonly AccountClient $accountClient,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
@@ -34,7 +34,7 @@ class RegistrationController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { try {
$response = $this->apiClient->register($registrationData); $response = $this->accountClient->register($registrationData);
if ($response instanceof Notification && true === $response->isError()) { if ($response instanceof Notification && true === $response->isError()) {
$this->addFlash('error', 'Möglicherweise bist du bereits registriert. Bitte setze dein Passwort zurück.'); $this->addFlash('error', 'Möglicherweise bist du bereits registriert. Bitte setze dein Passwort zurück.');
} else { } else {
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller; namespace App\Controller;
use App\BusProNet\ApiClient; use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\ApiClientException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\NotBlank;
class ResetPasswordController extends AbstractController class ResetPasswordController extends AbstractController
{ {
public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger) public function __construct(private readonly AccountClient $accountClient, private readonly LoggerInterface $logger)
{ {
} }
@@ -46,7 +46,7 @@ class ResetPasswordController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { try {
$this->apiClient->resetPassword($form->get('email')->getData()); $this->accountClient->resetPassword($form->get('email')->getData());
$this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts – vorausgesetzt, bei deiner Buchung wurde eine E-Mail-Adresse angegeben.'); $this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts – vorausgesetzt, bei deiner Buchung wurde eine E-Mail-Adresse angegeben.');
$this->logger->info('Initiated password reset', [ $this->logger->info('Initiated password reset', [
'email' => $form->get('email')->getData(), 'email' => $form->get('email')->getData(),
+25 -20
View File
@@ -3,8 +3,10 @@
namespace App\Controller; namespace App\Controller;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Model\PurchaseFlow;
use App\Service\BookingSessionManager; use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler; use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface; use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -23,31 +25,31 @@ class SecurityController extends AbstractController
BookingSessionManager $bookingSessionService, BookingSessionManager $bookingSessionService,
BookingSummaryAssembler $summaryDataService, BookingSummaryAssembler $summaryDataService,
ClientManagerInterface $clientManager, ClientManagerInterface $clientManager,
PurchaseFlowResolver $purchaseFlowResolver,
): Response { ): Response {
// flag this request in the session as external OAuth2 request if // flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful // applicable to immediately logout the current user after successful
// authentication (see App\EventListener\AuthorizationCodeListener). // authentication (see App\EventListener\AuthorizationCodeListener).
// Read before the booking flow is decided, because it overrules it. // Read before the purchase flow is decided, because it overrules it.
$session = $request->getSession(); $session = $request->getSession();
$targetPath = $session->get('_security.main.target_path'); $targetPath = $session->get('_security.main.target_path');
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize'); $isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
$session->set('_oauth2', $isOauth2); $session->set('_oauth2', $isOauth2);
// Check if this is a booking flow (BookingDto exists in session) // A purchase flow (booking or voucher) uses this page as its optional sign-in step
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE); $pendingFlow = $purchaseFlowResolver->resolvePending($request);
$hasPendingBooking = null !== $bookingDto;
// A pending authorization wins over a half-finished booking. Otherwise the target path // A pending authorization wins over a half-finished purchase. Otherwise the target path
// below is overwritten with booking step 1, the user is sent there after logging in, and // below is overwritten with the flow's first step, the user is sent there after logging in,
// the application that asked for the authorization never receives its code. // and the application that asked for the authorization never receives its code.
$isBookingFlow = $hasPendingBooking && false === $isOauth2; $purchaseFlow = false === $isOauth2 ? $pendingFlow : null;
// If authenticated and in booking flow, proceed to Step 1 // If authenticated and in a purchase flow, proceed to its first step
if (null !== $this->getUser() && true === $hasPendingBooking) { if (null !== $this->getUser() && null !== $pendingFlow) {
return $this->redirectToRoute('app_booking_create_step_1'); return $this->redirectToRoute($pendingFlow->firstStepRoute());
} }
// If authenticated but not in booking flow, redirect to dashboard // If authenticated but not in a purchase flow, redirect to dashboard
if (null !== $this->getUser()) { if (null !== $this->getUser()) {
return $this->redirectToRoute('app_account'); return $this->redirectToRoute('app_account');
} }
@@ -55,22 +57,25 @@ class SecurityController extends AbstractController
$error = $authenticationUtils->getLastAuthenticationError(); $error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername(); $lastUsername = $authenticationUtils->getLastUsername();
// For booking flow, set target path to Step 1 (after successful auth, redirect there) // For a purchase flow, continue with its first step after successful auth
if (true === $isBookingFlow) { if (null !== $purchaseFlow) {
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1')); $session->set('_security.main.target_path', $this->generateUrl($purchaseFlow->firstStepRoute()));
} }
// Render booking login template if in booking flow, the self-explaining one when another // Render the flow's login template if in a purchase flow, the self-explaining one when
// application sent them here to authorize it, otherwise standard login // another application sent them here to authorize it, otherwise standard login
$template = match (true) { $template = match (true) {
$isBookingFlow => 'booking/create/authenticate.html.twig', null !== $purchaseFlow => $purchaseFlow->authenticateTemplate(),
$isOauth2 => 'security/login_oauth2.html.twig', $isOauth2 => 'security/login_oauth2.html.twig',
default => 'security/login.html.twig', default => 'security/login.html.twig',
}; };
// Fetch CMS data for booking flow (uses cache warmed in IndexController) // Fetch travel and CMS data for the booking flow (uses cache warmed in IndexController)
$bookingDto = PurchaseFlow::Booking === $purchaseFlow
? $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE)
: null;
$cmsData = null; $cmsData = null;
if (true === $isBookingFlow) { if (null !== $bookingDto) {
$cmsData = $summaryDataService->getCmsDataForProduct( $cmsData = $summaryDataService->getCmsDataForProduct(
$bookingDto->travel->productCode, $bookingDto->travel->productCode,
$bookingDto->travel->hotel?->code $bookingDto->travel->hotel?->code
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Form\Model\VoucherPurchaseDto;
use App\Htmx\HxTrait;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherThumbnailResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Shared base for the steps of the voucher purchase flow.
*/
abstract class AbstractVoucherPurchaseController extends AbstractController
{
use HxTrait;
public const array STEP_ROUTES = [
1 => 'app_voucher_step_1',
2 => 'app_voucher_step_2',
3 => 'app_voucher_step_3',
];
public function __construct(
protected readonly VoucherPurchaseSessionManager $sessionManager,
protected readonly VoucherThumbnailResolver $thumbnailResolver,
) {
}
protected function createSessionExpiredResponse(Request $request): Response
{
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte starte den Gutscheinkauf neu.');
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
}
protected function validateStepAccess(VoucherPurchaseDto $dto, int $expectedStep): ?RedirectResponse
{
if ($expectedStep > $dto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToStep($dto->currentStep);
}
return null;
}
protected function redirectToStep(int $step): RedirectResponse
{
return $this->redirectToRoute(self::STEP_ROUTES[$step] ?? self::STEP_ROUTES[1]);
}
/**
* Context shared by every step template: the sidebar summary and the step pagination.
*
* @return array<string, mixed>
*/
protected function createStepContext(VoucherPurchaseDto $dto): array
{
return [
'voucher' => $dto,
'voucherThumbnail' => null !== $dto->template ? $this->thumbnailResolver->resolve($dto->template) : null,
'stepRoutes' => self::STEP_ROUTES,
];
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Exception\VoucherShopUnavailableException;
use App\Form\Model\VoucherPurchaseDto;
use App\Htmx\HxTrait;
use App\Model\PurchaseFlow;
use App\Service\PurchaseFlowResolver;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherTemplateProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
/**
* Entry, cancellation and error pages of the voucher purchase flow, which replaces the voucher
* shop hosted by BusProNet (maske_gutschein.php).
*/
class IndexController extends AbstractController
{
use HxTrait;
public function __construct(
private readonly VoucherPurchaseSessionManager $sessionManager,
private readonly VoucherTemplateProvider $templateProvider,
private readonly PurchaseFlowResolver $purchaseFlowResolver,
) {
}
/**
* Entry point: starts a purchase, or resumes the unfinished one, and sends the visitor to the
* login page (sign in or continue as guest).
*
* A GET with side effects, kept safe for speculative fetches by being idempotent: an existing
* draft is never reset, only its return URL is updated when one is given. Starting over goes through cancel, and
* a completed purchase clears the draft. /voucher is an English alias for links.
*/
#[Route('/gutschein', name: 'app_voucher', methods: ['GET'])]
#[Route('/voucher', name: 'app_voucher_alias', methods: ['GET'])]
public function index(Request $request, #[MapQueryParameter('r')] ?string $returnUrl = null): Response
{
$resumed = null !== $this->sessionManager->find($request);
if (!$resumed || null !== $returnUrl) {
$this->sessionManager->storeReturnUrl($request, $returnUrl);
}
if (!$resumed) {
try {
$templates = $this->templateProvider->getTemplates();
$paymentMethod = $this->templateProvider->getPaymentMethod();
} catch (VoucherShopUnavailableException) {
$this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->redirectToRoute('app_voucher_error');
}
$dto = new VoucherPurchaseDto();
$dto->minAmount = $templates->minAmount;
$dto->maxAmount = $templates->maxAmount;
$dto->paymentMethod = $paymentMethod;
if (1 === \count($templates->templates)) {
$dto->template = $templates->templates[0];
}
$this->sessionManager->save($request, $dto);
}
$this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Voucher);
return $this->redirectToRoute('app_login');
}
#[Route('/vouchers/cancel', name: 'app_voucher_cancel', methods: ['GET', 'POST'])]
public function cancel(Request $request): Response
{
if (Request::METHOD_POST !== $request->getMethod()) {
return $this->render('voucher/modal_cancel.html.twig');
}
$returnUrl = $this->sessionManager->getReturnUrl($request);
$this->sessionManager->clear($request);
// Without this, a later login would send the user back into the cancelled purchase
$request->getSession()->remove('_security.main.target_path');
if (null !== $this->getUser()) {
$this->addFlash('info', 'Gutscheinkauf abgebrochen.');
return $this->htmxRedirect($request, $this->generateUrl('app_account'));
}
return $this->htmxRedirect($request, $returnUrl);
}
#[Route('/vouchers/create/error', name: 'app_voucher_error', methods: ['GET'])]
public function error(Request $request): Response
{
return $this->render('booking/create/error.html.twig', [
'returnUrl' => $this->sessionManager->getReturnUrl($request),
'defaultErrorMessage' => 'Es ist ein Fehler beim Gutscheinkauf aufgetreten.',
]);
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\BusProNet\Client\VoucherClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Exception\VoucherSessionNotFoundException;
use App\Exception\VoucherShopUnavailableException;
use App\Form\Model\VoucherPurchaseDto;
use App\Form\VoucherPurchaseStep1Type;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherTemplateProvider;
use App\Service\VoucherThumbnailResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Step 1: design, von/für and value, with a PDF preview rendered by BusPro.
*/
class Step1Controller extends AbstractVoucherPurchaseController
{
public function __construct(
VoucherPurchaseSessionManager $sessionManager,
VoucherThumbnailResolver $thumbnailResolver,
private readonly VoucherTemplateProvider $templateProvider,
private readonly VoucherClient $voucherClient,
private readonly ValidatorInterface $validator,
private readonly LoggerInterface $logger,
) {
parent::__construct($sessionManager, $thumbnailResolver);
}
#[Route('/vouchers/create/design', name: 'app_voucher_step_1')]
public function index(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
try {
$form = $this->createStepForm($dto);
} catch (VoucherShopUnavailableException) {
return $this->createShopUnavailableResponse($request);
}
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
$dto->currentStep = 2;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_voucher_step_2');
}
return $this->renderStep($dto, $form);
}
/**
* Takes over the current input without validating it and refreshes the summary.
*/
#[Route('/vouchers/create/refresh', name: 'app_voucher_step_1_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
try {
$form = $this->createStepForm($dto, false);
} catch (VoucherShopUnavailableException) {
return $this->createShopUnavailableResponse($request);
}
$form->handleRequest($request);
$this->sessionManager->save($request, $dto);
return $this->htmxOobResponse('voucher/step_1.html.twig', ['voucher_summary'], [
...$this->createStepContext($dto),
'form' => $form->createView(),
]);
}
/**
* Takes over the current input like refresh() and opens the preview modal, whose frame loads
* the PDF from previewPdf().
*/
#[Route('/vouchers/create/preview', name: 'app_voucher_preview', methods: ['POST'])]
public function preview(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
try {
$form = $this->createStepForm($dto, false);
} catch (VoucherShopUnavailableException) {
return $this->createShopUnavailableResponse($request);
}
$form->handleRequest($request);
$this->sessionManager->save($request, $dto);
return $this->render('voucher/modal_preview.html.twig', [
'previewable' => $this->isPreviewable($dto),
// busts the browser cache of the frame, the URL is otherwise the same for every input
'previewVersion' => bin2hex(random_bytes(4)),
]);
}
#[Route('/vouchers/create/preview.pdf', name: 'app_voucher_preview_pdf', methods: ['GET'])]
public function previewPdf(Request $request): Response
{
try {
$dto = $this->sessionManager->get($request);
} catch (VoucherSessionNotFoundException) {
return $this->createPreviewErrorResponse('Deine Sitzung ist abgelaufen. Bitte starte den Gutscheinkauf neu.');
}
if (false === $this->isPreviewable($dto)) {
return $this->createPreviewErrorResponse('Für die Vorschau bitte zuerst Motiv, Von, Für und Gutscheinwert angeben.');
}
// Read-only from here on: release the session lock, so the page's other requests do not
// wait the seconds BusPro takes to render the PDF.
$request->getSession()->save();
try {
$preview = $this->voucherClient->getVoucherPreview($dto);
} catch (ApiClientException $e) {
$this->logger->error('Voucher preview failed', ['error' => $e->getMessage()]);
return $this->createPreviewErrorResponse('Die Vorschau ist gerade nicht verfügbar. Bitte versuche es später noch einmal.');
}
if ($preview instanceof Notification) {
$this->logger->warning('Voucher preview rejected', ['code' => $preview->code, 'message' => $preview->message]);
return $this->createPreviewErrorResponse($preview->message ?? 'Die Vorschau ist gerade nicht verfügbar.');
}
$response = new Response($preview->pdf, Response::HTTP_OK, ['Content-Type' => 'application/pdf']);
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
'gutschein-vorschau.pdf'
));
$response->setPrivate();
$response->headers->addCacheControlDirective('no-store');
return $response;
}
/**
* @return FormInterface<VoucherPurchaseDto>
*
* @throws VoucherShopUnavailableException
*/
private function createStepForm(VoucherPurchaseDto $dto, bool $validate = true): FormInterface
{
$templates = $this->templateProvider->getTemplates();
$options = [
'templates' => $templates->templates,
'min_amount' => $dto->minAmount,
'max_amount' => $dto->maxAmount,
];
if (false === $validate) {
$options['validation_groups'] = false;
}
return $this->createForm(VoucherPurchaseStep1Type::class, $dto, $options);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function renderStep(VoucherPurchaseDto $dto, FormInterface $form): Response
{
$templates = $this->templateProvider->getTemplates();
return $this->render('voucher/step_1.html.twig', [
...$this->createStepContext($dto),
'form' => $form,
'thumbnails' => $this->thumbnailResolver->resolveAll($templates->templates),
'previewable' => $this->isPreviewable($dto),
]);
}
/**
* The preview takes what step 1 accepts: BusPro refuses it without the names (604/605), and
* this keeps the button and the route to one rule, the step's own validation group.
*/
private function isPreviewable(VoucherPurchaseDto $dto): bool
{
return 0 === \count($this->validator->validate($dto, null, ['voucher_step_1']));
}
private function createShopUnavailableResponse(Request $request): Response
{
$this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
}
private function createPreviewErrorResponse(string $message): Response
{
return $this->render('voucher/preview_error.html.twig', ['message' => $message], new Response('', Response::HTTP_UNPROCESSABLE_ENTITY));
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Entity\User;
use App\Form\VoucherPurchaseStep2Type;
use App\Service\ParticipantDataPrefiller;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherThumbnailResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Step 2: the buyer (rechnungsempfänger), prefilled for signed-in accounts. Payment is fixed to
* transfer.
*/
class Step2Controller extends AbstractVoucherPurchaseController
{
public function __construct(
VoucherPurchaseSessionManager $sessionManager,
VoucherThumbnailResolver $thumbnailResolver,
private readonly ParticipantDataPrefiller $personalDataPrefiller,
) {
parent::__construct($sessionManager, $thumbnailResolver);
}
#[Route('/vouchers/create/buyer', name: 'app_voucher_step_2')]
public function index(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
if (null !== $redirect = $this->validateStepAccess($dto, 2)) {
return $redirect;
}
$user = $this->getUser();
if ($user instanceof User && false === $dto->hasBuyerData()) {
$personalData = $this->personalDataPrefiller->fetchPersonalData($user);
if (null !== $personalData) {
$dto->prefillBuyer($personalData, $user->getEmail());
$this->sessionManager->save($request, $dto);
}
}
$form = $this->createForm(VoucherPurchaseStep2Type::class, $dto);
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
$dto->currentStep = 3;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_voucher_step_3');
}
return $this->render('voucher/step_2.html.twig', [
...$this->createStepContext($dto),
'form' => $form,
]);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\BusProNet\Client\VoucherClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Form\Model\VoucherPurchaseDto;
use App\Form\VoucherPurchaseStep3Type;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherSaleRejectionMapper;
use App\Service\VoucherThumbnailResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Step 3: overview, the two required confirmations and the purchase.
*
* Every visit runs GUTSCHEINBUCHUNG as a dry run (buchungsart Anfrage) first, which applies BusPro's
* full validation without issuing anything, and the buy button is only offered when it passes. The
* purchase sends the same payload with buchungsart Buchung.
*
* Every HINWEIS is a rejection on this path, 650 included, so Notification::isError() is not used.
*/
class Step3Controller extends AbstractVoucherPurchaseController
{
public function __construct(
VoucherPurchaseSessionManager $sessionManager,
VoucherThumbnailResolver $thumbnailResolver,
private readonly VoucherClient $voucherClient,
private readonly VoucherSaleRejectionMapper $rejectionMapper,
private readonly LoggerInterface $logger,
) {
parent::__construct($sessionManager, $thumbnailResolver);
}
#[Route('/vouchers/create/confirmation', name: 'app_voucher_step_3')]
public function index(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
// also the second of two submits: the first one cleared the session before redirecting
return $this->createSessionExpiredResponse($request);
}
if (null !== $redirect = $this->validateStepAccess($dto, 3)) {
return $redirect;
}
$form = $this->createForm(VoucherPurchaseStep3Type::class, $dto, [
'amount' => $dto->amount ?? 0,
]);
if (false === $request->isMethod(Request::METHOD_POST)) {
$dto->termsAccepted = false;
$dto->purchaseConfirmed = false;
return $this->runDryRun($request, $dto, $form);
}
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
return $this->purchase($request, $dto, $form);
}
return $this->renderStep($dto, $form, true);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function runDryRun(Request $request, VoucherPurchaseDto $dto, FormInterface $form): Response
{
try {
$response = $this->voucherClient->createVoucherSaleInquiry($dto);
} catch (\Exception $e) {
$this->logger->error('Voucher sale inquiry failed', ['exception' => $e->getMessage()]);
$this->addFlash('error', 'Der Gutscheinkauf konnte gerade nicht geprüft werden. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, false);
}
if ($response instanceof Notification) {
return $this->handleRejection($request, $dto, $form, $response, 'Voucher sale inquiry rejected');
}
if (false === $response->isInquiryValid()) {
$this->logger->error('Voucher sale inquiry unsuccessful', ['status' => $response->status]);
$this->addFlash('error', 'Der Gutschein kann gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, false);
}
return $this->renderStep($dto, $form, true);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function purchase(Request $request, VoucherPurchaseDto $dto, FormInterface $form): Response
{
try {
$response = $this->voucherClient->createVoucherSale($dto);
} catch (TimeoutException $e) {
// The purchase may have gone through on BusPro's side, so a blind retry could buy twice
$this->logger->error('Voucher sale timeout', ['exception' => $e->getMessage()]);
$this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Möglicherweise wurde der Gutschein trotzdem gekauft. Bitte wende dich an den Kundenservice, bevor du es erneut versuchst.');
return $this->renderStep($dto, $form, false);
} catch (\Exception $e) {
$this->logger->error('Voucher sale exception', ['exception' => $e->getMessage()]);
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, true);
}
if ($response instanceof Notification) {
return $this->handleRejection($request, $dto, $form, $response, 'Voucher sale rejected');
}
if (false === $response->isSaleSuccessful()) {
$this->logger->error('Voucher sale unsuccessful', ['status' => $response->status]);
$this->addFlash('error', 'Der Gutschein konnte nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, false);
}
// Cleared before the redirect, so a second submit finds no purchase and cannot buy twice
$this->sessionManager->clear($request);
// The redemption code stays out of the log and the page: the voucher is only paid for once
// the transfer arrives, and BusPro mails it then.
$this->logger->info('Voucher successfully sold.', [
'voucher_id' => $response->voucherId,
'voucher_number' => $response->voucherNumber,
'invoice_number' => $response->invoiceNumber,
'template_id' => $dto->template?->id,
'amount' => $dto->amount,
]);
$this->addFlash('voucher_number', $response->voucherNumber);
$this->addFlash('voucher_invoice_number', $response->invoiceNumber);
$this->addFlash('voucher_total', $response->price ?? (float) $dto->amount);
$this->addFlash('voucher_label', $dto->template?->label);
$this->addFlash('voucher_email', $response->email ?? $dto->email);
return $this->redirectToRoute('app_voucher_success');
}
/**
* Sends the visitor to the step that owns the complaint, or keeps them here without a buy
* button when the complaint is about nothing they can change.
*
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function handleRejection(
Request $request,
VoucherPurchaseDto $dto,
FormInterface $form,
Notification $notification,
string $logMessage,
): Response {
$step = $this->rejectionMapper->stepFor($notification);
$this->logger->warning($logMessage, [
'code' => $notification->code,
'message' => $notification->message,
'step' => $step,
]);
$this->addFlash('error', $notification->message ?? 'Der Gutschein kann so nicht gekauft werden.');
if (VoucherSaleRejectionMapper::STEP_OVERVIEW === $step) {
return $this->renderStep($dto, $form, false);
}
$dto->currentStep = $step;
$this->sessionManager->save($request, $dto);
return $this->redirectToStep($step);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function renderStep(VoucherPurchaseDto $dto, FormInterface $form, bool $purchasable): Response
{
return $this->render('voucher/step_3.html.twig', [
...$this->createStepContext($dto),
'form' => $form,
'purchasable' => $purchasable,
]);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Service\VoucherPurchaseSessionManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* The page after a completed voucher purchase. Its data comes as flashes, so a reload or a direct
* visit falls back to the return URL.
*/
class SuccessController extends AbstractController
{
public function __construct(
private readonly VoucherPurchaseSessionManager $sessionManager,
) {
}
#[Route('/vouchers/create/success', name: 'app_voucher_success')]
public function success(Request $request): Response
{
$returnUrl = $this->sessionManager->getReturnUrl($request);
$session = $request->getSession();
if (!$session instanceof FlashBagAwareSessionInterface) {
return $this->redirect($returnUrl);
}
$flashBag = $session->getFlashBag();
$voucherNumber = $flashBag->get('voucher_number')[0] ?? null;
$invoiceNumber = $flashBag->get('voucher_invoice_number')[0] ?? null;
$total = $flashBag->get('voucher_total')[0] ?? null;
$label = $flashBag->get('voucher_label')[0] ?? null;
$email = $flashBag->get('voucher_email')[0] ?? null;
// the total is always set on a completed purchase, the voucher number only if BusPro sends it
if (null === $total) {
return $this->redirect($returnUrl);
}
return $this->render('voucher/success.html.twig', [
'voucherNumber' => $voucherNumber,
'invoiceNumber' => $invoiceNumber,
'total' => $total,
'label' => $label,
'email' => $email,
'returnUrl' => $returnUrl,
]);
}
}
+191 -1
View File
@@ -10,10 +10,13 @@ use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface; use App\Entity\TimestampableEntityInterface;
use App\Entity\User; use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin; use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType; use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException; use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AccommodationBookingRepository; use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
@@ -195,9 +198,21 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\JoinColumn(onDelete: 'SET NULL')] #[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?User $managedBy = null; private ?User $managedBy = null;
/**
* Nights split off the start or the end of the stay and quoted as free-form lines, at most
* one on each side. dateFrom and dateTo keep spanning the whole stay; the booking's own
* parameters price only the main period left between the leading and the trailing period.
*
* @var Collection<int, AccommodationBookingPeriod>
*/
#[ORM\OneToMany(targetEntity: AccommodationBookingPeriod::class, mappedBy: 'booking', cascade: ['persist'], orphanRemoval: true)]
#[ORM\OrderBy(['dateFrom' => 'ASC'])]
private Collection $periods;
public function __construct() public function __construct()
{ {
$this->uuid = Uuid::v4()->toRfc4122(); $this->uuid = Uuid::v4()->toRfc4122();
$this->periods = new ArrayCollection();
} }
/** /**
@@ -211,10 +226,17 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
* The price snapshot, the service snapshots and all four discounts are deliberately carried * The price snapshot, the service snapshots and all four discounts are deliberately carried
* over untouched. A copy is made to vary an offer that was already quoted, so it has to show * over untouched. A copy is made to vary an offer that was already quoted, so it has to show
* the same numbers as the record it came from rather than a re-quote at today's catalog * the same numbers as the record it came from rather than a re-quote at today's catalog
* prices; saving it through the edit form recomputes it. * prices; saving it through the edit form recomputes it. The split-off periods belong to
* that quote and are copied along with it.
*/ */
public function __clone() public function __clone()
{ {
$periods = $this->periods->toArray();
$this->periods = new ArrayCollection();
foreach ($periods as $period) {
$this->addPeriod(clone $period);
}
$this->id = null; $this->id = null;
$this->uuid = Uuid::v4()->toRfc4122(); $this->uuid = Uuid::v4()->toRfc4122();
$this->status = AccommodationBookingStatus::Draft; $this->status = AccommodationBookingStatus::Draft;
@@ -302,6 +324,174 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
} }
} }
/**
* At most one period on each side, attached to its edge of the stay, and at least one night
* left for the main period in between. A change to the stay's dates that no longer fits the
* periods is reported on that date rather than silently moving the periods along.
*/
#[Assert\Callback]
public function validatePeriods(ExecutionContextInterface $context): void
{
if (null === $this->dateFrom || null === $this->dateTo || $this->periods->isEmpty()) {
return;
}
foreach (AccommodationBookingPeriodPosition::cases() as $position) {
if (count($this->periodsAt($position)) > 1) {
$context->buildViolation('Es kann nur einen Zeitraum am Anfang und einen am Ende geben.')
->addViolation();
return;
}
}
$leading = $this->getLeadingPeriod();
if (null !== $leading && (!self::sameDay($leading->getDateFrom(), $this->dateFrom) || $leading->getNights() < 1)) {
$context->buildViolation('Der Zeitraum am Anfang passt nicht mehr zur Anreise — bitte Anreise oder Zeitraum prüfen.')
->atPath('dateFrom')
->addViolation();
return;
}
$trailing = $this->getTrailingPeriod();
if (null !== $trailing && (!self::sameDay($trailing->getDateTo(), $this->dateTo) || $trailing->getNights() < 1)) {
$context->buildViolation('Der Zeitraum am Ende passt nicht mehr zur Abreise — bitte Abreise oder Zeitraum prüfen.')
->atPath('dateTo')
->addViolation();
return;
}
if ($this->getMainNights() < 1) {
$context->buildViolation('Nach dem Abteilen der Zeiträume muss mindestens eine Nacht für die Gruppe bleiben.')
->atPath('dateTo')
->addViolation();
}
}
/** @return Collection<int, AccommodationBookingPeriod> */
public function getPeriods(): Collection
{
return $this->periods;
}
public function addPeriod(AccommodationBookingPeriod $period): self
{
if (!$this->periods->contains($period)) {
$this->periods->add($period);
$period->setBooking($this);
}
return $this;
}
public function canSplitOffPeriod(AccommodationBookingPeriodPosition $position): bool
{
return [] === $this->periodsAt($position);
}
/** Splits $nights off the main period, at the edge of the stay that $position names. */
public function splitOffPeriod(AccommodationBookingPeriodPosition $position, int $nights): AccommodationBookingPeriod
{
if (null === $this->dateFrom || null === $this->dateTo) {
throw new \LogicException('A period can only be split off a booking with dates.');
}
if (!$this->canSplitOffPeriod($position)) {
throw new \LogicException(sprintf('The booking already has a %s period.', $position->value));
}
$period = new AccommodationBookingPeriod($position, $this->dateFrom, $this->dateTo);
$this->addPeriod($period);
$this->resizePeriod($period, $nights);
return $period;
}
/** Gives $period a new length, keeping it at its edge of the stay; the main period absorbs the difference. */
public function resizePeriod(AccommodationBookingPeriod $period, int $nights): self
{
if (null === $this->dateFrom || null === $this->dateTo) {
return $this;
}
$period->isLeading()
? $period->setDates($this->dateFrom, $this->dateFrom->modify(sprintf('+%d days', $nights)))
: $period->setDates($this->dateTo->modify(sprintf('-%d days', $nights)), $this->dateTo);
return $this;
}
/** Its nights go back to the main period. */
public function removePeriod(AccommodationBookingPeriod $period): self
{
if ($this->periods->removeElement($period) && $period->getBooking() === $this) {
$period->setBooking(null);
}
return $this;
}
public function getLeadingPeriod(): ?AccommodationBookingPeriod
{
return $this->periodsAt(AccommodationBookingPeriodPosition::Leading)[0] ?? null;
}
public function getTrailingPeriod(): ?AccommodationBookingPeriod
{
return $this->periodsAt(AccommodationBookingPeriodPosition::Trailing)[0] ?? null;
}
/** Arrival of the group the booking's own parameters price: the stay's, unless a period leads it. */
public function getMainDateFrom(): ?\DateTimeImmutable
{
return $this->getLeadingPeriod()?->getDateTo() ?? $this->dateFrom;
}
/** Departure of the group the booking's own parameters price: the stay's, unless a period trails it. */
public function getMainDateTo(): ?\DateTimeImmutable
{
return $this->getTrailingPeriod()?->getDateFrom() ?? $this->dateTo;
}
public function getMainNights(): int
{
$from = $this->getMainDateFrom();
$to = $this->getMainDateTo();
if (null === $from || null === $to || $from >= $to) {
return 0;
}
return $from->diff($to)->days;
}
/** In minor units, the sum of every line of every split-off period. */
public function getPeriodsTotal(): int
{
$total = 0;
foreach ($this->periods as $period) {
$total += $period->getTotal();
}
return $total;
}
/** @return list<AccommodationBookingPeriod> */
private function periodsAt(AccommodationBookingPeriodPosition $position): array
{
return array_values(array_filter(
$this->periods->toArray(),
fn (AccommodationBookingPeriod $p) => $p->getPosition() === $position,
));
}
private static function sameDay(\DateTimeImmutable $a, \DateTimeImmutable $b): bool
{
return $a->format('Y-m-d') === $b->format('Y-m-d');
}
public function getPaxCount(): int public function getPaxCount(): int
{ {
return $this->paxCount; return $this->paxCount;
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Entity\Groups;
use App\Entity\TimestampableEntityInterface;
use App\Entity\TimestampableParentAwareInterface;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* A stretch of nights split off the start or the end of a stay, such as the day an advance team
* arrives before the group. It carries no pax, board or services of its own and is never priced
* from the catalog: the office quotes it as free-form lines, which are added to the booking's
* total and fall under its fixed total discount. The booking's own parameters price only what
* remains between the leading and the trailing periods.
*/
#[ORM\Entity]
class AccommodationBookingPeriod implements TimestampableParentAwareInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'periods')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?AccommodationBooking $booking = null;
#[ORM\Column(length: 10, enumType: AccommodationBookingPeriodPosition::class)]
private AccommodationBookingPeriodPosition $position;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private \DateTimeImmutable $dateFrom;
// Exclusive, like the booking's: the departure day, not the last night.
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private \DateTimeImmutable $dateTo;
/** What the price breakdown calls the period, e.g. "Vorausteam"; without one it shows the dates alone. */
#[ORM\Column(length: 255, nullable: true)]
private ?string $label = null;
/**
* Amounts in minor units of the booking's pricing currency; a line has no currency of its own.
*
* The column is not called `lines`: that is a reserved word in MariaDB, and the ORM does
* not quote column names in the statements it writes.
*
* @var list<array{label: string, amount: int}>
*/
#[ORM\Column(name: 'price_lines', type: Types::JSON)]
private array $lines = [];
public function __construct(AccommodationBookingPeriodPosition $position, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo)
{
$this->position = $position;
$this->dateFrom = $dateFrom;
$this->dateTo = $dateTo;
}
public function __clone()
{
$this->id = null;
$this->booking = null;
}
public function getId(): ?int
{
return $this->id;
}
public function getBooking(): ?AccommodationBooking
{
return $this->booking;
}
public function setBooking(?AccommodationBooking $booking): self
{
$this->booking = $booking;
return $this;
}
public function getTimestampableParent(): ?TimestampableEntityInterface
{
return $this->booking;
}
public function getPosition(): AccommodationBookingPeriodPosition
{
return $this->position;
}
public function isLeading(): bool
{
return AccommodationBookingPeriodPosition::Leading === $this->position;
}
public function getDateFrom(): \DateTimeImmutable
{
return $this->dateFrom;
}
public function getDateTo(): \DateTimeImmutable
{
return $this->dateTo;
}
public function setDates(\DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo): self
{
$this->dateFrom = $dateFrom;
$this->dateTo = $dateTo;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): self
{
$this->label = $label;
return $this;
}
public function getNights(): int
{
return $this->dateFrom < $this->dateTo ? $this->dateFrom->diff($this->dateTo)->days : 0;
}
/** @return list<array{label: string, amount: int}> */
public function getLines(): array
{
return $this->lines;
}
/** @param list<array{label: string, amount: int}> $lines */
public function setLines(array $lines): self
{
$this->lines = $lines;
return $this;
}
public function getTotal(): int
{
return array_sum(array_column($this->lines, 'amount'));
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Enum\Groups;
/**
* Which edge of the stay a split-off period sits at. Periods only ever come off the start or
* the end, so the main period — the one the booking's own parameters price — always stays a
* single contiguous range between them.
*/
enum AccommodationBookingPeriodPosition: string
{
case Leading = 'leading';
case Trailing = 'trailing';
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* What a code typed into the booking voucher field is, as far as its format tells.
*
* Kauf vs. Kulanz is not visible in the code; BusPro tells them apart when the purchase code is checked.
*/
enum VoucherCodeKind: string
{
// Aktionsgutschein, checked with AKTIONSGUTSCHEIN and sent as the participant's <aktionscode>
case Promo = 'promo';
// Kauf- or Kulanzgutschein, checked with GUTSCHEINPRUEFUNGEINLOESUNG
case Purchase = 'purchase';
// Cannot be either, so it is rejected without asking BusPro
case Malformed = 'malformed';
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Thrown when a voucher purchase step is reached without a running purchase, either directly or
* because the purchase was completed or the session expired.
*/
class VoucherSessionNotFoundException extends HttpException
{
public function __construct(?\Throwable $previous = null)
{
parent::__construct(404, 'No valid voucher purchase session found. Please start a new purchase.', $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Exception;
/**
* Thrown when BusPro cannot tell us what is on sale (GUTSCHEINVORLAGEN) or how it can be paid
* (GUTSCHEINZAHLUNGSARTEN), so the voucher purchase cannot be offered.
*/
class VoucherShopUnavailableException extends \RuntimeException
{
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Form\Model\AccommodationBookingPeriodLineDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<AccommodationBookingPeriodLineDto> */
class AccommodationBookingPeriodLineType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('label', TextType::class, [
'label' => 'Bezeichnung',
])
->add('amount', MoneyType::class, [
'label' => 'Betrag',
'currency' => $options['currency'],
'divisor' => 100,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingPeriodLineDto::class,
'empty_data' => static fn () => new AccommodationBookingPeriodLineDto(),
'currency' => 'EUR',
]);
$resolver->setAllowedTypes('currency', 'string');
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Groups;
use App\Form\Model\AccommodationBookingPeriodDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<AccommodationBookingPeriodDto> */
class AccommodationBookingPeriodType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// "Überschrift" rather than "Bezeichnung", which the lines below use for their own field.
->add('label', TextType::class, [
'label' => 'Überschrift (optional)',
'required' => false,
'attr' => ['placeholder' => 'z. B. Vorausteam'],
])
->add('nights', IntegerType::class, [
'label' => 'Nächte',
'attr' => ['min' => 1],
])
->add('lines', CollectionType::class, [
'label' => 'Positionen',
'entry_type' => AccommodationBookingPeriodLineType::class,
'entry_options' => ['label' => false, 'currency' => $options['currency']],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingPeriodDto::class,
'currency' => 'EUR',
]);
$resolver->setAllowedTypes('currency', 'string');
}
}
+3 -2
View File
@@ -16,6 +16,7 @@ use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
@@ -334,8 +335,8 @@ class BookingParticipantType extends AbstractType
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class; $dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
$dynamicFields['insurance'] = ChoiceType::class; $dynamicFields['insurance'] = ChoiceType::class;
$dynamicFields['purchaseVoucherCode'] = TextType::class; $dynamicFields['voucherCode'] = TextType::class;
$dynamicFields['promoVoucherCode'] = TextType::class; $dynamicFields['removeVoucherCode'] = HiddenType::class;
} }
foreach ($dynamicFields as $fieldName => $fieldType) { foreach ($dynamicFields as $fieldName => $fieldType) {
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationBookingPeriod;
use Symfony\Component\Validator\Constraints as Assert;
/**
* What the split and edit modals submit for a split-off period. Whether the group keeps a
* night for itself depends on the booking, so that is left to the booking's own validation.
*/
class AccommodationBookingPeriodDto
{
/**
* @param array<int, AccommodationBookingPeriodLineDto> $lines keyed as the form submits them, so not necessarily a list
*/
public function __construct(
#[Assert\NotNull(message: 'Bitte die Anzahl der Nächte angeben.')]
#[Assert\Range(min: 1, max: AccommodationBooking::MAX_NIGHTS)]
public ?int $nights = 1,
#[Assert\Valid]
public array $lines = [],
#[Assert\Length(max: 255)]
public ?string $label = null,
) {
}
public static function fromPeriod(AccommodationBookingPeriod $period): self
{
return new self(
$period->getNights(),
array_map(
fn (array $line) => new AccommodationBookingPeriodLineDto($line['label'], $line['amount']),
$period->getLines(),
),
$period->getLabel(),
);
}
/** @return list<array{label: string, amount: int}> */
public function linesSnapshot(): array
{
return array_values(array_map(
fn (AccommodationBookingPeriodLineDto $line) => ['label' => (string) $line->label, 'amount' => (int) $line->amount],
$this->lines,
));
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use Symfony\Component\Validator\Constraints as Assert;
/** One free-form price line of a split-off period, as the office keys it in. */
class AccommodationBookingPeriodLineDto
{
public function __construct(
#[Assert\NotBlank(message: 'Bitte eine Bezeichnung angeben.')]
#[Assert\Length(max: 255)]
public ?string $label = null,
/** In minor units of the booking's pricing currency. */
#[Assert\NotNull(message: 'Bitte einen Betrag angeben.')]
#[Assert\PositiveOrZero]
public ?int $amount = null,
) {
}
}
+33 -25
View File
@@ -48,8 +48,8 @@ class ParticipantDto
'licensePlate', 'licensePlate',
'bulkInsuranceBooking', 'bulkInsuranceBooking',
'insurance', 'insurance',
'purchaseVoucherCode', 'voucherCode',
'promoVoucherCode', 'removeVoucherCode',
]; ];
public ?int $index = null; public ?int $index = null;
@@ -163,47 +163,54 @@ class ParticipantDto
public bool $bulkInsuranceBooking = false; public bool $bulkInsuranceBooking = false;
/** /**
* Purchase voucher redemption code. * The voucher field as typed: a purchase or a promo code, told apart by VoucherCodeClassifier.
* Collected from all participants and aggregated into single <gutscheine> collection in booking payload. * ParticipantVoucherFieldHandler applies it and clears it; a rejected code stays for correction.
*/
public ?string $voucherCode = null;
/**
* Why the typed voucher code was rejected. Kept on the DTO because the htmx refresh rebuilds
* the form from the DTO, which drops form errors; the Voucher constraint turns it into a violation.
*/
public ?string $voucherCodeError = null;
/**
* Applied code the customer asked to remove. Consumed and reset by ParticipantVoucherFieldHandler.
*/
public ?string $removeVoucherCode = null;
/**
* Applied purchase voucher redemption code (Kauf or Kulanz).
* Kauf codes are aggregated into the booking's <gutscheine>, a Kulanz code is sent as the participant's <einloesecode>.
*/ */
#[Assert\Length(
max: 50,
maxMessage: 'Der Gutscheincode darf maximal {{ limit }} Zeichen lang sein'
)]
#[Assert\Regex(
pattern: '/^[A-Za-z0-9\-]+$/',
message: 'Der Gutscheincode darf nur Buchstaben, Zahlen und Bindestriche enthalten'
)]
public ?string $purchaseVoucherCode = null; public ?string $purchaseVoucherCode = null;
/** /**
* Validated purchase voucher from API. * Validated purchase voucher from API.
* Set by ParticipantPurchaseVoucherFieldHandler during form processing. * Set by ParticipantVoucherFieldHandler during form processing.
* Contains voucher number, remaining balance, and type (Kauf/Kulanz). * Contains voucher number, remaining balance, and type (Kauf/Kulanz).
*/ */
public ?PurchaseVoucher $validatedPurchaseVoucher = null; public ?PurchaseVoucher $validatedPurchaseVoucher = null;
/** /**
* Promo voucher code. * Applied promo voucher code.
* Applied per participant in booking payload as <aktionscode>. * Applied per participant in booking payload as <aktionscode>.
*/ */
#[Assert\Length(
max: 50,
maxMessage: 'Der Aktionscode darf maximal {{ limit }} Zeichen lang sein'
)]
#[Assert\Regex(
pattern: '/^[A-Za-z0-9\-]+$/',
message: 'Der Aktionscode darf nur Buchstaben, Zahlen und Bindestriche enthalten'
)]
public ?string $promoVoucherCode = null; public ?string $promoVoucherCode = null;
/** /**
* Validated promo voucher from API. * Validated promo voucher from API.
* Set by ParticipantPromoVoucherFieldHandler during form processing. * Set by ParticipantVoucherFieldHandler during form processing.
* Contains discount amount/percentage and applicability (per person/booking). * Contains discount amount/percentage and applicability (per person/booking).
* Null while promoVoucherCode is set means BusPro rejected the applied code on a later check.
*/ */
public ?PromoVoucher $validatedPromoVoucher = null; public ?PromoVoucher $validatedPromoVoucher = null;
/**
* Why BusPro rejected the applied promo code on a later check, e.g. the price fell below its threshold.
*/
public ?string $promoVoucherError = null;
/** /**
* @var array<array{type: string, message: string}> Notification messages for user feedback * @var array<array{type: string, message: string}> Notification messages for user feedback
*/ */
@@ -345,8 +352,8 @@ class ParticipantDto
/** /**
* Checks if this participant has a goodwill (Kulanz) voucher. * Checks if this participant has a goodwill (Kulanz) voucher.
* *
* Goodwill vouchers are treated as promotional vouchers in BPN XML * Goodwill vouchers are sent as the participant's <einloesecode>,
* (sent as <aktionscode> per participant, not in <gutscheine> collection). * not in the booking's <gutscheine> collection.
*/ */
public function hasGoodwillVoucher(): bool public function hasGoodwillVoucher(): bool
{ {
@@ -425,6 +432,7 @@ class ParticipantDto
$this->mobile = $this->normalizeNullableString($this->mobile); $this->mobile = $this->normalizeNullableString($this->mobile);
$this->remarksRoom = $this->normalizeNullableString($this->remarksRoom); $this->remarksRoom = $this->normalizeNullableString($this->remarksRoom);
$this->licensePlate = $this->normalizeNullableString($this->licensePlate); $this->licensePlate = $this->normalizeNullableString($this->licensePlate);
$this->voucherCode = $this->normalizeNullableString($this->voucherCode);
$this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode); $this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode);
$this->promoVoucherCode = $this->normalizeNullableString($this->promoVoucherCode); $this->promoVoucherCode = $this->normalizeNullableString($this->promoVoucherCode);
+23 -2
View File
@@ -18,8 +18,6 @@ use function Symfony\Component\String\u;
* the participant being edited and the full booking context needed for * the participant being edited and the full booking context needed for
* cross-participant validation. * cross-participant validation.
*/ */
#[AppAssert\PurchaseVoucher(groups: ['booking_create', 'booking_edit'])]
#[AppAssert\PromoVoucher(groups: ['booking_create', 'booking_edit'])]
#[AppAssert\MandatoryAdditionalServicesSelected(groups: ['booking_create', 'booking_edit'])] #[AppAssert\MandatoryAdditionalServicesSelected(groups: ['booking_create', 'booking_edit'])]
#[AppAssert\SkiPassSelection(groups: ['strict_required'])] #[AppAssert\SkiPassSelection(groups: ['strict_required'])]
class ParticipantEditDto class ParticipantEditDto
@@ -71,6 +69,29 @@ class ParticipantEditDto
} }
} }
/**
* Reports voucher codes BusPro rejected.
*
* ParticipantVoucherFieldHandler checks the codes with BusPro while handling the submission and
* keeps the outcome on the participant, since the htmx refresh rebuilds the form and drops form
* errors. This turns it into a violation so the step cannot be left with a rejected code.
*/
#[Assert\Callback(groups: ['booking_create', 'booking_edit'])]
public function validateVoucherCodes(ExecutionContextInterface $context): void
{
if (null !== $this->participant->voucherCodeError) {
$context->buildViolation($this->participant->voucherCodeError)
->atPath('participant.voucherCode')
->addViolation();
}
if (null !== $this->participant->promoVoucherCode && null !== $this->participant->promoVoucherError) {
$context->buildViolation($this->participant->promoVoucherError)
->atPath('participant.voucherCode')
->addViolation();
}
}
/** /**
* Validates that non-applicant adult participants have unique email addresses. * Validates that non-applicant adult participants have unique email addresses.
* *
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplate;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Session state of the voucher purchase flow (/gutschein).
*
* The fields are exactly what the BusProNet voucher shop collects, with its mandatory flags rather
* than those of ADRESSFELDERGUTSCHEIN (which would also demand a phone number and a gender). There
* is no Zusatztext: GUTSCHEINBUCHUNG is sent an empty <text/>, as the live shop does.
*
* Step 2 is validated with the groups ['voucher_step_2', 'personal_data']: the buyer address reuses
* the Address model, whose constraints live in the personal_data group.
*/
class VoucherPurchaseDto
{
public const string SALUTATION_MR = 'Herr';
public const string SALUTATION_MS = 'Frau';
public int $currentStep = 1;
// BusPro's bounds from GUTSCHEINVORLAGEN at the start of the flow. BusPro re-checks them on
// every GUTSCHEINBUCHUNG (607/608), so a change in between still surfaces as a step 1 error.
public int $minAmount = 0;
public int $maxAmount = 0;
// Fixed at the start of the flow from GUTSCHEINZAHLUNGSARTEN; transfer is the only one offered.
public ?VoucherPaymentMethod $paymentMethod = null;
// Step 1: voucher
#[Assert\NotNull(message: 'Bitte wähle ein Motiv', groups: ['voucher_step_1'])]
public ?VoucherTemplate $template = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
public ?string $from = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
public ?string $to = null;
#[Assert\NotNull(message: 'Bitte gib einen Gutscheinwert an', groups: ['voucher_step_1'])]
#[Assert\Range(
notInRangeMessage: 'Der Gutscheinwert muss zwischen {{ min }} und {{ max }} Euro liegen',
minPropertyPath: 'minAmount',
maxPropertyPath: 'maxAmount',
groups: ['voucher_step_1'],
)]
public ?int $amount = null;
// Step 2: buyer (rechnungsempfänger)
#[Assert\Choice(choices: [self::SALUTATION_MR, self::SALUTATION_MS], groups: ['voucher_step_2'])]
public ?string $salutation = null;
public ?string $title = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
public ?string $lastName = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
public ?string $firstName = null;
#[Assert\Valid]
public Address $address;
public ?string $phone = null;
public ?string $mobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['voucher_step_2'])]
public ?string $email = null;
// Step 3: confirmation, reset on every visit so a stale tick never carries over
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
public bool $termsAccepted = false;
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
public bool $purchaseConfirmed = false;
public function __construct()
{
$this->address = new Address();
$this->address->country = 'D';
}
public function getBuyerFullName(): string
{
return trim(sprintf('%s %s', $this->firstName ?? '', $this->lastName ?? ''));
}
public function hasBuyerData(): bool
{
return null !== $this->lastName && '' !== $this->lastName;
}
/**
* Copies the buyer fields from the signed-in account's BusPro record.
*
* The e-mail is the one the account signed in with, not the record's communication address:
* a BusPro address can carry several e-mails for different persons.
*/
public function prefillBuyer(PersonalData $personalData, ?string $loginEmail): void
{
$this->salutation = \in_array($personalData->salutation, [self::SALUTATION_MR, self::SALUTATION_MS], true)
? $personalData->salutation
: null;
$this->title = $personalData->title;
$this->lastName = $personalData->name;
$this->firstName = $personalData->firstName;
$this->address = clone $personalData->address;
$this->address->normalize();
$this->address->country ??= 'D';
$this->phone = $personalData->communication->phone;
$this->mobile = $personalData->communication->mobile;
$this->email = $loginEmail;
}
}
@@ -332,11 +332,11 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
// Vouchers cannot be redeemed or submitted for inquiry bookings // Vouchers cannot be redeemed or submitted for inquiry bookings
$finalBookingOnlyCondition = new FinalBookingOnlyCondition(); $finalBookingOnlyCondition = new FinalBookingOnlyCondition();
$this->fieldStateConditions['purchaseVoucherCode'] = [ $this->fieldStateConditions['voucherCode'] = [
'hidden' => CompositeCondition::not($finalBookingOnlyCondition), 'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
]; ];
$this->fieldStateConditions['promoVoucherCode'] = [ $this->fieldStateConditions['removeVoucherCode'] = [
'hidden' => CompositeCondition::not($finalBookingOnlyCondition), 'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
]; ];
@@ -263,6 +263,9 @@ class ParticipantFieldHandlerRegistry
'dropOff', 'dropOff',
'parking', 'parking',
'licensePlate', 'licensePlate',
// Cleared by the voucher handler once applied or removed; must not be bound again
'voucherCode',
'removeVoucherCode',
]; ];
foreach ($serviceFields as $fieldName) { foreach ($serviceFields as $fieldName) {
@@ -704,28 +704,22 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
]; ];
}; };
// Purchase voucher field provider - redemption code for vouchers that apply to complete booking // Single voucher field - purchase (Kauf/Kulanz) or promo code, told apart by VoucherCodeClassifier.
// Collected from all participants and aggregated into single <gutscheine> collection in booking payload // Applied by ParticipantVoucherFieldHandler, which moves accepted codes to purchaseVoucherCode/promoVoucherCode
$this->fieldOptionProviders['purchaseVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ $this->fieldOptionProviders['voucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Gutschein-Code', 'label' => 'Gutschein- oder Aktionscode',
'required' => false, 'required' => false,
'attr' => [ 'attr' => [
'placeholder' => 'z.B. 1F7V6PADRZ', 'placeholder' => 'z.B. 1F7V6PADRZ oder R-SOMMER',
'maxlength' => 50, 'maxlength' => 50,
'autocomplete' => 'off',
], ],
'sanitize_html' => true, 'sanitize_html' => true,
]; ];
// Promo voucher field provider - promo code applied per participant in booking payload // Applied voucher code to remove, set by the remove button of its chip
// Included in participant XML node as <aktionscode> $this->fieldOptionProviders['removeVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
$this->fieldOptionProviders['promoVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rabatt-Code',
'required' => false, 'required' => false,
'attr' => [
'placeholder' => 'z.B. LW0705',
'maxlength' => 50,
],
'sanitize_html' => true,
]; ];
// Future field providers would be added here, for example: // Future field providers would be added here, for example:
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherValidator;
/**
* Handles promotional voucher code field processing.
*
* Validates promo codes via API and stores the validated voucher data
* for early discount display. Validation is also performed by the
* PromoVoucher constraint on ParticipantEditDto for form error feedback.
*
* Dependencies: ALL price-affecting fields (insurance, services, ski pass, rentals, etc.)
* since promo validation requires accurate participant pricing.
*/
class ParticipantPromoVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
private readonly BookingPriceCalculator $priceCalculatorService,
) {
}
public function getFieldName(): string
{
return 'promoVoucherCode';
}
public function getDependencies(): array
{
// CRITICAL: Must run after ALL price-affecting handlers to ensure accurate pricing
return [
'dateOfBirth', // Required for age-based services
'skiPass', // Affects travel price
'rentals', // Affects travel price
'courses', // Affects travel price
'additionalServices', // Affects travel price
'board', // Affects travel price
'transportationOutbound', // Affects travel price
'transportationInbound', // Affects travel price
'pickup', // Affects travel price
'parking', // Affects travel price
'insurance', // Affects travel price (must be last)
];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
$promoCode = $this->getFieldValue($submittedData, $this->getFieldName());
return null !== $promoCode && '' !== trim($promoCode);
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$promoCode = $this->getFieldValue($submittedData, $this->getFieldName());
// Clear validated voucher if code is empty
if (null === $promoCode || '' === trim($promoCode)) {
$participant->validatedPromoVoucher = null;
return;
}
// Calculate participant price for validation
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice(
$bookingDto,
$participantIndex
);
// Validate promo voucher and store result
$result = $this->voucherValidationService->validatePromoVoucher(
trim($promoCode),
$bookingDto->travel->id,
$participantPrice
);
// Store validated voucher (null if invalid)
$participant->validatedPromoVoucher = $result->isValid ? $result->voucher : null;
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\VoucherValidator;
/**
* Handles purchase voucher code field processing.
*
* Detects goodwill (Kulanz) vouchers and marks them for special XML handling.
* Goodwill vouchers must be sent as <aktionscode> per participant, not aggregated
* in <gutscheine> collection. Validation is performed by PurchaseVoucher constraint.
*
* Dependencies: None (purchase vouchers don't require price calculation)
*/
class ParticipantPurchaseVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
) {
}
public function getFieldName(): string
{
return 'purchaseVoucherCode';
}
public function getDependencies(): array
{
// No dependencies - purchase vouchers don't require price calculation
return [];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
$voucherCode = $this->getFieldValue($submittedData, $this->getFieldName());
return null !== $voucherCode && '' !== trim($voucherCode);
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$voucherCode = $this->getFieldValue($submittedData, $this->getFieldName());
// Clear validated voucher if code is empty
if (null === $voucherCode || '' === trim($voucherCode)) {
$participant->validatedPurchaseVoucher = null;
return;
}
// Validate voucher and store result
$result = $this->voucherValidationService->validatePurchaseVoucher($voucherCode);
// Store validated voucher (null if invalid)
$participant->validatedPurchaseVoucher = $result->isValid ? $result->voucher : null;
}
}
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Enum\VoucherCodeKind;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherCodeClassifier;
use App\Service\VoucherValidator;
/**
* Handles the single voucher field of the booking create flow.
*
* The typed code is classified as promo or purchase code (VoucherCodeClassifier), checked with
* BusPro and, when accepted, moved into promoVoucherCode or purchaseVoucherCode, which the payload
* builder reads. The field is then cleared; a rejected code stays with its reason in voucherCodeError.
* A participant holds at most one code of each kind.
*
* Runs on every create-mode submission, also without a typed code, because an applied promo code
* is checked against the participant price, which may have changed since it was applied.
*
* Dependencies: ALL price-affecting fields (insurance, services, ski pass, rentals, etc.)
* since promo validation requires accurate participant pricing.
*/
class ParticipantVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly VoucherCodeClassifier $voucherCodeClassifier,
) {
}
public function getFieldName(): string
{
return 'voucherCode';
}
public function getDependencies(): array
{
// CRITICAL: Must run after ALL price-affecting handlers to ensure accurate pricing
return [
'dateOfBirth', // Required for age-based services
'skiPass', // Affects travel price
'rentals', // Affects travel price
'courses', // Affects travel price
'additionalServices', // Affects travel price
'board', // Affects travel price
'transportationOutbound', // Affects travel price
'transportationInbound', // Affects travel price
'pickup', // Affects travel price
'parking', // Affects travel price
'insurance', // Affects travel price (must be last)
];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
// Vouchers can only be redeemed when creating a booking
return BookingDto::MODE_CREATE === $mode;
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (!$participant instanceof ParticipantDto) {
return;
}
$this->removeRequestedCode($submittedData, $participant);
$this->recheckAppliedPromoCode($bookingDto, $participant, $participantIndex);
$this->applyTypedCode($submittedData, $bookingDto, $participant, $participantIndex);
}
/**
* @param array<string, mixed> $submittedData
*/
private function removeRequestedCode(array $submittedData, ParticipantDto $participant): void
{
$participant->removeVoucherCode = null;
$code = $this->getFieldValue($submittedData, 'removeVoucherCode');
if (false === is_string($code) || '' === trim($code)) {
return;
}
$code = $this->voucherCodeClassifier->normalize($code);
if ($code === $participant->promoVoucherCode) {
$participant->promoVoucherCode = null;
$participant->validatedPromoVoucher = null;
$participant->promoVoucherError = null;
}
if ($code === $participant->purchaseVoucherCode) {
$participant->purchaseVoucherCode = null;
$participant->validatedPurchaseVoucher = null;
}
}
private function recheckAppliedPromoCode(BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): void
{
if (null === $participant->promoVoucherCode) {
return;
}
$error = $this->validatePromoCode($participant->promoVoucherCode, $bookingDto, $participant, $participantIndex);
$participant->promoVoucherError = $error;
}
/**
* @param array<string, mixed> $submittedData
*/
private function applyTypedCode(array $submittedData, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): void
{
$participant->voucherCodeError = null;
// Only a submitted field carries a typed code; when the field is hidden nothing may linger
$typedCode = $this->getFieldValue($submittedData, $this->getFieldName());
if (false === is_string($typedCode) || '' === trim($typedCode)) {
$participant->voucherCode = null;
return;
}
$participant->voucherCode = trim($typedCode);
$code = $this->voucherCodeClassifier->normalize($typedCode);
$error = match (true) {
$code === $participant->promoVoucherCode, $code === $participant->purchaseVoucherCode => 'Dieser Code ist bereits eingelöst.',
default => match ($this->voucherCodeClassifier->classify($code)) {
VoucherCodeKind::Malformed => 'Dieser Code ist ungültig. Bitte prüfe deine Eingabe.',
VoucherCodeKind::Promo => $this->applyPromoCode($code, $bookingDto, $participant, $participantIndex),
VoucherCodeKind::Purchase => $this->applyPurchaseCode($code, $participant),
},
};
if (null === $error) {
$participant->voucherCode = null;
}
$participant->voucherCodeError = $error;
}
/**
* @return string|null Why the code was not applied, or null when it was
*/
private function applyPromoCode(string $code, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): ?string
{
if (null !== $participant->promoVoucherCode) {
return sprintf('Es kann nur ein Aktionscode eingelöst werden. Bitte entferne zuerst %s.', $participant->promoVoucherCode);
}
$error = $this->validatePromoCode($code, $bookingDto, $participant, $participantIndex);
if (null !== $error) {
return $error;
}
$participant->promoVoucherCode = $code;
$participant->promoVoucherError = null;
return null;
}
/**
* @return string|null Why the code was not applied, or null when it was
*/
private function applyPurchaseCode(string $code, ParticipantDto $participant): ?string
{
if (null !== $participant->purchaseVoucherCode) {
return sprintf('Es kann nur ein Gutschein eingelöst werden. Bitte entferne zuerst %s.', $participant->purchaseVoucherCode);
}
$result = $this->voucherValidationService->validatePurchaseVoucher($code);
if (false === $result->isValid) {
return sprintf('Gutschein ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler');
}
$participant->purchaseVoucherCode = $code;
$participant->validatedPurchaseVoucher = $result->voucher;
return null;
}
/**
* Checks a promo code against the participant's current price and stores the validated voucher.
*
* @return string|null Why BusPro rejected the code, or null when it is valid
*/
private function validatePromoCode(string $code, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): ?string
{
$travelId = $bookingDto->travel->id;
if (null === $travelId) {
$participant->validatedPromoVoucher = null;
return 'Aktionscode konnte nicht validiert werden: Reise-ID fehlt.';
}
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice($bookingDto, $participantIndex);
$result = $this->voucherValidationService->validatePromoVoucher($code, $travelId, $participantPrice);
if (false === $result->isValid) {
// Only an applied code is replaced: a rejected new code must not discard the applied one's discount
if ($code === $participant->promoVoucherCode) {
$participant->validatedPromoVoucher = null;
}
return sprintf('Aktionscode ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler');
}
$participant->validatedPromoVoucher = $result->voucher;
return null;
}
}
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more