feat(pricing): date-ranged price lists and immutable appointment invoices

Section 12 and the fifth closing rule: changing a price never changes an
already-booked appointment.

The pricing chain already existed and worked. Two things were missing. Tariff only
carries a year, so a rate change starting in Mehr could not be expressed — PriceList
now takes an explicit date range and Tariff remains the layer beneath it. And an
appointment stored a single number, so after a price change or a discount nobody
could say what those 2,400,000 rials were made of.

Price resolution walks four layers per service and takes the first hit: branch
override, then the covering price list, then the yearly tariff, then the service's own
price. The last one is the guarantee that a date no list covers still returns a price
rather than zero or an exception. breakdown.sources reports which layer answered, so a
surprising number can be traced instead of guessed at.

Two calculation decisions worth stating. Tax is computed on the patient's share, not
the gross — a patient does not pay tax on the portion the insurer covers. And a
discount larger than the amount floors the total at zero rather than going negative,
because a negative balance would mean the clinic owes the patient money, which nothing
downstream is built to mean.

A branch-specific list deliberately does not count as overlapping a general one; it
takes precedence instead. Treating them as a conflict would have made per-branch
exceptions impossible to express. Lists have no effect until activated, so drafting
next quarter's prices cannot disturb today's.

PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can
be edited is not a snapshot, and two invoices for one appointment would be two truths.
Corrections are a new row plus voiding the old one. Invoices are written during
confirm with the prices of that moment — computing later would let a rate change
between booking and invoicing produce a different number, which is exactly what rule
five forbids.

12 tests. The one that matters is
testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the
service price, watch quote return the new number while the appointment's invoice
returns the old one. Without it rule five is only a claim.

1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot
contract green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 09:42:21 +03:30
co-authored by Claude Opus 5
parent cd12fabe14
commit 34b07421bd
17 changed files with 1765 additions and 66 deletions
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Pricing\ValueObject;
/**
* نتیجهٔ زنجیرهٔ قیمت‌گذاری، پیش از اینکه جایی ذخیره شود.
*
* همان اعدادی که `PriceSnapshot` نگه می‌دارد — عمداً یک شکل، تا «قیمتی که به کاربر
* نشان دادیم» و «قیمتی که ثبت کردیم» نتوانند واگرا شوند.
*/
final readonly class PriceQuote
{
/** @param list<array<string, mixed>> $discounts */
public function __construct(
public int $baseRials,
public int $itemsRials,
public int $discountRials,
public int $insuranceBaseRials,
public int $insuranceSupplementaryRials,
public int $taxRials,
public int $finalRials,
public int $depositRials,
public array $discounts = [],
public array $sources = [],
) {}
public function breakdown(): array
{
return ['discounts' => $this->discounts, 'sources' => $this->sources];
}
public function toArray(): array
{
return [
'base_rials' => $this->baseRials,
'items_rials' => $this->itemsRials,
'discount_rials' => $this->discountRials,
'insurance_base_rials' => $this->insuranceBaseRials,
'insurance_supplementary_rials' => $this->insuranceSupplementaryRials,
'tax_rials' => $this->taxRials,
'final_rials' => $this->finalRials,
'deposit_rials' => $this->depositRials,
'breakdown' => $this->breakdown(),
];
}
}