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
+367
View File
@@ -0,0 +1,367 @@
<?php
namespace App\Tests\Pricing;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* لیست قیمت بازه‌دار و فاکتور تفکیک‌شده — بند ۱۲ و قانون پنجم مستند.
*/
class PricingTest extends ApiTestCase
{
/** @return array{user: User, section: ServiceSection, address: DoctorAddress} */
private function clinic(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک قیمت');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return ['user' => $user, 'section' => $section, 'address' => $address];
}
private function service(ServiceSection $section, string $name, int $price): ServiceItem
{
$section = $this->em->getRepository(ServiceSection::class)->find($section->getId());
$item = new ServiceItem($section, $name);
$item->setPriceRials($price);
$item->setSoloDurationMinutes(20);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function quote(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
{
return $this->authJson('POST', '/api/v1/pricing/quote', $user, $extra + [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
}
private function priceList(User $user, string $name, int $from, int $to, ?string $addressUuid = null): array
{
$body = $this->authJson('POST', '/api/v1/price-lists', $user, array_filter([
'name' => $name,
'starts_at' => $from,
'ends_at' => $to,
'address_uuid' => $addressUuid,
]));
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
/** بدون هیچ لیست قیمتی، قیمت خودِ سرویس برمی‌گردد — هرگز صفر یا خطا. */
public function testFallsBackToTheServicePrice(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$body = $this->quote($c['user'], $service, $c['address']);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(5_000_000, $body['data']['base_rials']);
self::assertSame(5_000_000, $body['data']['final_rials']);
self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]);
}
/** لیست قیمت فقط در بازهٔ خودش حاکم است. */
public function testPriceListAppliesOnlyInsideItsRange(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+10 days');
$to = strtotime('+40 days');
$list = $this->priceList($c['user'], 'نیمهٔ دوم', $from, $to);
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 8_000_000]],
]);
self::assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode());
$inside = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(8_000_000, $inside['data']['base_rials'], 'داخل بازه: قیمت جدید');
$before = $this->quote($c['user'], $service, $c['address'], ['at' => $from - 86400]);
self::assertSame(5_000_000, $before['data']['base_rials'], 'پیش از بازه: قیمت قبلی');
}
/** دو لیست فعالِ هم‌پوشان یعنی یک تاریخ دو قیمت — هنگام فعال‌سازی رد می‌شود. */
public function testOverlappingActiveListsAreRejected(): void
{
$c = $this->clinic();
$from = strtotime('+10 days');
$first = $this->priceList($c['user'], 'اول', $from, $from + 30 * 86400);
$this->authJson('POST', "/api/v1/price-list/{$first['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode());
$second = $this->priceList($c['user'], 'دوم', $from + 10 * 86400, $from + 50 * 86400);
$body = $this->authJson('POST', "/api/v1/price-list/{$second['uuid']}/activate", $c['user']);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('هم‌پوشانی', $body['errors'][0]['message']);
}
/** لیستِ یک شعبه با لیست عمومی تداخل ندارد و بر آن مقدم است. */
public function testBranchListWinsOverTheGeneralList(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+10 days');
$to = $from + 30 * 86400;
$general = $this->priceList($c['user'], 'عمومی', $from, $to);
$this->authJson('PUT', "/api/v1/price-list/{$general['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]],
]);
$this->authJson('POST', "/api/v1/price-list/{$general['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode());
$branch = $this->priceList($c['user'], 'شعبهٔ مرکزی', $from, $to, $c['address']->getUuid());
$this->authJson('PUT', "/api/v1/price-list/{$branch['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_000_000]],
]);
$this->authJson('POST', "/api/v1/price-list/{$branch['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode(), 'لیست شعبه با لیست عمومی تداخل ندارد');
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(9_000_000, $body['data']['base_rials']);
}
/** override شعبه (تسک ۰۴) بر لیست قیمت مقدم است. */
public function testBranchOverrideBeatsThePriceList(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+10 days');
$list = $this->priceList($c['user'], 'عمومی', $from, $from + 30 * 86400);
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]],
]);
$this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']);
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [
'overrides' => [['address_uuid' => $c['address']->getUuid(), 'price_rials' => 11_000_000]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(11_000_000, $body['data']['base_rials']);
self::assertSame('branch_override', $body['data']['breakdown']['sources'][$service->getUuid()]);
}
public function testFullChainAppliesInOrder(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 10_000_000);
$extra = $this->service($c['section'], 'ناحیهٔ اضافه', 2_000_000);
$body = $this->quote($c['user'], $service, $c['address'], [
'item_uuids' => [$extra->getUuid()],
'policy' => [
'discount_percent' => 10,
'insurance_base_percent' => 20,
'insurance_supplementary_percent' => 50,
'tax_percent' => 10,
'deposit_percent' => 30,
],
]);
$d = $body['data'];
self::assertSame(10_000_000, $d['base_rials']);
self::assertSame(2_000_000, $d['items_rials']);
self::assertSame(1_200_000, $d['discount_rials'], '۱۰٪ از ۱۲ میلیون');
self::assertSame(2_160_000, $d['insurance_base_rials'], '۲۰٪ از ۱۰٫۸ میلیون');
self::assertSame(4_320_000, $d['insurance_supplementary_rials'], '۵۰٪ از باقیمانده');
self::assertSame(432_000, $d['tax_rials'], '۱۰٪ روی سهم بیمار، نه روی کل');
self::assertSame(4_752_000, $d['final_rials']);
self::assertSame(1_425_600, $d['deposit_rials']);
}
/** تخفیف بیشتر از مبلغ، مبلغ را صفر می‌کند نه منفی. */
public function testDiscountLargerThanTheAmountFloorsAtZero(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 1_000_000);
$body = $this->quote($c['user'], $service, $c['address'], [
'policy' => ['discount_rials' => 5_000_000],
]);
self::assertSame(0, $body['data']['final_rials']);
self::assertGreaterThanOrEqual(0, $body['data']['discount_rials']);
}
/** سقف جمع تخفیف‌ها per محیط اعمال می‌شود. */
public function testTotalDiscountCapIsApplied(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 10_000_000);
$body = $this->quote($c['user'], $service, $c['address'], [
'policy' => [
'discount_percent' => 40,
'discount_rials' => 3_000_000,
'max_total_discount_percent' => 25,
],
]);
self::assertSame(2_500_000, $body['data']['discount_rials'], 'سقف ۲۵٪ از ۱۰ میلیون');
self::assertSame(7_500_000, $body['data']['final_rials']);
}
public function testForeignServiceIsNotFound(): void
{
$c = $this->clinic();
$other = $this->clinic();
$foreign = $this->service($other['section'], 'سرویس بیگانه', 1_000_000);
$this->quote($c['user'], $foreign, $c['address']);
self::assertSame(404, $this->responseCode());
}
public function testNegativePriceIsRejected(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 1_000_000);
$list = $this->priceList($c['user'], 'تست', strtotime('+1 day'), strtotime('+30 days'));
$body = $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => -100]],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('price_rials', $body['errors'][0]['field']);
}
/**
* ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.»
*
* نوبت ثبت می‌شود، بعد قیمت سرویس دو برابر می‌شود، و فاکتور همان اعداد قبلی را
* می‌دهد. بدون این تست، تسک تأییدشده نیست.
*/
public function testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 4_000_000);
$room = new \App\Resource\Entity\ResourceType(
$c['address']->tenantEntityType(),
$c['address']->tenantEntityId(),
'room',
'اتاق',
);
$this->em->persist($room);
$this->em->flush();
$resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [
'address_uuid' => $c['address']->getUuid(),
'type_uuid' => $room->getUuid(),
'name' => 'اتاق ۱',
]);
self::assertSame(201, $this->responseCode());
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [
'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]),
]);
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [
'segments' => [[
'sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20,
'requirements' => [['type_uuid' => $room->getUuid()]],
]],
]);
self::assertSame(200, $this->responseCode());
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاکتور');
$this->em->persist($doctor);
$this->em->flush();
$start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran')))
->setTime(9, 0)
->getTimestamp();
$hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $c['address']->getUuid(),
'start' => $start,
'assignment' => ['room' => [$resource['data']['uuid']]],
]);
self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE));
$confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $doctor->getUuid(),
'service_uuid' => $service->getUuid(),
'branch_uuid' => $c['address']->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE));
self::assertSame(4_000_000, $confirmed['data']['price_snapshot']['final_rials']);
$appointmentUuid = $confirmed['data']['appointment_uuid'];
// حالا قیمت دو برابر می‌شود.
$this->em->clear();
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $service->getUuid()]);
$reloaded->setPriceRials(8_000_000);
$this->em->flush();
// قیمت جدید در quote دیده می‌شود…
$fresh = $this->quote($c['user'], $reloaded, $c['address']);
self::assertSame(8_000_000, $fresh['data']['final_rials']);
// …ولی فاکتور نوبتِ ثبت‌شده دست‌نخورده است.
$snapshot = $this->authJson('GET', "/api/v1/appointment/$appointmentUuid/price-snapshot", $c['user']);
self::assertSame(200, $this->responseCode(), json_encode($snapshot, JSON_UNESCAPED_UNICODE));
self::assertSame(4_000_000, $snapshot['data']['final_rials'], 'قانون پنجم: فاکتور ثبت‌شده عوض نمی‌شود');
self::assertSame(4_000_000, $snapshot['data']['base_rials']);
}
public function testDraftListHasNoEffectUntilActivated(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+2 days');
$list = $this->priceList($c['user'], 'پیش‌نویس', $from, $from + 30 * 86400);
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_999_999]],
]);
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(5_000_000, $body['data']['base_rials'], 'پیش‌نویس نباید قیمت را عوض کند');
}
}