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
@@ -9,6 +9,9 @@ use App\Appointment\Booking\Service\HoldService;
use App\Appointment\Entity\Appointment;
use App\Auth\Repository\UserRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Pricing\Entity\PriceSnapshot;
use App\Pricing\Service\PriceSnapshotService;
use App\Pricing\Service\PricingEngine;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
@@ -44,6 +47,8 @@ class BookingController extends BaseController
private readonly ClinicResourceRepository $resources,
private readonly DoctorRepository $doctors,
private readonly UserRepository $users,
private readonly PricingEngine $pricing,
private readonly PriceSnapshotService $snapshots,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
@@ -143,8 +148,14 @@ class BookingController extends BaseController
$this->booking->confirm($hold, $appointment);
// فاکتور همین‌جا و با قیمت‌های همین لحظه ثبت می‌شود. اگر بعداً محاسبه می‌شد،
// تغییر تعرفه بین ثبت و صدور فاکتور، عدد دیگری می‌داد — دقیقاً چیزی که قانون
// پنجم مستند ممنوع کرده است.
$snapshot = $this->recordPrice($user, $hold, $appointment, $data);
return $this->success([
'appointment_uuid' => $appointment->getUuid(),
'price_snapshot' => $snapshot->toArray(),
'starts_at' => $hold->getStartsAt(),
'ends_at' => $hold->getEndsAt(),
'assignment' => $hold->getPayload()['assignment'] ?? [],
@@ -188,6 +199,40 @@ class BookingController extends BaseController
]);
}
/**
* فاکتور تفکیک‌شده. اگر سرویس پیدا نشد (نوبت ویزیت ساده)، فاکتور با همان
* `visit_price_rials` موجود ساخته می‌شود؛ خالی گذاشتنش یعنی گزارش مالی یک ردیف
* کم دارد.
*
* @param array<string, mixed> $data
*/
private function recordPrice(User $user, AppointmentHold $hold, Appointment $appointment, array $data): PriceSnapshot
{
if (!is_string($data['service_uuid'] ?? null) || !is_string($data['branch_uuid'] ?? null)) {
return $this->snapshots->recordFlatVisit($appointment, (int) $appointment->getVisitPriceRials());
}
$service = $this->requireItem($user, $data['service_uuid']);
$address = $this->branches->resolve($user, $data['branch_uuid']);
$items = [];
foreach (($data['item_uuids'] ?? []) as $itemUuid) {
if (is_string($itemUuid)) {
$items[] = $this->requireItem($user, $itemUuid);
}
}
$quote = $this->pricing->quote(
$service,
$items,
$address,
$hold->getStartsAt(),
is_array($data['policy'] ?? null) ? $data['policy'] : [],
);
return $this->snapshots->record($appointment, $quote);
}
/**
* هر نیازمندی باید در `assignment` منبع داشته باشد. بدون این، رزرو موقت
* می‌توانست نصفِ منابع لازم را بگیرد و بقیه هنگام حضور بیمار کم بیاید.