From e6c47019d57dc3a1de8e1ca8fbd52e3bfa6527b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sat, 18 Oct 2025 17:44:24 +0200 Subject: [PATCH] fix: fix floating point precision error --- docs/PROJECT_OVERVIEW.md | 37 +++++++++++++++++++ .../Booking/Create/Step3Controller.php | 6 +-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 51c7d07..c6e67df 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -89,6 +89,42 @@ Services from BPN API may lack complete data (especially prices). Always enrich ### Notification System Field handlers generate notifications (auto-changes) → collected by controller → sent via HX-Trigger → displayed as toasts. +### Floating-Point Precision in Price Calculations +**Critical**: All monetary comparisons must account for floating-point arithmetic accumulation errors. + +**The Problem:** +- Prices are parsed from XML as German-formatted strings (e.g., `"1.812,70"`) +- Multiple price additions (rooms + services + insurances) accumulate tiny precision errors (`~1e-15` per operation) +- With bulk insurance and deep calculation chains, errors compound to `~1e-13` or larger +- Example: API returns `1812.7`, calculation produces `1812.6999999999998` + +**The Solution:** +- **Always round monetary values to 2 decimal places before comparison** +- Use `round($price, 2)` for cent precision (standard for EUR currency) +- Never use strict equality (`===` or `!==`) on unrounded float prices + +**Implementation Example (Step3Controller:93-94):** +```php +// CORRECT: Round to cent precision before comparison +$apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2); +$calculatedTotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2); + +if ($apiTotal !== $calculatedTotal) { + // Handle mismatch +} + +// WRONG: Direct float comparison (will fail due to precision errors) +if ($inquiryResponse->totalPrice !== $this->priceCalculator->calculateGrandTotal($bookingCreateDto)) { + // This comparison is unreliable! +} +``` + +**Important Notes:** +- Rounding eliminates precision errors smaller than 1 cent (€0.01) +- The BPN API returns prices already rounded to cent precision +- This is the industry-standard approach for financial calculations +- Only affects comparison logic - does not alter actual price calculation flow + ### Dirty State Detection (Edit Mode) Fingerprint-based change detection to warn users about unsaved modifications: - **BookingFingerprintService** generates SHA-256 hash of all mutable booking data @@ -345,6 +381,7 @@ ddev exec "php -r 'opcache_reset()';" # Clear opcache after code cha - **Edit mode service availability**: Services with `available <= 0` remain visible and editable for participants who already have them (prevents fingerprint false positives) - **Session cleanup on exit**: All exit paths from edit mode (save, discard, cancel) properly clear session to reset dirty state - **Participant naming convention**: Index 0 is "Anmelder:in", others are "Teilnehmer:in N" (1-based, not 0-based) +- **Floating-point price comparisons**: ALWAYS use `round($price, 2)` before comparing monetary values to avoid precision errors (see "Floating-Point Precision in Price Calculations" section) ## References diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index 8ba2130..b2f4132 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -89,9 +89,9 @@ class Step3Controller extends AbstractController ); } - // Validate price match (exact comparison) - $apiTotal = $inquiryResponse->totalPrice ?? 0.0; - $calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto); + // Validate price match (rounded to cent precision to avoid floating-point errors) + $apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2); + $calculatedTotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2); if ($apiTotal !== $calculatedTotal) { return $this->handleApiError(