feat(tenant): mark the financial tables with their owning environment

Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.

payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.

Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:

- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
  SmsWalletController and already carries its environment in the metadata;
  without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
  so it cannot drive the subscription backfill. The environment is derived
  the way handleSubscriptionActivation derives it — and that method now
  reads the pair off the payment instead of re-deriving it, so a payment and
  the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
  none of the four creation sites set it; the wallet is a person's, with a
  running balance per user. It and Settlement, which withdraws from that same
  wallet, are global with a recorded reason instead.

bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.

Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 15:06:28 +03:30
co-authored by Claude Opus 5
parent d2f4b5c428
commit c9d4348c46
40 changed files with 1582 additions and 163 deletions
+95 -11
View File
@@ -2,14 +2,30 @@
> **Prefix:** `/api/v1/my/payment-methods`
Per-clinic payment methods managed from the settings screen (`/admin/my-financial`,
Per-**environment** payment methods managed from the settings screen (`/admin/my-financial`,
tab "مدیریت پرداخت"). Two resources: **bank accounts** and **POS (card reader) devices**.
> **دسترسی منشی:** روش‌های پرداخت زیرمجموعهٔ منبع `payments` هستند. برای `ROLE_SECRETARY` (`SecretaryAccessChecker`): GET→`payments.view`, POST→`payments.create`, PUT/PATCH→`payments.update`؛ نبودِ مجوز یا رابطهٔ فعال → `403`. جزئیات: [secretary.md](secretary.md).
Records are stored so a patient invoice can later reference which account/device a
service payment was made to.
All endpoints are scoped to the acting user — a clinic never sees another's records.
All endpoints are scoped to the **active environment** (`EntityContextResolver`), not to
the acting user. A doctor who also owns a clinic sees a different set of cards in each
environment; switching context switches the list. `user_id` is still stored, but only
records who registered the card.
> **کارت‌های بدون محیط.** ردیف‌هایی که پیش از این نشانه‌گذاری ثبت شده‌اند و مالکشان
> بیش از یک محیط دارد، `entity_type: null` می‌گیرند: هیچ ستونی نمی‌گفت کارت مال کدام
> محیط است و حدس زدنش یعنی پول به حساب اشتباه.
>
> چنین ردیفی در **هیچ** محیطی «متعلق» نیست (`TenantFilter` شرط تساوی می‌گذارد و NULL
> با هیچ مقداری برابر نیست) و تا تعیین محیط **قابل ویرایش نیست** — ولی در فهرست
> خودِ مالک می‌آید تا با `PATCH .../{uuid}/environment` به محیط فعال منتسبش کند.
> این کوئری عمداً بیرون فیلتر و محدود به `user_id` اجرا می‌شود.
> جزئیات: [architecture/tenancy.md](../architecture/tenancy.md).
**اگر محیط فعالی حل نشود** (مثلاً نقش کلینیک بدون کلینیکِ واقعی، یا منشیِ بدون
`UserActiveContext`) همهٔ این endpointها `403 ERR_FORBIDDEN_001` می‌دهند.
**Permission:** authenticated user with one of `ROLE_CLINIC`, `ROLE_DOCTOR`,
`ROLE_SECRETARY`, `ROLE_ADMIN` (otherwise `403 ERR_FORBIDDEN_001`).
@@ -20,7 +36,8 @@ All endpoints are scoped to the acting user — a clinic never sees another's re
### GET `/api/v1/my/payment-methods/bank-accounts`
List the current clinic's bank accounts (newest first).
List the bank accounts of the active environment (newest first), followed by any of the
caller's own cards that still have no environment.
#### Response `200`
```json
@@ -28,17 +45,33 @@ List the current clinic's bank accounts (newest first).
"success": true,
"data": [
{
"uuid": "b1e0...-...",
"uuid": "b73d0c8e-3833-4314-83ba-937b6d4dbc60",
"bank_name": "ملی",
"card_number": "6037991234567890",
"account_number": "0101234567890",
"shaba_number": "IR820540102680020817909002",
"is_active": true,
"created_at": 1752566400
"created_at": 1785238315,
"entity_type": "clinic"
},
{
"uuid": "21225430-0108-4adf-99f1-978e0a864c71",
"bank_name": "ملت",
"card_number": null,
"account_number": "0209876543210",
"shaba_number": null,
"is_active": true,
"created_at": 1785238315,
"entity_type": null
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `entity_type` | `"doctor"` \| `"clinic"` \| `null` | محیطِ مالک؛ `null` یعنی هنوز تعیین نشده و کارت قابل استفاده نیست |
Empty list returns `"data": []`.
---
@@ -56,7 +89,7 @@ Create a bank account.
| `shaba_number` | string | ❌ | IBAN / SHABA |
#### Response `201`
Single created record (same shape as list item).
Single created record (same shape as list item). `entity_type` is the active environment.
#### Errors
- `422 ERR_VALIDATION_001``bank_name` or `account_number` missing (`field` set).
@@ -72,7 +105,7 @@ keys change. Empty `bank_name`/`account_number` → `422`.
Updated record.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or owned by another clinic.
- `404 ERR_NOT_FOUND_001` — uuid unknown, owned by another environment, or still unassigned.
- `422 ERR_VALIDATION_001` — provided `bank_name`/`account_number` empty.
---
@@ -85,7 +118,43 @@ Toggle `is_active` (active ⇄ inactive). No body.
Record with flipped `is_active`.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned.
- `404 ERR_NOT_FOUND_001` — uuid unknown, owned by another environment, or still unassigned.
---
### PATCH `/api/v1/my/payment-methods/bank-accounts/{uuid}/environment`
کارتِ **بی‌محیطِ خودِ کاربر** را به محیط فعال می‌چسباند. بدون بدنه.
شرط‌های مالکیت و بی‌محیط بودن داخل خودِ `UPDATE` هستند، پس دو درخواست هم‌زمان
نمی‌توانند یک کارت را به دو محیط بچسبانند و کارتِ محیط‌دار هم ربوده نمی‌شود.
#### Response `200`
```json
{
"success": true,
"data": {
"uuid": "21225430-0108-4adf-99f1-978e0a864c71",
"bank_name": "ملت",
"card_number": null,
"account_number": "0209876543210",
"shaba_number": null,
"is_active": true,
"created_at": 1785238315,
"entity_type": "clinic"
}
}
```
#### Errors
`404 ERR_NOT_FOUND_001` — uuid ناشناس، مالِ کاربر دیگر، یا از قبل محیط دارد (انتساب دوباره بی‌اثر است):
```json
{
"success": false,
"data": null,
"errors": [{ "code": "ERR_NOT_FOUND_001", "message": "حساب بانکیِ بدون محیط یافت نشد" }]
}
```
---
@@ -93,7 +162,8 @@ Record with flipped `is_active`.
### GET `/api/v1/my/payment-methods/pos`
List the current clinic's card reader devices (newest first).
List the card reader devices of the active environment (newest first), followed by any of
the caller's own devices that still have no environment.
#### Response `200`
```json
@@ -101,13 +171,14 @@ List the current clinic's card reader devices (newest first).
"success": true,
"data": [
{
"uuid": "c2f1...-...",
"uuid": "382eb554-5931-4f07-b1cc-53ade2597438",
"bank_name": "ملت",
"serial_number": "SN-98765",
"terminal_number": "123456",
"account_number": null,
"is_active": true,
"created_at": 1752566400
"created_at": 1785238315,
"entity_type": "clinic"
}
]
}
@@ -157,3 +228,16 @@ Record with flipped `is_active`.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned.
---
### PATCH `/api/v1/my/payment-methods/pos/{uuid}/environment`
قرینهٔ endpoint انتساب حساب بانکی: کارتخوانِ بی‌محیطِ خودِ کاربر را به محیط فعال
می‌چسباند. بدون بدنه.
#### Response `200`
رکورد با `entity_type` پرشده.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid ناشناس، مالِ کاربر دیگر، یا از قبل محیط دارد (پیام: «کارت خوانِ بدون محیط یافت نشد»).
+12
View File
@@ -5,6 +5,8 @@
> **دسترسی منشی:** `GET /api/v1/my/payments` برای `ROLE_SECRETARY` به مجوز `payments.view` نیاز دارد (`SecretaryAccessChecker`)؛ نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md).
> **محیط پرداخت:** هر پرداخت جفت `(entity_type, entity_id)` دارد و به محیط **گیرنده** تعلق می‌گیرد، نه به پرداخت‌کننده — نوبت → محیط همان نوبت، اشتراک → محیطی که خریدار صاحبش است، شارژ پیامک → محیط همان کیف پول. بیمار محیطی انتخاب نکرده، پس `TenantFilter` برایش خاموش است و پرداخت خودش را می‌بیند. جزئیات: [architecture/tenancy.md](../architecture/tenancy.md).
---
## معماری (Flow & مسئولیت‌ها)
@@ -355,8 +357,18 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_PAYMENT_002` | 422 | Invalid amount |
| `ERR_PAYMENT_004` | 422 | خریدار صاحب هیچ محیطی نیست (نه پزشک، نه کلینیک) |
| `ERR_PAYMENT_001` | 503 | Gateway unavailable |
```json
{
"success": false,
"errors": [{ "code": "ERR_PAYMENT_004", "message": "محیط این پرداخت مشخص نیست" }]
}
```
> اشتراک روی محیطی می‌نشیند که خریدار **صاحبش** است (اول مطب شخصی، بعد کلینیک) — نه روی محیط فعالش. همان جفت روی خودِ اشتراک هم ثبت می‌شود، پس پرداخت و اشتراک هرگز روی دو محیط متفاوت نمی‌افتند.
---
## POST/GET `/api/v1/subscription-payment/callback/{gateway}`
+34 -3
View File
@@ -96,7 +96,7 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده
| جفت tenant دارد | فیلتر پوششش می‌دهد | `appointments`، `patient_records`، `service_sections` |
| `ENTITIES` | عمداً سراسری | `cities`، `specialties`، `users`، `blogs` |
| `AGGREGATE_CHILDREN` | محیط را از ریشه به ارث می‌برد | `patient_notes``patient_records` |
| `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | جدول‌های مالی |
| `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | **خالی** — فاز ۶ آخرین موردش را تعیین تکلیف کرد |
### ⚠️ فرزندان aggregate تور ایمنی ندارند
@@ -126,9 +126,32 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی
`TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند.
### بدهی باقی‌مانده
### جدول‌های مالی
جدول‌های مالی (`payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings`، `bank_accounts`، `pos_devices`) در `DEFERRED` ثبت شده‌اند. مالکیتشان دوگانه است — پرداخت‌کننده در برابر دریافت‌کننده — و تصمیم درباره‌شان تحلیل جدا می‌خواهد. `testDeferredDebtDoesNotGrow` جلوی رشد بی‌صدای این فهرست را می‌گیرد.
بدهی صفر است. `testThereIsNoUnclassifiedDebtLeft` خالی‌ماندنش را اجبار می‌کند.
استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ **گیرنده** تعلق دارد و پرداخت‌کننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است.
| جدول | تصمیم | چرا |
|---|---|---|
| `payments` | جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول |
| `payment_logs` · `financial_breakdowns` | فرزند `Payment` | با FK به پرداخت لنگر می‌خورند |
| `secretary_earnings` | فرزند `FinancialBreakdown` | زنجیره تا `payments` می‌رسد |
| `wallet_transactions` | `ENTITIES` | کیف پولِ **شخص** است: موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و `payment_id` تهی‌پذیر است — تفکیک به محیط، خودِ موجودی را بی‌معنا می‌کند |
| `settlements` | `ENTITIES` | برداشت از همان کیف پولِ شخصی (`SettlementController` موجودی را با `getWalletBalance(user)` می‌سنجد) |
| `bank_accounts` · `pos_devices` | جفت محیط، **تهی‌پذیر** | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) |
نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به `payments` لنگر بزند. `SecretaryEarningRepository::reportFor` این کار را با `join('b.payment','p')` می‌کند و فیلتر روی همان می‌نشیند؛ `FinancialChainTenantTest` همین را می‌سنجد.
### ⚠️ نقطهٔ ضعف: کارتِ بی‌محیط در هیچ محیطی دیده نمی‌شود
`bank_accounts` و `pos_devices` تنها جدول‌هایی‌اند که جفت محیطشان **تهی‌پذیر** است ({@see `NullableTenantOwnedTrait`}). دلیل: تا فاز ۶ روی `User` ثبت می‌شدند و برای کاربری که چند محیط دارد هیچ ستونی نمی‌گفت کدام کارت مال کدام محیط است. تصمیم گرفته شد **حدس زده نشود**؛ ردیف مبهم تهی می‌ماند تا مالک خودش تعیین کند.
هزینه‌اش این است: فیلتر شرط تساوی می‌گذارد و `NULL` با هیچ مقداری برابر نیست، پس چنین ردیفی از هر کوئری DQL غایب است — حتی برای کسی که خودش ثبتش کرده.
راه خروج، تنها استثنای این دامنه است: `findUnassignedByUser()` و `assignEntity()` عمداً با DBAL خام اجرا می‌شوند (فیلتر رویشان اعمال نمی‌شود) و به‌جای فیلتر، محدودیت `user_id` را در خودِ کوئری دارند. انتساب با `PATCH /api/v1/my/payment-methods/{bank-accounts|pos}/{uuid}/environment` انجام می‌شود و شرط‌های مالکیت و بی‌محیط بودن داخل خودِ `UPDATE`اند تا دو درخواست هم‌زمان یک کارت را به دو محیط نچسبانند.
پنل ادمین این ردیف‌ها را با نشانهٔ «محیط تعیین‌نشده» و دکمهٔ انتساب نشان می‌دهد، وگرنه کاربر چندمحیطی فکر می‌کند کارتش گم شده.
---
@@ -145,6 +168,7 @@ $this->tenantOwnership->allBelongTo($context, $entities); // یک بی
| `Doctor/Command/Purge*Command` · `Shared/Command/SeedDemoDataCommand` | کنسول | dry-run پیش‌فرض، `--force` لازم، prod از سطح kernel مسدود |
| `Shared/Controller/HealthController` | سراسری | `SELECT 1` |
| `Shared/Logging/DbLogger` | سراسری | `app_log` در `GlobalTables` |
| `PaymentMethod/Repository/{BankAccount,Pos}Repository` | **عمداً بیرون فیلتر** | تنها راه رسیدن به ردیفِ بی‌محیط؛ هر دو کوئری `WHERE user_id = ?` دارند و `assignEntity` شرط `entity_type IS NULL` را هم داخل `UPDATE` نگه می‌دارد. `PaymentMethodTenantTest` تلاش برای تصاحب کارت شخص دیگر را می‌سنجد |
`getReference()` در کل `src/` یک مورد است و روی `User` (سراسری) — بدون اثر tenant.
@@ -171,6 +195,10 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql
۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح
۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php`
`NullableTenantOwnedTrait` برای entity **جدید** نیست. فقط برای جدولی است که از قبل وجود داشته و مالکِ بعضی ردیف‌هایش از داده قابل تشخیص نیست؛ entity جدید از روز اول محیط دارد، پس ستون تهی‌پذیر فقط تور ایمنی را سوراخ می‌کند.
`DEFERRED` هم راه فرار نیست: خالی است و باید خالی بماند.
ایندکس‌ها: `entity_type, entity_id` باید **ستون‌های اول** هر ایندکس ترکیبیِ لیست باشند، وگرنه MariaDB برای شرط فیلتر از آن استفاده نمی‌کند.
---
@@ -189,3 +217,6 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql
| `tests/Shared/TenantLookupInventoryTest.php` | جست‌وجوی uuid تازه‌ای بدون بازبینی اضافه نشده |
| `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد |
| `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود |
| `tests/Payment/PaymentTenantTest.php` | پرداخت به محیط گیرنده می‌نشیند؛ بیمار پرداخت خودش را می‌بیند، محیط دیگر نمی‌بیند |
| `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند |
| `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست |