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
type: php
docroot: public
php_version: "8.4"
php_version: "8.5"
webserver_type: apache-fpm
xdebug_enabled: false
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_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
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_BASE_URL="https://my.ep-reisen.de"
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.
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
APP_DEFAULT_EMAIL_FROM=[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() {
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() {
const index = this.fieldTargets.length + 1
const index = this.nextIndex++
const prototype = JSON.parse(this.prototypeValue)
const newField = prototype.replace(/__name__/g, index)
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 {
@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;
@apply uppercase leading-none text-center text-sm md:text-base;
/* Same box as the @tailwindcss/forms inputs (1px border, py-2, 24px line), so a button lines up with a field beside it.
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 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 {
@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 {
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 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",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"php": ">=8.5",
"ext-ctype": "*",
"ext-dom": "*",
"ext-iconv": "*",
@@ -22,7 +22,7 @@
"league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^1.0",
"nelexa/zip": "^4.0",
"maennchen/zipstream-php": "^3.2",
"nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4",
@@ -77,7 +77,7 @@
},
"sort-packages": true,
"platform": {
"php": "8.4.24"
"php": "8.5.0"
}
},
"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)%'
headers:
accept: 'text/json'
X-Api-Key: '%env(APP_CMS_API_KEY)%'
when@test:
framework:
+1
View File
@@ -5,6 +5,7 @@ twig:
'%kernel.project_dir%/assets/images': images
globals:
htmx_change_trigger: 'change delay:100ms'
htmx_boost: '%env(bool:APP_HTMX_BOOST)%'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
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)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
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_lists:
@@ -128,18 +137,22 @@ services:
- '../src/Model'
- '../src/Kernel.php'
App\BusProNet\ApiClient:
App\BusProNet\Transport\XmlChannel:
arguments:
$logger: '@monolog.logger.bpn'
$options:
bpn_username: '%env(APP_BPN_USER)%'
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)%'
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
App\BusProNet\Transport\SocketTransport:
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:
arguments:
@@ -228,17 +241,44 @@ services:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\ProductExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator:
arguments:
$cache: '@cache.app'
$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
App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
App\Form\Service\ParticipantVoucherFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -264,8 +304,7 @@ services:
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
- '@App\Form\Service\ParticipantVoucherFieldHandler'
- '@App\Form\Service\ParticipantParkingFieldHandler'
# Booking Status Rules
@@ -297,7 +336,7 @@ services:
App\Service\CmsDataProvider:
arguments:
$httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
$apiKey: '%env(APP_CMS_API_KEY)%'
App\Logger\RequestIdProcessor:
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.
- Access token TTL is **10 minutes** for every grant.
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; 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.
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
- Send the token as `Authorization: Bearer <access_token>`.
@@ -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**.
`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.
@@ -92,7 +92,9 @@ Product master data from the upstream BusProNet system, cached server-side for 1
#### `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
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
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",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"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,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+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
empty, never null.
**`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
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
@@ -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.
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
bare-time sources too, and carries that resolution.
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
an offset to compare or sort them; they already sort correctly as strings.
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
`datetime` carries that resolution for bare-time sources too.
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
@@ -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.
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
@@ -398,14 +555,16 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
"totalDiscountLabel": null,
"totalPrice": 123456,
"pricingCurrency": "EUR",
"pricingVersion": 3,
"pricingVersion": 2,
"priceBreakdown": {}
}
```
`totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges 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.
@@ -413,6 +572,10 @@ The first three discounts each apply to one section of the breakdown: `accommoda
| field | meaning |
| --- | --- |
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
| `periodsTotal` | the sum of all `periods[].total` |
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
+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 )
```
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.
## Satz types
@@ -39,7 +39,7 @@ the key before sending, so a hand-built payload can carry a placeholder.
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
**Used here but undocumented:** `VERFUEGBARKEIT2` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — the
**Used here but undocumented:** `VERFUEGBARKEIT2` (`MessageType::AvailabilityExtended`) — the
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
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
`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 |
|---|---|---|---|
| `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 |
| `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 |
@@ -253,7 +253,7 @@ booking before relying on it.
### Gap: edit never dry-runs
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
`buchungsart=Buchung` directly (`ApiClient.php:233`), even though `BUCHUNGAENDERUNG` supports
`buchungsart=Buchung` directly (`BookingClient::updateBooking()`), even though `BUCHUNGAENDERUNG` supports
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
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
`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
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
> carrier whose entire content is substituted. `Notification::isError()`
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`, where the same code delivers
> 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"*.
> `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
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm /
Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein
**Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
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
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 —
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.
Response side: `CrmAttributesResponseParser`
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
@@ -24,6 +24,7 @@ Symfony roles:
| `1478` | `ROLE_GROUPS_ADMIN` |
| `1483` | `ROLE_CUSTOMER_EXPERT` |
| `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}` |
| nothing matched | `ROLE_CUSTOMER` |
@@ -45,7 +46,7 @@ filtered before anything is stored:
| --- | --- | --- |
| `ROLE_TEAMER` | granted | granted |
| `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`
(`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,
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
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
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:
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
from a response captured after the insert, the staleness is on the BusProNet host and
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.
This complements `travel-queries.md`. That doc replaced the **XML export** (static
catalog). This one covers the **live socket API** methods on
`src/BusProNet/ApiClient.php` and classifies how replicable each is by direct MSSQL
catalog). This one covers the **live socket API** methods of the clients in
`src/BusProNet/Client/` and classifies how replicable each is by direct MSSQL
query in the Go proxy.
**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;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Country;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;
@@ -10,7 +11,7 @@ use Symfony\Contracts\Cache\ItemInterface;
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);
return $this
->apiClient
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES)
->referenceDataClient
->getBaseData(MessageType::Countries)
->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;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class XmlExportInfo
{
private const INFO_FILENAME = 'uebertragung.info';
@@ -20,6 +23,26 @@ final readonly class XmlExportInfo
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
{
$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
* 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.
*
* 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
{
@@ -89,12 +92,12 @@ trait TypeConversionTrait
try {
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
// time only provided
$dateTime = Carbon::createFromFormat('H:i', $string);
$dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
// assign default date
$dateTime->setDateFrom($defaultDate);
} else {
// 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();
@@ -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;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\Notification;
use Psr\Cache\InvalidArgumentException;
@@ -19,7 +19,7 @@ class AgencyLoader
public function __construct(
private readonly CacheInterface $cache,
private readonly ApiClient $apiClient,
private readonly ReferenceDataClient $referenceDataClient,
private readonly LoggerInterface $logger,
) {
}
@@ -34,7 +34,7 @@ class AgencyLoader
// Cache for 24 hours (agencies change infrequently)
$item->expiresAfter(24 * 60 * 60);
$result = $this->apiClient->getAgencies();
$result = $this->referenceDataClient->getAgencies();
if ($result instanceof Notification) {
$this->logger->error('Failed to fetch agencies from BPN API', [
+32 -22
View File
@@ -2,8 +2,8 @@
namespace App\BusProNet\XmlParser;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\XmlCrawlerFactory;
class ApiResponseParser extends AbstractParser
@@ -14,10 +14,11 @@ class ApiResponseParser extends AbstractParser
}
/**
* @param array<string, mixed> $additionalArgs
*
* @throws ResponseParserException
*/
/** @param array<string, mixed> $additionalArgs */
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
public function parseXmlString(MessageType $type, string $xml, array $additionalArgs = []): mixed
{
$crawler = XmlCrawlerFactory::create($xml);
@@ -25,17 +26,20 @@ class ApiResponseParser extends AbstractParser
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
}
// Override type unless present in XML to catch error responses
$responseType = $this
->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ')) ?? $type;
// The type in the XML wins, to catch error responses. BusPro may send one we don't know.
$typeAttribute = $this->getStringOrNullValue($crawler->filterXPath('//ergebnis/satz/@typ'));
$responseType = null === $typeAttribute ? $type : MessageType::tryFrom($typeAttribute);
if (null === $responseType) {
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $typeAttribute));
}
$resultNode = $crawler->filterXPath('//ergebnis');
try {
switch ($responseType) {
case ApiClient::TYPE_NOTIFICATION:
case MessageType::Notification:
return (new NotificationParser())->parse($crawler);
case ApiClient::TYPE_CUSTOMER_DATA:
case MessageType::CustomerData:
$subType = $this->getStringOrNullValue($resultNode->filterXPath('//art'));
if (null === $subType || '' === $subType) {
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));
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
case MessageType::Countries:
return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA:
case MessageType::MutableData:
return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY:
case MessageType::Availability:
return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_EXTENDED:
case MessageType::AvailabilityExtended:
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL:
case MessageType::AvailabilityHotel:
return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE:
case MessageType::BookingUpdate:
return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_BOOKING:
case MessageType::Booking:
return (new BookingResponseParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS:
case MessageType::Products:
return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA:
case MessageType::ProductData:
$travelNode = $crawler->filterXPath('//reise/termin');
if (0 === $travelNode->count()) {
@@ -87,17 +91,23 @@ class ApiResponseParser extends AbstractParser
}
return $this->travelParser->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES:
case MessageType::Agencies:
return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER:
case MessageType::PurchaseVoucher:
return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER:
case MessageType::PromoVoucher:
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) {
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,
1483 => Role::CUSTOMER_EXPERT,
1484 => Role::TEAM_ADMIN,
1487 => Role::SUPERVISOR,
];
/**
+17 -6
View File
@@ -3,9 +3,9 @@
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\File;
use PhpZip\Exception\ZipException;
use PhpZip\ZipFile;
use Symfony\Component\DomCrawler\Crawler;
use ZipStream\Exception as ZipException;
use ZipStream\ZipStream;
class DocumentsParser
{
@@ -28,17 +28,28 @@ class DocumentsParser
}
// In case of multiple documents create ZIP file
$stream = fopen('php://temp', 'w+b');
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);
$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) {
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;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking;
@@ -78,7 +79,8 @@ class BookingRepairDraftCommand extends Command
public function __construct(
private readonly BookingEditDraftRepository $draftRepository,
private readonly EntityManagerInterface $entityManager,
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly TravelClient $travelClient,
private readonly Crypt $crypt,
private readonly TravelDataProvider $travelDataProvider,
private readonly BookingDataProcessor $bookingDataProcessor,
@@ -214,7 +216,7 @@ class BookingRepairDraftCommand extends Command
private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking
{
$result = $this->apiClient->getBooking(
$result = $this->bookingClient->getBooking(
(string) $user->getEmail(),
$this->crypt->decrypt((string) $user->getPassword()),
$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) {
$io->warning(sprintf(
'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;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
@@ -29,7 +29,7 @@ class BpnFetchTravelCommand extends Command
];
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelClient $travelClient,
private readonly FilesystemOperator $xmlExport,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
@@ -56,7 +56,7 @@ class BpnFetchTravelCommand extends Command
$io->text(sprintf('Fetching travel data for product ID %d...', $travelId));
try {
$xml = $this->apiClient->getTravelDataXml($travelId);
$xml = $this->travelClient->getTravelDataXml($travelId);
} catch (ApiClientException $e) {
$io->error(sprintf('API request failed: %s', $e->getMessage()));
$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;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Transport\XmlChannel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -21,7 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class BpnReplayCommand extends Command
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly XmlChannel $channel,
) {
parent::__construct();
}
@@ -77,7 +77,7 @@ class BpnReplayCommand extends Command
$io->info('Sending request to BPN API...');
try {
$response = $this->apiClient->sendRawXml($xml);
$response = $this->channel->replay($xml);
} catch (ApiClientException $e) {
$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;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
@@ -39,7 +39,7 @@ class PersonalDataController extends AbstractController
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 BookingEditDataLoader $dataLoader Data loader for cache invalidation
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
@@ -48,7 +48,7 @@ class PersonalDataController extends AbstractController
* @param LoggerInterface $logger Logger for audit trails and debugging
*/
public function __construct(
private readonly ApiClient $apiClient,
private readonly AccountClient $accountClient,
private readonly Crypt $crypt,
private readonly BookingEditDataLoader $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker,
@@ -100,7 +100,7 @@ class PersonalDataController extends AbstractController
if ($personalDataForm->isSubmitted() && $personalDataForm->isValid()) {
try {
$updateResult = $this->apiClient->updatePersonalData($email, $password, $personalData);
$updateResult = $this->accountClient->updatePersonalData($email, $password, $personalData);
if ($updateResult instanceof Notification) {
$this->logger->error('Personal data update rejected by BusProNet', [
@@ -223,7 +223,7 @@ class PersonalDataController extends AbstractController
try {
$personalData = $this
->apiClient
->accountClient
->getPersonalData($email, $password);
} catch (ApiClientException $e) {
$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\BoardServiceRepository;
use App\Repository\UserRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -33,6 +34,7 @@ class EditController extends AbstractController
private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly UserRepository $userRepo,
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
// comes back filled in with the error on it. Logging is safe here: the database
// handler writes through DBAL, not the EntityManager.
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
]);
return $this->renderForm($booking, $form);
}
// 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->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', [
'booking' => $booking,
'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')]
public function index(AccommodationBooking $booking): Response
{
if (false === $booking->isConfirmed()) {
$this->addFlash('warning', 'Ein PDF kann nur für bestätigte Buchungen erstellt werden.');
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
// Offered in every status, so the office can check the document before it goes out
// with the confirmation email; it labels itself Angebot or Buchung accordingly.
try {
return $this->pdfGenerator->createDownloadResponse($booking);
} 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;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\ContactFormSubmission;
use App\BusProNet\Model\Notification;
@@ -21,7 +21,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_API')]
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);
try {
$result = $this->apiClient->createAddress($personalData);
$result = $this->accountClient->createAddress($personalData);
if ($result instanceof Notification) {
$this->logger->info('Unable to submit contact form', [
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_PROFILE')]
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());
try {
$data = $this->apiClient->getCrmAttributes($email, $password);
$data = $this->accountClient->getCrmAttributes($email, $password);
if ($data instanceof Notification) {
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;
use App\Model\TravelExportQuery;
use App\Model\Export;
use App\Model\ExportQuery;
use App\Service\ProductExportBuilder;
use App\Service\TravelExportBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -29,6 +31,7 @@ class ExportController extends AbstractController
{
public function __construct(
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'])]
public function travels(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
TravelExportQuery $query = new TravelExportQuery(),
ExportQuery $query = new ExportQuery(),
): JsonResponse {
// 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
// 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);
return $this->json([
@@ -59,6 +80,7 @@ class ExportController extends AbstractController
'totalPages' => (int) ceil($total / $query->perPage),
'skipped' => $export->skipped,
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
'generatedAt' => $export->generatedAt->format('c'),
],
]);
+7 -14
View File
@@ -2,6 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\Model\XmlExportInfo;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
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'])]
public function index(): JsonResponse
{
if (false === $this->xmlExport->has('uebertragung.info')) {
$lastUploadAt = null;
} else {
$file = $this->xmlExport->read('uebertragung.info');
$lines = explode("\n", $file);
$line = trim($lines[0]);
$info = XmlExportInfo::fromStorage($this->xmlExport);
if (true === empty($line)) {
$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')]);
return $this->json(['timestamp' => $info?->lastTransfer->format('Y-m-d H:i:s')]);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -17,7 +17,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class ProductController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelClient $travelClient,
private readonly CacheInterface $cache,
) {
}
@@ -29,7 +29,7 @@ class ProductController extends AbstractController
$products = $this->cache->get('api_products', function (ItemInterface $item) {
$item->expiresAfter(3600); // 1 hour cache
return $this->apiClient->getProducts();
return $this->travelClient->getProducts();
});
return $this->json($products);
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\TravelClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DateCodeUtility;
@@ -20,7 +20,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class TravelController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelClient $travelClient,
private readonly TravelDataProvider $travelDataService,
) {
}
@@ -89,7 +89,7 @@ class TravelController extends AbstractController
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo,
): JsonResponse {
try {
$result = $this->apiClient->getHotelAvailability($dateId, $hotelId, $dateTo);
$result = $this->travelClient->getHotelAvailability($dateId, $hotelId, $dateTo);
return $this->json($result);
} catch (ApiClientException $e) {
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_EMAIL')]
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());
try {
$data = $this->apiClient->getPersonalData($email, $password);
$data = $this->accountClient->getPersonalData($email, $password);
if ($data instanceof Notification) {
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\Htmx\HxTrait;
use App\Model\BookingQueryParams;
use App\Model\PurchaseFlow;
use App\Service\BookingConfigurator;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -36,6 +38,7 @@ class IndexController extends AbstractController
private readonly BookingSessionManager $bookingSessionService,
private readonly AgencyLoader $agencyLoader,
private readonly BookingSummaryAssembler $summaryDataService,
private readonly PurchaseFlowResolver $purchaseFlowResolver,
) {
}
@@ -103,6 +106,7 @@ class IndexController extends AbstractController
// Create fresh booking session with the provided parameters
$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
$this->summaryDataService->getCmsDataForProduct(
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\Notification;
@@ -40,7 +40,7 @@ class Step3Controller extends AbstractBookingCreateController
private readonly BookingCreateContextFactory $createContextFactory,
private readonly BookingPriceCalculator $priceCalculator,
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger,
) {
@@ -76,7 +76,7 @@ class Step3Controller extends AbstractBookingCreateController
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
// 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
// stale price tier reassigned/cleared right before submission)
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
@@ -37,7 +37,7 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingConfigurator $bookingService,
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingCreateContextFactory $createContextFactory,
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly CacheInterface $cache,
private readonly BookingNewsletterOptIn $bookingNewsletterOptIn,
private readonly ParticipantFormSupport $participantFormSupportService,
@@ -80,7 +80,7 @@ class Step4Controller extends AbstractBookingCreateController
$this->bookingService->applyCreateBookingStatusRules($bookingCreateDto);
// 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
// stale price tier reassigned/cleared right before submission)
@@ -2,7 +2,7 @@
namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
@@ -20,7 +20,7 @@ use function Symfony\Component\String\u;
class DownloadController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly Crypt $crypt,
private readonly LoggerInterface $logger,
) {
@@ -60,7 +60,7 @@ class DownloadController extends AbstractController
try {
$file = $this
->apiClient
->bookingClient
->getDocuments($email, $password, $id, $type);
} catch (ApiClientException $e) {
$file = null;
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\BookingClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingClient $bookingClient,
private readonly TravelLoader $travelDataLoader,
private readonly Crypt $crypt,
private readonly LoggerInterface $logger,
@@ -36,7 +36,7 @@ class IndexController extends AbstractController
$password = $this->crypt->decrypt($user->getPassword());
try {
$bookings = $this->apiClient->getBookings($email, $password);
$bookings = $this->bookingClient->getBookings($email, $password);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Buchungen nicht abrufbar');
$bookings = new BaseData([]);
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Form\Model\RegistrationDto;
@@ -17,7 +17,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class RegistrationController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly AccountClient $accountClient,
private readonly LoggerInterface $logger,
) {
}
@@ -34,7 +34,7 @@ class RegistrationController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$response = $this->apiClient->register($registrationData);
$response = $this->accountClient->register($registrationData);
if ($response instanceof Notification && true === $response->isError()) {
$this->addFlash('error', 'Möglicherweise bist du bereits registriert. Bitte setze dein Passwort zurück.');
} else {
+3 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller;
use App\BusProNet\ApiClient;
use App\BusProNet\Client\AccountClient;
use App\BusProNet\Exception\ApiClientException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\NotBlank;
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()) {
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->logger->info('Initiated password reset', [
'email' => $form->get('email')->getData(),
+25 -20
View File
@@ -3,8 +3,10 @@
namespace App\Controller;
use App\Form\Model\BookingDto;
use App\Model\PurchaseFlow;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -23,31 +25,31 @@ class SecurityController extends AbstractController
BookingSessionManager $bookingSessionService,
BookingSummaryAssembler $summaryDataService,
ClientManagerInterface $clientManager,
PurchaseFlowResolver $purchaseFlowResolver,
): Response {
// flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful
// 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();
$targetPath = $session->get('_security.main.target_path');
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
$session->set('_oauth2', $isOauth2);
// Check if this is a booking flow (BookingDto exists in session)
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
$hasPendingBooking = null !== $bookingDto;
// A purchase flow (booking or voucher) uses this page as its optional sign-in step
$pendingFlow = $purchaseFlowResolver->resolvePending($request);
// A pending authorization wins over a half-finished booking. Otherwise the target path
// below is overwritten with booking step 1, the user is sent there after logging in, and
// the application that asked for the authorization never receives its code.
$isBookingFlow = $hasPendingBooking && false === $isOauth2;
// A pending authorization wins over a half-finished purchase. Otherwise the target path
// below is overwritten with the flow's first step, the user is sent there after logging in,
// and the application that asked for the authorization never receives its code.
$purchaseFlow = false === $isOauth2 ? $pendingFlow : null;
// If authenticated and in booking flow, proceed to Step 1
if (null !== $this->getUser() && true === $hasPendingBooking) {
return $this->redirectToRoute('app_booking_create_step_1');
// If authenticated and in a purchase flow, proceed to its first step
if (null !== $this->getUser() && null !== $pendingFlow) {
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()) {
return $this->redirectToRoute('app_account');
}
@@ -55,22 +57,25 @@ class SecurityController extends AbstractController
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
// For booking flow, set target path to Step 1 (after successful auth, redirect there)
if (true === $isBookingFlow) {
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
// For a purchase flow, continue with its first step after successful auth
if (null !== $purchaseFlow) {
$session->set('_security.main.target_path', $this->generateUrl($purchaseFlow->firstStepRoute()));
}
// Render booking login template if in booking flow, the self-explaining one when another
// application sent them here to authorize it, otherwise standard login
// Render the flow's login template if in a purchase flow, the self-explaining one when
// another application sent them here to authorize it, otherwise standard login
$template = match (true) {
$isBookingFlow => 'booking/create/authenticate.html.twig',
null !== $purchaseFlow => $purchaseFlow->authenticateTemplate(),
$isOauth2 => 'security/login_oauth2.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;
if (true === $isBookingFlow) {
if (null !== $bookingDto) {
$cmsData = $summaryDataService->getCmsDataForProduct(
$bookingDto->travel->productCode,
$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\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingPeriodPosition;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AdditionalServiceType;
use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AccommodationBookingRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
@@ -195,9 +198,21 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\JoinColumn(onDelete: 'SET 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()
{
$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
* 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
* 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()
{
$periods = $this->periods->toArray();
$this->periods = new ArrayCollection();
foreach ($periods as $period) {
$this->addPeriod(clone $period);
}
$this->id = null;
$this->uuid = Uuid::v4()->toRfc4122();
$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
{
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\ChoiceType;
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\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -334,8 +335,8 @@ class BookingParticipantType extends AbstractType
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
$dynamicFields['insurance'] = ChoiceType::class;
$dynamicFields['purchaseVoucherCode'] = TextType::class;
$dynamicFields['promoVoucherCode'] = TextType::class;
$dynamicFields['voucherCode'] = TextType::class;
$dynamicFields['removeVoucherCode'] = HiddenType::class;
}
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',
'bulkInsuranceBooking',
'insurance',
'purchaseVoucherCode',
'promoVoucherCode',
'voucherCode',
'removeVoucherCode',
];
public ?int $index = null;
@@ -163,47 +163,54 @@ class ParticipantDto
public bool $bulkInsuranceBooking = false;
/**
* Purchase voucher redemption code.
* Collected from all participants and aggregated into single <gutscheine> collection in booking payload.
* The voucher field as typed: a purchase or a promo code, told apart by VoucherCodeClassifier.
* 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;
/**
* 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).
*/
public ?PurchaseVoucher $validatedPurchaseVoucher = null;
/**
* Promo voucher code.
* Applied promo voucher code.
* 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;
/**
* 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).
* Null while promoVoucherCode is set means BusPro rejected the applied code on a later check.
*/
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
*/
@@ -345,8 +352,8 @@ class ParticipantDto
/**
* Checks if this participant has a goodwill (Kulanz) voucher.
*
* Goodwill vouchers are treated as promotional vouchers in BPN XML
* (sent as <aktionscode> per participant, not in <gutscheine> collection).
* Goodwill vouchers are sent as the participant's <einloesecode>,
* not in the booking's <gutscheine> collection.
*/
public function hasGoodwillVoucher(): bool
{
@@ -425,6 +432,7 @@ class ParticipantDto
$this->mobile = $this->normalizeNullableString($this->mobile);
$this->remarksRoom = $this->normalizeNullableString($this->remarksRoom);
$this->licensePlate = $this->normalizeNullableString($this->licensePlate);
$this->voucherCode = $this->normalizeNullableString($this->voucherCode);
$this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode);
$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
* 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\SkiPassSelection(groups: ['strict_required'])]
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.
*
+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
$finalBookingOnlyCondition = new FinalBookingOnlyCondition();
$this->fieldStateConditions['purchaseVoucherCode'] = [
$this->fieldStateConditions['voucherCode'] = [
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
];
$this->fieldStateConditions['promoVoucherCode'] = [
$this->fieldStateConditions['removeVoucherCode'] = [
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
];
@@ -263,6 +263,9 @@ class ParticipantFieldHandlerRegistry
'dropOff',
'parking',
'licensePlate',
// Cleared by the voucher handler once applied or removed; must not be bound again
'voucherCode',
'removeVoucherCode',
];
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
// Collected from all participants and aggregated into single <gutscheine> collection in booking payload
$this->fieldOptionProviders['purchaseVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Gutschein-Code',
// Single voucher field - purchase (Kauf/Kulanz) or promo code, told apart by VoucherCodeClassifier.
// Applied by ParticipantVoucherFieldHandler, which moves accepted codes to purchaseVoucherCode/promoVoucherCode
$this->fieldOptionProviders['voucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Gutschein- oder Aktionscode',
'required' => false,
'attr' => [
'placeholder' => 'z.B. 1F7V6PADRZ',
'placeholder' => 'z.B. 1F7V6PADRZ oder R-SOMMER',
'maxlength' => 50,
'autocomplete' => 'off',
],
'sanitize_html' => true,
];
// Promo voucher field provider - promo code applied per participant in booking payload
// Included in participant XML node as <aktionscode>
$this->fieldOptionProviders['promoVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rabatt-Code',
// Applied voucher code to remove, set by the remove button of its chip
$this->fieldOptionProviders['removeVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'required' => false,
'attr' => [
'placeholder' => 'z.B. LW0705',
'maxlength' => 50,
],
'sanitize_html' => true,
];
// 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