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
@@ -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;
@@ -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,
@@ -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();
@@ -37,14 +37,17 @@ class AccommodationBookingBreakdownCalculator
*
* @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
{
// 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;
}