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>
This commit is contained in:
hamed
2026-07-31 11:11:03 +03:30
co-authored by Claude Opus 5
parent d6294242b7
commit ca9648732d
35 changed files with 3205 additions and 78 deletions
+10
View File
@@ -174,3 +174,13 @@ ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php # ۱۲ تست
دقیقه نگه‌داشتن صندلی، هم وقت بیمار را تلف می‌کند هم صندلی را.
جزئیات: [policy.md](policy.md)
---
## اعتبار پکیج
`confirm` یک جلسه از پکیج معتبر بیمار کسر می‌کند (ردیف `consume`) و لغو نوبت آن را
برمی‌گرداند (ردیف `refund`) — ردیف مصرف حذف نمی‌شود. کلید یکتای دفتر تضمین می‌کند
اجرای دوبارهٔ `confirm` جلسهٔ دوم نخورد.
جزئیات: [package.md](package.md)
+302
View File
@@ -0,0 +1,302 @@
# Package — پکیج و دفتر اعتبار جلسات
اندپوینت‌های `src/Package/*`. «پکیج شش جلسه لیزر» حالت رایج کلینیک زیبایی است: بیمار
یکجا پول می‌دهد و بعداً جلساتش را رزرو می‌کند.
**اعتبار یک دفتر حساب است، نه یک شمارنده.** هیچ ستون `remaining` یا `used_count` در هیچ
جدولی وجود ندارد و مانده همیشه `SUM(delta)` ردیف‌های دفتر است. ردیف‌ها append-only اند؛
تصحیح یعنی ردیف تازه، نه ویرایش ردیف قبلی.
همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط
دیگر). `adjust` و `expire` علاوه بر آن نقش پزشک/کلینیک/ادمین می‌خواهند (`403` برای منشی).
---
## چرخهٔ اعتبار
| نوع ردیف | delta | کِی نوشته می‌شود |
|---|---|---|
| `purchase` | `+session_count` | فروش پکیج به بیمار |
| `consume` | `-1` | `BookingService::confirm()` — ثبت نهایی نوبت |
| `refund` | `+1` | لغو همان نوبت؛ ردیف `consume` **حذف نمی‌شود** |
| `adjustment` | `±n` | اصلاح دستی، همیشه با `reason` و `created_by` |
| `expiry` | `-balance` | `app:package:expire` یا ابطال دستی |
`UNIQUE(appointment_id, kind)` مصرف دوباره را می‌بندد: `confirm` idempotent است و
اجرای دومش جلسهٔ دوم نمی‌خورد.
**FIFO:** وقتی بیمار چند پکیج معتبر برای یک سرویس دارد، قدیمی‌ترین اول مصرف می‌شود —
چون به انقضا نزدیک‌تر است و نگه داشتنش یعنی بیمار پولش را از دست بدهد.
---
## GET `/api/v1/packages`
| Query | Type | Description |
|---|---|---|
| `active` | bool | فقط فعال‌ها یا فقط غیرفعال‌ها |
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "5502aa44-213a-44bd-9b24-64094c675f6e",
"name": "۶ جلسه لیزر فول‌بادی",
"session_count": 6,
"price_rials": 25000000,
"validity_days": 365,
"active": true,
"services": [{ "uuid": "be2e7a93-…", "name": "لیزر فول‌بادی" }],
"created_at": 1785483226
}
]
}
```
---
## POST `/api/v1/packages`
### Request Body
```json
{
"name": "۶ جلسه لیزر فول‌بادی",
"session_count": 6,
"price_rials": 25000000,
"validity_days": 365,
"service_uuids": ["be2e7a93-976a-468b-aa7a-b8f4a4278953"]
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | ✅ | |
| `session_count` | int | ✅ | حداقل ۱ |
| `price_rials` | int | — | ریال؛ ستون `bigint` است |
| `validity_days` | int\|null | — | اعتبار از تاریخ خرید؛ `null` = بی‌پایان |
| `service_uuids` | string[] | ✅ | حداقل یک سرویس از همین محیط |
| `active` | bool | — | پیش‌فرض `true` |
### Response `201`
همان شکل بالا، با `uuid` تازه.
### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | نام خالی، `session_count < 1`، یا `service_uuids` خالی |
| `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری |
> پکیج بدون سرویس هرگز قابل مصرف نیست؛ ساختنش فقط یک تلهٔ خاموش برای اپراتور است،
> پس `422` می‌گیرد.
---
## GET · PATCH · DELETE `/api/v1/package/{uuid}`
`PATCH` همان فیلدهای ساخت را می‌پذیرد. فرستادن `service_uuids` **جایگزینی کامل** است.
`DELETE` پکیج را **غیرفعال** می‌کند، حذف نمی‌کند: ردیف‌های دفترِ بیماران به آن ارجاع
دارند و حذفش تاریخچهٔ اعتبار را بی‌معنا می‌کند.
---
## POST `/api/v1/patient/{uuid}/package`
فروش پکیج به بیمار. یک ردیف `purchase` هم‌زمان نوشته می‌شود.
### Request Body
```json
{ "package_uuid": "5502aa44-…", "price_paid_rials": 25000000 }
```
`price_paid_rials` اختیاری است؛ نبودنش یعنی قیمت تعریفِ لحظهٔ خرید.
### Response `201`
```json
{
"success": true,
"data": {
"uuid": "dcd21f8e-ddee-4f3a-96fb-9fd6c1867a27",
"package_uuid": "5502aa44-…",
"package_name": "۶ جلسه لیزر فول‌بادی",
"patient_uuid": "5faa22e0-…",
"session_count": 6,
"price_paid_rials": 25000000,
"purchased_at": 1785483226,
"valid_to": 1817019226,
"expired": false,
"balance": 6
}
}
```
`session_count` و `price_paid_rials` **کپی**اند نه ارجاع: تغییر تعریف پکیج فردا، پکیج
فروخته‌شدهٔ دیروز را عوض نمی‌کند.
---
## GET `/api/v1/patient/{uuid}/packages`
پکیج‌های بیمار با ماندهٔ روز. پکیج منقضی `balance: 0` نشان می‌دهد ولی دفترش دست‌نخورده
می‌ماند.
---
## GET `/api/v1/patient-package/{uuid}/ledger`
### Response `200`
```json
{
"success": true,
"data": {
"package": { "uuid": "dcd21f8e-…", "balance": 7, "…": "مثل بالا" },
"rows": [
{
"uuid": "3d11dc1f-…",
"kind": "purchase",
"delta": 6,
"appointment_uuid": null,
"service_uuid": null,
"service_name": null,
"reason": "خرید پکیج «۶ جلسه لیزر فول‌بادی»",
"created_by": "b093deb7-…",
"created_at": 1785483226,
"running_balance": 6
},
{
"uuid": "ca4ab9fb-…",
"kind": "adjustment",
"delta": 1,
"reason": "جبران جلسهٔ لغوشده",
"created_by": "b093deb7-…",
"created_at": 1785483226,
"running_balance": 7
}
]
}
}
```
`running_balance` جمع تجمعی است و در پاسخ محاسبه می‌شود — همین به کاربر نشان می‌دهد
عدد نهایی از کجا آمده.
---
## POST `/api/v1/patient-package/{uuid}/adjust`
**Permission:** پزشک · کلینیک · ادمین (منشی `403`)
### Request Body
```json
{ "delta": 2, "reason": "جبران جلسهٔ لغوشده توسط کلینیک" }
```
### Errors
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | `delta` صفر یا غایب، یا `reason` خالی |
| `ERR_VALIDATION_001` | 422 | اصلاحی که مانده را منفی می‌کند |
| `ERR_FORBIDDEN_001` | 403 | نقش منشی |
```json
{
"success": false,
"data": null,
"errors": [{ "code": "ERR_VALIDATION_002", "message": "دلیل اصلاح الزامی است", "field": "reason" }]
}
```
---
## POST `/api/v1/patient-package/{uuid}/expire`
ابطال دستی: یک ردیف `expiry` با `delta = -balance`. ماندهٔ صفر → `422`.
---
## اثر پکیج روی قیمت
`POST /api/v1/pricing/quote` یک فیلد اختیاری `patient_uuid` می‌گیرد. با آن، اگر بیمار
پکیج معتبری برای همان سرویس داشته باشد **قیمت پایهٔ سرویس** پوشش داده می‌شود:
```json
{
"base_rials": 5000000,
"final_rials": 0,
"package_will_be_consumed": true,
"package_uuid": "dcd21f8e-…",
"breakdown": {
"discounts": [
{ "label": "پوشش پکیج «۶ جلسه لیزر فول‌بادی»", "rials": 5000000, "kind": "package" }
]
}
}
```
⚠️ **`quote` هیچ‌وقت مصرف نمی‌کند** — فقط اعلام می‌کند. مصرف واقعی در
`BookingService::confirm()` است. اگر پیش‌نمایش مصرف می‌کرد، هر رفرش صفحه یک جلسه از
بیمار می‌گرفت.
پکیج **قیمت پایه** را می‌پوشاند نه آیتم‌های اضافه: «شش جلسه لیزر» یعنی شش بار خودِ
لیزر، نه هر چیزی که کنارش انتخاب شود.
ماندهٔ صفر خطا نیست: پکیج اعمال نمی‌شود و بیمار مبلغ کامل را نقدی می‌پردازد.
---
## دستور انقضا
```bash
ddev exec php bin/console app:package:expire # روزانه
ddev exec php bin/console app:package:expire --dry-run
```
برای هر پکیج با `valid_to` گذشته و ماندهٔ مثبت، یک ردیف `expiry` می‌نویسد. دفتر
دست‌نخورده می‌ماند تا «۳ جلسه‌ام چه شد؟» همیشه جواب داشته باشد.
---
## قفل بدبینانه اینجا، سطل زمانی آنجا
مصرف اعتبار با `PESSIMISTIC_WRITE` روی همان یک ردیف پکیج قفل می‌شود — برخلاف رزرو
اسلات (تسک ۰۷) که با سطل‌های پنج‌دقیقه‌ای و کلید یکتا کار می‌کند. این تفاوت عمدی است:
| | رزرو اسلات (تسک ۰۷) | اعتبار جلسه (تسک ۱۱) |
|---|---|---|
| نرخ رقابت | بالا — ساعت پرتقاضا | ناچیز — یک بیمار، یک پکیج |
| ردیف‌های درگیر | ده‌ها سطل | یک ردیف |
| هزینهٔ قفل | صف‌شدن رزروها | ناچیز |
| راه‌حل | کلید یکتا روی سطل | قفل بدبینانه |
اگر روزی کسی خواست «برای یکدستی» یکی را به دیگری تبدیل کند، همین جدول جواب است.
---
## طبقه‌بندی محیط
| جدول | وضعیت |
|---|---|
| `packages` · `patient_packages` · `session_credit_ledger` | جفت محیط |
| `package_services` | `AGGREGATE_CHILDREN` — ریشه `Package` |
برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، اعتبار جلسه جفت
محیط واقعی می‌گیرد: اعتبار جلسهٔ لیزر در کلینیک الف در کلینیک ب معنا ندارد.
## صفحه‌های پنل
| مسیر | صفحه |
|---|---|
| `/admin/packages` | تعریف پکیج‌ها |
| `/admin/patient-package/{uuid}/ledger` | دفتر اعتبار یک پکیج خریداری‌شده |
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Package # ۱۶ تست
```
مهم‌ترینش `testNoStoredBalanceColumnExists` است: هیچ ستون مانده‌ای در schema نباید
باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی»
می‌ایستد.
+10
View File
@@ -154,3 +154,13 @@ ddev exec php bin/phpunit tests/Pricing # ۱۲ تست
```
جزئیات دسته‌ها و اثرها: [policy.md](policy.md)
---
## پکیج
`quote` یک `patient_uuid` اختیاری می‌گیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را
می‌پوشاند و `package_will_be_consumed` روشن می‌شود. **پیش‌نمایش هرگز مصرف نمی‌کند**
مصرف در ثبت نهایی است.
جزئیات: [package.md](package.md)