Files
clinicpro/docs/api/pricing.md
T
hamedandClaude Opus 5 ca9648732d feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:11:03 +03:30

6.8 KiB
Raw Blame History

Pricing API — لیست قیمت بازه‌دار و فاکتور تفکیک‌شده

Base: /api/v1 · Auth: JWT مکمل clinic-services.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

{
  "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 دارد — عمداً یکی، تا «قیمتی که نشان دادیم» و «قیمتی که ثبت کردیم» نتوانند واگرا شوند.

{
  "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

تست‌ها

ddev exec php bin/phpunit tests/Pricing   # ۱۲ تست

مهم‌ترینش testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange است: نوبت ثبت می‌شود، قیمت سرویس دو برابر می‌شود، quote عدد جدید می‌دهد و فاکتور نوبت همان عدد قبلی را. بدون آن، قانون پنجم فقط یک ادعاست.


قوانین دستهٔ «قیمت»

تخفیفی که موتور قوانین می‌دهد کنار تخفیف دستیِ درخواست می‌نشیند نه به‌جایش، و شناسه و نسخهٔ هر قانون در breakdown.sources.applied_policies ثبت می‌شود:

"breakdown": {
  "sources": {
    "applied_policies": [
      { "uuid": "…", "name": "تخفیف همین سرویس", "version": 2 }
    ]
  }
}

جزئیات دسته‌ها و اثرها: policy.md


پکیج

quote یک patient_uuid اختیاری می‌گیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را می‌پوشاند و package_will_be_consumed روشن می‌شود. پیش‌نمایش هرگز مصرف نمی‌کند — مصرف در ثبت نهایی است.

جزئیات: package.md