Compare commits

..
Author SHA1 Message Date
Björn Fromme 2d8c86d26b chore: update project dependencies 2026-04-16 17:11:16 +02:00
Björn Fromme 91332b7eb0 chore: upgrade to symfony 7.4 2026-04-16 17:11:16 +02:00
Björn Fromme 3da73d626b chore: major code cleanup and type safety using phpstan 2026-04-16 17:11:16 +02:00
Björn Fromme 060cbea9da chore: update project dependencies 2026-04-16 17:11:16 +02:00
Björn Fromme 9bb5d141c3 chore: update project dependencies 2026-04-16 17:11:16 +02:00
Björn Fromme 571b3e9f37 feat: backport contact form api endpoint 2026-04-16 17:11:16 +02:00
Björn Fromme 6a2e44d36e chore: update project dependencies 2026-04-16 17:11:16 +02:00
Björn Fromme c3263eec73 feat: restrict access to booking edit and invoice to applicant 2026-04-16 17:11:16 +02:00
Björn Fromme 401a28132e feat: integrate with zenstruck schedule bundle 2026-04-16 17:11:16 +02:00
Björn Fromme 8250a07c02 fix: respect environment variable to toggle BPN debug mode 2026-04-16 17:11:16 +02:00
Björn Fromme 26a19388f3 chore: update project dependencies 2026-04-16 17:11:16 +02:00
Björn Fromme ca6e38cb9c chore: replace global binds with dedicated service definition 2026-04-16 17:11:02 +02:00
Björn Fromme ca5a604a3e fix: corrupted logic of model transformer 2026-04-16 16:40:38 +02:00
Björn Fromme 0fcecc9c58 chore: fix phpstan errors 2026-04-16 16:27:35 +02:00
Björn Fromme 4e925171b3 chore: update frontend tooling 2026-04-16 13:47:26 +02:00
Björn Fromme 41e2fe19bc chore: update project dependencies 2026-04-16 13:39:35 +02:00
Björn Fromme a927d6bf59 chore: cgl cleanup 2026-04-16 13:34:54 +02:00
Björn Fromme c0cc183c59 feat: rename BookingSessionStore to BookingSessionManager 2026-04-16 13:34:54 +02:00
Björn Fromme b68c4ff9b4 chore: update booking-flow reference to current class names in docs 2026-04-16 13:34:54 +02:00
Björn Fromme 132329361f fix: pass logger explicitly to BookingCreateTrait::handleApiError 2026-04-16 13:34:54 +02:00
Björn Fromme 4d86629315 feat: move display-assembly out of BookingPriceCalculator 2026-04-16 13:34:54 +02:00
Björn Fromme d1c92f2957 feat: replace *Service suffix with role-based class names 2026-04-16 13:34:54 +02:00
Björn Fromme 0d2cc5b998 feat: consolidate micro-services and unify draft merge logic 2026-04-16 13:34:54 +02:00
Björn Fromme fdfd5d56c9 chore: add booking flow reference document 2026-04-16 13:34:54 +02:00
Björn Fromme 059c520a50 fix: link header logo with user profile page 2026-04-13 12:24:15 +02:00
Björn Fromme d8818dc435 feat: extract shared booking summary travel partial 2026-04-13 12:24:15 +02:00
Björn Fromme 2baa147afa feat: clarify booking summary participant count 2026-04-13 12:24:09 +02:00
Björn Fromme e179bbe259 feat: remove deprecated booking service alias 2026-04-13 12:23:57 +02:00
Björn Fromme 16cab4b768 feat: add typed dto for cms data 2026-04-12 18:17:54 +02:00
Björn Fromme 6ab34fa87c chore: merge service methods 2026-04-12 17:55:29 +02:00
Björn Fromme fcfd366dec chore: flatten namespace 2026-04-12 17:54:24 +02:00
Björn Fromme 98dd685679 feat: extract participant draft field applier 2026-04-12 14:40:05 +02:00
Björn Fromme 2597318488 chore: stabilize Bpn XML sync CLI assertions 2026-04-12 13:49:10 +02:00
Björn Fromme aa997826f8 feat: split summary DTOs into typed sub-objects 2026-04-12 11:23:19 +02:00
Björn Fromme 69705052b3 feat: centralize create/edit flow contexts, extract edit submit service 2026-04-12 10:17:04 +02:00
Björn Fromme f903f17ebd feat: replace edit participant facade with context factory 2026-04-11 18:28:32 +02:00
Björn Fromme 95d50e9833 fix: return proper response for stale participant edit urls 2026-04-11 18:28:32 +02:00
Björn Fromme 63e8a068b3 feat: move prepopulation guards into participant service 2026-04-11 18:28:32 +02:00
Björn Fromme dbc0b4acee feat: extract booking participant count service 2026-04-11 18:28:32 +02:00
Björn Fromme f31ac660d2 feat: extract room grouping from booking service 2026-04-11 18:28:32 +02:00
Björn Fromme 8b72f8c277 feat: extract booking session handling 2026-04-11 18:28:32 +02:00
Björn Fromme ca052682bb feat: simplify pricing and room selection display 2026-04-11 18:28:32 +02:00
Björn Fromme 1b0e479e49 feat: refactor participant card DTOs and labels 2026-04-11 18:28:32 +02:00
Björn Fromme f22ceae41c feat: split booking participant controllers 2026-04-11 18:28:32 +02:00
Björn Fromme 346256f895 feat: simplify booking pricing flow 2026-04-11 18:28:32 +02:00
Björn Fromme a0cbd5a23f chore: adjust http client config for mailjet api 2026-04-11 18:28:09 +02:00
Björn Fromme 803b0608cc chore: add test 2026-04-03 15:24:56 +02:00
Björn Fromme 7eb1cbdabd fix: remove redundant insurance hydration, cache snapshots and files map 2026-04-03 15:20:37 +02:00
Björn Fromme ca873a99f4 feat: improved button sizes in mobile view 2026-04-03 14:57:41 +02:00
Björn Fromme 8d666baf28 feat: back-button in booking create flow 2026-04-03 14:20:19 +02:00
Björn Fromme be0c9d67bb chore: update tests to adopt recent changes 2026-04-03 13:49:50 +02:00
Björn Fromme f0594ba09c chore: update project dependencies 2026-04-03 13:46:13 +02:00
Björn Fromme fce6d1615a chore: cleanup, add PHPStan type annotations 2026-04-03 13:45:41 +02:00
Björn Fromme a264526127 chore: integrate with PHPStan 2026-04-03 13:45:23 +02:00
Björn Fromme fbfffbb31a fix: simplify trait to address incorrect arguments 2026-03-28 10:18:34 +01:00
Björn Fromme bca3610cff feat: delete orphaned future snapshots after xml sync 2026-03-25 15:35:12 +01:00
Björn Fromme facd91375c chore: update project dependencies 2026-03-25 14:11:00 +01:00
Björn Fromme 96321005e0 feat: combine log entries into single sum-up entry 2026-03-25 14:10:25 +01:00
Björn Fromme 6f07678f04 fix: reduce snapshot payloads via serializer group and hydration 2026-03-24 17:06:10 +01:00
Björn Fromme 3b6ee90a7e feat: refresh existing snapshots from XML after sync 2026-03-24 11:13:11 +01:00
Björn Fromme 909e8a1d8e chore: update project dependencies 2026-03-23 19:41:04 +01:00
Björn Fromme 8f2baca339 chore: update scheduler config to delete expired oauth2 tokens 2026-03-23 19:38:12 +01:00
Björn Fromme 637166bfd6 chore: remove schema drift 2026-03-23 19:27:37 +01:00
Björn Fromme 45af3a21b7 chore: replace deprecated attribute 2026-03-23 19:23:59 +01:00
Björn Fromme 7e686cef6b chore: add inline documentation 2026-03-23 19:22:18 +01:00
Björn Fromme be43d30cc7 fix: skip snapshots of travels still available as XML from refreshing 2026-03-23 19:15:45 +01:00
Björn Fromme 77b16f287a chore: remove obsolete feature flag 2026-03-23 17:43:12 +01:00
Björn Fromme bc7fb794bf feat: persist travel snapshots and refresh extended availability 2026-03-23 17:29:10 +01:00
Björn Fromme c1ab6d8687 chore: adopt monolog config for staging environment from prod 2026-03-23 17:28:51 +01:00
Björn Fromme ccfafc5da4 fix: allow hotel-less travel data
addresses #869ckkwh7
2026-03-23 16:59:49 +01:00
Björn Fromme 502281e219 chore: add staging to http client environments 2026-03-23 11:50:40 +01:00
Björn Fromme b81cc58d5e chore: update http-client config 2026-03-20 14:24:04 +01:00
Björn Fromme 05ac07d656 fix: delete unavailable files during xml sync
addresses #869chrmp4
2026-03-20 14:23:51 +01:00
Björn Fromme 3234c78f0d feat: harden xml parsing to avoid uncaught errors 2026-03-20 12:14:02 +01:00
Björn Fromme 3849ae306c feat: extended log view and searching by error codes 2026-03-20 11:44:55 +01:00
Björn Fromme 301b14f518 feat: dedicated room pricing calculation for step 1 of create flow 2026-03-19 16:40:59 +01:00
Björn Fromme df8566e1b5 feat: move scope of enforced 'option' status to full booking
addresses #869chtfeq
2026-03-19 16:15:07 +01:00
Björn Fromme 45d05786da fix: correctly calculate room pricing based on participant assignment 2026-03-19 15:45:23 +01:00
Björn Fromme 6a6bebb2d1 feat: update wording and style of password reset flash message 2026-03-19 12:41:06 +01:00
Björn Fromme ea76c808de chore: update field label 2026-03-19 09:46:06 +01:00
Björn Fromme f9069540a9 chore: update project dependencies 2026-03-19 09:44:03 +01:00
Björn Fromme 8a9868b644 chore: update http request config with current travel date 2026-03-19 09:43:37 +01:00
Björn Fromme d1e2bf3e7d feat: integrate with MailJet API for newsletter registration 2026-03-19 09:42:00 +01:00
Björn Fromme 66c55f0722 chore: update project dependencies 2026-03-18 15:25:31 +01:00
Björn Fromme ca7d0b6965 feat: extended price mismatch diagnostics in logs 2026-03-18 15:24:52 +01:00
Björn Fromme 02ff28acdd fix: use correct amount of rooms to calculate rooms pricing 2026-03-18 15:24:29 +01:00
Björn Fromme cb56a4fe46 feat: implement mutability cutoff for rentals
addresses #869cca42x
2026-03-17 10:29:43 +01:00
Björn Fromme 752e6752b4 feat: limit room remarks field to 200 characters to align with buspro 2026-03-17 10:01:40 +01:00
Björn Fromme 00df76269a chore: cleanup after master flatten rewrite 2026-03-17 09:44:50 +01:00
Björn Fromme 99483b3506 chore: update project dependencies 2026-03-17 08:58:44 +01:00
Björn Fromme 5619a6f22f fix: XML scope for services too broad 2026-03-16 20:25:56 +01:00
Björn Fromme c510cbf590 feat: auto-booking services
addresses #869cfcptv
2026-03-16 20:24:58 +01:00
Björn Fromme 6d9265de0f fix: restore unintended file changes after master flatten rewrite 2026-03-16 14:15:51 +01:00
Björn Fromme 49ea562322 fix: enforce submit-time immutability, scope booking cache per user 2026-03-16 12:03:00 +01:00
Björn Fromme 1f087b6c08 chore: update test to recent changes 2026-03-16 12:03:00 +01:00
Björn Fromme 37c839600e fix: reduce mutability data cache ttl to avoid staleness
addresses #869cdzmkq
2026-03-16 12:03:00 +01:00
Björn Fromme cef2e4fa49 fix: ensure mutability is accounted for when restoring drafts
addresses #869cdzmkq
2026-03-16 12:03:00 +01:00
Björn Fromme 11ff2b9084 fix: assign mandatory services when applying dummy data 2026-03-16 12:03:00 +01:00
Björn Fromme 69f16b59cb chore: update project dependencies 2026-03-16 12:03:00 +01:00
Björn Fromme 0e644260b6 feat: update wording 2026-03-16 12:03:00 +01:00
Björn Fromme 33609ab534 feat: shortened and filename-safe request ids 2026-03-16 12:03:00 +01:00
Björn Fromme 1cc7a025a1 fix: ensure pickups node exists in api payload 2026-03-16 12:03:00 +01:00
Björn Fromme 709b0ff61e fix: ensure drop-offs are submitted when same as pickups 2026-03-16 12:03:00 +01:00
Björn Fromme 0d7d90a442 feat: anonymization command for request/response XML dumps 2026-03-16 12:03:00 +01:00
Björn Fromme 5cbdc5eaf2 chore: update project dependencies 2026-03-16 12:03:00 +01:00
Björn Fromme a7c3064df6 feat: adopt new pickups planning payload JSON structure 2026-03-16 12:03:00 +01:00
Björn Fromme f63d52c9d6 chore: cleanup 2026-03-16 12:03:00 +01:00
Björn Fromme b752256bda fix: validate mandatory services selection 2026-03-16 12:03:00 +01:00
Björn Fromme 474d171779 fix: correctly validate pickup selection
closes #869c94jqn
2026-03-16 12:03:00 +01:00
Björn Fromme 51ce9b6e45 fix: don't restore selections from draft for immutable fields 2026-03-16 12:03:00 +01:00
Björn Fromme 9cef24f827 fix: enforce mutability checks for all fields 2026-03-16 12:03:00 +01:00
Björn Fromme e6880b7fed chore: remove dead code 2026-03-16 12:03:00 +01:00
Björn Fromme e505eba111 chore: add git ignored temp directory 2026-03-16 12:03:00 +01:00
Björn Fromme 4e067d13b3 feat: avoid indexing of content by crawlers 2026-03-16 12:03:00 +01:00
Björn Fromme 40c0042158 feat: use normalized checkbox values in field handlers 2026-03-16 12:03:00 +01:00
Björn Fromme 6abfa122e4 chore: update plan for symfony 7.4 upgrade 2026-03-16 12:03:00 +01:00
Björn Fromme 032540a712 chore: update project dependencies 2026-03-16 12:03:00 +01:00
Björn Fromme cf95048f2b fix: prevent race conditions in htmx boost response handling 2026-03-16 12:03:00 +01:00
Björn Fromme 29a1ce7de7 fix: ensure loading indicator when entering booking flow in Chrome 2026-03-16 12:03:00 +01:00
Björn Fromme 1670b8b8d2 fix: avoid selection trap for mandatory services with age constraints 2026-03-16 12:03:00 +01:00
Björn Fromme 3644b935fd fix: always fill-in empty participants names when submitting to api 2026-03-16 12:03:00 +01:00
Björn Fromme 20162cceef chore: update project dependencies 2026-03-16 12:03:00 +01:00
Björn Fromme 44a9de7db7 fix: correct sort order of pickups and drop-offs 2026-03-16 12:03:00 +01:00
Björn Fromme f99a2d99a2 fix: show pickup select field for outbound bus travel only 2026-03-16 12:03:00 +01:00
Björn Fromme f795a3ea21 feat: selectable drop-offs for inbound bus travel 2026-03-16 12:03:00 +01:00
Björn Fromme f8a56b7de2 chore: split scheduler config for prod and staging 2026-03-16 12:03:00 +01:00
Björn Fromme 8a667ccaba chore: update project dependencies 2026-03-16 12:03:00 +01:00
Björn Fromme 6ddd0b00bd fix: handle empty api responses more gracefully 2026-03-16 12:03:00 +01:00
Björn Fromme c597cfda51 feat: legal links in page footer 2026-03-16 12:03:00 +01:00
Björn Fromme f673bc59d0 fix: catch unexpected api response for personal data 2026-03-16 12:03:00 +01:00
Björn Fromme 78b925daa6 fix: backward compatibility for new session format 2026-03-16 12:03:00 +01:00
Björn Fromme 4e05c94912 fix: correctly determine file size and avoid type mismatch 2026-03-16 12:03:00 +01:00
Björn Fromme acb599b760 feat: show booking status in sidebar 2026-03-16 12:03:00 +01:00
Björn Fromme c7b70dbd56 feat: dummy data fill
closes #869c3khk0
2026-03-16 12:03:00 +01:00
Björn Fromme b4f7299f27 chore: update project dependencies 2026-03-16 12:03:00 +01:00
Björn Fromme 6fd5a218a3 feat: reduce session payload 2026-03-16 12:03:00 +01:00
Björn Fromme a1518c06fb chore: use correct database type to replicate prod environment 2026-03-16 12:03:00 +01:00
Björn Fromme c517a0b41f chore: add staging environment to deployer config 2026-03-16 12:03:00 +01:00
Björn Fromme ded904fac8 fix: correct room assignment and count
closes #869c2jnzr
2026-03-16 12:03:00 +01:00
Björn Fromme abea6bff7b chore: align node and npm versions of DDEV with host, use npm ci 2026-03-16 12:03:00 +01:00
Björn Fromme 06f53ba6eb feat: fill default names for canceled participants in agency bookings 2026-03-16 12:03:00 +01:00
Björn Fromme eda0919f52 chore: update frontend tooling 2026-03-16 12:03:00 +01:00
Björn Fromme 1f27c744d2 chore: code cleanup 2026-03-16 12:02:59 +01:00
Björn Fromme bbc011ddb9 feat: update personal data of canceled participants as well
addresses #869bze0kq
2026-03-16 12:02:59 +01:00
Björn Fromme c2054a1ef6 chore: update project dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme f9d420fecd feat: revert allow updating room assignments in edit mode 2026-03-16 12:02:59 +01:00
Björn Fromme 3e1d719512 chore: update platform specs in composer.json for prod 2026-03-16 12:02:59 +01:00
Björn Fromme f685bd18ab chore: update project dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme f46cc59041 fix: allow updating room assignments in edit mode 2026-03-16 12:02:59 +01:00
Björn Fromme 380b1ed329 chore: streamline namespacing 2026-03-16 12:02:59 +01:00
Björn Fromme 1056b4fba6 chore: update project dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme 19031dba99 fix: enforce profile completion of applicant in all situations 2026-03-16 12:02:59 +01:00
Björn Fromme 028db7a7ee fix: ensure complete participant data even when canceled 2026-03-16 12:02:59 +01:00
Björn Fromme a1764faaeb chore: update project dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme 8529bb0e21 fix: streamline datalayer integration for all events 2026-03-16 12:02:59 +01:00
Björn Fromme 9eefdd1858 fix: properly initialize CMP, GTM and data layer for tracking 2026-03-16 12:02:59 +01:00
Björn Fromme e5803860df feat: remove street from hotel address in sidebar
addresses #869bwq1vh
2026-03-16 12:02:59 +01:00
Björn Fromme 2754d734aa chore: update frontend dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme 3487ca4822 chore: update project dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme 02ebddc34a feat: require all personal data fields for registration 2026-03-16 12:02:59 +01:00
Björn Fromme 3957f6bd09 feat: tracking integration with GTM and CMP 2026-03-16 12:02:59 +01:00
Björn Fromme 11ab98b3d8 feat: domain-based theme-detection 2026-03-16 12:02:59 +01:00
Björn Fromme e24403949c chore: update database and php versions to match prod environment 2026-03-16 12:02:59 +01:00
Björn Fromme 288f6b91d0 chore: update deployer config for new hosting environment 2026-03-16 12:02:59 +01:00
Björn Fromme 8ca105966d fix: don't normalize hotel and product codes when fetching cms data 2026-03-16 12:02:59 +01:00
Björn Fromme 592cd517ff chore: update project url in ddev 2026-03-16 12:02:59 +01:00
Björn Fromme 586b410a66 chore: update project dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme a444940db2 feat: display error codes in admin overview 2026-03-16 12:02:59 +01:00
Björn Fromme afe5aef9f7 feat: error-codes in flash messages to reference log entries 2026-03-16 12:02:59 +01:00
Björn Fromme 21a3e8fafc feat: user-friendly error messages on login 2026-03-16 12:02:59 +01:00
Björn Fromme 26bbba5a2e feat: streamlined flash messages 2026-03-16 12:02:59 +01:00
Björn Fromme b68d5c9398 fix: revert pickup pricing to match current BusPro behavior
Reverts split pricing calculation to use outbound-only pricing,
matching BusPro's current behavior where pickup is only charged when
outbound transportation is BUS.

- Use pickup->price directly instead of calculateEffectivePrice()
- PKW+BUS scenario now correctly charges €0 (matches BusPro loophole)
- priceOutbound/priceInbound remain populated for future activation
- Updated documentation with current behavior and future plan
2026-03-16 12:02:59 +01:00
Björn Fromme c1425f0562 feat: extensible participant status determination for booking creation
Adds a rule-based system to determine participant status in CREATE
payloads. Participants selecting a 'Begleitperson' service now receive
status 'O' (Option), all others default to 'F' (Final).

- Add ParticipantStatusRuleInterface for defining status rules
- Add ParticipantStatusRuleRegistry for priority-based rule evaluation
- Add ChaperonServiceStatusRule for Begleitperson detection
- Integrate status evaluation into BookingPayloadBuilder
2026-03-16 12:02:59 +01:00
Björn Fromme e1f1b60608 feat: infotext for insurance booking conditions
closes #869bvcu2p
2026-03-16 12:02:59 +01:00
Björn Fromme ea1c95528c chore: update httpclient config to use random values for contactform 2026-03-16 12:02:59 +01:00
Björn Fromme 0cac2969ae feat: log contact form submissions 2026-03-16 12:02:59 +01:00
Björn Fromme b856c51389 chore: remove debug logging in toast controller 2026-03-16 12:02:59 +01:00
Björn Fromme aa3b23ecb8 feat: info text and correct field type for service group 'VEG'
addresses #869bvch71
2026-03-16 12:02:59 +01:00
Björn Fromme 1e653e213a fix: don't hide voucher fields for booking status 'O'
closes #869bvbkrd
2026-03-16 12:02:59 +01:00
Björn Fromme 7d91dcfb70 chore: update test to fix deprecation warning 2026-03-16 12:02:59 +01:00
Björn Fromme 8e7e6266f0 chore: update project dependencies 2026-03-16 12:02:59 +01:00
Björn Fromme 167ccf5151 feat: ignored room types
closes #869bvbmcw
2026-03-16 12:02:59 +01:00
Björn Fromme c582e0820a feat: move profile completeness check from login to booking flow entry
- Add profileComplete flag to User entity to avoid API calls
- Set flag during login (BpnAuthenticator) and profile save
- Check flag in IndexController when entering booking flow
- Remove ProfileCompletionSubscriber (no longer needed)

Users with incomplete profiles are only redirected when starting
a new booking, not on every login. Eliminates extra API call by
caching completeness status on the user entity.
2026-03-16 12:02:59 +01:00
Björn Fromme 7d3a63d744 chore: update alert wording to be more descriptive 2026-03-16 12:02:59 +01:00
Björn Fromme 0a0987873f feat: enforce profile data completeness before entering booking flow 2026-03-16 12:02:59 +01:00
Björn Fromme 18d60c8781 feat: allow editing of nationality in personal data 2026-03-16 12:02:59 +01:00
Björn Fromme 7708ea6a0f fix: default participant's nationality to German to avoid missing data 2026-03-16 12:02:59 +01:00
Björn Fromme 71a46c6b63 chore: update rsync excludes in deployer config 2026-03-16 12:02:55 +01:00
Björn Fromme 667324df69 feat: body dimensions mandatory with selected rentals, updated help text
feat: body dimensions mandatory with selected rentals
2026-03-16 12:02:28 +01:00
Björn Fromme 957e20d763 fix: preserve selected services from booking even when unavailable
When editing a booking, services that were previously booked but are no
longer available in the travel catalog were being dropped. This caused
API error 650 ("Anzahl Leistung stimmt nicht mit Teilnehmerzuordnung
überein") because the service participant counts no longer matched.

The fix ensures that in edit mode, booked services are merged with
travel data services in both:
- Form rendering (ParticipantFieldOptionsProvider): so checkboxes appear
- Handler validation (AbstractParticipantFieldHandler): so selections
  are accepted

This allows users to keep their existing service selections or
deliberately replace them with other available options. Create mode
remains unchanged.
2026-03-16 12:02:28 +01:00
Björn Fromme 029a87c6ca doc: add technical documentation 2026-03-16 12:02:28 +01:00
Björn Fromme baa4609102 fix: ensure unique services mapping when creating api payload 2026-03-16 12:02:28 +01:00
Björn Fromme 875943e4da feat: match modes for room selection condition to detect MBZ*
addresses #869btdcyh
2026-03-16 12:02:28 +01:00
Björn Fromme 10e674cdd0 feat: improved logic to control field visibility
addresses #869btdphv
2026-03-16 12:02:28 +01:00
Björn Fromme 0bf78b0b81 fix: correctly handle discounts applied by dpn api 2026-03-16 12:02:28 +01:00
Björn Fromme c90b93556a feat: cli command to download travel xml data from buspro api 2026-03-16 12:02:28 +01:00
Björn Fromme f45718ea06 fix: correct body dimensions values 2026-03-16 12:02:28 +01:00
Björn Fromme 6d418ee8cc fix: account for discounts applied by bpn api in validation request 2026-03-16 12:02:28 +01:00
Björn Fromme 4851b1b72d feat: revert room price fetching for performance reasons 2026-03-16 12:02:28 +01:00
Björn Fromme dfb6f88be9 feat: trigger form refresh for dob when completely entered only 2026-03-16 12:02:28 +01:00
Björn Fromme f28227384d fix: fetch fresh room prices from api to avoid calculation mismatch 2026-03-16 12:02:28 +01:00
Björn Fromme cef9be92f3 fix: consider booking status O correctly 2026-03-16 12:02:28 +01:00
Björn Fromme f849d67a79 fix: correctly compare age range 2026-03-16 12:02:28 +01:00
Björn Fromme fba3becb06 feat: always load fresh api data when starting to edit booking 2026-03-16 12:02:28 +01:00
Björn Fromme a23ff6d119 chore: update project dependencies 2026-03-16 12:02:28 +01:00
Björn Fromme 384744c958 feat: xlsx export of booking edit draft data for admins 2026-03-16 12:02:28 +01:00
Björn Fromme 70c6617e88 feat: merge draft data with api response for selected fields 2026-03-16 12:02:28 +01:00
Björn Fromme 9afe3b334e fix: ensure nationality is assigned when editing booking data 2026-03-16 12:02:28 +01:00
Björn Fromme ad67d4c7ae chore: update project dependencies 2026-03-16 12:02:28 +01:00
Björn Fromme b7eaed3ac3 fix: populate booking number with edit drafts 2026-03-16 12:02:28 +01:00
Björn Fromme 136e2d68a2 feat: integrate with easy admin bundle 2026-03-16 12:02:27 +01:00
Björn Fromme bdcd54687d feat: streamlined booking number property 2026-03-16 12:02:27 +01:00
Björn Fromme 99190b0ef1 wip: integrate with easy admin bundle 2026-03-16 12:02:27 +01:00
Björn Fromme c7702b0c37 feat: cleanup favicons 2026-03-16 12:02:27 +01:00
Björn Fromme 9b88a2c14d feat: custom hx redirect class 2026-03-16 12:02:27 +01:00
Björn Fromme 6a945e6a08 feat: adjust xml dump reader command to new filename format 2026-03-16 12:02:27 +01:00
Björn Fromme 9c7cf0778f chore: update lts upgrade plan with current state of the project 2026-03-16 12:02:27 +01:00
Björn Fromme dd0f1a25d0 feat: always allow editing of participants for internal bookings
Prior to this commit the mutability flag from BusProNet was evaluated
and would potentially conflict with this rule. This flag is now
completely ignored for internal bookings.
2026-03-16 12:02:27 +01:00
Björn Fromme 7f4cf3060e fix: add suffix to request id in xml-dump filenames to avoid overwrites 2026-03-16 12:02:27 +01:00
Björn Fromme 30496a4875 feat: cleanup, log successful bookings 2026-03-16 12:02:27 +01:00
Björn Fromme 027508f495 chore: add draft cleanup command to schedule 2026-03-16 12:02:27 +01:00
Björn Fromme b5e2b2a2ad fix: use correct property path for travel date determination 2026-03-16 12:02:27 +01:00
Björn Fromme 9c9db05b86 feat: cli command to backfill travel dates on draft records, single use 2026-03-16 12:02:27 +01:00
Björn Fromme 1fa17a7df7 feat: cli command to clean up outdated or stale booking draft records 2026-03-16 12:02:27 +01:00
Björn Fromme e09e5858d8 fix: don't update booking session and draft on form refresh
Each form refresh would create a draft even for users that just explore
booking options causing stale draft records in the database.
2026-03-16 12:02:27 +01:00
Björn Fromme 6ede062a66 feat: streamlined display of hotel image, name and address 2026-03-16 12:02:27 +01:00
Björn Fromme b351b4042f chore: update project dependencies 2026-03-16 12:02:27 +01:00
Björn Fromme 95d0ce33d4 feat: new service category 'VEG' 2026-03-16 12:02:27 +01:00
Björn Fromme 75366167e8 chore: update httpclient config 2026-03-16 12:02:27 +01:00
Björn Fromme 7fa7e4be45 feat: command to read xml-dumps from bpn api by provided request id 2026-03-16 12:02:27 +01:00
Björn Fromme d14dafbade fix: include gender and nationality into group of mandatory fields 2026-03-16 12:02:27 +01:00
Björn Fromme e222f448bb feat: proper determination of agency initiated bookings
For booking that are agency initiated (groups) different rules apply
concerning mutability and requirement of personal data and whether the
first participant is the applicant or not. This commit adds logic to
evaluate the agency id assigned to the booking data to decide.
2026-03-16 12:02:27 +01:00
Björn Fromme f55ed3dc4c feat: log request ids to map xml dumps of bpn api communication 2026-03-16 12:02:27 +01:00
Björn Fromme fedfcc247e fix: update draft together with session on refresh 2026-03-16 12:02:27 +01:00
Björn Fromme 8186adfaeb fix: show actual error message 2026-03-16 12:02:27 +01:00
Björn Fromme 00304facba fix: allow null argument for gender mapping 2026-03-16 12:02:27 +01:00
Björn Fromme ff241f437f fix: insurance ids are strings 2026-03-16 12:02:27 +01:00
Björn Fromme 4a6b9cd64d fix: exclude pickup surcharge when self-organized outbound 2026-03-16 12:02:27 +01:00
Björn Fromme 8646dfbf02 fix: avoid float rounding errors when comparing 2026-03-16 12:02:27 +01:00
Björn Fromme d6f2681c61 feat: load hotel data locally for sidebar 2026-03-16 12:02:27 +01:00
Björn Fromme 5a47ca6053 feat: default status of new bookings to option during beta phase
addresses #869bpb7fg
2026-03-16 12:02:22 +01:00
Björn Fromme d7fd273475 fix: ensure rental insurance is selectable when insurance is available
addresses #869bp9ev5
2026-03-16 12:01:09 +01:00
Björn Fromme ce828a6ef5 fix: correctly distinguish between first participant and applicant 2026-03-16 12:01:09 +01:00
Björn Fromme 15dbd72f93 fix: don't include default date of birth in contact form submissions 2026-03-16 12:01:09 +01:00
Björn Fromme e3bdd8737f feat: update field label 2026-03-16 12:01:09 +01:00
Björn Fromme 7942656026 fix: redirect to dashboard as default when logged in 2026-03-16 12:01:09 +01:00
Björn Fromme 1fe8a2cb17 feat: load balancing of api requests, cleanup api client 2026-03-16 12:01:09 +01:00
Björn Fromme ab48b1f867 fix: properly handle parking service selection 2026-03-16 12:01:09 +01:00
Björn Fromme 3043842254 fix: add missing LoggerInterface dependency to BookingDataProcessorTest 2026-03-16 12:01:09 +01:00
Björn Fromme 249e326de4 feat: retry api requests when closed immediately due to overload 2026-03-16 12:01:09 +01:00
Björn Fromme b3cace3b43 fix: avoid null pointer exceptions, log warning about removed services 2026-03-16 12:01:09 +01:00
Björn Fromme ad578179d8 fix: avoid scrolling to the bottom when making a selection
closes #869bnn1bw
2026-03-16 12:01:09 +01:00
Björn Fromme c57351f8b5 chore: update scheduler config 2026-03-16 12:01:09 +01:00
Björn Fromme c5934e4c5b chore: reduce logger noise 2026-03-16 12:01:09 +01:00
Björn Fromme 06e64bd3b8 fix: only assign dummy data to the applicant 2026-03-16 12:01:09 +01:00
Björn Fromme 48572812a9 fix: assign dummy values to missing applicant data fields
This fix avoids rejection of API requests due to missing data of the
applicant. The current user most probably is not aware about this so
they get confused about the error messages otherwise.
2026-03-16 12:01:09 +01:00
Björn Fromme 46b610370a feat: draft inspection cli command 2026-03-16 12:01:09 +01:00
Björn Fromme 96d4b8ed45 fix: apply correct return type and response handling 2026-03-16 12:01:09 +01:00
Björn Fromme c18ba3d815 fix: only invalidate cache and remove draft on successful edit 2026-03-16 12:01:09 +01:00
Björn Fromme fd81079247 chore: code cleanup 2026-03-16 12:01:09 +01:00
Björn Fromme e5781fbabc fix: invalidate booking data caches after updating personal data 2026-03-16 12:01:09 +01:00
Björn Fromme 8887b06583 fix: streamline price label for transportation services 2026-03-16 12:01:09 +01:00
Björn Fromme 9955ff7df9 feat: link terms in confirmation checkbox label 2026-03-16 12:01:09 +01:00
Björn Fromme db74b6049c feat: remove cancel confirmation since obsolete because of drafts 2026-03-16 12:01:09 +01:00
Björn Fromme 0b5c7af4be feat: automatic saving of drafts when editing bookings 2026-03-16 12:01:09 +01:00
Björn Fromme 721016a00e fix: adopt general styles to error page template 2026-03-16 12:01:09 +01:00
Björn Fromme 563522d261 chore: update deployer config 2026-03-16 12:01:09 +01:00
Björn Fromme 04f4455491 chore: update project dependencies 2026-03-16 12:01:09 +01:00
Björn Fromme b976ce480a fix: restore .user.ini from master 2026-03-16 12:01:09 +01:00
Björn Fromme 6d5f7cfd22 chore: update upgrade plan with current state of the project 2026-03-16 12:01:09 +01:00
Björn Fromme 9baed0987e chore: update project dependencies 2026-03-16 12:01:09 +01:00
Björn Fromme b76d17d35c feat: add env variable for website base url 2026-03-16 12:01:09 +01:00
Björn Fromme 4e182612e8 chore: complete scheduler config for email notifications 2026-03-16 12:01:09 +01:00
Björn Fromme 645729a518 feat: remove outdated log entries scheduled 2026-03-16 12:01:09 +01:00
Björn Fromme 9ee4c1cd5c fix: fail gracefully when remote XML data cannot be read during sync 2026-03-16 12:01:09 +01:00
Björn Fromme 02742aef5b fix: catch travel data not found errors to show proper flash message 2026-03-16 12:01:09 +01:00
Björn Fromme 87af2c3855 chore: update project dependencies 2026-03-16 12:01:09 +01:00
Björn Fromme ea1ce4a029 docs: add inline documentation where missing 2026-03-16 12:01:09 +01:00
Björn Fromme 2fe9185a6f feat: cron controlled cli command to sync xml data with remote 2026-03-16 12:00:56 +01:00
Björn Fromme ef191c55be chore: cleanup flysystem config 2026-03-16 12:00:56 +01:00
Björn Fromme abd74a127a chore: update json test-data for pickups planning 2026-03-16 12:00:56 +01:00
Björn Fromme 52ac269b8d fix: avoid duplicated pickup planning entries 2026-03-16 12:00:56 +01:00
Björn Fromme 643f65ce3b chore: reduce noise in logs 2026-03-16 12:00:56 +01:00
Björn Fromme 465ce3e69a feat: add logging to api endpoint 2026-03-16 12:00:56 +01:00
Björn Fromme 207b648f36 fix: don't show bulk insurance labels in summary 2026-03-16 12:00:56 +01:00
Björn Fromme 7f2d52a5ce feat: accept multiple travel codes per pickup planning 2026-03-16 12:00:56 +01:00
Björn Fromme f1eaa95c44 chore: update project dependencies 2026-03-16 12:00:56 +01:00
Björn Fromme e203de172d chore: rotate auth log 2026-03-16 12:00:56 +01:00
Björn Fromme 79b7e2aaa5 feat: forcibly invalidate availabilities cache on booking start 2026-03-16 12:00:56 +01:00
Björn Fromme 3c2ade9299 feat: loading indicator on initial page load 2026-03-16 12:00:56 +01:00
Björn Fromme f3f2dc6cec fix: adopt changes in test 2026-03-16 12:00:56 +01:00
Björn Fromme 12b69bd681 feat: load availability data once per booking session 2026-03-16 12:00:56 +01:00
Björn Fromme 8f542ff448 chore: disable saving cookies for api requests 2026-03-16 12:00:56 +01:00
Björn Fromme 80c81c11fb feat: log pickups planning data api calls 2026-03-16 12:00:56 +01:00
Björn Fromme e3809bd930 chore: update project dependencies 2026-03-16 12:00:56 +01:00
Björn Fromme d755e29459 chore: reduce logger noise 2026-03-16 12:00:56 +01:00
Björn Fromme 105eb5a9cd chore: retain xml dumps for three days to save on webspace 2026-03-16 12:00:56 +01:00
Björn Fromme 65f8436aa7 feat: extended oauth2 user info and refactored authenticator 2026-03-16 12:00:56 +01:00
Björn Fromme 4afdd79252 fix: ensure template variable exists 2026-03-16 12:00:56 +01:00
Björn Fromme d1e807b11f feat: adjust oauth2 implementation for TYPO3 backend login 2026-03-16 12:00:56 +01:00
Björn Fromme ee84690cd9 feat: basic themes for event sites 2026-03-16 12:00:56 +01:00
Björn Fromme c7a27f1dc7 feat: api endpoints for pickups planning 2026-03-16 12:00:56 +01:00
Björn Fromme 1fd9f8d1f8 feat: always show all available ski passes but readonly if age doesn't match 2026-03-16 12:00:56 +01:00
Björn Fromme b57d849ecd feat: update labels of voucher fields
addresses #869bf8tzv
2026-03-16 12:00:56 +01:00
Björn Fromme 654ae4dc0b feat: use query parameters for booking urls 2026-03-16 12:00:56 +01:00
Björn Fromme 3984a623bf fix: properly assign readonly state to form fields for unavailable services 2026-03-16 12:00:56 +01:00
Björn Fromme 96619c6cec fix: reset room assignments after updated selection if required 2026-03-16 12:00:56 +01:00
Björn Fromme 3dd92ccdb4 feat: consistent display of included services and prices 2026-03-16 12:00:56 +01:00
Björn Fromme b111a615a9 feat: show unavailable transportation services as readonly 2026-03-16 12:00:56 +01:00
Björn Fromme 2372958713 fix: don't show incorrect notification about updated rentals 2026-03-16 12:00:56 +01:00
Björn Fromme 82a8541af1 feat: show inquiry status with rooms in step 1 2026-03-16 12:00:56 +01:00
Björn Fromme ea162972fa feat: hide parking from participants under 18 2026-03-16 12:00:55 +01:00
Björn Fromme 094d6858d1 fix: don't auto assign discounted transport to babies 2026-03-16 12:00:55 +01:00
Björn Fromme 65398d59d4 chore: simplify parameter handling 2026-03-16 12:00:55 +01:00
Björn Fromme 3df9659ce2 feat: configurable choices for body dimensions
closes #8697phk21
2026-03-16 12:00:55 +01:00
Björn Fromme c4f4f53fd6 chore: update deployer config for prod 2026-03-16 12:00:55 +01:00
Björn Fromme b567b6a840 feat: re-implement contact form submissions api endpoint for website 2026-03-16 12:00:55 +01:00
Björn Fromme ab18e5f134 feat: adjust participant number size depending on value 2026-03-16 12:00:55 +01:00
Björn Fromme 929115c02b feat: update deployer config to build assets upfront 2026-03-16 12:00:55 +01:00
Björn Fromme e26884c099 feat: align rendering of pickups field with other services 2026-03-16 12:00:55 +01:00
Björn Fromme 077bcdcaf5 fix: avoid loading indicator issues when using browser history for navigation 2026-03-16 12:00:55 +01:00
Björn Fromme 685ac5a94a chore: update project dependencies 2026-03-16 12:00:55 +01:00
Björn Fromme 3d1b75b591 fix: ensure flash message is displayed after redirect 2026-03-16 12:00:55 +01:00
Björn Fromme e1bfe9b93c feat: show room prices in room assignment form field 2026-03-16 12:00:55 +01:00
Björn Fromme 5b75a165ce fix: account for htmx issues in mobile safari with select fields 2026-03-16 12:00:55 +01:00
Björn Fromme e12fbc85d4 fix: disable autocomplete in chrome for good 2026-03-16 12:00:55 +01:00
Björn Fromme 6bbef0a975 feat: various improvements in styles and layout 2026-03-16 12:00:55 +01:00
Björn Fromme bc175ca438 fix: avoid stale caches 2026-03-16 12:00:55 +01:00
Björn Fromme ef15772b97 feat: streamlined rendering of fields as static text in forms 2026-03-16 12:00:55 +01:00
Björn Fromme da5b913757 feat: render room assignment as radio buttons 2026-03-16 12:00:55 +01:00
Björn Fromme 6b5a51f652 feat: replace overly complex and unnecessary form choice loader for rooms 2026-03-16 12:00:55 +01:00
Björn Fromme af2620c8b3 fix: ensure footer bar is visible on mobile safari 2026-03-16 12:00:55 +01:00
Björn Fromme 15fdfdc42e feat: load cms data early to display image on login page 2026-03-16 12:00:55 +01:00
Björn Fromme ce88e1f97b feat: improved layout for participant form 2026-03-16 12:00:55 +01:00
Björn Fromme a67e3a5eac fix: disable autocompletion on personal data fields 2026-03-16 12:00:55 +01:00
Björn Fromme cc747f0690 feat: body dimensions optional 2026-03-16 12:00:55 +01:00
Björn Fromme a6d2661562 feat: improved error handling and redirect on invalid session 2026-03-16 12:00:55 +01:00
Björn Fromme 4b95b12a7b feat: indicate incomplete participant data in cards 2026-03-16 12:00:55 +01:00
Björn Fromme cca8554e3d feat: special treatment of services for participants of age 0-2 2026-03-16 12:00:55 +01:00
Björn Fromme 28c426ca3c feat: increase availability cache ttl and ensure fresh data when entering form 2026-03-16 12:00:55 +01:00
Björn Fromme be83a4295a docs: upgrade plan to symfony 7.4 2026-03-16 12:00:55 +01:00
Björn Fromme 87439ee509 feat: optional return url 2026-03-16 12:00:41 +01:00
Björn Fromme 36ef54d541 chore: cleanup 2026-03-16 11:59:12 +01:00
Björn Fromme 400407358d fix: remove obsolete htmx attributes from forms 2026-03-16 11:59:12 +01:00
Björn Fromme 502b121e43 chore: cleanup 2026-03-16 11:59:12 +01:00
Björn Fromme 61447ced4f fix: enforce https 2026-03-16 11:59:12 +01:00
Björn Fromme 1e107c68b4 feat: implement hx-boost for reliable loading indication 2026-03-16 11:59:12 +01:00
Björn Fromme 602fb31f27 fix: ensure loading indicator is triggered 2026-03-16 11:59:12 +01:00
Björn Fromme 2a8531a025 chore: update project dependencies 2026-03-16 11:59:12 +01:00
Björn Fromme 980589e190 fix: remove form options cache causing issues 2026-03-16 11:59:12 +01:00
Björn Fromme 4a2d1f4a02 feat: implement screendesign 2026-03-16 11:59:12 +01:00
Björn Fromme 39e6cc4d68 chore: update project dependencies 2026-03-16 11:59:12 +01:00
Björn Fromme e5018432cb feat: adjust loading indicator styles 2026-03-16 11:59:12 +01:00
Björn Fromme dbf0b03bac fix: add loading indicator to all 'back' buttons 2026-03-16 11:59:12 +01:00
Björn Fromme 01060035bc fix: consider mode in summary 2026-03-16 11:59:12 +01:00
Björn Fromme fa0cf5a654 fix: correctly handle email uniqueness by excluding the applicant from validation 2026-03-16 11:59:12 +01:00
Björn Fromme 4f1a1cad8e feat: request replay command for bpn api 2026-03-16 11:59:12 +01:00
Björn Fromme 8c8aae9a1e feat: refactoring and cleanup 2026-03-16 11:59:12 +01:00
Björn Fromme 230c53875f feat: finalize voucher handling and discount display in summary and overview 2026-03-16 11:59:12 +01:00
Björn Fromme 2e40cbc7c7 feat: special treatment of participants with age 0-2 (babies) 2026-03-16 11:59:12 +01:00
Björn Fromme cd1ffc3e9a fix: don't disable room selection field with single option 2026-03-16 11:59:12 +01:00
Björn Fromme 4db2cd7eda fix: clear target path when canceling booking flow 2026-03-16 11:59:12 +01:00
Björn Fromme f230b36382 feat: conditionally render room assignment field in participant edit form 2026-03-16 11:59:12 +01:00
Björn Fromme b872f2a5db feat: prevent selection of baby rooms without regular rooms 2026-03-16 11:59:11 +01:00
Björn Fromme c6678d16a6 feat: step input for room type selection 2026-03-16 11:59:11 +01:00
Björn Fromme b73a02048e fix: correctly determine number of participants 2026-03-16 11:59:11 +01:00
Björn Fromme 1f2f2751e4 feat: correct determination of inquiry status and conditional voucher field display 2026-03-16 11:59:11 +01:00
Björn Fromme 85998534fe feat: correct handling of vouchers 2026-03-16 11:59:11 +01:00
Björn Fromme 8e7b8bd75f feat: layout improvements 2026-03-16 11:59:11 +01:00
Björn Fromme fb983f23dd feat: improved promotional and purchase vouchers 2026-03-16 11:59:11 +01:00
Björn Fromme 7df99521c4 feat: promotional and purchase vouchers 2026-03-16 11:59:11 +01:00
Björn Fromme a8c0a1aa61 feat: fetch additional content from cms via api 2026-03-16 11:59:11 +01:00
Björn Fromme fc05350ae4 feat: sort pickups by their times 2026-03-16 11:59:11 +01:00
Björn Fromme ba7de8fa01 fix: never validate insurance selection in edit mode 2026-03-16 11:59:11 +01:00
Björn Fromme 778cf80e6b fix: don't validate insurance selection of participants when in bulk assignment 2026-03-16 11:59:11 +01:00
Björn Fromme f01bb7d271 chore: update project dependencies 2026-03-16 11:59:11 +01:00
Björn Fromme 6a031e5a71 feat: loading indicators for regular get requests 2026-03-16 11:59:11 +01:00
Björn Fromme 37924841e1 feat: improved timeout handling for api client 2026-03-16 11:59:11 +01:00
Björn Fromme be90248fc7 feat: remove 'remember me' functionality from login form in booking process 2026-03-16 11:59:11 +01:00
Björn Fromme cb38beb32c feat: surcharges and discounts per participant and in summary 2026-03-16 11:59:11 +01:00
Björn Fromme cc8500d0f4 fix: don't rely on individual prices being available for all participants 2026-03-16 11:59:11 +01:00
Björn Fromme 61d4e36f1f feat: ensure inquiry info is always visible 2026-03-16 11:59:11 +01:00
Björn Fromme 5c5f164aa2 feat: improved dynamic validation 2026-03-16 11:59:11 +01:00
Björn Fromme 5275e6c732 fix: ensure validation of participant data 2026-03-16 11:59:11 +01:00
Björn Fromme f3ea6734a1 fix: keep browser history intact when navigating between cards view and participant form 2026-03-16 11:59:11 +01:00
Björn Fromme bb20176c76 wip: integrate with typo3 to fetch product and hotel data 2026-03-16 11:59:11 +01:00
Björn Fromme beee44a10e fix: streamlined single room selection handling 2026-03-16 11:59:11 +01:00
Björn Fromme 43823dbda1 feat: improved mutability checks for personal data in edit or create mode 2026-03-16 11:59:11 +01:00
Björn Fromme ff39bf365c feat: improved handling of fields to be rendered as static text 2026-03-16 11:59:11 +01:00
Björn Fromme 5d69e4a8aa feat: gravatar integration 2026-03-16 11:59:11 +01:00
Björn Fromme 142db19627 feat: integrate with scheduler bundle 2026-03-16 11:59:11 +01:00
Björn Fromme 7b84ecbb56 fix: adapt changes in create mode for edit mode, cleanup 2026-03-16 11:59:11 +01:00
Björn Fromme dc6de1734a feat: authenticated booking 2026-03-16 11:59:11 +01:00
Björn Fromme 1e464cef0a feat: performance improvements 2026-03-16 11:59:11 +01:00
Björn Fromme 4decaa6789 chore: fix deprecations in preparation uf upgrading to symfony 7.3 2026-03-16 11:59:11 +01:00
Björn Fromme a057088808 fix: incorrect and unnecessary template inheritance 2026-03-16 11:59:11 +01:00
Björn Fromme e46053a7b7 chore: consolidate documentation 2026-03-16 11:59:11 +01:00
Björn Fromme 91d9e82d84 feat: price info tooltip for bulk insurance booking
addresses #869axbjzz
2026-03-16 11:59:11 +01:00
Björn Fromme 608f5ed3e9 feat: rearrange field order
addresses #869axbhz3
2026-03-16 11:59:11 +01:00
Björn Fromme 9b2e209b4f feat: group rentals and rental insurance fields visually
addresses #869ay16cp
2026-03-16 11:59:11 +01:00
Björn Fromme bae1582631 feat: dedicated section for discounts in summary
addresses #869ay1724
2026-03-16 11:59:11 +01:00
Björn Fromme c3fe76d78c feat: automatic unselection of discounted transportation 2026-03-16 11:59:11 +01:00
Björn Fromme 8abab0e379 fix: improved position of info icon 2026-03-16 11:59:11 +01:00
Björn Fromme d31204188b fix: adopt wrapper dto to dynamic form field 2026-03-16 11:59:11 +01:00
Björn Fromme a74f34b559 feat: display service descriptions as tooltip with info icon
addresses #869axbnj0
2026-03-16 11:59:11 +01:00
Björn Fromme 37cda348b0 feat: remove room selection field entirely for single room type
addresses #869axcukn
2026-03-16 11:59:11 +01:00
Björn Fromme 38656e73c5 chore: cleanup, update documentation 2026-03-16 11:59:11 +01:00
Björn Fromme 03ac430be4 fix: validation error indication on cards 2026-03-16 11:59:11 +01:00
Björn Fromme 89979d6ce4 wip: email uniqueness validation 2026-03-16 11:59:11 +01:00
Björn Fromme 54a3145710 wip: improved validation and feedback 2026-03-16 11:59:11 +01:00
Björn Fromme 7aea149b6c feat: unique email addresses of participants unless child, cleanup
addresses #869axbn21
2026-03-16 11:59:11 +01:00
Björn Fromme f50aa55e8b feat: additional conformation checkbox for terms and conditions
addresses #869axbm44
2026-03-16 11:59:11 +01:00
Björn Fromme 6c0d7c30c6 feat: improved layout for pickup selection to make price visible
addresses #869axbjmd
2026-03-16 11:59:11 +01:00
Björn Fromme af163d597e feat: prefer discounted transportation services
addresses #869axbjbz
2026-03-16 11:59:11 +01:00
Björn Fromme d0c8302d21 feat: improved form field layout for date of birth 2026-03-16 11:59:11 +01:00
Björn Fromme 44e2d76525 feat: show placeholders for individual date parts in date of birth field 2026-03-16 11:59:11 +01:00
Björn Fromme 8f274bbb44 feat: replace date field for participant's birthday 2026-03-16 11:59:11 +01:00
Björn Fromme 8436124cbc wip: implement e2e tests with cypress 2026-03-16 11:59:11 +01:00
Björn Fromme 647fa8e212 feat: unassign rooms from other participants when unavailable 2026-03-16 11:59:11 +01:00
Björn Fromme 6cc5886073 feat: automatically assign rooms only for unique room type selection 2026-03-16 11:59:11 +01:00
Björn Fromme 513c889926 chore: update project dependencies 2026-03-16 11:59:11 +01:00
Björn Fromme 9c6f4d59b3 chore: cleanup, updated docs 2026-03-16 11:59:11 +01:00
Björn Fromme a0d5768752 feat: consolidated insurance service 2026-03-16 11:59:11 +01:00
Björn Fromme 4f951cdf8a feat: integrate with spatie/ray for debugging and performance optimization 2026-03-16 11:59:11 +01:00
Björn Fromme 619e2ba1ee feat: extended pricing breakdown on confirmation page 2026-03-16 11:59:11 +01:00
Björn Fromme 7d6a80eefa fix: fix floating point precision error 2026-03-16 11:59:11 +01:00
Björn Fromme 2403f8fb29 chore: update documentation 2026-03-16 11:59:11 +01:00
Björn Fromme b485112ed3 chore: adjust and cleanup tests 2026-03-16 11:59:11 +01:00
Björn Fromme 1f5c835f8a wip: bulk insurance refactoring 2026-03-16 11:59:11 +01:00
Björn Fromme a3d7ee63d3 wip: bulk insurance booking fix 2026-03-16 11:59:11 +01:00
Björn Fromme a8cf67728b wip: finalize implementation 2026-03-16 11:59:11 +01:00
Björn Fromme c830fc7841 wip: finalize implementation 2026-03-16 11:59:11 +01:00
Björn Fromme 5ef3bb9cbf wip: finalize implementation 2026-03-16 11:59:11 +01:00
Björn Fromme b882175f36 feat: readonly room selection for single room types 2026-03-16 11:59:11 +01:00
Björn Fromme d20215929d feat: display booked insurance in edit mode 2026-03-16 11:59:11 +01:00
Björn Fromme ea312b9986 chore: remove debug statements 2026-03-16 11:59:11 +01:00
Björn Fromme 0559341f31 fix: show feedback modal 2026-03-16 11:59:10 +01:00
Björn Fromme 1f4dc2d4a2 fix: adopt changed template name 2026-03-16 11:59:10 +01:00
Björn Fromme 14ca0f5c59 fix: adjust deployer config 2026-03-16 11:59:10 +01:00
Björn Fromme e51c4843c5 feat: refactor to cards 2026-03-16 11:59:10 +01:00
Björn Fromme 32a9fac9ed fix: ensure skipass is selected 2026-03-16 11:59:10 +01:00
Björn Fromme de8cbf6178 wip: refactor to cards with individual participant forms 2026-03-16 11:59:10 +01:00
Björn Fromme 33e1aea80c wip: add documentation for plan 2026-03-16 11:59:10 +01:00
Björn Fromme 83589ee677 feat: htmx powered requests, improved loading indicator
feat: loading indicator and htmx form submit
2026-03-16 11:59:10 +01:00
Björn Fromme 023ecdebc9 feat: inquiry bookings 2026-03-16 11:59:10 +01:00
Björn Fromme 2d9cb2c0b9 chore: remove outdated documentation 2026-03-16 11:59:10 +01:00
Björn Fromme 5c0814ef9b wip: modernized edit flow, fix insurance tier calculation 2026-03-16 11:59:10 +01:00
Björn Fromme 28831a3b06 wip: modernized edit flow 2026-03-16 11:59:10 +01:00
Björn Fromme 0d073a36a3 wip: insurance booking in edit flow 2026-03-16 11:59:10 +01:00
Björn Fromme 069600d602 fix: set correct default agency 2026-03-16 11:59:10 +01:00
Björn Fromme d70a27f2fd wip: backport booking create flow form handling to edit flow 2026-03-16 11:59:10 +01:00
Björn Fromme e330626b03 feat: unify pickup selection 2026-03-16 11:59:10 +01:00
Björn Fromme 0f4b16c194 feat: comprehensive booking overview for confirmation 2026-03-16 11:59:10 +01:00
Björn Fromme c89bda86b7 fix: use correct country tokens 2026-03-16 11:59:10 +01:00
Björn Fromme 56fa5355fd chore: update project dependencies 2026-03-16 11:59:10 +01:00
Björn Fromme 7ea52670b9 wip: submit booking to api 2026-03-16 11:59:10 +01:00
Björn Fromme ee264e88d4 wip: confirmation step 2026-03-16 11:59:10 +01:00
Björn Fromme 3f7db4e772 feat: implement payment step 2026-03-16 11:59:10 +01:00
Björn Fromme 3d9fc18a83 fix: don't submit data of removed fields 2026-03-16 11:59:10 +01:00
Björn Fromme 9eeed7f992 wip: backport form handing system from create flow to edit flow 2026-03-16 11:59:10 +01:00
Björn Fromme ba0a86cbea fix: ignore past travels in list view when patching data 2026-03-16 11:59:10 +01:00
Björn Fromme 41d8d65d82 feat: inform user about automatic reassignments or reset selections 2026-03-16 11:59:10 +01:00
Björn Fromme 57843b8a6f feat: insurance info urls in tooltips 2026-03-16 11:59:10 +01:00
Björn Fromme d6258d0ca1 feat: customizable label for unavailable services 2026-03-16 11:59:10 +01:00
Björn Fromme 66ccebb02b feat: make room selection fields read-only for single room type 2026-03-16 11:59:10 +01:00
Björn Fromme e3081e56f8 feat: extended insurance parsing, pass info urls to view 2026-03-16 11:59:10 +01:00
Björn Fromme 201b223813 fix: filter rentals by skipass duration 2026-03-16 11:59:10 +01:00
Björn Fromme 08d534e39b feat: auto-assign rooms 2026-03-16 11:59:10 +01:00
Björn Fromme 4489169553 wip: improved insurance selection 2026-03-16 11:59:10 +01:00
Björn Fromme 16c239716b wip: check eligibility based on ski pass availability 2026-03-16 11:59:10 +01:00
Björn Fromme f5913eb947 chore: update docs 2026-03-16 11:59:10 +01:00
Björn Fromme f2681af9ee feat: bulk insurance booking by applicant 2026-03-16 11:59:10 +01:00
Björn Fromme 66dd51d75a feat: improved form layout 2026-03-16 11:59:10 +01:00
Björn Fromme 5f78361533 chore: remove debugging statements 2026-03-16 11:59:10 +01:00
Björn Fromme 3c1192298b feat: show room pricing details 2026-03-16 11:59:10 +01:00
Björn Fromme 0bd9d79bbe fix: layout improvements 2026-03-16 11:59:10 +01:00
Björn Fromme a2e055f313 fix: consider participant's age at travel date 2026-03-16 11:59:10 +01:00
Björn Fromme ca1d5c4303 fix: ensure mandatory services are selected 2026-03-16 11:59:10 +01:00
Björn Fromme e6eebc00a3 wip: insurance booking 2026-03-16 11:59:10 +01:00
Björn Fromme 29bdf794f3 wip: insurance booking 2026-03-16 11:59:10 +01:00
Björn Fromme 4ba81b8f03 wip: insurance booking 2026-03-16 11:59:10 +01:00
Björn Fromme 8af7f0e797 wip: insurance booking phase 5 2026-03-16 11:59:10 +01:00
Björn Fromme c67fa18ad3 wip: insurance booking phase 4 2026-03-16 11:59:10 +01:00
Björn Fromme 6c9da6537e wip: insurance booking phase 3 2026-03-16 11:59:10 +01:00
Björn Fromme 241785b428 wip: insurance booking phases 1 and 2 2026-03-16 11:59:10 +01:00
Björn Fromme 411166ffba feat: correct calculation and display of individual pricing 2026-03-16 11:59:10 +01:00
Björn Fromme 203c9ebbca chore: update project dependencies 2026-03-16 11:59:10 +01:00
Björn Fromme ede37d5d0f feat: improved error handling in controllers, cleanup 2026-03-16 11:59:10 +01:00
Björn Fromme 4f02529b15 feat: improved error handling of xml parsers and loaders 2026-03-16 11:59:10 +01:00
Björn Fromme 0f6b73340e feat: parser and loader for insurance data 2026-03-16 11:59:10 +01:00
Björn Fromme 96c53cc173 feat: functionality to determine family status of a booking 2026-03-16 11:59:10 +01:00
Björn Fromme 7ce3bc18b2 feat: prevent booking flow without available rooms 2026-03-16 11:59:10 +01:00
Björn Fromme 82b47e314b feat: validate room selection 2026-03-16 11:59:10 +01:00
Björn Fromme 9d2474c1e3 feat: render tooltips 2026-03-16 11:59:10 +01:00
Björn Fromme 787f69fc46 feat: streamlined tooltip data attributes 2026-03-16 11:59:10 +01:00
Björn Fromme 054d4e52b2 chore: update project dependencies 2026-03-16 11:59:10 +01:00
Björn Fromme 0d1ef6d40e feat: mark unavailable services as read-only and add tooltip 2026-03-16 11:59:10 +01:00
Björn Fromme ead448b8d4 feat: simplify validation 2026-03-16 11:59:10 +01:00
Björn Fromme 7ff10e6792 feat: simplify handling of ski pass service 2026-03-16 11:59:10 +01:00
Björn Fromme 3d6c94e7c5 feat: cached api results for availabilities 2026-03-16 11:59:10 +01:00
Björn Fromme 7673e64738 chore: streamline property naming 2026-03-16 11:59:10 +01:00
Björn Fromme 4230c53812 feat: enrich travel data with live availabilities 2026-03-16 11:59:10 +01:00
Björn Fromme 3198e03cc4 feat: replace xss extension with symfony component 2026-03-16 11:59:10 +01:00
Björn Fromme 1e48e107fc wip: skipass-duration based rentals filtering 2026-03-16 11:59:10 +01:00
Björn Fromme ed7c889c4f wip: transportation services with discount 2026-03-16 11:59:10 +01:00
Björn Fromme 84f4481ef0 wip: service descriptions and car license plate field 2026-03-16 11:59:10 +01:00
Björn Fromme b84997d058 wip: rentals insurance field 2026-03-16 11:59:10 +01:00
Björn Fromme 3063a7c05f wip: improved refresh logic 2026-03-16 11:59:10 +01:00
Björn Fromme 36fe4c9e9a wip: transportation services in summary 2026-03-16 11:59:10 +01:00
Björn Fromme 5c5134b69f wip: update documentation 2026-03-16 11:59:10 +01:00
Björn Fromme e7fb764158 wip: dynamic availability of services 2026-03-16 11:59:10 +01:00
Björn Fromme fe624cfd1c wip: implement transportation, pickups and parking 2026-03-16 11:59:10 +01:00
Björn Fromme 81b8237570 wip: transportation services 2026-03-16 11:59:10 +01:00
Björn Fromme a019c7dae0 wip: prepare transportation services 2026-03-16 11:59:10 +01:00
Björn Fromme 552dd029fa wip: update docs 2026-03-16 11:59:10 +01:00
Björn Fromme bd5c6359fe wip: show pricing 2026-03-16 11:59:10 +01:00
Björn Fromme 6bd483d768 wip: dynamic services fields 2026-03-16 11:59:09 +01:00
Björn Fromme 7003c0d81b wip: age based filtering of options 2026-03-16 11:59:09 +01:00
Björn Fromme b038d9a41f wip: age based filtering of options 2026-03-16 11:59:09 +01:00
Björn Fromme 89c42cf7e4 wip: age based filtering of options 2026-03-16 11:59:09 +01:00
Björn Fromme cd1e5ad3ad chore: update project dependencies 2026-03-16 11:59:09 +01:00
Björn Fromme f36c761c28 wip: conditional mandatory body dimensions 2026-03-16 11:59:09 +01:00
Björn Fromme 0f7d95d31b wip: additional services with constraints 2026-03-16 11:59:09 +01:00
Björn Fromme 4b92f56ed7 wip: courses 2026-03-16 11:59:09 +01:00
Björn Fromme b54fb07e84 wip: booking process 2026-03-16 11:59:09 +01:00
Björn Fromme 11825191cf wip: major refactoring 2026-03-16 11:59:09 +01:00
Björn Fromme d719b17aec wip: form refactoring 2026-03-16 11:59:09 +01:00
Björn Fromme 65a33922cf wip: improved booking summary 2026-03-16 11:59:09 +01:00
Björn Fromme 727ee9b014 wip: refactor 2026-03-16 11:59:09 +01:00
Björn Fromme 4d51f0334f wip: booking summary for step 1 2026-03-16 11:59:09 +01:00
Björn Fromme 86e4866d6a wip: refactor 2026-03-16 11:59:09 +01:00
Björn Fromme e75276ed88 wip: refactor 2026-03-16 11:59:09 +01:00
Björn Fromme bfdf4c0453 fix: correct handling of readonly checkboxes with tooltips 2026-03-16 11:59:09 +01:00
Björn Fromme f90902c5e3 chore: update browserslist 2026-03-16 11:59:09 +01:00
Björn Fromme d0d6844fbd wip: field states and dependencies 2026-03-16 11:59:09 +01:00
Björn Fromme cd7a724e62 wip: refactor forms 2026-03-16 11:59:09 +01:00
Björn Fromme 1f5877460b wip: refactor forms 2026-03-16 11:59:09 +01:00
Björn Fromme 8da77ee13b wip: collapsible forms 2026-03-16 11:59:09 +01:00
Björn Fromme d205cd09ef wip: refactor forms 2026-03-16 11:59:09 +01:00
Björn Fromme b44d8505b2 wip: feedback about booked and assigned rooms 2026-03-16 11:59:09 +01:00
Björn Fromme 9d99fe9272 wip: refactoring 2026-03-16 11:59:09 +01:00
Björn Fromme 431839d36d wip: refactoring 2026-03-16 11:59:09 +01:00
Björn Fromme cce1f688b9 wip: refactoring 2026-03-16 11:59:09 +01:00
377 changed files with 19490 additions and 8949 deletions

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
## Description: Run Claude Code
## Usage: claude [args]
## Example: ddev claude
## Example: ddev claude --help
## Example: ddev claude doctor
# Ensure ~/.local/bin is in PATH
export PATH="$HOME/.local/bin:$PATH"
# Check if claude is installed
if [ ! -f "$HOME/.local/bin/claude" ]; then
echo "Claude Code is not installed. Installing now..."
curl -fsSL https://claude.ai/install.sh | bash
fi
# Run claude with all arguments
exec "$HOME/.local/bin/claude" "$@"
+14
View File
@@ -40,6 +40,12 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
MAILER_DSN=null://null
###< symfony/mailer ###
MAILJET_API_KEY=
MAILJET_API_SECRET=
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
MAILJET_NEWSLETTER_LIST_ID=
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
APP_BPN_USER=
APP_BPN_PASSWORD=
APP_BPN_IP=
@@ -60,6 +66,14 @@ APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
# Travel Data Service Configuration
APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# Feature Flags
FEATURE_TRAVEL_SNAPSHOT=true
# Emails
APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected]
# Booking Configuration
# Status for new bookings: 'F' (Fixed/Final), 'O' (Option - requires agency confirmation)
+4
View File
@@ -33,3 +33,7 @@ yarn-error.log
###< friendsofphp/php-cs-fixer ###
/ray.php
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
+1 -1
View File
@@ -60,7 +60,7 @@ Authorization: Bearer {{$auth.token("oauth2_api")}}
### API travel
# @no-cookie-jar
GET {{base_url}}/api/travels/SBW131225
GET {{base_url}}/api/travels/DPWMP140326
Accept: application/json
Authorization: Bearer {{$auth.token("oauth2_api")}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+16 -11
View File
@@ -1,7 +1,7 @@
.button {
@apply inline-flex items-center justify-center space-x-2 h-10 leading-10 px-4 md:px-8 cursor-pointer;
@apply uppercase leading-none text-center;
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-light;
@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;
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
@apply rounded-md;
}
@@ -20,42 +20,47 @@
}
.button--secondary {
@apply bg-none bg-primary-dark text-white;
@apply hover:bg-primary-light/80;
@apply bg-none bg-primary-light text-white;
@apply hover:bg-primary-light/50;
}
.button--neutral {
@apply bg-none bg-gray-200;
@apply hover:bg-gray-200/50;
}
.sbw {
.button--primary {
@apply bg-none bg-sbw-secondary text-white;
@apply hover:bg-none hover:bg-sbw-primary/80;
@apply hover:bg-none hover:bg-sbw-primary/50;
}
.button--secondary {
@apply bg-none bg-sbw-primary-dark text-white;
@apply hover:bg-none hover:bg-sbw-primary/80;
@apply hover:bg-none hover:bg-sbw-primary/50;
}
}
.snz {
.button--primary {
@apply bg-none bg-snz-secondary text-white;
@apply hover:bg-none hover:bg-snz-primary/80;
@apply hover:bg-none hover:bg-snz-primary/50;
}
.button--secondary {
@apply bg-none bg-snz-primary-dark text-white;
@apply hover:bg-none hover:bg-snz-primary/80;
@apply hover:bg-none hover:bg-snz-primary/50;
}
}
.ser {
.button--primary {
@apply bg-none bg-ser-secondary text-white;
@apply hover:bg-none hover:bg-ser-primary/80;
@apply hover:bg-none hover:bg-ser-primary/50;
}
.button--secondary {
@apply bg-none bg-ser-primary text-white;
@apply hover:bg-none hover:bg-ser-primary/80;
@apply hover:bg-none hover:bg-ser-primary/50;
}
}
+40 -36
View File
@@ -4,7 +4,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"php": ">=8.2",
"ext-ctype": "*",
"ext-dom": "*",
"ext-iconv": "*",
@@ -16,6 +16,7 @@
"doctrine/orm": "^3.3",
"easycorp/easyadmin-bundle": "^4.27",
"fakerphp/faker": "^1.24",
"flagception/flagception-bundle": "^6.1",
"league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^1.0",
@@ -26,38 +27,38 @@
"phpstan/phpdoc-parser": "^2.0",
"spatie/crypto": "^2.1",
"symfony/apache-pack": "^1.0",
"symfony/asset": "6.4.*",
"symfony/console": "6.4.*",
"symfony/doctrine-messenger": "6.4.*",
"symfony/dom-crawler": "6.4.*",
"symfony/dotenv": "6.4.*",
"symfony/expression-language": "6.4.*",
"symfony/asset": "7.4.*",
"symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*",
"symfony/dom-crawler": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2",
"symfony/form": "6.4.*",
"symfony/framework-bundle": "6.4.*",
"symfony/html-sanitizer": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/lock": "6.4.*",
"symfony/mailer": "6.4.*",
"symfony/mime": "6.4.*",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/html-sanitizer": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*",
"symfony/lock": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "6.4.*",
"symfony/process": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.21",
"symfony/string": "6.4.*",
"symfony/translation": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/uid": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/web-link": "6.4.*",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.2",
"symfony/yaml": "6.4.*",
"symfony/yaml": "7.4.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/html-extra": "^3.15",
"twig/intl-extra": "*",
@@ -112,22 +113,25 @@
"extra": {
"symfony": {
"allow-contrib": true,
"require": "6.4.*",
"require": "7.4.*",
"docker": true
}
},
"require-dev": {
"deployer/deployer": "^7.5",
"friendsofphp/php-cs-fixer": "^3.75",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^9.5",
"spatie/ray": "^1.43",
"spatie/x-ray": "^1.2",
"symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/debug-bundle": "6.4.*",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^7.1",
"symfony/stopwatch": "6.4.*",
"symfony/web-profiler-bundle": "6.4.*"
"symfony/phpunit-bridge": "7.4.*",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
}
}
Generated
+1478 -1102
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -18,4 +18,5 @@ return [
Zenstruck\ScheduleBundle\ZenstruckScheduleBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true],
Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true],
];
+11
View File
@@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout
+2
View File
@@ -0,0 +1,2 @@
flagception:
features: {}
+2 -2
View File
@@ -13,7 +13,7 @@ framework:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
sync: 'sync://'
default_bus: messenger.bus.default
@@ -21,7 +21,7 @@ framework:
messenger.bus.default: []
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
+4 -2
View File
@@ -59,7 +59,7 @@ when@test:
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
when@prod: &prod_config
monolog:
handlers:
database:
@@ -104,4 +104,6 @@ when@prod:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json
formatter: monolog.formatter.json
when@staging: *prod_config
+3
View File
@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true
+2
View File
@@ -1,6 +1,8 @@
twig:
file_name_pattern: '*.twig'
form_themes: ['forms.html.twig']
paths:
'%kernel.project_dir%/assets/images': images
globals:
htmx_change_trigger: 'change delay:100ms'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
+18 -2
View File
@@ -24,6 +24,22 @@ zenstruck_schedule:
frequency: "30 1 * * *"
description: "Removes expired drafts"
- task: app:cleanup:newsletter-opt-in-requests
frequency: "45 1 * * *"
description: "Removes expired pending newsletter double opt-in requests"
- task: league:oauth2-server:clear-expired-tokens
frequency: "0 * * * *"
description: "Removes expired oauth2 tokens hourly"
- task: app:bpn:refresh-travel-snapshot --refresh-after 60
frequency: "10 8-19 * * *"
description: "Syncs travel data snapshots with BusPro XML data hourly during peak hours"
- task: app:bpn:refresh-travel-snapshot --refresh-after 180
frequency: "10 20-23,0-7 * * *"
description: "Syncs travel data snapshots with BusPro XML data every 3 hours outside peak hours"
when@staging:
zenstruck_schedule:
mailer:
@@ -37,8 +53,8 @@ when@prod:
tasks:
- task: app:bpn:xml-sync
frequency: "*/15 8-19 * * *"
description: "Sync BusPro XML data every 15 min. during peak hours"
description: "Syncs BusPro XML data every 15 min. during peak hours"
- task: app:bpn:xml-sync
frequency: "0 20-23,0-7 * * *"
description: "Sync BusPro XML data hourly outside peak hours"
description: "Syncs BusPro XML data hourly outside peak hours"
+1943
View File
File diff suppressed because it is too large. Load diff
+38 -8
View File
@@ -9,6 +9,10 @@ parameters:
path_to_keys: '%kernel.project_dir%/config/secret'
bpn_debug: '%env(APP_BPN_DEBUG)%'
default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%'
newsletter_confirmation_ttl_hours: '%env(int:APP_NEWSLETTER_CONFIRMATION_TTL_HOURS)%'
travel_snapshot_retention_buffer_days: '%env(int:APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS)%'
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
# Body dimensions choices for BodyDimensionsType
body_dimensions.height_choices:
@@ -81,12 +85,18 @@ services:
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)%'
bpn_api_ports: '%env(csv_int: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\DependencyInjection\Env\CsvIntEnvVarProcessor: ~
App\BusProNet\XmlParser\DocumentsParser:
arguments:
$tempDir: '%kernel.project_dir%/temp'
App\Twig\AppRuntime:
arguments:
$intlExtension: '@twig.extension.intl'
@@ -99,17 +109,21 @@ services:
arguments:
$path: '%path_to_keys%'
App\Service\TravelDataService:
App\Service\TravelSnapshotManager:
arguments:
$retentionBufferDays: '%travel_snapshot_retention_buffer_days%'
App\Service\TravelDataProvider:
arguments:
$logger: '@monolog.logger.core'
$preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%'
$enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%'
App\Service\BookingEditDataLoaderService:
App\Service\BookingEditDataLoader:
arguments:
$bpnCache: '@bpn.cache'
App\Service\VoucherValidationService:
App\Service\VoucherValidator:
arguments:
$cache: '@cache.app'
$logger: '@monolog.logger.bpn'
@@ -148,16 +162,16 @@ services:
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
# Participant Status Rules
# Booking Status Rules
App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule: ~
# Participant Status Rule Registry
App\BusProNet\Service\ParticipantStatusRuleRegistry:
# Booking Status Rule Registry
App\BusProNet\Service\BookingStatusRuleRegistry:
arguments:
$rules:
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
App\Service\CmsDataService:
App\Service\CmsDataProvider:
arguments:
$httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
@@ -166,7 +180,23 @@ services:
tags:
- { name: monolog.processor }
App\Service\NewsletterManager:
arguments:
$newsletterConfirmationTtlHours: '%newsletter_confirmation_ttl_hours%'
App\Service\DomainConfigProvider:
arguments:
$domainConfig: '%domain_config%'
App\Email\Mailer:
arguments:
$defaults:
from: '%default_email_from%'
to: '%default_email_to%'
App\Service\MailjetApiClient:
arguments:
$mailjetApiKey: '%env(default::MAILJET_API_KEY)%'
$mailjetApiSecret: '%env(default::MAILJET_API_SECRET)%'
$mailjetApiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
$mailjetNewsletterListId: '%env(default::MAILJET_NEWSLETTER_LIST_ID)%'
-484
View File
@@ -1,484 +0,0 @@
# Symfony 7.4 Upgrade Plan
**Project:** MyEP Next Booking
**Current Version:** Symfony 6.4.x (6.4.33)
**Target Version:** Symfony 7.4 (LTS)
**Date:** 2026-02-23
**Status:** Ready for Upgrade
---
## Executive Summary
**Estimated Effort: LOW**
**Risk Level: LOW**
**Status: READY FOR UPGRADE**
The MyEP Next Booking application is well-positioned for a Symfony 7.4 upgrade. The codebase follows modern Symfony best practices, uses PHP 8 features extensively, and avoids most deprecated patterns. All pre-upgrade fixes have been completed.
**Key Facts:**
- Symfony 7.4 was released on November 27, 2025
- Requires PHP 8.2.0+ (current: PHP 8.3.30)
- LTS version with bug fixes until November 2028, security fixes until November 2029
- Symfony 8.0 = Symfony 7.4 minus deprecations (same features, PHP 8.4 required)
**Pre-Upgrade Status:**
- PHP version: 8.3.30 (compatible; Symfony 7.4 requires 8.2+)
- OAuth2 Server Bundle: 1.1.0 (compatible)
- Stimulus Bundle: 2.32.0 (compatible)
- Doctrine ORM: 3.6.0 (compatible)
- All 433 tests passing
- Email validation mode: Configured (html5)
- 18 test-only deprecation warnings (cosmetic, do not affect runtime)
---
## Previous Plan Status
The previous upgrade plan (dated 2025-12-06) has been updated. All pre-upgrade tasks are now complete.
### Completed Items
| Item | Previous Status | Current Status |
|------|----------------|----------------|
| OAuth2 Server Bundle | 1.0.0 | **1.1.0 installed** |
| Stimulus Bundle | 2.31.0 | **2.32.0 installed** |
| PHP 8.2 deprecations (ParticipantDto::$touched) | Planned | **Completed** |
| Doctrine config (controller_resolver) | Planned | **Completed** |
| SecurityController null safety | Needs verification | **Completed** (uses `?->` and `??`) |
| Email validator "loose" mode deprecation | Needs fix | **Completed** (validator.yaml configured) |
| Failing test in BookingDataProcessorTest | Unrelated to upgrade | **Resolved** (test now passes) |
---
## Current State Analysis
### PHP Version Compatibility
- **Current Runtime:** PHP 8.3.30
- **Composer Platform:** PHP 8.3.30
- **Symfony 7.4 Required:** PHP 8.2.0+
- **Status:** Compatible
### Installed Dependencies
#### Core Symfony Packages (6.4.x)
All 33 Symfony packages currently at 6.4.x need version constraint updates to 7.4.*
#### Third-Party Bundles - Compatible
| Package | Installed | Symfony 7.4 Compatible |
|---------|-----------|------------------------|
| `doctrine/orm` | 3.6.0 | Yes |
| `doctrine/doctrine-bundle` | 2.18.1 | Yes |
| `doctrine/doctrine-migrations-bundle` | 3.7.0 | Yes |
| `league/oauth2-server-bundle` | 1.1.0 | Yes |
| `symfony/stimulus-bundle` | 2.32.0 | Yes |
| `symfony/webpack-encore-bundle` | 2.4.0 | Yes |
| `symfony/monolog-bundle` | 3.11.0 | Yes |
| `zenstruck/schedule-bundle` | 1.9.0 | Yes |
| `league/flysystem-bundle` | 3.6.1 | Yes |
| `league/flysystem-sftp-v3` | 3.30.0 | Yes |
| `nelexa/zip` | 4.0.2 | Yes (verify post-upgrade) |
| `spatie/crypto` | 2.1.1 | Yes |
| `easycorp/easyadmin-bundle` | 4.29.1 (^4.27) | Yes |
### Code Quality Assessment
The codebase implements modern Symfony/PHP patterns:
- All controllers use PHP 8 attributes (`#[Route]`)
- Constructor property promotion used throughout
- Strict types enforced on all files
- Typed properties and return type declarations
- Entities use PHP 8 attributes for ORM mapping
- Modern security system (AbstractLoginFormAuthenticator)
- No deprecated annotation usage
---
## Pre-Upgrade Fixes - COMPLETED
### 1. Email Validator "loose" Mode Deprecation - DONE
**Issue:** The default mode for `#[Assert\Email]` is "loose", which is deprecated in Symfony 6.2+ and removed in Symfony 7.0.
**Resolution:** Framework default configured in `config/packages/validator.yaml`:
```yaml
framework:
validation:
email_validation_mode: html5
```
All Email constraints in the codebase also explicitly use `mode: 'strict'`:
- `src/Form/Model/ParticipantDto.php` - Has `mode: 'strict'`
- `src/Form/Model/RegistrationDto.php` - Has `mode: 'strict'`
- `src/BusProNet/Model/Communication.php` - Has `mode: 'strict'`
- `src/Controller/ResetPasswordController.php` - Has `mode: 'strict'`
### 2. Test Deprecation Warnings - COSMETIC ONLY
**Issue:** 18 deprecation warnings appear during test runs.
**Cause:** `ParticipantEditDtoTest` creates a standalone validator using `Validation::createValidatorBuilder()` which bypasses the framework's `validator.yaml` configuration.
**Impact:** These warnings are cosmetic and do not affect:
- Runtime behavior (all Email constraints use explicit `mode: 'strict'`)
- Symfony 7.4 compatibility (no code changes needed)
- Test results (all 433 tests pass)
**Note:** Warnings can be silenced with `SYMFONY_DEPRECATIONS_HELPER=disabled` if desired.
---
## Required Changes
### 1. Update Composer Version Constraints
**File:** `composer.json`
#### Symfony Core Packages
Update all packages from `6.4.*` to `7.4.*`:
```json
{
"require": {
"symfony/asset": "7.4.*",
"symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*",
"symfony/dom-crawler": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/html-sanitizer": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*",
"symfony/lock": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/string": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*",
"symfony/yaml": "7.4.*"
}
}
```
#### Development Packages
```json
{
"require-dev": {
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "7.4.*",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*"
}
}
```
Keep `symfony/maker-bundle` at `^1.0` (compatible with Symfony 7.4).
### 2. Update Symfony Flex Configuration
**File:** `composer.json`
```json
{
"extra": {
"symfony": {
"allow-contrib": true,
"require": "7.4.*",
"docker": true
}
}
}
```
---
## Potential Breaking Changes
### 1. Symfony Core Changes (Symfony 7.0 -> 7.4)
Symfony 7.0 removed all deprecated features from 6.4. Our codebase is fully compliant:
- **Native Return Types:** Already implemented
- **Attribute-based Configuration:** Already implemented
- **Security System:** Modern authenticator pattern in use
- **Form System:** Modern approach in use
- **No Annotation Usage:** All using PHP 8 attributes
### 2. Email Validation Mode
**Breaking Change:** The default `mode` for `Email` constraint changes from `loose` to `html5` in Symfony 7.0.
**Status:** Already handled - all constraints use explicit `mode: 'strict'` and framework default is set to `html5`.
### 3. Deprecation Cleanup
Deprecations accumulated in Symfony 7.1-7.4 will need review:
- Check Symfony Profiler after upgrade for any new deprecation warnings
- Review [UPGRADE-7.0.md](https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md)
- Review [UPGRADE-7.4.md](https://github.com/symfony/symfony/blob/7.4/UPGRADE-7.4.md)
---
## Upgrade Procedure
### Phase 1: Preparation (15 minutes)
1. **Create Feature Branch**
```bash
git checkout -b feature/symfony-7.4-upgrade
```
2. **Backup Current State**
```bash
git tag before-symfony-7.4-upgrade
```
### Phase 2: Dependency Updates (30 minutes - 1 hour)
1. **Update composer.json**
- Update all Symfony package constraints to `7.4.*`
- Update `extra.symfony.require` to `"7.4.*"`
- Consider tightening `php` to `>=8.2` (Symfony 7.4 requires 8.2+; optional if already on 8.3)
2. **Run Composer Update**
```bash
ddev composer update
```
3. **Review Recipe Updates**
```bash
ddev composer recipes
```
Review any recipe updates from Symfony Flex and apply as needed.
4. **Clear All Caches**
```bash
ddev exec bin/console cache:clear
ddev exec bin/console cache:warmup
ddev exec "php -r 'opcache_reset();'"
```
### Phase 3: Testing & Validation (1-2 hours)
1. **Run PHPUnit Test Suite**
```bash
ddev exec ./vendor/bin/phpunit
```
Check for:
- Test failures
- Deprecation warnings
- Fatal errors
2. **Run PHP-CS-Fixer**
```bash
ddev php vendor/bin/php-cs-fixer fix --dry-run --diff
```
3. **Manual Testing - Critical Paths**
- [ ] User authentication (BPN API login)
- [ ] OAuth2 token generation (`/token` endpoint)
- [ ] OAuth2 authorization flow (`/authorize` endpoint)
- [ ] API endpoints (all `/api/*` routes)
- [ ] Booking creation flow (Steps 1-4)
- [ ] Participant form rendering and submission
- [ ] Dynamic field updates (HTMX)
- [ ] Real-time pricing calculations
- [ ] Insurance selection and filtering
- [ ] Transportation service selection
- [ ] Payment method selection
- [ ] Final booking submission to BPN API
- [ ] Booking edit functionality
- [ ] PDF/XML download functionality
- [ ] User registration
- [ ] Password reset
- [ ] Personal data management
- [ ] EasyAdmin CRUD / admin panels
4. **Check Symfony Profiler**
- Review deprecation warnings (should be minimal)
- Check performance metrics
- Verify no errors in logs
5. **Test Frontend Assets**
```bash
npm run build
```
- Verify Stimulus controllers load correctly
- Test HTMX interactions
- Verify Toastify notifications
- Check TailwindCSS compilation
6. **Verify ZIP File Functionality**
- Test any features using `nelexa/zip` (package last updated 2022)
- Verify document generation and downloads work correctly
### Phase 4: Bug Fixes (0-2 hours)
Address any issues discovered during testing:
1. **Review Error Logs**
```bash
ddev logs
```
2. **Fix Any Remaining Deprecations**
- Use Symfony Profiler to identify deprecations
- Update code to use modern alternatives
### Phase 5: Deployment (1 hour)
1. **Deploy to Staging**
```bash
ddev exec vendor/bin/dep deploy staging
```
2. **Staging Validation**
- Full regression testing
- Performance testing
3. **Production Deployment**
- Schedule maintenance window
- Deploy during low-traffic period
- Monitor error logs closely
---
## Rollback Plan
### Immediate Rollback
If critical issues are discovered:
1. **Git Revert**
```bash
git checkout master
git reset --hard before-symfony-7.4-upgrade
```
2. **Restore Dependencies**
```bash
ddev composer install
```
3. **Clear Caches**
```bash
ddev exec bin/console cache:clear
```
### Production Rollback
If issues occur in production:
```bash
ddev exec vendor/bin/dep rollback production
```
---
## Testing Checklist
### Unit Tests
- [ ] All PHPUnit tests pass (433 tests)
- [ ] No new deprecation warnings
- [ ] Code coverage maintained
### Integration Tests
- [ ] BusProNet API integration
- [ ] XML parsing and data processing
- [ ] Booking price calculations
- [ ] Insurance assignment logic
- [ ] Field handler system
### Functional Tests
- [ ] User authentication flow
- [ ] OAuth2 token generation
- [ ] API endpoints respond correctly
- [ ] Booking creation (all steps)
- [ ] Form validation
- [ ] HTMX partial updates
- [ ] Dynamic field state changes
- [ ] Real-time pricing updates
- [ ] Toast notifications
### Browser Testing
- [ ] Chrome (latest)
- [ ] Firefox (latest)
- [ ] Safari (latest)
- [ ] Mobile browsers
---
## Timeline Estimate
| Phase | Estimated Time | Notes |
|-------|---------------|-------|
| **Phase 1:** Preparation | 15 min | Branch creation |
| **Phase 2:** Dependency Updates | 30 min - 1 hour | composer.json updates, composer update |
| **Phase 3:** Testing & Validation | 1-2 hours | Automated + manual testing |
| **Phase 4:** Bug Fixes | 0-2 hours | Address any issues |
| **Phase 5:** Deployment | 1 hour | Staging deployment |
| **Total** | **2.5-6 hours** | Average: **3.5 hours** |
---
## Future Considerations: Symfony 8.0
Symfony 8.0 was released alongside 7.4 (November 2025) with identical features but:
- Requires PHP 8.4
- No deprecation layers (all deprecated code removed)
- Shorter support window (8 months)
**Recommendation:** Stay on Symfony 7.4 LTS for stability. Plan PHP 8.4 upgrade separately, then consider Symfony 8.x path.
---
## Success Criteria
- [ ] All PHPUnit tests pass (433 tests)
- [ ] No deprecation warnings in Symfony Profiler
- [ ] All critical user flows functional
- [ ] OAuth2 authentication working
- [ ] BPN API integration operational
- [ ] Booking creation flow complete
- [ ] Frontend assets compile and load
- [ ] Performance metrics comparable or better
- [ ] No errors in production logs after 24 hours
---
## Additional Resources
### Official Symfony Documentation
- [Upgrade Major Version Guide](https://symfony.com/doc/current/setup/upgrade_major.html)
- [UPGRADE-7.0.md](https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md)
- [UPGRADE-7.4.md](https://github.com/symfony/symfony/blob/7.4/UPGRADE-7.4.md)
- [Symfony 7.4 Release Notes](https://symfony.com/releases/7.4)
- [Preparing for Symfony 7.4 and 8.0](https://symfony.com/blog/preparing-for-symfony-7-4-and-symfony-8-0)
### Bundle-Specific Documentation
- [OAuth2 Server Bundle](https://github.com/thephpleague/oauth2-server-bundle)
- [Doctrine ORM 3.x Documentation](https://www.doctrine-project.org/projects/orm.html)
---
**Document Version:** 7.0
**Previous Version:** 6.0 (dated 2026-01-09)
**Last Updated:** 2026-02-23
**Status:** Ready for Symfony 7.4 Upgrade (updated for current project state)
+196
View File
@@ -0,0 +1,196 @@
# Booking Flow Reference
Last updated: 2026-04-16
This document traces the booking create and edit flows from controller entry point through to session/DTO state. It is intended as orientation for developers unfamiliar with the flow, not as a substitute for reading the code.
---
## Shared Concepts
### `BookingDto`
The central data object for both flows. Holds room selections, participants, payment method, and travel reference. Lives in the HTTP session between requests. Has two modes: `MODE_CREATE` and `MODE_EDIT`.
### `BookingSessionManager`
Owns all reads and writes to session-stored `BookingDto` instances. Also manages the baseline room snapshot used for change detection in the create flow.
### `BookingCreateContextFactory`
Assembles the view model (`BookingCreateContext`) passed to create-flow templates. Handles room grouping (by-pax vs. by-room) and wires summary sidebar data.
---
## Create Flow
```
Step 1 (room selection)
→ Step 2 (participants)
→ Step 2 sub-form (per-participant, HTMX)
→ Step 3 (payment + API pre-validation)
→ Step 4 (confirmation + final submission)
→ Success
```
### Entry / Bootstrap
`BookingExceptionHandlerTrait::getOrCreateBookingCreateDto()` is called by every step controller. If no DTO exists in session, it calls `BookingConfigurator::startFreshBooking()` which loads travel data and initialises a fresh `BookingDto`.
### Step 1 — Room Selection
**Controller:** `Create/Step1Controller`
**Route:** `GET|POST /bookings/create/rooms`
1. Load or create DTO via `getOrCreateBookingCreateDto()`
2. `BookingSessionManager::getOrCreateBaselineSnapshot()` — saves the current room selection as a comparison point for change detection
3. Render `BookingCreateStep1Type` form
4. On submit:
- If room selection changed from baseline: `BookingDto::resetParticipantAssignments()` — clears stale assignments
- `BookingConfigurator::updateBookingStatusFromRoomSelection()` — may switch booking to inquiry mode if capacity constraints require it
- `BookingSessionManager::saveBookingDto()`, advance `currentStep` to 2
- `BookingSessionManager::clearBaselineSnapshot()`
- Redirect to Step 2
HTMX refresh (`/bookings/create/refresh`): processes the form without validation and returns updated room selection + summary sidebar blocks.
### Step 2 — Participants (Card Overview)
**Controller:** `Create/Step2Controller`
**Route:** `GET|POST /bookings/create/participants`
1. Load DTO from session (or redirect to Step 1 if missing)
2. `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
3. `ensureCorrectNumberOfParticipants()` — syncs the `BookingDto::participants` array length with the room selections (e.g., 2× double room → 4 participants). Preserves existing participant data for slots that still exist.
4. `ParticipantDataPrefiller::shouldPrepopulateApplicant()` + `prepopulateApplicantFromUser()` — prefills participant[0] from user profile on first visit
5. `RoomAssigner::validateAndResetInvalidAssignments()` — clears any room assignments that no longer match the current room selection (handles back-navigation from Step 2 → Step 1 → Step 2)
6. `RoomAssigner::assignRoomsIfNeeded()` — auto-assigns participants to rooms
7. `BookingConfigurator::preselectDefaultServices()` — selects mandatory and auto-book services for all participants
8. `BookingConfigurator::applyCreateBookingStatusRules()` — re-evaluates booking status (book / inquiry / not-bookable)
9. `BookingSessionManager::saveBookingDto()`
10. On form submit (all participants valid): advance to Step 3, redirect
### Step 2 — Individual Participant Form (HTMX)
**Controller:** `Create/Step2ParticipantController`
**Routes:** `GET|POST /bookings/create/participants/{index}` and `/participants/{index}/refresh`
Each participant card lazy-loads its form via HTMX. The controller:
1. Verifies the participant slot exists
2. Refreshes availability
3. Handles dummy data fill (`ParticipantDataPrefiller::isDummyDataFillRequested()` / `fillDummyParticipant()`)
4. On submit: re-applies status rules, saves to session, redirects to card overview
5. `ParticipantFormSupport` is used for form creation options and notification draining
### Step 3 — Payment + API Pre-Validation
**Controller:** `Create/Step3Controller`
**Route:** `GET|POST /bookings/create/payment`
1. Load DTO from session
2. `BookingConfigurator::applyCreateBookingStatusRules()`
3. On submit:
- `ApiClient::createBookingInquiry()` — submits booking to API for validation without committing it
- If inquiry fails with "nicht möglich" + "Anfrage" in message: auto-switch to inquiry mode, advance to Step 4
- If inquiry fails otherwise: show error, stay on step
- Price verification: `BookingPriceCalculator::calculateGrandTotal()` is compared against the API response total (accounting for promotional/goodwill voucher discounts and API-applied group discounts). Mismatch triggers `BookingPriceMismatchAnalyzer` for detailed logging.
- On success: advance `currentStep` to 4, redirect
### Step 4 — Confirmation + Final Submission
**Controller:** `Create/Step4Controller`
**Route:** `GET|POST /bookings/create/confirmation`
1. Load DTO from session
2. Newsletter subscription check for opt-in display
3. On submit:
- `BookingConfigurator::applyCreateBookingStatusRules()`
- `ApiClient::createBooking()` — final booking submission (already validated in Step 3)
- Newsletter double-opt-in if selected
- On success: store booking number + total in flash (for analytics), clear travel data cache, `BookingSessionManager::clearBookingDto()`, redirect to success page
---
## Edit Flow
```
Entry (start)
→ Load (session or API + draft restore)
→ Participant sub-forms (HTMX, per-participant)
→ Submission
```
### Entry
**Controller:** `Edit/IndexController::start()`
**Route:** `GET /bookings/{id}/edit/start`
Clears any existing session DTO for edit mode and invalidates the API booking cache, then redirects to the edit page. This ensures a clean load from API on every intentional re-entry.
### Load
**Controller:** `Edit/IndexController::index()`
**Route:** `GET|POST /bookings/{id}/edit`
Loading is delegated entirely to `BookingEditDataLoader::loadFormData()`:
**If session DTO exists and matches the requested booking ID:**
- `TravelDataProvider::enrichWithFreshAvailabilities()` — refreshes room availability
- `TravelDataProvider::getMutabilityData()` + `patchMutability()` — applies cached mutability state to DTO
**If no session DTO (first load or after `start`):**
`initializeFromApi()`:
1. `fetchBookingData()` — `ApiClient::getBooking()` with 5-minute cache (tagged by user ID for bulk invalidation)
2. `TravelDataProvider::getTravelData()` — loads travel data (XML or snapshot)
3. `TravelDataProvider::getAvailabilityData(forceRefresh: true)` + `patchAvailabilities()` — force-refreshes availability at edit start, populates cache for subsequent loads within the session
4. `TravelDataProvider::getMutabilityData(cached: true)` + `patchMutability()` — applies which service categories are currently editable
5. `BookingDataProcessor::createBookingDtoFromBooking()` — hydrates `BookingDto` from API booking entity
6. Agency code resolved via `AgencyLoader` (used by field state conditions)
7. `BookingChangeTracker::generateFingerprint()` — sets `originalFingerprint` on DTO **before** draft is applied, so dirty detection compares against original API data
8. `BookingEditDraftManager::findDraft()` + `applyDraftToDto()` — if draft exists, merges user edits on top of fresh API data. Sets `draftWasRestored = true` if successful.
9. `BookingSessionManager::saveBookingDto()`
Back in the controller: shows "draft restored" flash if applicable, fetches booking data for surcharge/status display, renders the card overview with `BookingEditContextFactory::createOverviewContext()`.
### Individual Participant Form (HTMX)
**Controller:** `Edit/ParticipantController`
**Routes:** `GET|POST /bookings/{id}/edit/participants/{index}` and `/{index}/refresh`
Per-participant forms are lazy-loaded, matching the create flow pattern.
1. `BookingEditContextFactory::prepareBookingDto()` — refreshes availabilities on the DTO travel object
2. `ParticipantFormSupport` — form creation, notification draining
3. On submit: `BookingSessionManager::saveBookingDto()` + `BookingEditDraftManager::saveDraft()` — persists participant edits to both session and draft
### Submission
**Service:** `BookingEditSubmitter::handleSubmission()`
Called from `Edit/IndexController` on valid form submit.
1. Invalidate API cache, fetch fresh booking data
2. Force-refresh mutability data (`TravelDataProvider::getMutabilityData(forceRefresh: true)`) and patch DTO travel
3. `BookingEditSubmitGuard::reconcileImmutableCategories()` — reverts any DTO fields that have become immutable since the session was loaded. Shows flash if anything was reverted.
4. `ApiClient::updateBooking()` — submit
5. On success: invalidate cache again, `BookingSessionManager::clearBookingDto()`, `BookingEditDraftManager::deleteDraft()`
### Other Edit Routes
| Route | Action |
|-------|--------|
| `GET /bookings/{id}/edit/reload` | Shows confirmation modal |
| `POST /bookings/{id}/edit/reload` | Clears session + **deletes draft**, reloads from API |
| `GET /bookings/{id}/edit/cancel` | Clears session, **preserves draft**, redirects to booking list |
The distinction between reload and cancel is intentional: cancel keeps the draft so the user can return to their changes later; reload discards everything.
---
## Key Invariants
- `currentStep` on the DTO gates forward navigation in create mode; controllers redirect to the current step if a higher step is requested directly.
- The original fingerprint is set before draft application in edit mode. Dirty detection (`BookingChangeTracker::isDirty()`) always compares the live session DTO against the original API state, not against the draft.
- Mutability is always patched from fresh or cached data before rendering. The DTO travel object is the authoritative source of which fields are editable at render time.
- Drafts survive cancel but not reload, and are deleted on successful submission.
+122
View File
@@ -0,0 +1,122 @@
# Service Simplification Plan
Status: active
Last updated: 2026-04-13
## Purpose
This document tracks the next pass of service simplification work. The goal is to reduce orchestration density, make the booking flow easier to follow for a human reader, and keep responsibilities aligned with the actual boundaries in the code.
The emphasis is not on deleting services for its own sake. The emphasis is on:
- keeping one clear owner for each meaningful boundary
- removing thin wrappers and pass-through helpers
- avoiding services that mostly shuffle data between layers
- keeping presentation concerns out of calculation and orchestration code
## Current Read
The codebase is already in a better place than it was at the start of the refactor, but a few services still carry more than one responsibility:
- `BookingService` now mostly covers booking bootstrap, service preselection, and booking status rules.
- `BookingParticipantCountService` was removed — its logic lives as a private method in the one controller that needed it (`Step2Controller`).
- `BookingRoomSelectionService` was removed — its logic lives as a private method in `BookingCreateContextFactory`.
- `BookingSummaryParticipantCountService` was removed — its logic lives as a private method in `BookingSummaryDataService`.
- `ParticipantPrepopulationService` now owns applicant prefill plus the create-mode dummy-data shortcut.
- `BookingEditParticipantContextFactory` now prepares the edit participant page context directly, replacing the older pass-through participant form service.
- `BookingPriceCalculatorService` is focused on pricing, but it still sits close to display-oriented behavior in adjacent code paths.
- `TravelDataService` remains broad and is likely the next larger boundary after booking orchestration is reduced.
One registry stands out as a real orchestration boundary and should be left alone for now:
- `ParticipantFieldHandlerRegistry`
It is not just a lookup table. It owns execution order, edit-mode mutability gating, and synchronization of submitted form data back into the DTO state.
## Next Pass
### 1. Keep pricing calculation focused
Primary goal: keep pricing code about pricing, not rendering.
Concrete next steps:
- keep `BookingPriceCalculatorService` as the pricing boundary
- continue removing display formatting from pricing code paths
- keep any remaining view-specific formatting in the presentation layer or a dedicated UI helper
- avoid introducing another service that only formats values already known to the view
Decision rule:
- if a value is only needed for display, prefer exposing the raw numeric/domain value and formatting it as close to the UI as possible
### 2. Leave the field-handler registry in place
Primary goal: avoid unnecessary churn in a class that is already a meaningful orchestration layer.
Concrete next steps:
- do not refactor `ParticipantFieldHandlerRegistry` in this pass
- revisit only if a later change can split ordering, mutability, and synchronization into clear collaborators without making the flow harder to trace
Decision rule:
- if a registry owns actual workflow behavior, treat it as a boundary rather than a smell
## Follow-Up Queue
After the booking service pass, the next likely candidates are:
### `TravelDataService`
This is the largest broad service still in the codebase. It likely needs a later pass if the application should become easier to follow end to end.
Likely directions:
- separate runtime travel loading from cache/snapshot maintenance if the public API still feels too wide
- keep the read path explicit and avoid hiding maintenance work behind one large method surface
### `BookingPriceCalculatorService`
This service should stay focused on pricing logic, but it may still have room for further internal cleanup if more display or transport aggregation concerns surface.
Likely directions:
- keep calculation responsibilities together
- avoid dragging presentation behavior back into the calculator
- split only if a sub-boundary becomes obvious and reusable
### `ParticipantFieldHandlerRegistry`
This stays on the list only as a future optional refactor, not as an immediate target.
Likely directions, only if justified later:
- isolate ordering/sorting logic if it becomes independently meaningful
- split synchronization code if a clearer DTO/form boundary emerges
- otherwise leave it as the central orchestration point for participant field processing
## Current Notes
- `templates/booking/_summary_travel_info.html.twig` already renders the summary participant count directly, so the remaining work here is naming and contract clarity rather than Twig branching.
- The summary count still needs a clear name if the code should distinguish the display-oriented count from the canonical participant total in the DTO/service layer.
- `ParticipantFormSupportService` was reviewed and kept: it is shared between two controllers (`Step2ParticipantController` and `Edit/ParticipantController`) with real shared logic, not just delegation.
## Progress Tracker
| Item | Status | Notes |
|------|--------|-------|
| Participant card DTO cleanup | Done | Card data now uses typed DTOs instead of nested array payloads |
| Room label formatting cleanup | Done | Pricing labels now have a dedicated presentation helper |
| Booking service split | Done | Session lifecycle, hydration, baseline snapshot, return URL handling, room grouping, and participant count shaping moved out of `BookingService` |
| Dummy prefill extraction | Done | Moved into `ParticipantPrepopulationService` |
| Micro-service consolidation | Done | `BookingRoomSelectionService`, `BookingParticipantCountService`, `BookingSummaryParticipantCountService` inlined into their single callers |
| Pricing service review | Pending | Keep focused on calculation, not rendering |
| Travel data service review | Pending | Broad boundary, likely later pass |
| Participant field registry review | Deferred | Real orchestration boundary, intentionally left alone for now |
## Acceptance Criteria
The next booking-service pass is only worth keeping if it:
- reduces the number of unrelated responsibilities in `BookingService`
- makes the booking flow easier to trace from controller to session/DTO state
- preserves existing booking behavior and test coverage
- does not replace one large service with several generic "manager" classes
## Working Agreement
- Update this document as decisions are made.
- Record rejected simplification ideas here with a short reason.
- Keep the plan aligned with actual code, not with an abstract architecture ideal.
- If a future simplification does not clearly reduce cognitive load, do not add it.
+16 -4
View File
@@ -886,10 +886,9 @@ Keys stored in `config/secret/` directory.
**Location:** `src/Security/Voter/BookingVoter.php`
| Operation | Requirements |
|-----------|--------------|
| `VIEW` | User owns booking (addressId match) |
| `EDIT` | User owns booking AND booking is editable |
This voter currently exists as legacy authorization logic and is not actively invoked by booking controllers.
Current booking routes (`/bookings/*`) are guarded with `ROLE_USER`, while final document access checks are enforced by BusProNet.
### 7.4 OAuth2
@@ -1154,6 +1153,19 @@ php bin/console app:cleanup:xml-dumps
Removes XML debug dumps older than 3 days.
#### app:cleanup:newsletter-opt-in-requests
```bash
php bin/console app:cleanup:newsletter-opt-in-requests
```
Removes expired pending newsletter double opt-in requests.
- Request TTL is controlled by `NEWSLETTER_CONFIRMATION_TTL_HOURS` (default: 1 hour).
- Expired pending requests are removed immediately when they are encountered (new request / confirmation attempt).
- Scheduled cleanup removes all currently expired pending requests (`expires_at <= now`) as a safety net.
- Confirmed requests are retained for audit/legal traceability.
### 10.3 Setup Commands
#### app:crypto:generate-keys
+42 -7
View File
@@ -34,7 +34,7 @@
}
}
},
"prod": {
"staging": {
"Security": {
"Auth": {
"oauth2_email": {
@@ -42,8 +42,8 @@
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep.ep-reisen.de/authorize",
"Token URL": "https://myep.ep-reisen.de/token",
"Auth URL": "https://my.ep-reisen.net/authorize",
"Token URL": "https://my.ep-reisen.net/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email"
},
@@ -52,8 +52,8 @@
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://myep.ep-reisen.de/authorize",
"Token URL": "https://myep.ep-reisen.de/token",
"Auth URL": "https://my.ep-reisen.net/authorize",
"Token URL": "https://my.ep-reisen.net/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email profile"
},
@@ -62,8 +62,43 @@
"Grant Type": "Client Credentials",
"Client ID": "{{oauth2_api_client_id}}",
"Client Secret": "{{oauth2_api_client_secret}}",
"Auth URL": "https://myep.ep-reisen.de/authorize",
"Token URL": "https://myep.ep-reisen.de/token",
"Auth URL": "https://my.ep-reisen.net/authorize",
"Token URL": "https://my.ep-reisen.net/token",
"Scope": "api"
}
}
}
},
"prod": {
"Security": {
"Auth": {
"oauth2_email": {
"Type": "OAuth2",
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://my.ep-reisen.de/authorize",
"Token URL": "https://my.ep-reisen.de/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email"
},
"oauth2_profile": {
"Type": "OAuth2",
"Grant Type": "Authorization Code",
"Client ID": "{{oauth2_client_id}}",
"Client Secret": "{{oauth2_client_secret}}",
"Auth URL": "https://my.ep-reisen.de/authorize",
"Token URL": "https://my.ep-reisen.de/token",
"Redirect URL": "https://myep-team.ep-reisen.de/auth/check",
"Scope": "email profile"
},
"oauth2_api": {
"Type": "OAuth2",
"Grant Type": "Client Credentials",
"Client ID": "{{oauth2_api_client_id}}",
"Client Secret": "{{oauth2_api_client_secret}}",
"Auth URL": "https://my.ep-reisen.de/authorize",
"Token URL": "https://my.ep-reisen.de/token",
"Scope": "api"
}
}
+191
View File
@@ -0,0 +1,191 @@
###
# Mailjet API test requests for IntelliJ HTTP Client
#
# 1) Fill in variables below (or move them to http-client.private.env.json).
# 2) Set `mailjet_api_key` and `mailjet_api_secret` in your env file.
# IntelliJ HTTP Client will inject them into the Authorization header below.
#
# Required variables:
# - mailjet_base_url (example: https://api.mailjet.com/v3/REST)
# - mailjet_api_key
# - mailjet_api_secret
# - mailjet_contact_email
# - mailjet_list_id
#
# @no-cookie-jar
### Mailjet health check (authenticated)
# @no-cookie-jar
GET {{mailjet_base_url}}/apikey
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Find contact by email
# @no-cookie-jar
GET {{mailjet_base_url}}/contact?Email={{mailjet_contact_email}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
> {%
if (response.body && response.body.Data && response.body.Data.length > 0) {
client.global.set("contact_id", String(response.body.Data[0].ID));
}
%}
### Create contact (run if not found)
# @no-cookie-jar
POST {{mailjet_base_url}}/contact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Email": "{{mailjet_contact_email}}",
"IsExcludedFromCampaigns": false
}
> {%
if (response.body && response.body.Data && response.body.Data.length > 0) {
client.global.set("contact_id", String(response.body.Data[0].ID));
}
%}
### Get contact by ID (after find/create)
# @no-cookie-jar
GET {{mailjet_base_url}}/contact/{{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Mark contact as subscribed (opt-in)
# @no-cookie-jar
PUT {{mailjet_base_url}}/contact/{{contact_id}}
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"IsExcludedFromCampaigns": false
}
### Mark contact as unsubscribed (opt-out)
# @no-cookie-jar
PUT {{mailjet_base_url}}/contact/{{contact_id}}
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"IsExcludedFromCampaigns": true
}
### Get newsletter list by ID
# @no-cookie-jar
GET {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### List all available contact lists
# @no-cookie-jar
GET {{mailjet_base_url}}/contactslist?Limit=1000
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Check list recipient status (before actions)
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
> {%
if (response.body && response.body.Data && response.body.Data.length > 0) {
client.global.set("listrecipient_id", String(response.body.Data[0].ID));
}
%}
### Subscribe to list (addnoforce)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "addnoforce",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after addnoforce
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Unsubscribe from list (unsub)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "unsub",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after unsub
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Re-subscribe attempt with addnoforce (expected to keep unsubscribed state)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "addnoforce",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after addnoforce re-subscribe attempt
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
### Re-subscribe with addforce (expected to re-subscribe)
# @no-cookie-jar
POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact
Accept: application/json
Content-Type: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
{
"Action": "addforce",
"Email": "{{mailjet_contact_email}}"
}
### Verify list recipient after addforce
# @no-cookie-jar
GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}}
Accept: application/json
Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260318113000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create newsletter_opt_in_confirmation table for double opt-in confirmations';
}
public function up(Schema $schema): void
{
$this->addSql("CREATE TABLE newsletter_opt_in_confirmation (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', confirmed_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_NEWSLETTER_OPT_IN_TOKEN_HASH (token_hash), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE newsletter_opt_in_confirmation');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260320071724 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add error_code column and index to log_entry table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE log_entry ADD error_code VARCHAR(12) DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_LOG_ENTRY_ERROR_CODE ON log_entry (error_code)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IDX_LOG_ENTRY_ERROR_CODE ON log_entry');
$this->addSql('ALTER TABLE log_entry DROP error_code');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260321120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create travel_snapshot table for DB-primary travel persistence';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE travel_snapshot (id INT AUTO_INCREMENT NOT NULL, date_id INT NOT NULL, hotel_id INT NOT NULL, date_code VARCHAR(64) DEFAULT NULL, label VARCHAR(255) DEFAULT NULL, product_id INT DEFAULT NULL, product_code VARCHAR(64) DEFAULT NULL, hotel_code VARCHAR(64) DEFAULT NULL, hotel_label VARCHAR(255) DEFAULT NULL, date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', payload LONGTEXT NOT NULL, payload_hash VARCHAR(64) NOT NULL, captured_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', extended_refreshed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX uniq_snapshot_date_hotel (date_id, hotel_id), INDEX idx_snapshot_date_code (date_code), INDEX idx_snapshot_product_id (product_id), INDEX idx_snapshot_date_to (date_to), INDEX idx_snapshot_extended_refreshed_at (extended_refreshed_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE travel_snapshot');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260323182524 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE booking_edit_draft CHANGE form_data form_data JSON NOT NULL');
$this->addSql('ALTER TABLE log_entry CHANGE context context JSON DEFAULT NULL, CHANGE extra extra JSON DEFAULT NULL');
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation RENAME INDEX uniq_newsletter_opt_in_token_hash TO UNIQ_70505214B3BC57DA');
$this->addSql('ALTER TABLE user CHANGE roles roles JSON NOT NULL, CHANGE hotel_codes hotel_codes JSON NOT NULL');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE newsletter_opt_in_confirmation RENAME INDEX uniq_70505214b3bc57da TO UNIQ_NEWSLETTER_OPT_IN_TOKEN_HASH');
$this->addSql('ALTER TABLE user CHANGE roles roles JSON NOT NULL COLLATE `utf8mb4_bin`, CHANGE hotel_codes hotel_codes JSON NOT NULL COLLATE `utf8mb4_bin`');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
$this->addSql('ALTER TABLE log_entry CHANGE context context JSON DEFAULT NULL COLLATE `utf8mb4_bin`, CHANGE extra extra JSON DEFAULT NULL COLLATE `utf8mb4_bin`');
$this->addSql('ALTER TABLE booking_edit_draft CHANGE form_data form_data JSON NOT NULL COLLATE `utf8mb4_bin`');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Changes travel_snapshot.payload from TEXT to LONGTEXT so large
* plain JSON snapshot payloads can be stored safely.
*/
final class Version20260324120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Change travel_snapshot.payload from TEXT to LONGTEXT for plain JSON snapshot payloads';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_snapshot CHANGE payload payload LONGTEXT NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_snapshot CHANGE payload payload TEXT NOT NULL');
}
}
+428 -881
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -5,7 +5,7 @@
"@babel/preset-env": "^7.16.0",
"@hotwired/stimulus": "^3.0.0",
"@symfony/stimulus-bridge": "^3.2.0",
"@symfony/webpack-encore": "^5.0.0",
"@symfony/webpack-encore": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"autoprefixer": "^10.4.20",
@@ -20,7 +20,7 @@
"tippy.js": "^6.3.7",
"toastify-js": "^1.12.0",
"webpack": "^5.74.0",
"webpack-cli": "^5.1.0",
"webpack-cli": "^6.0.1",
"webpack-notifier": "^1.15.0"
},
"license": "WTFPL",
+2
View File
@@ -0,0 +1,2 @@
parameters:
ignoreErrors: []
+13
View File
@@ -0,0 +1,13 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
parameters:
level: 6
paths:
- src
tmpDir: var/phpstan
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
doctrine:
objectManagerLoader: tests/doctrine-object-manager.php
+13
View File
@@ -0,0 +1,13 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
parameters:
level: 6
paths:
- src
- tests
tmpDir: var/cache/phpstan
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Admin\Field;
+43
View File
@@ -7,11 +7,13 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PromoVoucher;
@@ -38,6 +40,7 @@ class ApiClient
public const TYPE_BASE_DATA_COUNTRIES = 'STAMMLAENDER';
public const TYPE_MUTABLE_DATA = 'MOEGLICHEAENDERUNGEN';
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2';
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
public const TYPE_BOOKING = 'BUCHUNG';
@@ -47,11 +50,13 @@ class ApiClient
public const TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG';
public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN';
/** @var array<string, mixed> */
private array $config;
private float $operationStartTime;
private int $selectedPort;
private int $requestCounter = 0;
/** @param array<string, mixed> $options */
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ApiResponseParser $responseParser,
@@ -315,6 +320,21 @@ class ApiClient
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
}
/**
* @throws ApiClientException
*/
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_EXTENDED),
'satz' => ['@typ' => static::TYPE_AVAILABILITY_EXTENDED],
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_AVAILABILITY_EXTENDED, $data);
}
/**
* @throws ApiClientException
*/
@@ -596,6 +616,10 @@ class ApiClient
/**
* @throws ApiClientException
*/
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $additionalArgs
*/
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type);
@@ -604,6 +628,7 @@ class ApiClient
/**
* @throws ApiClientException
*/
/** @param array<string, mixed> $data */
private function sendRequestRaw(array $data): string
{
return $this->executeWithRetry(fn () => $this->doSendRequestRaw($data));
@@ -659,6 +684,10 @@ class ApiClient
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $additionalArgs
*/
private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
{
$requestId = $this->getRequestId();
@@ -716,6 +745,8 @@ class ApiClient
}
/**
* @param array<string, mixed> $data
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
@@ -777,6 +808,8 @@ class ApiClient
}
/**
* @return resource
*
* @throws ApiClientException
* @throws TimeoutException
*/
@@ -840,6 +873,8 @@ class ApiClient
}
/**
* @param resource $socket
*
* @throws TimeoutException
*/
private function send($socket, string $data): void
@@ -858,6 +893,8 @@ class ApiClient
}
/**
* @param resource $socket
*
* @throws TimeoutException
* @throws ImmediateConnectionCloseException
*/
@@ -920,11 +957,17 @@ class ApiClient
return $response;
}
/** @param resource $socket */
private function disconnect($socket): void
{
@fclose($socket);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet;
@@ -12,8 +12,8 @@ use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingPriceCalculatorService;
use App\Service\InsuranceService;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
/**
* Processes booking form data and converts it into BusProNet API payload format.
@@ -25,8 +25,8 @@ use App\Service\InsuranceService;
class BookingDataProcessor
{
public function __construct(
private readonly InsuranceService $insuranceService,
private readonly BookingPriceCalculatorService $priceCalculatorService,
private readonly InsuranceManager $insuranceService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly ServiceMappingCollector $mappingCollector,
private readonly ParticipantServiceProcessor $serviceProcessor,
private readonly BookingPayloadBuilder $payloadBuilder,
@@ -73,14 +73,12 @@ class BookingDataProcessor
if (0 === $index) {
// Copy address if first participant has no street (indicating empty/incomplete address)
// This allows applicant and first participant to be different people with different addresses
if (null !== $booking->applicant->address) {
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| '' === trim($participantData->address->street);
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| '' === trim($participantData->address->street);
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
@@ -130,7 +128,7 @@ class BookingDataProcessor
// Parking service (for self-organized PKW transportation)
$parkingServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_PARKING);
if (false === empty($parkingServices)) {
if ([] !== $parkingServices) {
$participantData->parking = true;
$participantData->parkingService = reset($parkingServices);
} else {
@@ -284,7 +282,7 @@ class BookingDataProcessor
*
* @param BookingDto|null $formData The booking edit form data containing updated participant and service selections
*
* @return array The structured payload array ready for BusProNet API submission
* @return array<string, mixed> The structured payload array ready for BusProNet API submission
*/
public function createUpdateRequestPayload(?BookingDto $formData): array
{
@@ -322,7 +320,7 @@ class BookingDataProcessor
// Add purchase vouchers if any exist
$purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData);
if (false === empty($purchaseVouchers)) {
if ([] !== $purchaseVouchers) {
$payload['gutscheine']['gutschein'] = [];
foreach ($purchaseVouchers as $code) {
$payload['gutscheine']['gutschein'][] = [
@@ -345,7 +343,7 @@ class BookingDataProcessor
* @param BookingDto $bookingDto The booking creation form data
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
*
* @return array The structured payload array for BusProNet API submission
* @return array<string, mixed> The structured payload array for BusProNet API submission
*/
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
{
@@ -388,7 +386,7 @@ class BookingDataProcessor
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
}
// Use InsuranceService for proper type-based assignment with price tier matching
// Use InsuranceManager for proper type-based assignment with price tier matching
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
$selectableInsurances,
$applicant->insurance,
@@ -6,8 +6,8 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Service\ParticipantStatusRuleRegistry;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Builds API payload structures for BusProNet booking requests.
@@ -17,9 +17,10 @@ use App\Form\Model\BookingDto;
*/
class BookingPayloadBuilder
{
private const DEFAULT_PARTICIPANT_STATUS = 'F';
public function __construct(
private readonly ServiceMappingCollector $mappingCollector,
private readonly ParticipantStatusRuleRegistry $statusRuleRegistry,
) {
}
@@ -30,7 +31,7 @@ class BookingPayloadBuilder
*
* @param Booking $bookingData The booking data object
*
* @return array The base payload structure
* @return array<string, mixed> The base payload structure
*/
public function buildBasePayload(Booking $bookingData): array
{
@@ -66,8 +67,8 @@ class BookingPayloadBuilder
*
* Bank account information is required for direct debit payments.
*
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function addBankAccountToPayload(array &$payload, Booking $bookingData): void
{
@@ -86,9 +87,9 @@ class BookingPayloadBuilder
*
* Includes status, personal data, and wishes (room remarks, license plate) for each participant.
*
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array $participantDtos The participant DTOs from the form (for wishes data)
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array<int, ParticipantDto> $participantDtos The participant DTOs from the form (for wishes data)
*/
public function buildParticipantPayload(array &$payload, Booking $bookingData, array $participantDtos): void
{
@@ -141,8 +142,8 @@ class BookingPayloadBuilder
* Includes additional services, transportation services, and accommodation details
* with participant mappings and quantities.
*
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function buildServicesPayload(array &$payload, Booking $bookingData): void
{
@@ -198,8 +199,8 @@ class BookingPayloadBuilder
*
* Only included in payload if there are actual pickup assignments for bus transportation.
*
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function buildPickupPayload(array &$payload, Booking $bookingData): void
{
@@ -223,8 +224,8 @@ class BookingPayloadBuilder
*
* Only included in payload if there are actual drop-off assignments for bus transportation.
*
* @param array $payload The payload array to modify
* @param Booking $bookingData The booking data object
* @param array<string, mixed> $payload The payload array to modify
* @param Booking $bookingData The booking data object
*/
public function buildDropOffPayload(array &$payload, Booking $bookingData): void
{
@@ -249,7 +250,7 @@ class BookingPayloadBuilder
* @param BookingDto $bookingDto The booking creation form data
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
*
* @return array The structured payload array for BusProNet API submission
* @return array<string, mixed> The structured payload array for BusProNet API submission
*/
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
{
@@ -302,7 +303,7 @@ class BookingPayloadBuilder
foreach ($bookingDto->participants as $index => $participant) {
$participantData = [
'@id' => $index + 1,
'status' => $this->statusRuleRegistry->evaluateStatus($participant),
'status' => self::DEFAULT_PARTICIPANT_STATUS,
'name' => $participant->lastName,
'vorname' => $participant->firstName,
'geschlecht' => $participant->gender ?? '',
@@ -439,11 +440,11 @@ class BookingPayloadBuilder
* Generic helper method that converts service ID => participant IDs mappings
* into XML payload structure.
*
* @param array $payload The payload array to modify
* @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen')
* @param string $itemKey The item key (e.g., 'beförderung', 'versicherung')
* @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung')
* @param array $serviceMap Map of service ID to participant IDs
* @param array<string, mixed> $payload The payload array to modify
* @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen')
* @param string $itemKey The item key (e.g., 'beförderung', 'versicherung')
* @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung')
* @param array<int|string, int[]> $serviceMap Map of service ID to participant IDs
*/
public function addServicesFromMap(
array &$payload,
@@ -475,9 +476,9 @@ class BookingPayloadBuilder
* - abreise (departure date)
* - anzahl (number of rooms of this type booked)
*
* @param array $payload The payload array to modify
* @param array $roomMap Map of room ID to participant IDs
* @param BookingDto $bookingDto The booking data for accessing room details and quantities
* @param array<string, mixed> $payload The payload array to modify
* @param array<int|string, int[]> $roomMap Map of room ID to participant IDs
* @param BookingDto $bookingDto The booking data for accessing room details and quantities
*/
public function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingDto $bookingDto): void
{
@@ -520,8 +521,8 @@ class BookingPayloadBuilder
/**
* Adds purchase vouchers to the payload.
*
* @param array $payload The payload array to modify
* @param BookingDto $bookingDto The booking data
* @param array<string, mixed> $payload The payload array to modify
* @param BookingDto $bookingDto The booking data
*/
public function addPurchaseVouchersToPayload(array &$payload, BookingDto $bookingDto): void
{
@@ -6,7 +6,6 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\Form\Model\ParticipantDto;
@@ -86,9 +85,6 @@ class PersonalDataSynchronizer
}
if ($participant->email || $participant->mobile) {
if (null === $bookingData->participants[$participant->index]->communication) {
$bookingData->participants[$participant->index]->communication = new Communication();
}
$bookingData->participants[$participant->index]->communication->email = $participant->email;
$bookingData->participants[$participant->index]->communication->mobile = $participant->mobile;
}
@@ -127,10 +123,6 @@ class PersonalDataSynchronizer
$applicant->dateOfBirth = new \DateTimeImmutable('-20 years');
}
if (null === $applicant->communication) {
$applicant->communication = new Communication();
}
if (null === $applicant->communication->mobile || '' === $applicant->communication->mobile) {
$applicant->communication->mobile = '12345';
}
@@ -20,7 +20,7 @@ class ServiceMappingCollector
*
* Groups participants by their assigned room ID.
*
* @return array<string, array<int>> Map of room ID to participant IDs
* @return array<int, list<int>> Map of room ID to participant IDs
*/
public function collectRoomMappings(BookingDto $bookingDto): array
{
@@ -43,7 +43,7 @@ class ServiceMappingCollector
* Groups board, ski passes, rentals, rental insurance, courses, parking, and additional services
* by service ID with their participant assignments (1-based).
*
* @return array<string, array<int>> Map of service ID to participant IDs
* @return array<int, list<int>> Map of service ID to participant IDs
*/
public function collectServiceMappings(BookingDto $bookingDto): array
{
@@ -99,7 +99,7 @@ class ServiceMappingCollector
/**
* Collects transportation service mappings.
*
* @return array<string, array<int>> Map of transportation service ID to participant IDs
* @return array<int, list<int>> Map of transportation service ID to participant IDs
*/
public function collectTransportationMappings(BookingDto $bookingDto): array
{
@@ -126,7 +126,7 @@ class ServiceMappingCollector
* Collects the unified pickup selection which applies to both directions.
* The API receives this as outbound pickup data.
*
* @return array<string, array<int>> Map of pickup ID to participant IDs
* @return array<int, list<int>> Map of pickup ID to participant IDs
*/
public function collectPickupMappings(BookingDto $bookingDto): array
{
@@ -148,7 +148,7 @@ class ServiceMappingCollector
*
* Collects the drop-off selection for the inbound/return direction.
*
* @return array<string, array<int>> Map of drop-off ID to participant IDs
* @return array<int, list<int>> Map of drop-off ID to participant IDs
*/
public function collectDropOffMappings(BookingDto $bookingDto): array
{
@@ -171,7 +171,7 @@ class ServiceMappingCollector
* CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow.
* IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission.
*
* @return array<string, array<int>> Map of insurance ID to participant IDs
* @return array<string, list<int>> Map of insurance ID to participant IDs
*/
public function collectInsuranceMappings(BookingDto $bookingDto): array
{
@@ -14,6 +14,7 @@ class CountryDataProvider
{
}
/** @return array<string, Country> */
public function getAll(): array
{
try {
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Exception;
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Exception;
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Form\ChoiceLoader;
+2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Form;
@@ -10,6 +11,7 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<string> */
class CountryType extends AbstractType
{
public function __construct(private readonly CountryDataProvider $countries)
@@ -12,6 +12,7 @@ namespace App\BusProNet\Model;
*/
class AgeConstraintResult
{
/** @param array<string, mixed> $metadata */
public function __construct(
public readonly string $type,
public readonly ?int $ageFrom = null,
+2 -1
View File
@@ -12,6 +12,7 @@ namespace App\BusProNet\Model;
*/
class BaseData
{
/** @param array<array-key, mixed> $items */
public function __construct(private readonly array $items)
{
}
@@ -19,7 +20,7 @@ class BaseData
/**
* Retrieves all items in the base data collection.
*
* @return array The complete items array
* @return array<array-key, mixed> The complete items array
*/
public function getItems(): array
{
+41 -2
View File
@@ -19,6 +19,7 @@ class Booking
public ?int $agencyId = null;
public ?int $bookingNumber = null;
public ?string $status = null;
public ?int $applicantId = null;
public ?PersonalData $applicant = null;
public ?int $participantCount = null;
public ?float $price = null;
@@ -36,14 +37,50 @@ class Booking
public ?string $paymentType = null;
public ?string $paymentLabel = null;
public ?BankAccount $bankAccount = null;
/**
* @var array<int, string>
*/
public array $participantsStatus = [];
/**
* @var array<int, PersonalData>
*/
public array $participants = [];
/**
* @var array<int, Service>
*/
public array $transportationServices = [];
/**
* @var array<int, Service>
*/
public array $additionalServices = [];
/**
* @var array<int, Room>
*/
public array $rooms = [];
/**
* @var array<int, Pickup>
*/
public array $pickups = [];
/**
* @var array<int, Pickup>
*/
public array $dropOffs = [];
/**
* @var array<int, Surcharge>
*/
public array $surcharges = [];
/**
* @var array<string|int, Insurance>
*/
public array $insurances = [];
public ?int $invoiceNumber = null;
public ?float $totalPrice = null;
@@ -184,7 +221,7 @@ class Booking
public function getInsuranceForParticipant(int $participantIndex): ?Insurance
{
foreach ($this->insurances as $insurance) {
if (in_array($participantIndex, $insurance->mapping ?? [], true)) {
if (in_array($participantIndex, $insurance->mapping, true)) {
return $insurance;
}
}
@@ -334,6 +371,8 @@ class Booking
*/
public function isEditable(): bool
{
return $this->travelDate > new \DateTimeImmutable() && false === in_array($this->status, ['S', 'U']);
$now = new \DateTimeImmutable();
return $this->travelDate > $now && false === in_array($this->status, ['S', 'U']);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\BusProNet\Model;
class ContactFormResponse
{
public function __construct(
public readonly int $addressId,
public readonly int $personId,
public readonly bool $isNewRecord,
) {
}
}
+15
View File
@@ -12,8 +12,23 @@ namespace App\BusProNet\Model;
*/
class CrmAttributes
{
/**
* @var array<int, CrmSelectionGroup>|null
*/
public ?array $selectionGroups = null;
/**
* @var array<int, CrmAction>|null
*/
public ?array $crmActions = null;
/**
* @var array<int, string>
*/
public array $hotelCodes = [];
/**
* @var array<int, string>
*/
public array $roles = [];
}
+4 -4
View File
@@ -15,15 +15,15 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
class CrmSelection
{
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?int $id = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?string $label = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $mutable = false;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $selected = false;
}
+7 -4
View File
@@ -19,7 +19,7 @@ class CrmSelectionGroup
* Maps BusPro IDs of CRM selection groups representing
* included services.
*
* @var array<string, string>
* @var array<int, string>
*/
public static array $includedServicesMapping = [
7 => 'Skipass',
@@ -32,13 +32,16 @@ class CrmSelectionGroup
90 => 'Transport',
];
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?int $id = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?string $label = null;
#[Groups(['api:single'])]
/**
* @var array<int, CrmSelection>|null
*/
#[Groups(['api:single', 'snapshot'])]
public ?array $selections = null;
/**
-115
View File
@@ -1,115 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Represents error codes and messages for the BusProNet API.
*
* This class provides a static mapping of error codes to their corresponding
* German error messages for consistent error handling across the application.
* Error codes range from 100-999 and cover various API scenarios.
*/
class Error
{
/**
* @var array<int, string>
*/
private static array $errors = [
100 => 'Anfrageknoten fehlt',
101 => 'Satzknoten fehlt in Anfrageknoten',
102 => 'Ungültiger Satztyp [#1#]',
103 => 'User fehlt',
104 => 'User fehlerhaft',
105 => 'Key fehlt',
106 => 'Key fehlerhaft',
200 => 'Keine Einträge vorhanden',
201 => 'Keine Partner zur Auswahl gefunden',
202 => 'Keinen Partner mit der ID [#1#] gefunden',
203 => 'Ungültiger Termin',
300 => 'IDReise fehlt',
301 => 'Reise nicht gefunden',
302 => 'IDLeistung (Hin & Rück) fehlt',
303 => 'IDLeistung passt nicht zu IDReise',
304 => 'Es konnte nicht für alle Teilnehmer ein Sitzplatz ermittelt werden',
305 => 'Anzahl Personen fehlt',
306 => 'Leistungen mit ID #1# nicht gefunden',
307 => 'Leistungen mit ID #1# passen nicht zur Reise mit ID #2#',
308 => 'Keine Zahlungsarten gefunden',
400 => 'IDPartner fehlt',
401 => 'Partner nicht gefunden',
402 => 'Bis-Termin fehlt',
403 => 'Keine Unterbringungsleistungen gefunden',
500 => 'Art fehlt',
501 => 'Reise- und Buchungszeitraum fehlen',
502 => 'Keine Kunden gefunden',
600 => 'Keine Gutscheine gefunden',
601 => 'Gutschein-IDs fehlen',
602 => 'Gutscheine mit ID #1# nicht gefunden',
610 => 'Buchungsart fehlt',
611 => 'Buchungsart falsch',
612 => 'Gutscheinart fehlt',
613 => 'Gutscheinart falsch',
614 => 'Gutscheinstamm-ID fehlt',
615 => 'Gutschein (Stamm) mit ID #1# nicht gefunden',
616 => 'Kapazität beim Gutschein #1# nicht ausreichend',
617 => 'Rechnungsempfänger fehlt',
618 => 'Zahlungsart fehlt',
619 => 'Agentur-ID fehlt',
620 => 'Agentur mit ID #1# nicht gefunden',
650 => '#1#',
700 => 'Keine Agenturen gefunden',
701 => 'Agentur mit ID #1# nicht gefunden',
800 => 'Produkt-ID fehlt',
801 => 'Produkt mit ID #1# nicht gefunden',
805 => 'Es wurden keine Produkte gefunden',
810 => 'Stammdaten (#1#) nicht gefunden',
900 => 'Buchungsart fehlt',
901 => 'Buchungsart falsch',
902 => 'Status fehlt',
903 => 'Status falsch',
904 => 'Agentur-ID fehlt',
905 => 'Agentur mit ID #1# nicht gefunden',
906 => 'Reise-ID fehlt',
907 => 'Reise mit ID #1# nicht gefunden',
908 => 'Reise mit ID #1# ist storniert',
909 => 'Beförderungen fehlen',
910 => 'Leistung mit ID #1# gehört nicht zur Reise',
911 => 'Leistung mit ID #1# gehört nicht zum Produkt',
912 => 'Beförderungsleistung für die #1# fehlt',
913 => 'Unterbringungen fehlen',
914 => 'Partner-ID fehlt',
915 => 'Partner mit ID #1# nicht gefunden',
916 => 'Ferienziel-Unterbringungen fehlen',
917 => 'Ferienziel: Zimmer-IDZ fehlt',
918 => 'Ferienziel: Kategorie fehlt',
919 => 'Ferienziel: Verpflegungs-ID fehlt',
920 => 'Ferienziel: Anreise fehlt',
921 => 'Ferienziel: Anreise passt nicht zur Beförderungsleistung (Hinfahrt)',
922 => 'Ferienziel: Abreise fehlt',
923 => 'Ferienziel: Abreise passt nicht zur Beförderungsleistung (Rückfahrt)',
924 => 'Ferienziel: Keine Preise zu den Daten gefunden',
925 => 'Ferienziel: Preis zu den Daten nicht gefunden',
930 => 'Zustiege fehlen',
931 => 'Zustieg mit ID #1# nicht gefunden',
932 => 'Zustieg mit ID #1# bei Leistung #2# nicht freigegeben',
933 => 'Sitzplan mit ID #1# bei Leistung #2# nicht freigegeben',
935 => 'Versicherung mit ID #1# nicht gefunden',
940 => 'Zahlungsart fehlt',
941 => 'Zahlungsart-ID fehlt',
942 => 'Zahlungsart mit ID #1# nicht gültig',
950 => 'Anmelder fehlt',
951 => 'Teilnehmerliste fehlt',
952 => 'Teilnehmer-ID fehlt',
960 => 'Preisfehler: Struct #1# / Obj #2#',
961 => 'Buchung konnte nicht gespeichert werden',
980 => 'Reise ist fürs Internet gesperrt',
981 => 'Reise ist nicht mehr buchbar (#1#)',
982 => 'Optionsbuchungen nicht zugelassen',
983 => 'Anfragebuchung nicht zugelassen',
984 => 'Leistung mit ID #1# nicht fürs Internet buchbar',
985 => 'Zustieg mit ID #1# nicht fürs Internet buchbar',
999 => 'Systemfehler: #1#',
];
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Extended availability details for one service from VERFUEGBARKEIT2.
*
* This model augments classic availability with optional timing/date/description
* fields used to enrich persisted travel snapshots.
*/
class ExtendedAvailability
{
public ?int $serviceId = null;
public ?string $status = null;
public ?int $available = null;
public ?float $price = null;
public ?\DateTimeImmutable $dateFrom = null;
public ?\DateTimeImmutable $dateTo = null;
public ?string $timeFrom = null;
public ?string $description = null;
public ?int $ageFrom = null;
public ?int $ageTo = null;
public ?bool $mandatory = null;
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* Response model for extended availability endpoint payloads.
*/
class ExtendedServiceAvailabilityResponse
{
/**
* @param array<int, ExtendedAvailability> $services
* @param array<string> $allowedBookingStatus
*/
public function __construct(
private readonly array $services,
public readonly array $allowedBookingStatus = [],
public readonly ?string $travelStatus = null,
) {
}
/**
* @return array<int, ExtendedAvailability>
*/
public function getServices(): array
{
return $this->services;
}
}
+2 -2
View File
@@ -14,9 +14,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
class Guide
{
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?string $name = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?string $phone = null;
}
+7 -7
View File
@@ -15,24 +15,24 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
class Hotel
{
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $id = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $code = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $name = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $city = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $street = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $phone = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $country = null;
}
+9 -2
View File
@@ -182,8 +182,15 @@ class Insurance
$urls = [];
foreach ($this->containedInsurances as $containedInsurance) {
if (null !== $containedInsurance->{$urlProperty}) {
$urls[] = $containedInsurance->{$urlProperty};
$url = match ($urlProperty) {
'urlInfo' => $containedInsurance->urlInfo,
'urlProductInfo' => $containedInsurance->urlProductInfo,
'urlTerms' => $containedInsurance->urlTerms,
default => null,
};
if (null !== $url) {
$urls[] = $url;
}
}
+2
View File
@@ -56,7 +56,9 @@ class PersonalData
#[Assert\Valid(groups: ['personal_data'])]
public Communication $communication;
/** @var list<string> */
public array $roles = [];
/** @var list<string> */
public array $hotelCodes = [];
public function __construct()
+10 -9
View File
@@ -17,34 +17,35 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
*/
class Pickup
{
#[Groups(['api:list'])]
#[Groups(['api:list', 'snapshot'])]
public ?int $id = null;
#[Groups(['api:list'])]
#[Groups(['api:list', 'snapshot'])]
public ?string $code = null;
#[Groups(['api:list'])]
#[Groups(['api:list', 'snapshot'])]
public ?string $city = null;
#[Groups(['api:list'])]
#[Groups(['api:list', 'snapshot'])]
public ?string $postalCode = null;
#[Groups(['api:list'])]
#[Groups(['api:list', 'snapshot'])]
public ?string $street = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'])]
public ?\DateTimeImmutable $time = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?float $price = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?float $priceOutbound = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?float $priceInbound = null;
/** @var list<int> */
#[Groups(['api:booking'])]
public array $mapping = [];
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
+13 -11
View File
@@ -20,19 +20,19 @@ class Room
public const SELECTION_TYPE_BY_PAX = 'by_pax';
public const SELECTION_TYPE_BY_ROOM = 'by_room';
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $id = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $category = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $boardId = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $code = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $label = null;
#[Groups(['booking'])]
@@ -46,33 +46,35 @@ class Room
#[Groups(['booking'])]
public ?int $totalCount = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $minPax = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $maxPax = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $nights = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $status = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $available = null;
#[Groups(['booking'])]
public ?string $board = null;
/** @var list<int> */
#[Groups(['booking'])]
public array $mapping = [];
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?float $price = null;
#[Groups(['booking'])]
public ?float $totalPrice = null;
/** @var array<int, float|null> */
#[Groups(['booking'])]
public array $individualPrice = [];
+30 -24
View File
@@ -17,84 +17,90 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
*/
class Service
{
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?int $id = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $category = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $status = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public bool $mandatory = false;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public bool $autoBook = false;
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $label = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateFrom = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $timeFrom = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $dayTime = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateTo = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $subType = null;
#[Groups(['booking'])]
public ?int $totalCount = null;
#[Groups(['api:single'])]
/** @var list<int> */
#[Groups(['api:single', 'snapshot'])]
public array $mapping = [];
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?float $price = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?float $totalPrice = null;
#[Groups(['api:single'])]
/** @var array<int, float|null> */
#[Groups(['api:single', 'snapshot'])]
public array $individualPrice = [];
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?int $available = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $source = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $direction = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?int $ageFrom = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?int $ageTo = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?int $birthYearFrom = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?int $birthYearTo = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $ageConstraintType = null;
/** @var array<string, mixed>|null */
#[Groups(['api:single'])]
public ?array $ageConstraintMetadata = null;
#[Groups(['api:single'])]
public ?string $rawAgeConstraintData = null;
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public ?string $description = null;
/**
@@ -104,6 +110,6 @@ class Service
* When false, this service's price is excluded when determining which insurance
* tier is appropriate for a participant.
*/
#[Groups(['api:single', 'api:list'])]
#[Groups(['api:single', 'api:list', 'snapshot'])]
public bool $includeInInsuranceCalculation = true;
}
+2
View File
@@ -13,7 +13,9 @@ namespace App\BusProNet\Model;
class Surcharge
{
public ?string $label = null;
/** @var list<int> */
public array $mapping = [];
public ?float $totalPrice = null;
/** @var array<int, float|null> */
public array $individualPrice = [];
}
+48 -27
View File
@@ -20,40 +20,43 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Travel
{
use SortByPriceTrait;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $id = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $code = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $productCode = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $productId = null;
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $label = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $hotelId = null;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?Hotel $hotel = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateFrom = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
public ?\DateTimeImmutable $dateTo = null;
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $type = null;
/**
* Travel booking status from BusProNet API.
* Possible values: 'Frei', 'Anfrage', 'Buchungsstop'.
*/
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?string $status = null;
/**
@@ -62,53 +65,71 @@ class Travel
*
* @var array<string>
*/
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public array $allowedBookingStatus = [];
#[Groups(['api:list', 'api:single'])]
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?float $priceFrom = null;
#[Groups(['api:single'])]
/**
* @var array<int, CrmSelectionGroup>
*/
#[Groups(['api:single', 'snapshot'])]
public array $selectionGroups = [];
#[Groups(['api:single'])]
/**
* @var array<int, Service>
*/
#[Groups(['api:single', 'snapshot'])]
public array $additionalServices = [];
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $additionalServicesMutable = true;
#[Groups(['api:single'])]
/**
* @var array<int, Service>
*/
#[Groups(['api:single', 'snapshot'])]
public array $transportationServices = [];
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $transportationServicesMutable = true;
#[Groups(['api:single'])]
/**
* @var array<int, Pickup>
*/
#[Groups(['api:single', 'snapshot'])]
public array $pickups = [];
#[Groups(['api:single'])]
/**
* @var array<int, Pickup>
*/
#[Groups(['api:single', 'snapshot'])]
public array $dropOffs = [];
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $pickupsMutable = true;
#[Groups(['api:single'])]
/**
* @var array<int, Room>
*/
#[Groups(['api:single', 'snapshot'])]
public array $rooms = [];
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $roomsMutable = true;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $participantDataMutable = true;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public bool $participantCountMutable = true;
#[Groups(['api:single'])]
#[Groups(['api:single', 'snapshot'])]
public ?Guide $guide = null;
/**
* @var array<Insurance> Available insurances for this travel package
* @var array<string|int, Insurance> Available insurances for this travel package
*/
#[Groups(['api:single'])]
public array $insurances = [];
@@ -182,7 +203,7 @@ class Travel
* Maps selection group IDs to their corresponding services based on
* the predefined included services mapping.
*
* @return array<int, Service> The included services from selection groups
* @return array<int, CrmSelectionGroup> The included services from selection groups
*/
public function getIncludedServices(): array
{
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service;
use App\BusProNet\Service\Contract\BookingStatusRuleInterface;
use App\Form\Model\BookingDto;
/**
* Registry for booking status evaluation rules.
*
* Evaluates registered rules in priority order and returns the first matching
* status. Falls back to 'F' when no rule applies.
*/
class BookingStatusRuleRegistry
{
private const DEFAULT_STATUS = 'F';
/**
* @var BookingStatusRuleInterface[]
*/
private array $sortedRules;
/**
* @param BookingStatusRuleInterface[] $rules
*/
public function __construct(array $rules)
{
$this->sortedRules = $this->sortRulesByPriority($rules);
}
public function evaluateStatus(BookingDto $bookingDto): string
{
foreach ($this->sortedRules as $rule) {
if (true === $rule->evaluate($bookingDto)) {
return $rule->getStatus();
}
}
return self::DEFAULT_STATUS;
}
/**
* @param BookingStatusRuleInterface[] $rules
*
* @return BookingStatusRuleInterface[]
*/
private function sortRulesByPriority(array $rules): array
{
usort($rules, static fn (
BookingStatusRuleInterface $a,
BookingStatusRuleInterface $b,
): int => $b->getPriority() <=> $a->getPriority());
return $rules;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service\Contract;
use App\Form\Model\BookingDto;
/**
* Defines the contract for booking status evaluation rules.
*
* Rules are evaluated in priority order. The first matching rule determines
* the enforced booking status.
*/
interface BookingStatusRuleInterface
{
/**
* Evaluates whether this rule applies to the given booking.
*/
public function evaluate(BookingDto $bookingDto): bool;
/**
* Returns the status code to assign when this rule matches.
*/
public function getStatus(): string;
/**
* Returns the rule priority (higher values evaluated first).
*/
public function getPriority(): int;
/**
* Returns a human-readable rule description.
*/
public function getDescription(): string;
}
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service\Contract;
use App\Form\Model\ParticipantDto;
/**
* Defines the contract for participant status evaluation rules.
*
* Status rules are evaluated in priority order to determine the appropriate
* status code for a participant during booking creation. The first rule that
* matches (evaluate returns true) determines the participant's status.
*/
interface ParticipantStatusRuleInterface
{
/**
* Evaluates whether this rule applies to the given participant.
*
* @param ParticipantDto $participant The participant to evaluate
*
* @return bool True if this rule applies, false otherwise
*/
public function evaluate(ParticipantDto $participant): bool;
/**
* Returns the status code to assign when this rule matches.
*
* @return string The status code (e.g., 'O' for Option, 'F' for Final)
*/
public function getStatus(): string;
/**
* Returns the priority of this rule.
*
* Higher priority rules are evaluated first. Rules with the same priority
* are evaluated in registration order.
*
* @return int The priority value (higher = evaluated first)
*/
public function getPriority(): int;
/**
* Returns a human-readable description of this rule.
*
* Used for debugging and logging purposes.
*
* @return string Description of what this rule checks
*/
public function getDescription(): string;
}
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Service;
use App\BusProNet\Service\Contract\ParticipantStatusRuleInterface;
use App\Form\Model\ParticipantDto;
/**
* Registry for participant status evaluation rules.
*
* Manages a collection of status rules and evaluates them in priority order
* to determine the appropriate status code for a participant during booking
* creation. Returns a default status of 'F' (Final) when no rules match.
*/
class ParticipantStatusRuleRegistry
{
private const DEFAULT_STATUS = 'F';
/**
* @var ParticipantStatusRuleInterface[]
*/
private array $sortedRules;
/**
* @param ParticipantStatusRuleInterface[] $rules The rules to register
*/
public function __construct(array $rules)
{
$this->sortedRules = $this->sortRulesByPriority($rules);
}
/**
* Evaluates all rules to determine the participant's status.
*
* Rules are evaluated in priority order (highest first). The first rule
* that matches determines the status. Returns 'F' if no rules match.
*
* @param ParticipantDto $participant The participant to evaluate
*
* @return string The determined status code
*/
public function evaluateStatus(ParticipantDto $participant): string
{
foreach ($this->sortedRules as $rule) {
if (true === $rule->evaluate($participant)) {
return $rule->getStatus();
}
}
return self::DEFAULT_STATUS;
}
/**
* Sorts rules by priority in descending order.
*
* @param ParticipantStatusRuleInterface[] $rules The rules to sort
*
* @return ParticipantStatusRuleInterface[] The sorted rules
*/
private function sortRulesByPriority(array $rules): array
{
usort($rules, static fn (
ParticipantStatusRuleInterface $a,
ParticipantStatusRuleInterface $b,
): int => $b->getPriority() <=> $a->getPriority());
return $rules;
}
}
@@ -5,26 +5,37 @@ declare(strict_types=1);
namespace App\BusProNet\Service\StatusRule;
use App\BusProNet\Model\Service;
use App\BusProNet\Service\Contract\ParticipantStatusRuleInterface;
use App\BusProNet\Service\Contract\BookingStatusRuleInterface;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
/**
* Status rule for participants with chaperon (Begleitperson) services.
* Status rule for bookings with chaperon (Begleitperson) services.
*
* Assigns 'Option' status to participants who have selected any service
* containing 'Begleitperson' in its label. This applies to accompanying
* persons whose booking confirmation may depend on the main participant.
* Assigns 'Option' status to bookings where any participant has selected any
* service containing 'Begleitperson' in its label.
*/
class ChaperonServiceStatusRule implements ParticipantStatusRuleInterface
class ChaperonServiceStatusRule implements BookingStatusRuleInterface
{
private const STATUS = 'O';
private const PRIORITY = 100;
private const SEARCH_TERM = 'begleitperson';
/**
* @see ParticipantStatusRuleInterface::evaluate()
* @see BookingStatusRuleInterface::evaluate()
*/
public function evaluate(ParticipantDto $participant): bool
public function evaluate(BookingDto $bookingDto): bool
{
foreach ($bookingDto->participants as $participant) {
if (true === $this->participantHasChaperonService($participant)) {
return true;
}
}
return false;
}
private function participantHasChaperonService(ParticipantDto $participant): bool
{
// Check array services
$arrayServices = [
@@ -36,7 +47,7 @@ class ChaperonServiceStatusRule implements ParticipantStatusRuleInterface
foreach ($arrayServices as $services) {
foreach ($services as $service) {
if ($service instanceof Service && true === $this->containsSearchTerm($service->label)) {
if (true === $this->containsSearchTerm($service->label)) {
return true;
}
}
@@ -58,7 +69,7 @@ class ChaperonServiceStatusRule implements ParticipantStatusRuleInterface
}
/**
* @see ParticipantStatusRuleInterface::getStatus()
* @see BookingStatusRuleInterface::getStatus()
*/
public function getStatus(): string
{
@@ -66,7 +77,7 @@ class ChaperonServiceStatusRule implements ParticipantStatusRuleInterface
}
/**
* @see ParticipantStatusRuleInterface::getPriority()
* @see BookingStatusRuleInterface::getPriority()
*/
public function getPriority(): int
{
@@ -74,7 +85,7 @@ class ChaperonServiceStatusRule implements ParticipantStatusRuleInterface
}
/**
* @see ParticipantStatusRuleInterface::getDescription()
* @see BookingStatusRuleInterface::getDescription()
*/
public function getDescription(): string
{
+2 -2
View File
@@ -28,9 +28,9 @@ trait SortByPriceTrait
* collections in the application. Items are sorted from lowest to highest price,
* with null prices being treated as zero (appearing first).
*
* @param array $items Array of objects with a price property to sort
* @param array<array-key, object> $items Array of objects with a price property to sort
*
* @return array The sorted array of objects (ascending by price)
* @return array<array-key, object> The sorted array of objects (ascending by price)
*/
protected function sortByPrice(array $items): array
{
@@ -7,6 +7,7 @@ use Carbon\Exceptions\InvalidFormatException;
trait TypeConversionTrait
{
/** @return list<string> */
protected function stringToArray(?string $string, string $separator = ','): array
{
if (true === empty($string)) {
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Traits;
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Utility;
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlLoader;
+7
View File
@@ -11,6 +11,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class HotelLoader extends AbstractLoader
{
/** @return array<int, Hotel> */
public function loadAll(?string $filename = 'hotel.xml'): array
{
try {
@@ -91,6 +92,12 @@ class HotelLoader extends AbstractLoader
public function patchHotelDetails(Travel $travel): void
{
$hotelId = $travel->hotelId;
if (null === $hotelId) {
$travel->hotel = null;
return;
}
try {
$hotel = $this->loadById($hotelId);
$travel->hotel = $hotel;
+2 -1
View File
@@ -22,11 +22,12 @@ class InsuranceLoader extends AbstractLoader
parent::__construct($cache, $xmlExport);
}
/** @return array<string|int, Insurance> */
public function loadAll(?string $filename = 'versicherungen.xml'): array
{
try {
return $this->cache->get('bpn_insurances', function (ItemInterface $item) use ($filename) {
$item->expiresAfter(3 * 60 * 60);
$item->expiresAfter(24 * 60 * 60);
$crawler = $this->loadXml($filename);
+9
View File
@@ -10,6 +10,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class PickupLoader extends AbstractLoader
{
/** @return array<int|string, Pickup> */
public function loadAll(?string $filename = 'zustiege.xml'): array
{
try {
@@ -65,6 +66,11 @@ class PickupLoader extends AbstractLoader
$travel->dropOffs = $this->patchAndOrderDropOffs($travel->dropOffs, $travel->pickups);
}
/**
* @param array<int, Pickup> $pickups
*
* @return array<int, Pickup>
*/
private function patchAndSortPickups(array $pickups): array
{
$this->enrichPickupDetails($pickups);
@@ -137,6 +143,9 @@ class PickupLoader extends AbstractLoader
{
foreach ($pickups as $pickup) {
$pickupData = $this->loadById($pickup->id);
if (null === $pickupData) {
continue;
}
$pickup->code = $pickupData->code;
$pickup->postalCode = $pickupData->postalCode;
+4 -3
View File
@@ -51,7 +51,7 @@ class TravelLoader extends AbstractLoader
* including ID, code, label, dates, and associated hotels. The mapping is
* cached for 3 hours to improve performance.
*
* @return array<int, array> Mapping of travel IDs to their metadata and file paths
* @return array<int, array<string, mixed>> Mapping of travel IDs to their metadata and file paths
*/
public function generateFilesMap(): array
{
@@ -74,7 +74,7 @@ class TravelLoader extends AbstractLoader
$travelDataNodes = $crawler->filterXPath('//reisen/reise/termin');
$travelDataNodes->each(function (Crawler $node) use (&$mapping, $hotels, $file) {
$travelId = $node->attr('idbuspro');
$travelId = (int) $node->attr('idbuspro');
$mapping[$travelId] = [
'id' => $travelId,
@@ -102,7 +102,7 @@ class TravelLoader extends AbstractLoader
return $mapping;
});
} catch (InvalidArgumentException $e) {
return [];
throw new \RuntimeException('Cache key error in generateFilesMap', 0, $e);
}
}
@@ -173,6 +173,7 @@ class TravelLoader extends AbstractLoader
*
* @throws TravelNotFoundException When travel ID is not found
* @throws HotelNotInTravelException When hotel ID exists but not for this travel
* @throws \RuntimeException When cached mapping generation fails unexpectedly
*/
public function loadById(int $dateId, ?int $hotelId = null, ?string $filename = null): Travel
{
@@ -39,6 +39,7 @@ abstract class AbstractParser
return 0 < $node->count() && $this->stringToBool($node->text());
}
/** @return list<string> */
protected function getArrayValue(Crawler $node, string $separator = ','): array
{
if (0 === $node->count()) {
@@ -47,4 +48,23 @@ abstract class AbstractParser
return $this->stringToArray($node->text(), $separator);
}
protected function getAttrOrNullValue(Crawler $node, string $attribute): ?string
{
if (0 === $node->count()) {
return null;
}
return $node->attr($attribute);
}
protected function getRequiredAttrValue(Crawler $node, string $attribute, string $context): string
{
$value = $this->getAttrOrNullValue($node, $attribute);
if (null === $value || '' === trim($value)) {
throw new \InvalidArgumentException(sprintf('Missing required attribute "%s" in %s.', $attribute, $context));
}
return $value;
}
}
@@ -53,6 +53,7 @@ class AgeConstraintParserRegistry
return $this->mergeConstraintResults($results, $constraintData);
}
/** @param list<AgeConstraintResult> $results */
private function mergeConstraintResults(array $results, string $rawData): AgeConstraintResult
{
if (empty($results)) {
+62 -48
View File
@@ -11,6 +11,7 @@ class ApiResponseParser extends AbstractParser
/**
* @throws ResponseParserException
*/
/** @param array<string, mixed> $additionalArgs */
public function parseXmlString(string $type, string $xml, array $additionalArgs = []): mixed
{
$crawler = new Crawler($xml);
@@ -25,56 +26,69 @@ class ApiResponseParser extends AbstractParser
$resultNode = $crawler->filterXPath('//ergebnis');
switch ($responseType) {
case ApiClient::TYPE_NOTIFICATION:
return (new NotificationParser())->parse($crawler);
case ApiClient::TYPE_CUSTOMER_DATA:
$subType = $resultNode->filterXPath('//art')->text();
try {
switch ($responseType) {
case ApiClient::TYPE_NOTIFICATION:
return (new NotificationParser())->parse($crawler);
case ApiClient::TYPE_CUSTOMER_DATA:
$subType = $this->getStringOrNullValue($resultNode->filterXPath('//art'));
if (null === $subType || '' === $subType) {
throw new ResponseParserException('Unable to determine customer data subtype from XML response');
}
switch ($subType) {
case 'Adressdaten':
case 'Adressdaten_Ändern':
case 'Newsletter':
return (new PersonalDataParser())->parse($resultNode);
case 'Adresse_Neu':
return (new ContactFormResponseParser())->parse($resultNode);
case 'SelektionCRM':
case 'SelektionCRM_Ändern':
return (new CrmAttributesResponseParser())->parse($resultNode);
case 'Vorgänge':
return (new BookingsParser())->parse($resultNode);
case 'Vorgang_Details':
return (new BookingParser())->parse($resultNode);
case 'Dokumentdruck':
return (new DocumentsParser())->parseDocuments($resultNode);
case 'Vorgangdruck':
return (new DocumentsParser())->parseConfirmation($resultNode);
}
break;
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA:
return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY:
return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL:
return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE:
return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_BOOKING:
return (new BookingResponseParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS:
return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA:
$travelNode = $crawler->filterXPath('//reise/termin');
switch ($subType) {
case 'Adressdaten':
case 'Adressdaten_Ändern':
case 'Newsletter':
return (new PersonalDataParser())->parse($resultNode);
case 'Adresse_Neu':
return (new ContactFormResponseParser())->parse($resultNode);
case 'SelektionCRM':
case 'SelektionCRM_Ändern':
return (new CrmAttributesResponseParser())->parse($resultNode);
case 'Vorgänge':
return (new BookingsParser())->parse($resultNode);
case 'Vorgang_Details':
return (new BookingParser())->parse($resultNode);
case 'Dokumentdruck':
return (new DocumentsParser())->parseDocuments($resultNode);
case 'Vorgangdruck':
return (new DocumentsParser())->parseConfirmation($resultNode);
}
break;
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
return (new CountriesParser())->parse($resultNode);
case ApiClient::TYPE_MUTABLE_DATA:
return (new MutableDataParser())->parse($resultNode);
case ApiClient::TYPE_AVAILABILITY:
return (new AvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_EXTENDED:
return (new ExtendedAvailabilitiesParser())->parseServices($resultNode);
case ApiClient::TYPE_AVAILABILITY_HOTEL:
return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE:
return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_BOOKING:
return (new BookingResponseParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS:
return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA:
$travelNode = $crawler->filterXPath('//reise/termin');
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES:
return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER:
return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER:
return (new PromoVoucherParser())->parse($resultNode);
if (0 === $travelNode->count()) {
throw new ResponseParserException('No travel data node found in product data response');
}
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
case ApiClient::TYPE_AGENCIES:
return (new AgencyParser())->parse($resultNode);
case ApiClient::TYPE_PURCHASE_VOUCHER:
return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER:
return (new PromoVoucherParser())->parse($resultNode);
}
} catch (\InvalidArgumentException $e) {
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
}
throw new ResponseParserException('Unable to parse XML response');
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
@@ -15,6 +15,7 @@ use Symfony\Component\DomCrawler\Crawler;
*/
class BookingInsurancesParser extends AbstractParser
{
/** @return array<string, Insurance> */
public function parse(Crawler $result): array
{
$insurances = [];
+17 -11
View File
@@ -40,10 +40,10 @@ class BookingParser extends AbstractParser
$travelData = $node->filterXPath('//reise');
$booking->travelName = $travelData->attr('bezeichnung');
$booking->travelName = $this->getAttrOrNullValue($travelData, 'bezeichnung');
$booking->dateId = $this->getIntOrNullValue($node->filterXPath('//idreise'));
$booking->travelCode = $travelData->attr('code');
$booking->travelDate = $this->stringToDate($travelData->attr('termin'));
$booking->travelCode = $this->getAttrOrNullValue($travelData, 'code');
$booking->travelDate = $this->stringToDate($this->getAttrOrNullValue($travelData, 'termin'));
$booking->hotelId = $this->getIntOrNullValue($node->filterXPath('//idpartner'));
$booking->hotelName = $this->getStringOrNullValue($node->filterXPath('//partner'));
@@ -56,17 +56,17 @@ class BookingParser extends AbstractParser
$booking->participants = $this->parseParticipants($node->filterXPath('//teilnehmerliste/teilnehmer'));
$paymentData = $node->filterXPath('//zahlung');
$booking->paymentId = (int) $paymentData->attr('idzahlungsart');
$booking->paymentLabel = $paymentData->attr('bezeichnung');
$booking->paymentType = $paymentData->attr('art');
$booking->paymentId = $this->getAttrOrNullValue($paymentData, 'idzahlungsart');
$booking->paymentLabel = $this->getAttrOrNullValue($paymentData, 'bezeichnung');
$booking->paymentType = $this->getAttrOrNullValue($paymentData, 'art');
if (0 < $paymentData->children()->count()) {
$bankDataNode = $paymentData->children()->first();
$bankAccount = new BankAccount();
$bankAccount->iban = $bankDataNode->attr('iban');
$bankAccount->bic = $bankDataNode->attr('bic');
$bankAccount->bankName = $bankDataNode->attr('kreditinstitut');
$bankAccount->holder = $bankDataNode->attr('kontoinhaber');
$bankAccount->iban = $this->getAttrOrNullValue($bankDataNode, 'iban');
$bankAccount->bic = $this->getAttrOrNullValue($bankDataNode, 'bic');
$bankAccount->bankName = $this->getAttrOrNullValue($bankDataNode, 'kreditinstitut');
$bankAccount->holder = $this->getAttrOrNullValue($bankDataNode, 'kontoinhaber');
$booking->bankAccount = $bankAccount;
}
@@ -112,12 +112,18 @@ class BookingParser extends AbstractParser
return $booking;
}
/** @return array<int, PersonalData> */
private function parseParticipants(Crawler $node): array
{
$participants = [];
$node->each(function (Crawler $node) use (&$participants) {
$id = (int) $node->attr('id');
$id = (int) ($this->getAttrOrNullValue($node, 'id') ?? 0);
if ($id <= 0) {
return;
}
$participants[$id - 1] = $this->parsePersonalData($node);
});
@@ -32,7 +32,11 @@ class BookingResponseParser extends AbstractParser
{
public function parse(Crawler $node): BookingResponse
{
$status = $node->filterXPath('//buchung')->text();
$status = $this->getStringOrNullValue($node->filterXPath('//buchung'));
if (null === $status || '' === $status) {
throw new \InvalidArgumentException('Missing required booking status in booking response.');
}
$bookingNumber = $this->getIntOrNullValue($node->filterXPath('//vorgang'));
$totalPrice = $this->getFloatOrNullValue($node->filterXPath('//gesamtpreis'));
$message = $this->getStringOrNullValue($node->filterXPath('//hinweis'));
@@ -60,18 +64,24 @@ class BookingResponseParser extends AbstractParser
$priceItems = [];
$node->filterXPath('//preise/preis')->each(function (Crawler $priceNode) use (&$priceItems): void {
$position = (int) $this->getRequiredAttrValue($priceNode, 'position', 'booking response price item');
$type = $this->getRequiredAttrValue($priceNode, 'art', 'booking response price item');
$label = $this->getRequiredAttrValue($priceNode, 'bezeichnung', 'booking response price item');
$unitPrice = $this->stringToFloat($this->getRequiredAttrValue($priceNode, 'preis', 'booking response price item'));
$totalPrice = $this->stringToFloat($this->getRequiredAttrValue($priceNode, 'gesamtpreis', 'booking response price item'));
$priceItems[] = new PriceItem(
position: (int) $priceNode->attr('position'),
type: $priceNode->attr('art'),
subType: $priceNode->attr('unterart'),
label: $priceNode->attr('bezeichnung'),
dateFrom: $priceNode->attr('terminvon'),
dateTo: $priceNode->attr('terminbis'),
quantity: (int) $priceNode->attr('anzahl'),
assignment: $priceNode->attr('zuordnung'),
unitPrice: $this->stringToFloat($priceNode->attr('preis')),
totalPrice: $this->stringToFloat($priceNode->attr('gesamtpreis')),
id: $priceNode->attr('id')
position: $position,
type: $type,
subType: $this->getAttrOrNullValue($priceNode, 'unterart'),
label: $label,
dateFrom: $this->getAttrOrNullValue($priceNode, 'terminvon'),
dateTo: $this->getAttrOrNullValue($priceNode, 'terminbis'),
quantity: (int) ($this->getAttrOrNullValue($priceNode, 'anzahl') ?? 0),
assignment: $this->getAttrOrNullValue($priceNode, 'zuordnung'),
unitPrice: $unitPrice,
totalPrice: $totalPrice,
id: $this->getAttrOrNullValue($priceNode, 'id')
);
});
@@ -94,25 +104,26 @@ class BookingResponseParser extends AbstractParser
$finalPaymentAmount = null;
$finalPaymentDate = null;
$depositNode = $paymentNode->filterXPath('//anzahlung');
$depositNode = $paymentNode->filterXPath('.//anzahlung');
if ($depositNode->count() > 0) {
$depositAmount = $this->stringToFloat($depositNode->attr('betrag'));
$depositDate = $depositNode->attr('termin');
$depositAmount = $this->stringToFloat($this->getAttrOrNullValue($depositNode, 'betrag'));
$depositDate = $this->getAttrOrNullValue($depositNode, 'termin');
}
$finalPaymentNode = $paymentNode->filterXPath('//restzahlung');
$finalPaymentNode = $paymentNode->filterXPath('.//restzahlung');
if ($finalPaymentNode->count() > 0) {
$finalPaymentAmount = $this->stringToFloat($finalPaymentNode->attr('betrag'));
$finalPaymentDate = $finalPaymentNode->attr('termin');
$finalPaymentAmount = $this->stringToFloat($this->getAttrOrNullValue($finalPaymentNode, 'betrag'));
$finalPaymentDate = $this->getAttrOrNullValue($finalPaymentNode, 'termin');
}
// Parse purchase vouchers (kaufgutschein elements)
$appliedPurchaseVouchers = [];
$voucherNodes = $paymentNode->filterXPath('//kaufgutschein');
$voucherNodes = $paymentNode->filterXPath('.//kaufgutschein');
$voucherNodes->each(function (Crawler $voucherNode) use (&$appliedPurchaseVouchers): void {
$amount = $this->getAttrOrNullValue($voucherNode, 'betrag');
$appliedPurchaseVouchers[] = [
'nummer' => $voucherNode->attr('nummer') ?? '',
'betrag' => $this->stringToFloat($voucherNode->attr('betrag')),
'nummer' => $this->getAttrOrNullValue($voucherNode, 'nummer') ?? '',
'betrag' => $this->stringToFloat($amount),
];
});
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
@@ -9,10 +10,12 @@ class BookingUpdateParser extends AbstractParser
{
public function parse(Crawler $node): BookingUpdate
{
$changeStatus = $this->getStringOrNullValue($node->filterXPath('//aenderung'));
$bookingUpdate = new BookingUpdate();
$bookingUpdate->valid = 'möglich' === $node->filterXPath('//aenderung')->text();
$bookingUpdate->success = 'erfolgt' === $node->filterXPath('//aenderung')->text();
$bookingUpdate->status = $node->filterXPath('//status')->text();
$bookingUpdate->valid = 'möglich' === $changeStatus;
$bookingUpdate->success = 'erfolgt' === $changeStatus;
$bookingUpdate->status = $this->getStringOrNullValue($node->filterXPath('//status'));
return $bookingUpdate;
}
+1 -1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
@@ -21,7 +22,6 @@ class BookingsParser extends AbstractParser
$booking = new Booking();
$booking->id = $this->getIntOrNullValue($node->filterXPath('//id'));
$booking->applicantId = $this->getIntOrNullValue($node->filterXPath('//idadresse_anmelder'));
$booking->bookingNumber = $this->getIntOrNullValue($node->filterXPath('//vorgangsnummer'));
$booking->status = $this->getStringOrNullValue($node->filterXPath('//status'));
$booking->participantCount = $this->getIntOrNullValue($node->filterXPath('//personen'));
@@ -11,8 +11,12 @@ class ContactFormResponseParser extends AbstractParser
{
public function parse(Crawler $node): RegistrationResponse
{
$addressId = (int) $node->filterXPath('//idadresse')->text();
$personId = (int) $node->filterXPath('//idperson')->text();
$addressId = $this->getIntOrNullValue($node->filterXPath('//idadresse'));
$personId = $this->getIntOrNullValue($node->filterXPath('//idperson'));
if (null === $addressId || null === $personId) {
throw new \InvalidArgumentException('Missing required idadresse or idperson in contact form response.');
}
$isNewRecord = $this->getBoolValue($node->filterXPath('//neuanlage'));
return new RegistrationResponse(
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
@@ -27,7 +27,7 @@ class CrmAttributesResponseParser
$result
->filterXPath('//selektionsmerkmale/selektionsgruppe')
->each(function (Crawler $node) use (&$groups, &$roles, &$hotelCode) {
->each(function (Crawler $node) use (&$groups, &$roles) {
$group = new CrmSelectionGroup();
$group->id = (int) $node->attr('id');
$group->label = $node->attr('bezeichnung');
@@ -47,14 +47,14 @@ class CrmAttributesResponseParser
$roles[] = 'ROLE_HOUSE_MANAGER';
$hotelCodes[] = $matches[1];
}
if (static::BPN_CRM_ID_ADMIN === $attribute->id && true === $attribute->selected) {
if (self::BPN_CRM_ID_ADMIN === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_ADMIN';
$roles[] = 'ROLE_HOUSE_MANAGER';
}
if (static::BPN_CRM_ID_MANAGER === $attribute->id && true === $attribute->selected) {
if (self::BPN_CRM_ID_MANAGER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_MANAGER';
}
if (static::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
if (self::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_TEAMER';
}
@@ -90,7 +90,7 @@ class CrmAttributesResponseParser
;
if (true === in_array('ROLE_ADMIN', $roles, true)) {
$hotelCodes[] = static::BPN_DEFAULT_HOTEL_CODE;
$hotelCodes[] = self::BPN_DEFAULT_HOTEL_CODE;
}
$response = new CrmAttributes();
+18 -3
View File
@@ -44,15 +44,30 @@ class DocumentsParser
private function decode(Crawler $node): string
{
[, $pdfData] = explode(',', $node->text());
if (0 === $node->count()) {
throw new \InvalidArgumentException('Missing PDF node in document response.');
}
$parts = explode(',', $node->text(), 2);
if (2 !== count($parts)) {
throw new \InvalidArgumentException('Invalid PDF payload in document response.');
}
[, $pdfData] = $parts;
return base64_decode($pdfData);
}
/** @return array{string, string} */
private function getFileInfo(Crawler $node): array
{
$pdfData = $this->decode($node->filterXPath('//pdf'));
$filename = $node->filterXPath('//datei')->text();
$pdfData = $this->decode($node->filterXPath('.//pdf'));
$filenameNode = $node->filterXPath('.//datei');
if (0 === $filenameNode->count()) {
throw new \InvalidArgumentException('Missing filename node in document response.');
}
$filename = $filenameNode->text();
return [$filename, $pdfData];
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\ExtendedAvailability;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parser for VERFUEGBARKEIT2 service payloads.
*
* Extracts extended service-level attributes (dates, times, hints, constraints)
* plus optional travel-level booking-status metadata.
*/
class ExtendedAvailabilitiesParser extends AbstractParser
{
/**
* Parses extended availability services from API result node.
*/
public function parseServices(Crawler $result): ExtendedServiceAvailabilityResponse
{
$availabilities = [];
$result
->filterXPath('//leistungen/leistung')
->each(function (Crawler $node) use (&$availabilities) {
$serviceId = (int) $node->attr('id');
$availability = new ExtendedAvailability();
$availability->serviceId = $serviceId;
$availability->status = $node->attr('status');
$availability->available = (int) $node->attr('frei');
$availability->price = $this->stringToFloat($node->attr('preis'));
$availability->dateFrom = $this->stringToDate($node->attr('termin'));
$availability->dateTo = $this->stringToDate($node->attr('bis'));
$availability->timeFrom = $node->attr('uhrzeit_von');
$availability->description = $node->attr('hinweis');
$ageFrom = $node->attr('altervon');
if (null !== $ageFrom && '' !== trim($ageFrom)) {
$availability->ageFrom = (int) $ageFrom;
}
$ageTo = $node->attr('alterbis');
if (null !== $ageTo && '' !== trim($ageTo)) {
$availability->ageTo = (int) $ageTo;
}
$mandatory = $node->attr('pflicht');
if (null !== $mandatory && '' !== trim($mandatory)) {
$availability->mandatory = $this->stringToBool($mandatory);
}
$availabilities[$serviceId] = $availability;
})
;
$allowedBookingStatus = [];
$travelStatus = null;
$reiseNode = $result->filterXPath('//reise');
if (0 < $reiseNode->count()) {
$travelStatus = $reiseNode->attr('status');
$bookingStatusPossible = $reiseNode->attr('buchungstatusmoeglich') ?? '';
if ('' !== trim($bookingStatusPossible)) {
$allowedBookingStatus = str_split($bookingStatusPossible);
}
}
return new ExtendedServiceAvailabilityResponse($availabilities, $allowedBookingStatus, $travelStatus);
}
}
+11 -11
View File
@@ -37,7 +37,7 @@ class InsuranceParser extends AbstractParser
// Parse all individual insurances for reference lookup
$insurance = $this->parseInsuranceNode($node, false);
if (null !== $insurance && null !== $insurance->id) {
if (null !== $insurance->id) {
// Set complementary flag from XML attribute
$insurance->complementary = $isComplementary;
@@ -53,7 +53,7 @@ class InsuranceParser extends AbstractParser
$xmlContent->filterXPath('//versicherungspakete/versicherungspaket')
->each(function (Crawler $node) use (&$insurances, $individualInsurances) {
$insurance = $this->parseInsuranceNode($node, true, $individualInsurances);
if (null !== $insurance && null !== $insurance->id) {
if (null !== $insurance->id) {
// Parse contained insurance IDs
$insurance->containedInsuranceIds = $this->parseContainedInsuranceIds($node);
@@ -75,8 +75,8 @@ class InsuranceParser extends AbstractParser
/**
* Determines if a package contains any family insurances.
*
* @param Crawler $packageNode The package XML node
* @param array<int, Insurance> $individualInsurances Parsed individual insurances for reference
* @param Crawler $packageNode The package XML node
* @param array<string, Insurance> $individualInsurances Parsed individual insurances for reference
*
* @return bool True if any contained insurance is a family insurance
*/
@@ -96,13 +96,13 @@ class InsuranceParser extends AbstractParser
/**
* Parses a single insurance or package node.
*
* @param Crawler $node The XML node to parse
* @param bool $isPackage Whether this is a package node
* @param array<int, Insurance> $individualInsurances Individual insurances for package family detection
* @param Crawler $node The XML node to parse
* @param bool $isPackage Whether this is a package node
* @param array<string, Insurance> $individualInsurances Individual insurances for package family detection
*
* @return Insurance|null The parsed insurance object
* @return Insurance The parsed insurance object
*/
private function parseInsuranceNode(Crawler $node, bool $isPackage, array $individualInsurances = []): ?Insurance
private function parseInsuranceNode(Crawler $node, bool $isPackage, array $individualInsurances = []): Insurance
{
$insurance = new Insurance();
$insurance->package = $isPackage;
@@ -159,7 +159,7 @@ class InsuranceParser extends AbstractParser
*
* @param Crawler $xmlContent The XML content to scan
*
* @return array<int> Array of referenced insurance IDs
* @return list<string> Array of referenced insurance IDs
*/
private function collectReferencedInsuranceIds(Crawler $xmlContent): array
{
@@ -180,7 +180,7 @@ class InsuranceParser extends AbstractParser
*
* @param Crawler $node The package XML node
*
* @return array<int> Array of contained insurance IDs (always int, as they reference individual insurances)
* @return list<string> Array of contained insurance IDs
*/
private function parseContainedInsuranceIds(Crawler $node): array
{
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
@@ -23,8 +24,10 @@ class PersonalDataParser extends AbstractParser
$personalData->name = $this->getStringOrNullValue($addressDataNode->filterXPath('//name'));
$personalData->salutation = $this->getStringOrNullValue($addressDataNode->filterXPath('//anrede'));
$personalData->title = $this->getStringOrNullValue($addressDataNode->filterXPath('//titel'));
$personalData->gender = strtoupper($this->getStringOrNullValue($addressDataNode->filterXPath('//geschlecht')));
$personalData->nationality = strtoupper($this->getStringOrNullValue($addressDataNode->filterXPath('//nationalitaet')));
$gender = $this->getStringOrNullValue($addressDataNode->filterXPath('//geschlecht'));
$nationality = $this->getStringOrNullValue($addressDataNode->filterXPath('//nationalitaet'));
$personalData->gender = null !== $gender ? strtoupper($gender) : null;
$personalData->nationality = null !== $nationality ? strtoupper($nationality) : null;
$personalData->height = $this->getStringOrNullValue($addressDataNode->filterXPath('//sonstiges1'));
$personalData->weight = $this->getStringOrNullValue($addressDataNode->filterXPath('//sonstiges2'));
@@ -57,7 +60,7 @@ class PersonalDataParser extends AbstractParser
$remarksNode = $addressDataNode->filterXPath('//bemerkung');
if (0 < $remarksNode->count()) {
$personalData->remarks = $remarksNode->text();
$personalData->remarks = $this->getStringOrNullValue($remarksNode);
}
return $personalData;
@@ -7,6 +7,7 @@ use Symfony\Component\DomCrawler\Crawler;
class PickupsParser extends AbstractParser
{
/** @return array<int, Pickup> */
public function parse(Crawler $result): array
{
$pickups = [];
Loaded 100 of 377 files, more files were not shown because too many files have changed in this diff. Show more