fix(tenant): scope the patient wallet ledger to the environment reading it

ownsRecord guards the patient record, not the rows underneath it, so
GET /api/v1/patient/{uuid}/wallet/transactions — and the recent_transactions
in the balance summary — returned the patient's entire history. Clinic A
could read what the patient paid at clinic B, down to the name of the staff
member who entered it.

The wallet stays the person's: the balance is still the sum of that user's
credits minus debits across every environment. Scoping it would show a
patient part of their own money and would make the running balance_after
meaningless. So this is attribution per row, not ownership per wallet.

The columns are deliberately named recorded_entity_type / recorded_entity_id
rather than entity_type / entity_id. TenantFilter keys on the latter and
would then scope the balance query too — the exact bug this avoids. The
naming is load-bearing, and both the entity and the architecture doc say so.

Rows that cannot be attributed — entered before this split, or outside any
environment such as a representation's commission — stay NULL and remain
visible everywhere; hiding them would make an existing patient's history
look deleted. The migration reports how many there are (0 in dev, all
attributable from payments and session references).

Consequence, documented in both docs/api/patient.md and the wallet tab: the
listed rows no longer sum to the displayed balance.

Removing the fix turns 3 of the 6 new tests red.

Tests: 902 backend (+6), 570 frontend. PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 15:21:55 +03:30
co-authored by Claude Opus 5
parent c9d4348c46
commit 6ab1eb6483
10 changed files with 410 additions and 10 deletions
+18 -2
View File
@@ -856,6 +856,21 @@ GET /api/v1/session/{uuid}/audit-log
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک رکورد)
> **موجودی سراسری، دفتر per-محیط.** کیف پول مالِ خودِ بیمار است، پس `balance_rials`
> مجموع credit debit در **همهٔ** محیط‌هاست؛ اگر per-محیط می‌شد، بیمار در هر مطب
> بخشی از پول خودش را می‌دید.
>
> ولی سطرهای دفتر (`recent_transactions` و `wallet/transactions`) فقط تراکنش‌هایی
> را برمی‌گردانند که در **همین محیط** ثبت شده‌اند: بدون این تفکیک، کلینیک A می‌خواند
> بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده (`created_by_name`).
>
> در نتیجه **جمعِ سطرهای نمایش‌داده‌شده لزوماً با `balance_rials` برابر نیست** —
> این تعمدی است و باید در UI هم گفته شود.
>
> تراکنش‌هایی که محیط ثبتشان معلوم نیست (پیش از این تفکیک، یا بیرون از هر محیط مثل
> سهم نماینده) در **همهٔ** محیط‌ها دیده می‌شوند؛ پنهان‌کردنشان تاریخچهٔ موجودِ یک
> بیمار را ناپدید می‌کرد. جزئیات: [architecture/tenancy.md](../architecture/tenancy.md).
### GET `/api/v1/patient/{uuid}/payments`
لیست پرداخت‌های درگاهیِ بیمار (paginated). Query: `page`, `limit` (≤100)، `status` (اختیاری: `pending|success|failed|canceled|refunded`).
Response: `{ success, data: [{ uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at }], meta: { totalRecords, totalPages, currentPage } }`
@@ -863,7 +878,7 @@ Response: `{ success, data: [{ uuid, order_id, amount_rials, status, gateway, ty
هر تراکنش برای شفافیت این فیلدها را دارد: `type` (credit/debit)، `payment_method` (card/pos/cash/gateway/wallet یا null)، `description` (دلیل)، `reference` (مرجعِ ماشینی مثل `session:{uuid}``created_by_name` (کاربرِ ثبت‌کننده)، `status` (`confirmed``balance_after`، `created_at`.
### GET `/api/v1/patient/{uuid}/wallet`
موجودی + ۱۰ تراکنش اخیر (تب کیف‌پول). `balance_rials` = مجموع credit debit.
موجودی + ۱۰ تراکنش اخیرِ **همین محیط** (تب کیف‌پول). `balance_rials` = مجموع credit debit در همهٔ محیط‌ها.
Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amount_rials, type, description, balance_after, created_by_name, payment_method, reference, status, created_at }] } }`
### POST `/api/v1/patient/{uuid}/wallet/charge`
@@ -884,7 +899,8 @@ Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amoun
با `{"payment_method": "wallet"}` سهمِ نهاییِ بیمار (`final_price_rials`) از کیف پول کسر می‌شود: یک تراکنشِ `debit` با `payment_method=wallet`، `reference=session:{uuid}` و دلیلِ «پرداخت سرویس: …» ثبت می‌گردد. فقط وقتی مراجعه هنوز تسویه نشده و مبلغ > 0 باشد. موجودیِ ناکافی → `422` `ERR_WALLET_INSUFFICIENT` (مراجعه تسویه نمی‌شود).
### GET `/api/v1/patient/{uuid}/wallet/transactions`
دفترِ کاملِ تراکنش‌های کیف‌پول (paginated). Query: `page`, `limit` (≤100).
دفترِ تراکنش‌های کیف‌پولِ بیمار **در همین محیط** (paginated). Query: `page`, `limit` (≤100).
`meta.totalRecords` هم فقط همین محیط را می‌شمارد، نه کل تاریخچهٔ بیمار را.
Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }`
### Errors
+22 -1
View File
@@ -137,12 +137,32 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی
| `payments` | جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول |
| `payment_logs` · `financial_breakdowns` | فرزند `Payment` | با FK به پرداخت لنگر می‌خورند |
| `secretary_earnings` | فرزند `FinancialBreakdown` | زنجیره تا `payments` می‌رسد |
| `wallet_transactions` | `ENTITIES` | کیف پولِ **شخص** است: موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و `payment_id` تهی‌پذیر است — تفکیک به محیط، خودِ موجودی را بی‌معنا می‌کند |
| `wallet_transactions` | `ENTITIES` + انتسابِ per-ردیف | کیف پولِ **شخص** است و موجودی سراسری می‌ماند؛ ولی هر ردیف محیطِ ثبتش را در `recorded_entity_*` نگه می‌دارد تا دفتری که کلینیک می‌بیند به همان محیط محدود شود (پایین) |
| `settlements` | `ENTITIES` | برداشت از همان کیف پولِ شخصی (`SettlementController` موجودی را با `getWalletBalance(user)` می‌سنجد) |
| `bank_accounts` · `pos_devices` | جفت محیط، **تهی‌پذیر** | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) |
نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به `payments` لنگر بزند. `SecretaryEarningRepository::reportFor` این کار را با `join('b.payment','p')` می‌کند و فیلتر روی همان می‌نشیند؛ `FinancialChainTenantTest` همین را می‌سنجد.
### کیف پول: موجودی سراسری، دفتر per-محیط
کیف پول ستون tenant ندارد و نباید داشته باشد: پول مالِ شخص است و اگر فیلتر روی موجودی می‌نشست، بیمار در هر محیط بخشی از پول خودش را می‌دید.
ولی دفترِ تراکنش را کلینیک هم می‌بیند (`GET /api/v1/patient/{uuid}/wallet/transactions` و `recent_transactions`)، و گاردِ `ownsRecord` فقط **پرونده** را می‌سنجد نه سطرها. پس بدون تفکیک، کلینیک A می‌خواند بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده.
راه‌حل، انتساب per-ردیف است نه مالکیت per-کیف‌پول:
```php
// WalletTransaction — نامِ ستون‌ها عمداً entity_type/entity_id نیست
#[ORM\Column(name: 'recorded_entity_type', ...)] private ?string $recordedEntityType = null;
#[ORM\Column(name: 'recorded_entity_id', ...)] private ?int $recordedEntityId = null;
```
⚠️ **نام‌ها باید همین بمانند.** `TenantFilter` روی `entityType/entityId` کلید می‌زند؛ اگر این دو همان نام را می‌گرفتند، فیلتر خودکار روی محاسبهٔ موجودی هم می‌نشست و پول بیمار را نصف نشان می‌داد. اینجا انتساب است، نه مالکیت.
نتیجه: `balance_rials` سراسری، سطرهای دفتر per-محیط — پس **جمع سطرها با موجودی برابر نیست** و UI باید بگوید. ردیف‌های بی‌انتساب (پیش از این تفکیک، یا بیرون از هر محیط مثل سهم نماینده) در همه‌جا دیده می‌شوند تا تاریخچهٔ موجود ناپدید نشود.
`PatientWalletTenantTest` هر سه را می‌سنجد: تفکیک سطرها، سراسری‌ماندن موجودی، و دیده‌شدن ردیف بی‌انتساب.
### ⚠️ نقطهٔ ضعف: کارتِ بی‌محیط در هیچ محیطی دیده نمی‌شود
`bank_accounts` و `pos_devices` تنها جدول‌هایی‌اند که جفت محیطشان **تهی‌پذیر** است ({@see `NullableTenantOwnedTrait`}). دلیل: تا فاز ۶ روی `User` ثبت می‌شدند و برای کاربری که چند محیط دارد هیچ ستونی نمی‌گفت کدام کارت مال کدام محیط است. تصمیم گرفته شد **حدس زده نشود**؛ ردیف مبهم تهی می‌ماند تا مالک خودش تعیین کند.
@@ -220,3 +240,4 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql
| `tests/Payment/PaymentTenantTest.php` | پرداخت به محیط گیرنده می‌نشیند؛ بیمار پرداخت خودش را می‌بیند، محیط دیگر نمی‌بیند |
| `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند |
| `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست |
| `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند |