feat: additional custom discount applied to total price

This commit is contained in:
2026-09-08 15:29:00 +02:00
parent dea7a3dc97
commit 8443dae5ad
14 changed files with 287 additions and 23 deletions
+16 -1
View File
@@ -276,6 +276,8 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
"accommodationDiscount": null, "accommodationDiscount": null,
"boardServiceDiscount": null, "boardServiceDiscount": null,
"additionalServicesDiscount": null, "additionalServicesDiscount": null,
"totalDiscount": null,
"totalDiscountLabel": null,
"totalPrice": 123456, "totalPrice": 123456,
"pricingCurrency": "EUR", "pricingCurrency": "EUR",
"pricingVersion": 3, "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** (1100) 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` #### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
+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 Version20260908145156 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a generic labelled discount on the accommodation booking total';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -156,6 +156,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[Assert\Range(min: 1, max: 100)] #[Assert\Range(min: 1, max: 100)]
private ?int $additionalServicesDiscount = null; 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\ManyToOne]
#[ORM\JoinColumn(onDelete: 'SET NULL')] #[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?User $managedBy = null; private ?User $managedBy = null;
@@ -638,6 +650,30 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this; 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 public function getManagedBy(): ?User
{ {
return $this->managedBy; return $this->managedBy;
@@ -158,6 +158,20 @@ class AccommodationBookingType extends AbstractType
new Range(min: 1, max: 100), 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, [ ->add('remarks', TextareaType::class, [
'label' => 'Bemerkungen', 'label' => 'Bemerkungen',
'required' => false, 'required' => false,
@@ -73,6 +73,13 @@ final readonly class AccommodationBookingApiResponse
#[Groups(['api:single'])] #[Groups(['api:single'])]
public ?int $additionalServicesDiscount; 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'])] #[Groups(['api:single'])]
public ?int $totalPrice; public ?int $totalPrice;
@@ -115,6 +122,8 @@ final readonly class AccommodationBookingApiResponse
$this->accommodationDiscount = $booking->getAccommodationDiscount(); $this->accommodationDiscount = $booking->getAccommodationDiscount();
$this->boardServiceDiscount = $booking->getBoardServiceDiscount(); $this->boardServiceDiscount = $booking->getBoardServiceDiscount();
$this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount(); $this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount();
$this->totalDiscount = $booking->getTotalDiscount();
$this->totalDiscountLabel = $booking->getTotalDiscountLabel();
$this->totalPrice = $booking->getTotalPrice(); $this->totalPrice = $booking->getTotalPrice();
$this->pricingCurrency = $booking->getPricingCurrency(); $this->pricingCurrency = $booking->getPricingCurrency();
$this->pricingVersion = $booking->getPricingVersion(); $this->pricingVersion = $booking->getPricingVersion();
@@ -37,14 +37,17 @@ class AccommodationBookingBreakdownCalculator
* *
* @param array<string, mixed> $breakdown * @param array<string, mixed> $breakdown
* *
* @return array<string, mixed> the breakdown plus `discounts` and `discountedTotal` * @return array<string, mixed> the breakdown plus `discounts`, `discountSubtotal`,
* `totalDiscountDetails` and `discountedTotal`
*/ */
public function withDiscounts(AccommodationBooking $booking, array $breakdown): array 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 = [ $buckets = [
['Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)], ['Rabatt Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)],
['Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)], ['Rabatt Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)],
['Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)], ['Rabatt Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)],
]; ];
$discounts = []; $discounts = [];
@@ -63,10 +66,26 @@ class AccommodationBookingBreakdownCalculator
$discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount]; $discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount];
} }
$breakdown['discounts'] = $discounts;
// Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while // Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while
// computing the new total, where the stored one is still the outdated value. // 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; return $breakdown;
} }
@@ -47,9 +47,7 @@
{{ form_row(form.boardService) }} {{ form_row(form.boardService) }}
{% endif %} {% endif %}
{% if form.selectedAdditionalServices is defined %} {% if form.selectedAdditionalServices is defined %}
<div class="lg:col-span-2"> {{ form_row(form.selectedAdditionalServices) }}
{{ form_row(form.selectedAdditionalServices) }}
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -76,9 +74,15 @@
{{ form_row(form.managedBy) }} {{ form_row(form.managedBy) }}
</div> </div>
{% endif %} {% endif %}
{{ form_row(form.accommodationDiscount) }} <div class="lg:col-span-2 grid lg:grid-cols-3 gap-y-4 lg:gap-x-8">
{{ form_row(form.boardServiceDiscount) }} {{ form_row(form.accommodationDiscount) }}
{{ form_row(form.additionalServicesDiscount) }} {{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
</div>
<div class="lg:col-span-2 grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
{{ form_row(form.totalDiscountLabel) }}
{{ form_row(form.totalDiscount) }}
</div>
<div class="lg:col-span-2"> <div class="lg:col-span-2">
{{ form_row(form.remarks) }} {{ form_row(form.remarks) }}
</div> </div>
@@ -84,6 +84,9 @@
{% if booking.additionalServicesDiscount is not null %} {% if booking.additionalServicesDiscount is not null %}
{% set discounts = discounts | merge(['Zusatzleistungen ' ~ booking.additionalServicesDiscount ~ '%']) %} {% set discounts = discounts | merge(['Zusatzleistungen ' ~ booking.additionalServicesDiscount ~ '%']) %}
{% endif %} {% endif %}
{% if booking.totalDiscount is not null %}
{% set discounts = discounts | merge([(booking.totalDiscountLabel ?: 'Gesamtpreis') ~ ' ' ~ booking.totalDiscount ~ '%']) %}
{% endif %}
<a href="{{ edit_url }}">{{ discounts | length > 0 ? (discounts | join(', ')) : '' }}</a> <a href="{{ edit_url }}">{{ discounts | length > 0 ? (discounts | join(', ')) : '' }}</a>
</td> </td>
<td class="text-right"> <td class="text-right">
@@ -148,11 +148,23 @@
</tr> </tr>
{% for discount in priceBreakdown.discounts %} {% for discount in priceBreakdown.discounts %}
<tr class="text-green-700"> <tr class="text-green-700">
<td class="pt-1">Rabatt {{ discount.label }} {{ discount.percent }}&nbsp;%</td> <td class="pt-1">{{ discount.label }} {{ discount.percent }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap"> {{ (discount.amount / 100) | format_currency(currency) }}</td> <td class="pt-1 text-right whitespace-nowrap"> {{ (discount.amount / 100) | format_currency(currency) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if priceBreakdown.discounts is not empty %} {% if priceBreakdown.discountSubtotal is not null %}
<tr class="border-t border-gray-200">
<td class="pt-2">Zwischensumme</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.discountSubtotal / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.totalDiscountDetails is not null %}
<tr class="text-green-700">
<td class="pt-1">{{ priceBreakdown.totalDiscountDetails.label }} {{ priceBreakdown.totalDiscountDetails.percent }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap"> {{ (priceBreakdown.totalDiscountDetails.amount / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.discounts is not empty or priceBreakdown.totalDiscountDetails is not null %}
<tr class="border-t border-gray-200 font-bold text-green-700"> <tr class="border-t border-gray-200 font-bold text-green-700">
<td class="pt-2">Gesamtpreis nach Rabatt</td> <td class="pt-2">Gesamtpreis nach Rabatt</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.discountedTotal / 100) | format_currency(currency) }}</td> <td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.discountedTotal / 100) | format_currency(currency) }}</td>
+16 -2
View File
@@ -75,11 +75,25 @@
</tr> </tr>
{% for discount in priceBreakdown.discounts %} {% for discount in priceBreakdown.discounts %}
<tr> <tr>
<td style="{{ cell }}">Rabatt {{ discount.label }} {{ discount.percent }}&nbsp;%</td> <td style="{{ cell }}">{{ discount.label }} {{ discount.percent }}&nbsp;%</td>
<td style="{{ amount }}"> {{ (discount.amount / 100)|format_currency(currency) }}</td> <td style="{{ amount }}"> {{ (discount.amount / 100)|format_currency(currency) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if priceBreakdown.discounts is not empty %} {% if priceBreakdown.discountSubtotal is not null %}
<tr>
<td style="{{ cell }} border-top: 1px solid #d1d5db;">Zwischensumme</td>
<td style="{{ amount }} border-top: 1px solid #d1d5db;">
{{ (priceBreakdown.discountSubtotal / 100)|format_currency(currency) }}
</td>
</tr>
{% endif %}
{% if priceBreakdown.totalDiscountDetails is not null %}
<tr>
<td style="{{ cell }}">{{ priceBreakdown.totalDiscountDetails.label }} {{ priceBreakdown.totalDiscountDetails.percent }}&nbsp;%</td>
<td style="{{ amount }}"> {{ (priceBreakdown.totalDiscountDetails.amount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.discounts is not empty or priceBreakdown.totalDiscountDetails is not null %}
<tr> <tr>
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">Gesamtpreis nach Rabatt</td> <td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">Gesamtpreis nach Rabatt</td>
<td style="{{ amount }} border-top: 1px solid #d1d5db; font-weight: bold;"> <td style="{{ amount }} border-top: 1px solid #d1d5db; font-weight: bold;">
+14 -2
View File
@@ -152,11 +152,23 @@
</tr> </tr>
{% for discount in ctx.priceBreakdown.discounts %} {% for discount in ctx.priceBreakdown.discounts %}
<tr class="text-green-700"> <tr class="text-green-700">
<td class="p-2">Rabatt {{ discount.label }} {{ discount.percent }}&nbsp;%</td> <td class="p-2">{{ discount.label }} {{ discount.percent }}&nbsp;%</td>
<td class="p-2 text-right whitespace-nowrap"> {{ (discount.amount / 100)|format_currency(currency) }}</td> <td class="p-2 text-right whitespace-nowrap"> {{ (discount.amount / 100)|format_currency(currency) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if ctx.priceBreakdown.discounts is not empty %} {% if ctx.priceBreakdown.discountSubtotal is not null %}
<tr class="border-t border-primary-bg">
<td class="p-2">Zwischensumme</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.discountSubtotal / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.totalDiscountDetails is not null %}
<tr class="text-green-700">
<td class="p-2">{{ ctx.priceBreakdown.totalDiscountDetails.label }} {{ ctx.priceBreakdown.totalDiscountDetails.percent }}&nbsp;%</td>
<td class="p-2 text-right whitespace-nowrap"> {{ (ctx.priceBreakdown.totalDiscountDetails.amount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.discounts is not empty or ctx.priceBreakdown.totalDiscountDetails is not null %}
<tr class="border-t border-primary-bg font-bold text-base text-green-700"> <tr class="border-t border-primary-bg font-bold text-base text-green-700">
<td class="p-2">Gesamtpreis nach Rabatt</td> <td class="p-2">Gesamtpreis nach Rabatt</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.discountedTotal / 100)|format_currency(currency) }}</td> <td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.discountedTotal / 100)|format_currency(currency) }}</td>
@@ -56,6 +56,8 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setAccommodationDiscount(10); $booking->setAccommodationDiscount(10);
$booking->setBoardServiceDiscount(20); $booking->setBoardServiceDiscount(20);
$booking->setAdditionalServicesDiscount(30); $booking->setAdditionalServicesDiscount(30);
$booking->setTotalDiscount(5);
$booking->setTotalDiscountLabel('Treuerabatt');
$booking->setStatus(AccommodationBookingStatus::Confirmed); $booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setOrigin(AccommodationBookingOrigin::Direct); $booking->setOrigin(AccommodationBookingOrigin::Direct);
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
@@ -127,6 +129,8 @@ class AccommodationBookingControllerTest extends TestCase
'accommodationDiscount' => 10, 'accommodationDiscount' => 10,
'boardServiceDiscount' => 20, 'boardServiceDiscount' => 20,
'additionalServicesDiscount' => 30, 'additionalServicesDiscount' => 30,
'totalDiscount' => 5,
'totalDiscountLabel' => 'Treuerabatt',
'totalPrice' => 11111, 'totalPrice' => 11111,
'pricingCurrency' => 'EUR', 'pricingCurrency' => 'EUR',
'pricingVersion' => 1, 'pricingVersion' => 1,
@@ -73,6 +73,42 @@ class AccommodationBookingTypeTest extends TestCase
self::assertSame(['email', 'firstName', 'groupName', 'lastName'], $properties); 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[] * @return string[]
*/ */
@@ -37,7 +37,7 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
$breakdown = $this->createCalculator()->compute($booking); $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']); 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 // accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250
self::assertSame([ self::assertSame([
['label' => 'Unterkunft', 'percent' => 10, 'amount' => 1000], ['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000],
['label' => 'Verpflegung', 'percent' => 20, 'amount' => 200], ['label' => 'Rabatt Verpflegung', 'percent' => 20, 'amount' => 200],
['label' => 'Zusatzleistungen', 'percent' => 50, 'amount' => 250], ['label' => 'Rabatt Zusatzleistungen', 'percent' => 50, 'amount' => 250],
], $breakdown['discounts']); ], $breakdown['discounts']);
self::assertSame(12345 - 1000 - 200 - 250, $breakdown['discountedTotal']); self::assertSame(12345 - 1000 - 200 - 250, $breakdown['discountedTotal']);
} }
@@ -94,9 +94,67 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
$breakdown = $this->createCalculator()->withDiscounts(new AccommodationBooking(), $this->breakdown()); $breakdown = $this->createCalculator()->withDiscounts(new AccommodationBooking(), $this->breakdown());
self::assertSame([], $breakdown['discounts']); self::assertSame([], $breakdown['discounts']);
self::assertNull($breakdown['totalDiscountDetails']);
self::assertNull($breakdown['discountSubtotal']);
self::assertSame(12345, $breakdown['discountedTotal']); 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 * The derived keys must never reach the database: refreshPriceSnapshot() freezes exactly
* what computeCurrent() returns. * what computeCurrent() returns.
@@ -112,6 +170,8 @@ class AccommodationBookingBreakdownCalculatorTest extends TestCase
$breakdown = $this->createCalculator()->computeCurrent($booking); $breakdown = $this->createCalculator()->computeCurrent($booking);
self::assertArrayNotHasKey('discounts', $breakdown); self::assertArrayNotHasKey('discounts', $breakdown);
self::assertArrayNotHasKey('discountSubtotal', $breakdown);
self::assertArrayNotHasKey('totalDiscountDetails', $breakdown);
self::assertArrayNotHasKey('discountedTotal', $breakdown); self::assertArrayNotHasKey('discountedTotal', $breakdown);
} }