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:
@@ -88,6 +88,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb
|
||||
| [appointment-plan.md](appointment-plan.md) | Appointment segments and plan preview | 3 |
|
||||
| [appointment-availability.md](appointment-availability.md) | Multi-resource availability search | 2 |
|
||||
| [appointment-booking.md](appointment-booking.md) | Holds, confirmation and multi-resource occupancy | 4 |
|
||||
| [pricing.md](pricing.md) | Date-ranged price lists and appointment invoices | 8 |
|
||||
| [appointment.md](appointment.md) | Appointments & slot booking | 6 |
|
||||
| [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 |
|
||||
| [payment.md](payment.md) | Payments (Mellat / Sep) | 5 |
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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` عدد جدید میدهد و فاکتور نوبت **همان عدد
|
||||
قبلی** را. بدون آن، قانون پنجم فقط یک ادعاست.
|
||||
Reference in New Issue
Block a user