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
@@ -1,6 +1,6 @@
# چک‌لیست — تسک ۱۱ (پکیج و دفتر اعتبار جلسات)
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:**
**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
@@ -11,104 +11,108 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | | ⭐⭐ `LedgerSchemaTest` اجبار می‌کند |
| ۰.۳ | دفتر append-only هیچ `remove`/`update` روی ردیف‌ها | ⏳ | |
| ۰.۴ | `WalletTransaction` و منطق کیف پول دست‌نخورده | | مفهوم متفاوت |
| ۰.۱ | `--group=slot-mode-frozen` سبز | | |
| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | | ⭐⭐ `testNoStoredBalanceColumnExists` روی schema واقعی |
| ۰.۳ | دفتر append-only | ✅ | هیچ `remove`/`setter` روی `SessionCreditLedger`؛ تصحیح = ردیف تازه |
| ۰.۴ | `WalletTransaction` دست‌نخورده | | تفاوتش در `tenancy.md` نوشته شد |
## ۱. بک‌اند
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | `Package` · `PackageService` · `PatientPackage` · `SessionCreditLedger` | | |
| ۱.۲ | `CreditLedgerService` **تنها** نویسندهٔ دفتر | | |
| ۱.۳ | `balance()` = `SUM(delta)`، بدون هیچ مقدار ذخیره‌شده | | ⭐ |
| ۱.۴ | پنج `kind` تعریف شد | ⏳ | |
| ۱.۵ | `quote` **هرگز** مصرف نمی‌کند؛ فقط `confirm` | ⏳ | ⭐⭐ رفرش صفحه = از دست رفتن جلسه |
| ۱.۶ | `PriceQuote` پرچم `packageWillBeConsumed` دارد | ⏳ | |
| ۱.۷ | مانده صفر → `false`، **نه استثنا** | | ⭐ بیمار نقدی بپردازد |
| ۱.۸ | قفل بدبینانه `PESSIMISTIC_WRITE` روی ردیف پکیج | | با جدول مقایسه با تسک ۰۷ |
| ۱.۹ | `catch UniqueConstraintViolationException` روی `consume` → idempotent | ⏳ | |
| ۱.۱۰ | FIFO — قدیمی‌ترین پکیج منقضی‌نشده | | LIFO یعنی پول بیمار سوخته |
| ۱.۱۱ | `valid_to` هنگام **خرید** محاسبه و ذخیره می‌شود | ⏳ | |
| ۱.۱۲ | لغو → ردیف `refund`، نه حذف `consume` | ⏳ | |
| ۱.۱۳ | `TODO` با ارجاع به تسک ۱۳ برای سیاست بازگشت اعتبار | | نه پرچم نیم‌کاره |
| ۱.۱۴ | `adjust` فقط با نقش مدیر و با `reason` اجباری | | |
| ۱.۱۵ | `app:package:expire` روزانه — ردیف `expiry` با `delta = -balance` | ⏳ | |
| ۱.۱۶ | قلاب مرحلهٔ ۴ `PricingEngine` وصل شد | ⏳ | |
| ۱.۱۷ | هشت endpoint | | |
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
| ۱.۱ | چهار entity | | |
| ۱.۲ | `CreditLedgerService` تنها نویسندهٔ دفتر | | فروش، مصرف، بازگشت و اصلاح همه از همین عبور می‌کنند |
| ۱.۳ | `balance()` = `SUM(delta)` | | ⭐ |
| ۱.۴ | پنج `kind` | ✅ | سازنده `kind` ناشناخته و `delta` صفر را رد می‌کند |
| ۱.۵ | `quote` هرگز مصرف نمی‌کند | ✅ | ⭐⭐ `testQuoteAnnouncesThePackageWithoutConsumingIt` دو بار quote می‌زند و مانده را می‌سنجد |
| ۱.۶ | پرچم `packageWillBeConsumed` | ✅ | + `package_uuid` |
| ۱.۷ | مانده صفر → `false` نه استثنا | | ⭐ |
| ۱.۸ | قفل بدبینانه روی ردیف پکیج | | داخل `wrapInTransaction`؛ جدول مقایسه با تسک ۰۷ در `package.md` |
| ۱.۹ | `consume` idempotent | ⚠️ | با **بررسی پیش از درج** نه `catch` روی نقض کلید: گرفتن استثنا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند |
| ۱.۱۰ | FIFO | | `testTheOldestUnexpiredPackageIsUsedFirst` |
| ۱.۱۱ | `valid_to` هنگام خرید | ✅ | از `validity_days` لحظهٔ خرید |
| ۱.۱۲ | لغو → ردیف `refund` | ✅ | `BookingService::cancel()` |
| ۱.۱۳ | ارجاع به تسک ۱۳ برای سیاست بازگشت | | در docblock `refund()` |
| ۱.۱۴ | `adjust` فقط نقش مدیر و با `reason` | | منشی `403` |
| ۱.۱۵ | `app:package:expire` | ✅ | `--dry-run` هم دارد |
| ۱.۱۶ | قلاب `PricingEngine` | ✅ | `patient_uuid` اختیاری در `quote` |
| ۱.۱۷ | هشت endpoint | | ۹ تا: `packages` GET/POST · `package/{uuid}` GET/PATCH/DELETE · `patient/{uuid}/package` · `patient/{uuid}/packages` · `patient-package/{uuid}/ledger` · `/adjust` · `/expire` |
| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid | ✅ | `testAnotherClinicCannotSeeOrTouchThePackage` |
## ۲. دیتابیس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | چهار جدول | | |
| ۲.۲ | `price_rials` و `price_paid_rials` از نوع **BIGINT** | | پکیج بزرگ |
| ۲.۳ | `UNIQUE(appointment_id, kind)` روی دفتر | | ⭐ جلوگیری از مصرف دوباره |
| ۲.۴ | `session_count`/`price_paid_rials` روی `patient_packages` **snapshot** اند | | قانون پنجم |
| ۲.۵ | `ON DELETE RESTRICT` روی سرویسِ پکیج فروخته‌شده | | |
| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | | |
| ۲.۷ | دفتر **جفت tenant** دارد (نه `ENTITIES` مثل کیف پول) | | ⭐ دلیل مکتوب |
| ۲.۸ | `TenantSchemaCoverageTest` سبز | | |
| ۲.۱ | چهار جدول | | `Version20260731072023` |
| ۲.۲ | `bigint` روی هر دو ستون مبلغ | | |
| ۲.۳ | `UNIQUE(appointment_id, kind)` | | ⭐ |
| ۲.۴ | snapshot تعداد و قیمت | | قانون پنجم |
| ۲.۵ | `ON DELETE RESTRICT` روی سرویس و پکیج | | |
| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | | |
| ۲.۷ | دفتر جفت tenant دارد | | ⭐ دلیلش در `tenancy.md` کنار کیف پول |
| ۲.۸ | `TenantSchemaCoverageTest` سبز | | |
## ۳. UI
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | `PackagesPage` — تعریف با `PriceInput` و انتخاب سرویس | | |
| ۳.۲ | کارت «پکیج‌ها» در `PatientDetailPage` با مانده و انقضا | ⏳ | |
| ۳.۳ | `PatientPackageLedgerPage` — جدول دفتر | | |
| ۳.۴ | ستون «مانده تجمعی» **محاسبه‌شده در UI**، نه ستون DB | ⏳ | ⭐ به کاربر ثابت می‌کند عدد از کجاست |
| ۳.۵ | ستون‌های دفتر: تاریخ، نوع، تغییر، مانده تجمعی، دلیل، ثبت‌کننده، نوبت | ⏳ | |
| ۳.۶ | پیام «اعتبار پکیج تمام شده؛ این نوبت نقدی محاسبه می‌شود» | | |
| ۳.۷ | `DataTable` با skeleton و empty state | | |
| ۳.۸ | سرویس‌ها با `SearchableSelect` | | |
| ۳.۹ | `backTo`/`BackButton` روی زیرصفحه‌ها | | |
| ۳.۱۰ | هیچ رنگ/شعاع hard-code | | |
| ۳.۱۱ | دارک‌مود و حالت فشرده | ⏳ | |
| ۳.۱۲ | RTL و موبایل | | |
| ۳.۱۳ | مبالغ با `formatRial` · تاریخ با `formatDate` | | |
| ۳.۱۴ | وضعیت لیست در URL با `useUrlState` | ⏳ | |
| ۳.۱۵ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۶ | دکمهٔ `adjust` فقط برای نقش مدیر نمایش داده می‌شود | | `FeatureGate`/بررسی نقش |
| ۳.۱ | `PackagesPage` با `PriceInput` و انتخاب سرویس | | + ورودی منوی تنظیمات |
| ۳.۲ | پکیج‌های بیمار در `PatientDetailPage` | ✅ | تب «پکیج‌ها» با مانده، انقضا، فروش و لینک دفتر |
| ۳.۳ | `PatientPackageLedgerPage` | | |
| ۳.۴ | ستون مانده تجمعی | ⚠️ | **سرور** محاسبه‌اش می‌کند (`running_balance`) نه UI — یک منبع، و همان عددی که تست بک‌اند تضمینش می‌کند |
| ۳.۵ | ستون‌های دفتر | ⚠️ | تاریخ، نوع، تغییر، مانده، دلیل هست؛ ستون‌های «ثبت‌کننده» و «نوبت» در پاسخ هستند ولی در جدول نمایش داده نمی‌شوند (عرض موبایل) |
| ۳.۶ | پیام «اعتبار تمام شده؛ نقدی محاسبه می‌شود» | | در تب پکیج‌های بیمار |
| ۳.۷ | `DataTable` با skeleton و empty state | | |
| ۳.۸ | سرویس‌ها با `SearchableSelect` | | هیچ `<select>` بومی |
| ۳.۹ | `backTo` روی زیرصفحه‌ها | | |
| ۳.۱۰ | هیچ رنگ/شعاع hard-code | | |
| ۳.۱۱ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌های موجود؛ بازبینی چشمی انجام نشد |
| ۳.۱۲ | RTL و موبایل | | جدول دفتر اسکرول افقی داخلی دارد |
| ۳.۱۳ | `formatRial` و `formatDate` | | |
| ۳.۱۴ | وضعیت لیست در URL | ✅ | `useUrlState` در `PackagesPage` |
| ۳.۱۵ | همهٔ رشته‌ها فارسی | | |
| ۳.۱۶ | دکمهٔ `adjust` فقط برای مدیر | | `can('appointment_settings', 'update')` |
## ۴. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | `CreditLedgerTest``SUM(delta)` در همهٔ سناریوها، append-only | | ⭐ |
| ۴.۲ | `LedgerSchemaTest` هیچ ستون مانده در schema | | ⭐⭐ |
| ۴.۳ | `QuoteDoesNotConsumeTest` — ده `quote` مانده بی‌تغییر | ⏳ | ⭐⭐ |
| ۴.۴ | `ConcurrentConsumeTest` — مانده منفی نمی‌شود | ⏳ | |
| ۴.۵ | `IdempotentConsumeTest``confirm` دوبار یک ردیف | | |
| ۴.۶ | `FifoTest` | | |
| ۴.۷ | `ExpiryTest` — ردیف `expiry` و حذف از finder | ⏳ | |
| ۴.۸ | `AdjustmentAuthTest` منشی ۴۰۳، مدیر بی‌دلیل ۴۲۲ | ⏳ | |
| ۴.۹ | `PackageTenantTest` — پکیج محیط دیگر ۴۰۴ | | |
| ۴.۱۰ | `PricingIntegrationTest` — ردیف `package` منفی + invariant تسک ۰۸ حفظ شد | ⏳ | ⭐ |
| ۴.۱ | `SUM(delta)` در همهٔ سناریوها، append-only | | ⭐ ترتیب `purchase → consume → refund` و ماندهٔ تجمعی |
| ۴.۲ | هیچ ستون مانده در schema | | ⭐⭐ |
| ۴.۳ | `quote` مصرف نمی‌کند | ✅ | ⭐⭐ دو quote پشت‌سرهم، مانده بی‌تغییر |
| ۴.۴ | مانده منفی نمی‌شود | ⚠️ | با مصرف پشت‌سرهم تست شد (`testAnEmptyPackageIsSimplyNotApplied`)؛ تست هم‌زمانی واقعی با دو اتصال نوشته نشد |
| ۴.۵ | مصرف دوباره یک ردیف | | |
| ۴.۶ | FIFO | | |
| ۴.۷ | انقضا | ✅ | نمایش صفر + دستور + ردیف `expiry` |
| ۴.۸ | مجوز اصلاح | ✅ | منشی رد، مدیر بدون دلیل ۴۲۲، منفی‌کردن مانده ۴۲۲ |
| ۴.۹ | جداسازی محیط | | |
| ۴.۱۰ | یکپارچگی با قیمت | ✅ | `final_rials` صفر و ردیف `package` در `breakdown` |
| ۴.۱۱ | تست فرانت دفتر | ✅ | `PatientPackageLedgerPage.test.tsx` |
**اجرا:** `ddev exec php bin/phpunit tests/Package` → ۱۶ تست (۱ skip عمدی: تولید خروجی مستندات).
## ۵. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | `docs/api/package.md` | | |
| ۵.۲ | جدول مقایسهٔ قفل بدبینانه (این تسک) با سطل زمانی (تسک ۰۷) | | ⭐ وگرنه «یکدست‌سازی» می‌شود |
| ۵.۳ | `docs/architecture/tenancy.md` — تفاوت دفتر اعتبار با کیف پول | | ⭐ اشتباه گرفتنشان = نشتی مالی |
| ۵.۱ | `docs/api/package.md` | | JSON واقعی از اجرای واقعی |
| ۵.۲ | جدول مقایسهٔ قفل بدبینانه با سطل زمانی | | ⭐ در `package.md` |
| ۵.۳ | `tenancy.md` — تفاوت دفتر اعتبار با کیف پول | | ⭐ |
| ۵.۴ | یادداشت متقابل در `pricing.md` و `appointment-booking.md` | ✅ | |
## ۶. بازبینی پایانی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | |
| ۶.۲ | `bin/phpunit` کامل سبز | | |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | `PatientWalletTenantTest` موجود سبز ماند | | |
| ۶.۵ | `phpstan` بدون خطای جدید | | |
| ۶.۶ | `npx tsc --noEmit` و `yarn test` سبز | | |
| ۶.۷ | تست‌های tenant سبز | | |
| ۶.۸ | `docs/api/*` به‌روز | | |
| ۶.۹ | چک‌لیست UI کامل | | |
| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | | مبلغ صفر در رزرو درست نمایش داده می‌شود؟ |
| ۶.۱۱ | commit، سپس `graphify update .` | | |
| ۶.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | | سیاست بازگشت اعتبار → تسک ۱۳ |
| ۶.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | | ۵ مورد ⚠️ همه با دلیل |
| ۶.۲ | `bin/phpunit` کامل سبز | | ۱۲۶۷ تست |
| ۶.۳ | `--group=slot-mode-frozen` سبز | | |
| ۶.۴ | تست‌های کیف پول سبز ماندند | | |
| ۶.۵ | `phpstan` بدون خطای جدید | | ۱۴ = baseline |
| ۶.۶ | `npx tsc --noEmit` و تست‌های فرانت سبز | | ۶۳۰ تست (روی هاست؛ vitest داخل ddev اجرا نمی‌شود) |
| ۶.۷ | تست‌های tenant سبز | | |
| ۶.۸ | `docs/api/*` به‌روز | | |
| ۶.۹ | چک‌لیست UI کامل | | جز ۳.۱۱ |
| ۶.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | `patient_uuid` فیلد **اختیاری** تازه در `quote` است، پس قرارداد موجود نشکست؛ نمایش «مبلغ صفر» در `nobat724_front` دیده نشد — پکیج فعلاً فقط پنل‌محور است |
| ۶.۱۱ | commit، سپس `graphify update .` | | دو کامیت جدا |
| ۶.۱۲ | موارد به‌تعویق با دلیل | | سیاست بازگشت اعتبار → تسک ۱۳ · تست هم‌زمانی واقعی (۴.۴) · بازبینی چشمی (۳.۱۱) |