Files
clinicpro/docs/api/pricing.md
T
hamedandClaude Opus 5 34b07421bd 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>
2026-07-31 09:42:21 +03:30

138 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Pricing API — لیست قیمت بازه‌دار و فاکتور تفکیک‌شده
> **Base:** `/api/v1` · **Auth:** JWT
> مکمل [clinic-services.md](clinic-services.md) و [appointment-booking.md](appointment-booking.md).
---
## دو شکافی که پر شد
زنجیرهٔ قیمت از قبل وجود داشت و کار می‌کرد
(`ServiceItem → Tariff → بیمه → DiscountRule → Invoice → Payment`). دو چیز کم بود:
۱. **`Tariff` فقط سال دارد.** تغییر تعرفه از اول مهر قابل بیان نبود. حالا `PriceList`
بازهٔ دقیق می‌گیرد و `Tariff` لایهٔ پشتیبان می‌ماند.
۲. **روی نوبت فقط یک عدد بود.** بعد از تغییر قیمت یا تخفیف نمی‌شد گفت آن ۲٬۴۰۰٬۰۰۰
ریال از چه تشکیل شده بود. حالا `PriceSnapshot` فاکتور تفکیک‌شدهٔ لحظهٔ ثبت را
نگه می‌دارد.
## زنجیرهٔ قیمت‌گذاری
```
قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه
```
برای **هر** سرویس، اولین منبعی که پیدا شود برنده است:
| اولویت | منبع | از کجا |
|---|---|---|
| ۱ | override شعبه | تسک ۰۴ |
| ۲ | لیست قیمتِ حاکم بر آن تاریخ | همین تسک |
| ۳ | `Tariff` سال | لایهٔ موجود |
| ۴ | `ServiceItem.price_rials` | همیشه هست |
مرحلهٔ چهارم ضامن است که **هرگز صفر یا خطا** برنگردد — تاریخی که هیچ لیستی نمی‌پوشاند
باید قیمت بدهد. `breakdown.sources` می‌گوید هر قیمت از کدام لایه آمده.
### دو تصمیم محاسباتی
**مالیات روی سهم بیمار حساب می‌شود، نه روی کل.** بیمار مالیاتِ سهمی که بیمه می‌دهد را
نمی‌پردازد.
**تخفیف بیشتر از مبلغ، مبلغ را صفر می‌کند نه منفی.** بدهی منفی یعنی کلینیک به بیمار
پول بدهکار شود، که هیچ‌جای این جریان معنا ندارد.
`max_total_discount_percent` سقف جمع تخفیف‌هاست: چند تخفیفِ جداگانه که هرکدام منطقی‌اند،
با هم می‌توانند مبلغ را بی‌معنا کنند.
---
## `POST /api/v1/pricing/quote`
```json
{
"service_uuid": "…",
"branch_uuid": "…",
"item_uuids": ["…"],
"at": 1785562200,
"policy": {
"discount_percent": 10,
"max_total_discount_percent": 25,
"insurance_base_percent": 20,
"insurance_supplementary_percent": 50,
"tax_percent": 10,
"deposit_percent": 30
}
}
```
`at` اختیاری است (پیش‌فرض الان) و تعیین می‌کند کدام لیست قیمت حاکم است.
**۲۰۰:** همان شکلی که `price_snapshot` دارد — عمداً یکی، تا «قیمتی که نشان دادیم» و
«قیمتی که ثبت کردیم» نتوانند واگرا شوند.
```json
{
"base_rials": 10000000, "items_rials": 2000000, "discount_rials": 1200000,
"insurance_base_rials": 2160000, "insurance_supplementary_rials": 4320000,
"tax_rials": 432000, "final_rials": 4752000, "deposit_rials": 1425600,
"breakdown": { "discounts": [ ], "sources": { "<service-uuid>": "price_list" } }
}
```
---
## لیست قیمت
| متد | مسیر |
|---|---|
| GET/POST | `/api/v1/price-lists` |
| GET/PATCH/DELETE | `/api/v1/price-list/{uuid}` |
| PUT | `/api/v1/price-list/{uuid}/items` |
| POST | `/api/v1/price-list/{uuid}/activate` |
`address_uuid` تهی‌پذیر است: `null` یعنی «همهٔ شعبه‌های این محیط». لیستِ مخصوصِ یک شعبه
بر لیست عمومی **مقدم** است و با آن **تداخل حساب نمی‌شود** — وگرنه تعریف استثنا برای یک
شعبه ناممکن می‌شد.
**لیست تا فعال نشده هیچ اثری ندارد.** ساختن پیش‌نویس نباید قیمت امروز را عوض کند.
`activate` بازهٔ هم‌پوشان با لیست فعالِ **هم‌دامنه** را `422` می‌کند: یک تاریخ نباید دو
قیمت داشته باشد.
---
## فاکتور نوبت
`GET /api/v1/appointment/{uuid}/price-snapshot`
فاکتور هنگام `POST /appointment-confirm` و با قیمت‌های **همان لحظه** ثبت می‌شود. اگر
بعداً محاسبه می‌شد، تغییر تعرفه بین ثبت و صدور فاکتور عدد دیگری می‌داد.
> **قانون پنجم مستند:** «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.»
> `PriceSnapshot` هیچ setter ای ندارد و کلید یکتای `appointment_id` دو فاکتور برای یک
> نوبت را در سطح دیتابیس غیرممکن می‌کند. اصلاح قیمت با ردیف تازه و ابطال قبلی انجام
> می‌شود، نه با بازنویسی.
نوبتِ بدون سرویس (ویزیت سادهٔ حالت اسلاتی) هم فاکتور می‌گیرد، با همان
`visit_price_rials` موجود — خالی گذاشتنش یعنی گزارش مالی یک ردیف کم دارد.
---
## طبقه‌بندی محیط
| جدول | وضعیت |
|---|---|
| `price_lists` · `price_snapshots` | جفت محیط |
| `price_list_items` | `AGGREGATE_CHILDREN` — ریشه `PriceList` |
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
```
مهم‌ترینش `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` است: نوبت ثبت
می‌شود، قیمت سرویس دو برابر می‌شود، `quote` عدد جدید می‌دهد و فاکتور نوبت **همان عدد
قبلی** را. بدون آن، قانون پنجم فقط یک ادعاست.