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:
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user