diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index 523a37c..2ad7352 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -276,6 +276,8 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown. "accommodationDiscount": null, "boardServiceDiscount": null, "additionalServicesDiscount": null, + "totalDiscount": null, + "totalDiscountLabel": null, "totalPrice": 123456, "pricingCurrency": "EUR", "pricingVersion": 3, @@ -283,7 +285,20 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown. } ``` -`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure. +`totalPrice` is in integer **minor units** (cents) — unlike the contingent endpoints. The `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure. + +The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges and running costs sit in `total` but in no section, so they are never discounted. + +`totalDiscount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. **It compounds**: the three section discounts reduce `total` first, and `totalDiscount` then applies to that already-reduced subtotal. Recomputing it against the gross `total` yields a different figure. Each amount is rounded to the nearest cent at the step it is applied. + +`priceBreakdown` carries the resulting rows, all in minor units: + +| field | meaning | +| --- | --- | +| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix | +| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscount` applies *and* at least one section discount did, otherwise `null` | +| `totalDiscountDetails` | `{label, percent, amount}` for the total discount, or `null` | +| `discountedTotal` | the final price, equal to the booking's `totalPrice` | #### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api` diff --git a/migrations/Version20260908145156.php b/migrations/Version20260908145156.php new file mode 100644 index 0000000..0ce99f7 --- /dev/null +++ b/migrations/Version20260908145156.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE accommodation_booking ADD total_discount INT DEFAULT NULL, ADD total_discount_label VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking DROP total_discount, DROP total_discount_label'); + } +} diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index c034221..2fc3592 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -156,6 +156,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt #[Assert\Range(min: 1, max: 100)] private ?int $additionalServicesDiscount = null; + #[ORM\Column(nullable: true)] + #[Assert\Range(min: 1, max: 100)] + private ?int $totalDiscount = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Assert\Expression( + 'null === this.getTotalDiscount() or (null !== value and "" !== value)', + message: 'Bitte eine Bezeichnung für den Rabatt angeben.', + groups: ['edit'], + )] + private ?string $totalDiscountLabel = null; + #[ORM\ManyToOne] #[ORM\JoinColumn(onDelete: 'SET NULL')] private ?User $managedBy = null; @@ -638,6 +650,30 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return $this; } + public function getTotalDiscount(): ?int + { + return $this->totalDiscount; + } + + public function setTotalDiscount(?int $totalDiscount): self + { + $this->totalDiscount = $totalDiscount; + + return $this; + } + + public function getTotalDiscountLabel(): ?string + { + return $this->totalDiscountLabel; + } + + public function setTotalDiscountLabel(?string $totalDiscountLabel): self + { + $this->totalDiscountLabel = $totalDiscountLabel; + + return $this; + } + public function getManagedBy(): ?User { return $this->managedBy; diff --git a/src/Form/Admin/Groups/AccommodationBookingType.php b/src/Form/Admin/Groups/AccommodationBookingType.php index 60a548f..bb1caa6 100644 --- a/src/Form/Admin/Groups/AccommodationBookingType.php +++ b/src/Form/Admin/Groups/AccommodationBookingType.php @@ -158,6 +158,20 @@ class AccommodationBookingType extends AbstractType new Range(min: 1, max: 100), ], ]) + // Applies to the total after the three section discounts. The label is not marked + // required here — a percentage may legitimately be absent — but the entity refuses + // a percentage without one, so no unnamed discount can reach the customer. + ->add('totalDiscount', IntegerType::class, [ + 'label' => 'Rabatt Gesamtpreis (%)', + 'required' => false, + 'constraints' => [ + new Range(min: 1, max: 100), + ], + ]) + ->add('totalDiscountLabel', TextType::class, [ + 'label' => 'Bezeichnung Rabatt Gesamtpreis', + 'required' => false, + ]) ->add('remarks', TextareaType::class, [ 'label' => 'Bemerkungen', 'required' => false, diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php index 328f52e..4c1d2f4 100644 --- a/src/Model/AccommodationBookingApiResponse.php +++ b/src/Model/AccommodationBookingApiResponse.php @@ -73,6 +73,13 @@ final readonly class AccommodationBookingApiResponse #[Groups(['api:single'])] public ?int $additionalServicesDiscount; + /** Applies to the total after the three section discounts above, not to the gross total. */ + #[Groups(['api:single'])] + public ?int $totalDiscount; + + #[Groups(['api:single'])] + public ?string $totalDiscountLabel; + #[Groups(['api:single'])] public ?int $totalPrice; @@ -115,6 +122,8 @@ final readonly class AccommodationBookingApiResponse $this->accommodationDiscount = $booking->getAccommodationDiscount(); $this->boardServiceDiscount = $booking->getBoardServiceDiscount(); $this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount(); + $this->totalDiscount = $booking->getTotalDiscount(); + $this->totalDiscountLabel = $booking->getTotalDiscountLabel(); $this->totalPrice = $booking->getTotalPrice(); $this->pricingCurrency = $booking->getPricingCurrency(); $this->pricingVersion = $booking->getPricingVersion(); diff --git a/src/Service/AccommodationBookingBreakdownCalculator.php b/src/Service/AccommodationBookingBreakdownCalculator.php index 7a7088c..657f21a 100644 --- a/src/Service/AccommodationBookingBreakdownCalculator.php +++ b/src/Service/AccommodationBookingBreakdownCalculator.php @@ -37,14 +37,17 @@ class AccommodationBookingBreakdownCalculator * * @param array $breakdown * - * @return array the breakdown plus `discounts` and `discountedTotal` + * @return array the breakdown plus `discounts`, `discountSubtotal`, + * `totalDiscountDetails` and `discountedTotal` */ public function withDiscounts(AccommodationBooking $booking, array $breakdown): array { + // The labels carry their own "Rabatt" prefix so that every row renders as plain + // {label} {percent} %, including the freely named discount on the total below. $buckets = [ - ['Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)], - ['Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)], - ['Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)], + ['Rabatt Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)], + ['Rabatt Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)], + ['Rabatt Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)], ]; $discounts = []; @@ -63,10 +66,26 @@ class AccommodationBookingBreakdownCalculator $discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount]; } - $breakdown['discounts'] = $discounts; // Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while // computing the new total, where the stored one is still the outdated value. - $breakdown['discountedTotal'] = (int) ($breakdown['total'] ?? 0) - $discountSum; + $subtotal = (int) ($breakdown['total'] ?? 0) - $discountSum; + + // Compounds on the already-reduced subtotal rather than on the gross total: the discount + // is granted on what the customer would otherwise pay, not on a figure no longer asked. + $totalDiscountAmount = self::discountAmount($subtotal, $booking->getTotalDiscount()); + + $breakdown['discounts'] = $discounts; + $breakdown['totalDiscountDetails'] = 0 !== $totalDiscountAmount + ? [ + 'label' => (string) $booking->getTotalDiscountLabel(), + 'percent' => $booking->getTotalDiscount(), + 'amount' => $totalDiscountAmount, + ] + : null; + // Only worth a row when it sits between two sets of discounts — with no section discount + // above it, a subtotal would merely restate the total one line up. + $breakdown['discountSubtotal'] = 0 !== $totalDiscountAmount && [] !== $discounts ? $subtotal : null; + $breakdown['discountedTotal'] = $subtotal - $totalDiscountAmount; return $breakdown; } diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index 39b26ee..9af75ee 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -47,9 +47,7 @@ {{ form_row(form.boardService) }} {% endif %} {% if form.selectedAdditionalServices is defined %} -
- {{ form_row(form.selectedAdditionalServices) }} -
+ {{ form_row(form.selectedAdditionalServices) }} {% endif %} {% endif %} @@ -76,9 +74,15 @@ {{ form_row(form.managedBy) }} {% endif %} - {{ form_row(form.accommodationDiscount) }} - {{ form_row(form.boardServiceDiscount) }} - {{ form_row(form.additionalServicesDiscount) }} +
+ {{ form_row(form.accommodationDiscount) }} + {{ form_row(form.boardServiceDiscount) }} + {{ form_row(form.additionalServicesDiscount) }} +
+
+ {{ form_row(form.totalDiscountLabel) }} + {{ form_row(form.totalDiscount) }} +
{{ form_row(form.remarks) }}
diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index 9ffbb72..d259361 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -84,6 +84,9 @@ {% if booking.additionalServicesDiscount is not null %} {% set discounts = discounts | merge(['Zusatzleistungen ' ~ booking.additionalServicesDiscount ~ '%']) %} {% endif %} + {% if booking.totalDiscount is not null %} + {% set discounts = discounts | merge([(booking.totalDiscountLabel ?: 'Gesamtpreis') ~ ' ' ~ booking.totalDiscount ~ '%']) %} + {% endif %} {{ discounts | length > 0 ? (discounts | join(', ')) : '–' }} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index 6166ba7..cb7d6c3 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -148,11 +148,23 @@ {% for discount in priceBreakdown.discounts %} - Rabatt {{ discount.label }} {{ discount.percent }} % + {{ discount.label }} {{ discount.percent }} % – {{ (discount.amount / 100) | format_currency(currency) }} {% endfor %} - {% if priceBreakdown.discounts is not empty %} + {% if priceBreakdown.discountSubtotal is not null %} + + Zwischensumme + {{ (priceBreakdown.discountSubtotal / 100) | format_currency(currency) }} + + {% endif %} + {% if priceBreakdown.totalDiscountDetails is not null %} + + {{ priceBreakdown.totalDiscountDetails.label }} {{ priceBreakdown.totalDiscountDetails.percent }} % + – {{ (priceBreakdown.totalDiscountDetails.amount / 100) | format_currency(currency) }} + + {% endif %} + {% if priceBreakdown.discounts is not empty or priceBreakdown.totalDiscountDetails is not null %} Gesamtpreis nach Rabatt {{ (priceBreakdown.discountedTotal / 100) | format_currency(currency) }} diff --git a/templates/email/_price_breakdown.html.twig b/templates/email/_price_breakdown.html.twig index b5be216..2acac1a 100644 --- a/templates/email/_price_breakdown.html.twig +++ b/templates/email/_price_breakdown.html.twig @@ -75,11 +75,25 @@ {% for discount in priceBreakdown.discounts %} - Rabatt {{ discount.label }} {{ discount.percent }} % + {{ discount.label }} {{ discount.percent }} % – {{ (discount.amount / 100)|format_currency(currency) }} {% endfor %} - {% if priceBreakdown.discounts is not empty %} + {% if priceBreakdown.discountSubtotal is not null %} + + Zwischensumme + + {{ (priceBreakdown.discountSubtotal / 100)|format_currency(currency) }} + + + {% endif %} + {% if priceBreakdown.totalDiscountDetails is not null %} + + {{ priceBreakdown.totalDiscountDetails.label }} {{ priceBreakdown.totalDiscountDetails.percent }} % + – {{ (priceBreakdown.totalDiscountDetails.amount / 100)|format_currency(currency) }} + + {% endif %} + {% if priceBreakdown.discounts is not empty or priceBreakdown.totalDiscountDetails is not null %} Gesamtpreis nach Rabatt diff --git a/templates/groups/offer/_summary.html.twig b/templates/groups/offer/_summary.html.twig index 43988e5..e363ad8 100644 --- a/templates/groups/offer/_summary.html.twig +++ b/templates/groups/offer/_summary.html.twig @@ -152,11 +152,23 @@ {% for discount in ctx.priceBreakdown.discounts %} - Rabatt {{ discount.label }} {{ discount.percent }} % + {{ discount.label }} {{ discount.percent }} % – {{ (discount.amount / 100)|format_currency(currency) }} {% endfor %} - {% if ctx.priceBreakdown.discounts is not empty %} + {% if ctx.priceBreakdown.discountSubtotal is not null %} + + Zwischensumme + {{ (ctx.priceBreakdown.discountSubtotal / 100)|format_currency(currency) }} + + {% endif %} + {% if ctx.priceBreakdown.totalDiscountDetails is not null %} + + {{ ctx.priceBreakdown.totalDiscountDetails.label }} {{ ctx.priceBreakdown.totalDiscountDetails.percent }} % + – {{ (ctx.priceBreakdown.totalDiscountDetails.amount / 100)|format_currency(currency) }} + + {% endif %} + {% if ctx.priceBreakdown.discounts is not empty or ctx.priceBreakdown.totalDiscountDetails is not null %} Gesamtpreis nach Rabatt {{ (ctx.priceBreakdown.discountedTotal / 100)|format_currency(currency) }} diff --git a/tests/Controller/Api/AccommodationBookingControllerTest.php b/tests/Controller/Api/AccommodationBookingControllerTest.php index 87813a1..978b732 100644 --- a/tests/Controller/Api/AccommodationBookingControllerTest.php +++ b/tests/Controller/Api/AccommodationBookingControllerTest.php @@ -56,6 +56,8 @@ class AccommodationBookingControllerTest extends TestCase $booking->setAccommodationDiscount(10); $booking->setBoardServiceDiscount(20); $booking->setAdditionalServicesDiscount(30); + $booking->setTotalDiscount(5); + $booking->setTotalDiscountLabel('Treuerabatt'); $booking->setStatus(AccommodationBookingStatus::Confirmed); $booking->setOrigin(AccommodationBookingOrigin::Direct); $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); @@ -127,6 +129,8 @@ class AccommodationBookingControllerTest extends TestCase 'accommodationDiscount' => 10, 'boardServiceDiscount' => 20, 'additionalServicesDiscount' => 30, + 'totalDiscount' => 5, + 'totalDiscountLabel' => 'Treuerabatt', 'totalPrice' => 11111, 'pricingCurrency' => 'EUR', 'pricingVersion' => 1, diff --git a/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php b/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php index 6ca5b9d..7196cf7 100644 --- a/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php +++ b/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php @@ -73,6 +73,42 @@ class AccommodationBookingTypeTest extends TestCase self::assertSame(['email', 'firstName', 'groupName', 'lastName'], $properties); } + /** + * The label is printed verbatim on the customer's PDF, so a percentage without one is + * refused — and in the Default group, so that even a draft cannot store the pair half-set. + */ + public function testTotalDiscountRequiresALabel(): void + { + $booking = new AccommodationBooking(); + $booking->setTotalDiscount(5); + + $violations = $this->validate($booking, ['Default']); + + self::assertCount(1, $violations); + self::assertSame('totalDiscountLabel', $violations[0]->getPropertyPath()); + } + + public function testALabelledTotalDiscountPassesValidation(): void + { + $booking = new AccommodationBooking(); + $booking->setTotalDiscount(5); + $booking->setTotalDiscountLabel('Treuerabatt'); + + self::assertCount(0, $this->validate($booking, ['Default'])); + } + + /** + * The reverse is harmless — without a percentage the label never reaches a breakdown row — + * so it is deliberately left valid rather than blocking a half-typed edit. + */ + public function testALabelWithoutAPercentageIsAccepted(): void + { + $booking = new AccommodationBooking(); + $booking->setTotalDiscountLabel('Treuerabatt'); + + self::assertCount(0, $this->validate($booking, ['Default'])); + } + /** * @return string[] */ diff --git a/tests/Service/AccommodationBookingBreakdownCalculatorTest.php b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php index ab254af..593cdad 100644 --- a/tests/Service/AccommodationBookingBreakdownCalculatorTest.php +++ b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php @@ -37,7 +37,7 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase $breakdown = $this->createCalculator()->compute($booking); - self::assertSame([['label' => 'Unterkunft', 'percent' => 10, 'amount' => 1000]], $breakdown['discounts']); + self::assertSame([['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000]], $breakdown['discounts']); self::assertSame(12345 - 1000, $breakdown['discountedTotal']); } @@ -52,9 +52,9 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase // accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250 self::assertSame([ - ['label' => 'Unterkunft', 'percent' => 10, 'amount' => 1000], - ['label' => 'Verpflegung', 'percent' => 20, 'amount' => 200], - ['label' => 'Zusatzleistungen', 'percent' => 50, 'amount' => 250], + ['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000], + ['label' => 'Rabatt Verpflegung', 'percent' => 20, 'amount' => 200], + ['label' => 'Rabatt Zusatzleistungen', 'percent' => 50, 'amount' => 250], ], $breakdown['discounts']); self::assertSame(12345 - 1000 - 200 - 250, $breakdown['discountedTotal']); } @@ -94,9 +94,67 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase $breakdown = $this->createCalculator()->withDiscounts(new AccommodationBooking(), $this->breakdown()); self::assertSame([], $breakdown['discounts']); + self::assertNull($breakdown['totalDiscountDetails']); + self::assertNull($breakdown['discountSubtotal']); self::assertSame(12345, $breakdown['discountedTotal']); } + /** + * On its own the total discount has nothing to sit below, so no subtotal row is offered and + * the percentage applies to the plain total. + */ + public function testTotalDiscountAloneAppliesToTheTotalWithoutASubtotal(): void + { + $booking = new AccommodationBooking(); + $booking->setTotalDiscount(10); + $booking->setTotalDiscountLabel('Treuerabatt'); + + $breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown()); + + self::assertSame([], $breakdown['discounts']); + self::assertSame(['label' => 'Treuerabatt', 'percent' => 10, 'amount' => 1235], $breakdown['totalDiscountDetails']); + self::assertNull($breakdown['discountSubtotal']); + self::assertSame(12345 - 1235, $breakdown['discountedTotal']); + } + + /** + * The worked example from the specification: the total discount compounds on the subtotal + * left by the section discounts, not on the gross total — 5 % of 930,00 € is 46,50 €, where + * 5 % of the gross 1.000,00 € would have been 50,00 €. + */ + public function testTotalDiscountCompoundsOnTheSubtotalAfterSectionDiscounts(): void + { + $booking = new AccommodationBooking(); + $booking->setAccommodationDiscount(10); + $booking->setBoardServiceDiscount(5); + $booking->setTotalDiscount(5); + $booking->setTotalDiscountLabel('Treuerabatt'); + + $breakdown = $this->createCalculator()->withDiscounts($booking, [ + 'total' => 100000, + 'basePrice' => 60000, + 'boardPrice' => 20000, + ]); + + // accommodation: 60000*10% = 6000, board: 20000*5% = 1000 → subtotal 93000 + self::assertSame(93000, $breakdown['discountSubtotal']); + self::assertSame(['label' => 'Treuerabatt', 'percent' => 5, 'amount' => 4650], $breakdown['totalDiscountDetails']); + self::assertSame(88350, $breakdown['discountedTotal']); + } + + public function testTotalDiscountThatAmountsToNothingIsSkipped(): void + { + $booking = new AccommodationBooking(); + $booking->setTotalDiscount(20); + $booking->setTotalDiscountLabel('Treuerabatt'); + + $breakdown = $this->createCalculator()->withDiscounts($booking, ['total' => 0]); + + self::assertNull($breakdown['totalDiscountDetails']); + self::assertNull($breakdown['discountSubtotal']); + self::assertSame(0, $breakdown['discountedTotal']); + } + /** * The derived keys must never reach the database: refreshPriceSnapshot() freezes exactly * what computeCurrent() returns. @@ -112,6 +170,8 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase $breakdown = $this->createCalculator()->computeCurrent($booking); self::assertArrayNotHasKey('discounts', $breakdown); + self::assertArrayNotHasKey('discountSubtotal', $breakdown); + self::assertArrayNotHasKey('totalDiscountDetails', $breakdown); self::assertArrayNotHasKey('discountedTotal', $breakdown); }