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
+5
View File
@@ -878,6 +878,11 @@ function WalletTab({ uuid }: { uuid: string }) {
<div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
<div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div>
{/* موجودی کلِ بیمار است ولی فهرست فقط تراکنش‌های همین محیط را می‌آورد؛
بدون این توضیح، اختلافِ جمعِ سطرها با موجودی شبیه باگ دیده می‌شود. */}
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
موجودی کل بیمار؛ تراکنشهای زیر فقط مربوط به همین محل کار است
</div>
</div>
{canUpdate && (
<button className="btn primary" onClick={() => setModalOpen(true)}>
+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-محیط است ولی موجودی سراسری می‌ماند |
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Record which environment a wallet transaction was entered in.
*
* The wallet itself stays the person's: the balance is derived from the sum of
* that user's credits minus debits, across every environment. But the ledger is
* also shown to a clinic through /api/v1/patient/{uuid}/wallet/transactions, and
* without this column clinic A could read what the patient paid at clinic B,
* down to the name of the staff member who entered it.
*
* The columns are deliberately NOT called entity_type/entity_id: TenantFilter
* keys on those names and would then scope the balance query too, which would
* show a patient half their own money. This is attribution, not ownership.
*
* Rows that cannot be attributed stay NULL and remain visible everywhere —
* hiding them would make an existing patient's history look deleted.
*/
final class Version20260728160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Record the environment a wallet transaction was entered in, without scoping the balance';
}
public function up(Schema $schema): void
{
$this->connection->executeStatement(
'ALTER TABLE wallet_transactions
ADD recorded_entity_type VARCHAR(10) NULL, ADD recorded_entity_id INT NULL'
);
// Gateway-backed rows: the payment already carries the environment.
$this->connection->executeStatement(
'UPDATE wallet_transactions w JOIN payments p ON p.id = w.payment_id
SET w.recorded_entity_type = p.entity_type, w.recorded_entity_id = p.entity_id
WHERE w.payment_id IS NOT NULL'
);
// Desk payments taken against a visit: reference is "session:{uuid}".
$this->connection->executeStatement(
"UPDATE wallet_transactions w
JOIN patient_sessions s ON s.uuid = SUBSTRING(w.reference, 9)
JOIN patient_records r ON r.id = s.record_id
SET w.recorded_entity_type = r.entity_type, w.recorded_entity_id = r.entity_id
WHERE w.recorded_entity_type IS NULL AND w.reference LIKE 'session:%'"
);
$unattributed = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM wallet_transactions WHERE recorded_entity_type IS NULL'
);
$this->write(sprintf(
' wallet_transactions: %d row(s) could not be attributed to an environment and stay visible in all of them.',
$unattributed,
));
$this->connection->executeStatement(
'CREATE INDEX idx_wallet_user_recorded_entity
ON wallet_transactions (user_id, recorded_entity_type, recorded_entity_id)'
);
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_wallet_user_recorded_entity ON wallet_transactions');
$this->addSql('ALTER TABLE wallet_transactions DROP recorded_entity_type, DROP recorded_entity_id');
}
/** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */
public function isTransactional(): bool
{
return false;
}
}
+14 -3
View File
@@ -105,11 +105,13 @@ class PatientController extends BaseController
$patient = $record->getUser();
// موجودی سراسری است (پول مالِ بیمار است، نه محیط) ولی سطرهای دفتر به همین
// محیط محدودند؛ وگرنه اینجا هم پیدا بود بیمار در محیط دیگر چه پرداخت کرده.
return $this->success([
'balance_rials' => $this->settlementRepo->getWalletBalance($patient),
'recent_transactions' => array_map(
fn(\App\Settlement\Entity\WalletTransaction $t) => $t->toArray(),
$this->walletRepo->findByUser($patient, 10)
$this->walletRepo->findByUserForEnvironment($patient, $entityType, (int) $entityId, 10)
),
]);
}
@@ -128,12 +130,19 @@ class PatientController extends BaseController
$limit = min(100, max(1, (int) $request->query->get('limit', 50)));
$patient = $record->getUser();
// دفتر به محیط جاری محدود می‌شود: موجودی مالِ شخص است ولی تاریخچهٔ او در
// کلینیک دیگر به این محیط ربطی ندارد.
$txns = array_map(
fn(\App\Settlement\Entity\WalletTransaction $t) => $t->toArray(),
$this->walletRepo->findByUser($patient, $limit, ($page - 1) * $limit)
$this->walletRepo->findByUserForEnvironment($patient, $entityType, (int) $entityId, $limit, ($page - 1) * $limit)
);
return $this->paginated($txns, $this->walletRepo->countByUser($patient), $page, $limit);
return $this->paginated(
$txns,
$this->walletRepo->countByUserForEnvironment($patient, $entityType, (int) $entityId),
$page,
$limit,
);
}
/**
@@ -164,6 +173,7 @@ class PatientController extends BaseController
trim((string) ($data['description'] ?? '')) ?: null,
$this->normalizeMethod($data['payment_method'] ?? null),
trim((string) ($data['reference'] ?? '')) ?: null,
$entityType, (int) $entityId,
);
return $this->success([
@@ -202,6 +212,7 @@ class PatientController extends BaseController
trim((string) ($data['description'] ?? '')) ?: null,
$this->normalizeMethod($data['payment_method'] ?? null),
trim((string) ($data['reference'] ?? '')) ?: null,
$entityType, (int) $entityId,
);
return $this->success([
+4 -1
View File
@@ -678,13 +678,16 @@ class PatientService
$session->getServices()->toArray(),
)));
$label = $names !== [] ? implode('، ', $names) : 'ویزیت';
$record = $session->getRecord();
$this->walletService->withdraw(
$session->getRecord()->getUser(),
$record->getUser(),
$amountRials,
$actor,
'پرداخت سرویس: ' . $label,
'wallet',
'session:' . $session->getUuid(),
$record->getEntityType(),
$record->getEntityId(),
);
}
@@ -67,6 +67,27 @@ class WalletTransaction
#[ORM\Column(type: 'string', length: 15, options: ['default' => self::STATUS_CONFIRMED])]
private string $status = self::STATUS_CONFIRMED;
/**
* محیطی که این تراکنش **در آن ثبت شده** — نه مالکِ تراکنش.
*
* کیف پول مالِ شخص است و موجودی از مجموع credit−debitِ همهٔ ردیف‌های او مشتق
* می‌شود؛ ولی دفترِ تراکنش را کلینیک هم می‌بیند و بدون این ستون، کلینیک A
* می‌دید بیمار در کلینیک B چه پرداختی کرده و چه کسی ثبتش کرده.
*
* ⚠️ نام ستون عمداً `entity_type/entity_id` **نیست**: آن نام را TenantFilter
* می‌شناسد و خودکار روی هر کوئری می‌نشیند — از جمله روی محاسبهٔ موجودی، که
* آن‌وقت per-محیط می‌شد و پول بیمار را نصف نشان می‌داد. اینجا انتساب است، نه
* مالکیت؛ فیلتر نباید ببیندش. {@see docs/architecture/tenancy.md}
*
* تهی یعنی «ثبت‌شده پیش از این تفکیک، یا بیرون از هر محیط» (مثل سهم نماینده)؛
* چنین ردیفی در همهٔ محیط‌ها دیده می‌شود تا تاریخچهٔ کسی ناپدید نشود.
*/
#[ORM\Column(name: 'recorded_entity_type', type: 'string', length: 10, nullable: true)]
private ?string $recordedEntityType = null;
#[ORM\Column(name: 'recorded_entity_id', type: 'integer', nullable: true)]
private ?int $recordedEntityId = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -93,6 +114,8 @@ class WalletTransaction
public function getPaymentMethod(): ?string { return $this->paymentMethod; }
public function getReference(): ?string { return $this->reference; }
public function getStatus(): string { return $this->status; }
public function getRecordedEntityType(): ?string { return $this->recordedEntityType; }
public function getRecordedEntityId(): ?int { return $this->recordedEntityId; }
public function setPayment(?Payment $p): self { $this->payment = $p; return $this; }
public function setDescription(?string $d): self { $this->description = $d; return $this; }
@@ -102,6 +125,15 @@ class WalletTransaction
public function setReference(?string $r): self { $this->reference = $r; return $this; }
public function setStatus(string $s): self { $this->status = $s; return $this; }
/** محیطی که تراکنش در آن ثبت شد؛ تهی برای ثبت‌های بیرون از هر محیط. */
public function setRecordedEnvironment(?string $entityType, ?int $entityId): self
{
$this->recordedEntityType = $entityType;
$this->recordedEntityId = $entityId;
return $this;
}
public function toArray(): array
{
return [
@@ -25,6 +25,49 @@ class WalletTransactionRepository extends ServiceEntityRepository
return $this->count(['user' => $user]);
}
/**
* دفترِ تراکنشِ یک شخص، محدود به محیطی که آن را می‌خواند.
*
* موجودی همچنان سراسری است (پول مالِ شخص است)، ولی کلینیک A نباید ببیند بیمار
* در کلینیک B چه پرداختی کرده و چه کسی ثبتش کرده.
*
* ردیف‌های بی‌محیط (ثبت‌شده پیش از این تفکیک، یا بیرون از هر محیط) در همه‌جا
* دیده می‌شوند: پنهان‌کردنشان تاریخچهٔ موجودِ یک بیمار را ناپدید می‌کرد.
*
* @return WalletTransaction[]
*/
public function findByUserForEnvironment(
User $user,
string $entityType,
int $entityId,
int $limit = 50,
int $offset = 0,
): array {
return $this->environmentScoped($user, $entityType, $entityId)
->orderBy('t.createdAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function countByUserForEnvironment(User $user, string $entityType, int $entityId): int
{
return (int) $this->environmentScoped($user, $entityType, $entityId)
->select('COUNT(t.id)')
->getQuery()
->getSingleScalarResult();
}
private function environmentScoped(User $user, string $entityType, int $entityId): \Doctrine\ORM\QueryBuilder
{
return $this->createQueryBuilder('t')
->where('t.user = :user')->setParameter('user', $user)
->andWhere('t.recordedEntityType IS NULL OR (t.recordedEntityType = :type AND t.recordedEntityId = :id)')
->setParameter('type', $entityType)
->setParameter('id', $entityId);
}
public function save(WalletTransaction $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
+14 -3
View File
@@ -14,6 +14,10 @@ use App\UserProfile\Repository\UserProfileRepository;
* منطق کیف پولِ بیمار: موجودی، شارژ (credit)، برداشت (debit) و تسویهٔ سرویس از
* کیف پول — همگی با ثبتِ کاربرِ عامل، روش پرداخت و دلیل برای شفافیت کامل.
* موجودی همیشه از مجموع credit − debit مشتق می‌شود (SettlementRepository).
*
* موجودی **سراسری** است چون کیف پول مالِ شخص است، ولی هر تراکنش محیطِ ثبتش را
* هم نگه می‌دارد تا دفترِ تراکنشی که کلینیک می‌بیند به همان محیط محدود شود.
* فراخوانی بدون محیط (سهم نماینده، برداشت صاحب حساب) عمداً تهی می‌ماند.
*/
class WalletService
{
@@ -48,13 +52,15 @@ class WalletService
?string $description = null,
?string $paymentMethod = null,
?string $reference = null,
?string $entityType = null,
?int $entityId = null,
): WalletTransaction {
$balanceAfter = $this->balance($patient) + $amountRials;
return $this->record(
$patient, $amountRials, WalletTransaction::TYPE_CREDIT, $balanceAfter, $actor,
$description !== null && $description !== '' ? $description : 'شارژ کیف پول',
$paymentMethod, $reference,
$paymentMethod, $reference, $entityType, $entityId,
);
}
@@ -69,6 +75,8 @@ class WalletService
?string $description = null,
?string $paymentMethod = null,
?string $reference = null,
?string $entityType = null,
?int $entityId = null,
): WalletTransaction {
$current = $this->balance($patient);
if ($amountRials > $current) {
@@ -78,7 +86,7 @@ class WalletService
return $this->record(
$patient, $amountRials, WalletTransaction::TYPE_DEBIT, $current - $amountRials, $actor,
$description !== null && $description !== '' ? $description : 'برداشت از کیف پول',
$paymentMethod, $reference,
$paymentMethod, $reference, $entityType, $entityId,
);
}
@@ -91,9 +99,12 @@ class WalletService
string $description,
?string $paymentMethod,
?string $reference,
?string $entityType,
?int $entityId,
): WalletTransaction {
$txn = new WalletTransaction($patient, $amountRials, $type, $balanceAfter);
$txn->setDescription($description)
$txn->setRecordedEnvironment($entityType, $entityId)
->setDescription($description)
->setCreatedBy($actor)
->setCreatedByName($this->resolveActorName($actor))
->setPaymentMethod($paymentMethod)
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Settlement\Entity\WalletTransaction;
use App\Tests\ApiTestCase;
/**
* کیف پول مالِ **شخص** است و موجودی‌اش سراسری می‌ماند — ولی دفترِ تراکنش را کلینیک
* هم می‌بیند، و بدون تفکیک، کلینیک A می‌خواند که بیمار در کلینیک B چه پرداختی کرده
* و چه کسی ثبتش کرده.
*
* پس انتساب per-تراکنش است، نه مالکیت per-کیف‌پول: `recorded_entity_*` عمداً نامی
* دارد که TenantFilter نمی‌شناسد، وگرنه محاسبهٔ موجودی هم per-محیط می‌شد و پول
* بیمار را نصف نشان می‌داد.
*/
class PatientWalletTenantTest extends ApiTestCase
{
/** @return array{0: User, 1: Doctor} */
private function makeDoctor(string $name): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, $name);
$this->em->persist($doctor);
$this->em->flush();
return [$owner, $doctor];
}
private function recordFor(Doctor $doctor, User $patient): PatientRecord
{
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$this->em->persist($record);
$this->em->flush();
return $record;
}
private function charge(User $owner, PatientRecord $record, int $amount, string $description): void
{
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, [
'amount_rials' => $amount,
'description' => $description,
]);
self::assertSame(201, $this->responseCode(), 'شارژ کیف پول باید موفق باشد');
}
/** @return array<int, string> توضیحِ تراکنش‌هایی که این محیط می‌بیند */
private function ledger(User $owner, PatientRecord $record): array
{
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $owner);
self::assertSame(200, $this->responseCode());
return array_column($res['data'], 'description');
}
private function balance(User $owner, PatientRecord $record): int
{
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
self::assertSame(200, $this->responseCode());
return $res['data']['balance_rials'];
}
/**
* یک بیمار، دو محیط: هر محیط پرونده و شارژ خودش را دارد.
*
* @return array{0: User, 1: PatientRecord, 2: User, 3: PatientRecord}
*/
private function sharedPatientInTwoEnvironments(): array
{
$patient = $this->createUser(['ROLE_USER']);
[$ownerA, $doctorA] = $this->makeDoctor('دکتر الف');
[$ownerB, $doctorB] = $this->makeDoctor('دکتر ب');
$recordA = $this->recordFor($doctorA, $patient);
$recordB = $this->recordFor($doctorB, $patient);
$this->charge($ownerA, $recordA, 300_000, 'شارژ نزد الف');
$this->charge($ownerB, $recordB, 500_000, 'شارژ نزد ب');
return [$ownerA, $recordA, $ownerB, $recordB];
}
/** ✅ هر محیط تراکنش خودش را می‌بیند. */
public function testEachEnvironmentSeesItsOwnEntries(): void
{
[$ownerA, $recordA, $ownerB, $recordB] = $this->sharedPatientInTwoEnvironments();
self::assertSame(['شارژ نزد الف'], $this->ledger($ownerA, $recordA));
self::assertSame(['شارژ نزد ب'], $this->ledger($ownerB, $recordB));
}
/** ❌ تراکنشِ محیط دیگر — همان نشتی — دیگر در دفتر نمی‌آید. */
public function testOneEnvironmentCannotReadWhatThePatientPaidInAnother(): void
{
[$ownerA, $recordA] = $this->sharedPatientInTwoEnvironments();
self::assertNotContains('شارژ نزد ب', $this->ledger($ownerA, $recordA));
}
/**
* ⚠️ مرزی: موجودی عمداً سراسری می‌ماند. پول مالِ بیمار است و اگر per-محیط
* می‌شد، همان بیمار در هر مطب نصف پولش را می‌دید.
*/
public function testTheBalanceStaysGlobalEvenThoughTheLedgerIsScoped(): void
{
[$ownerA, $recordA, $ownerB, $recordB] = $this->sharedPatientInTwoEnvironments();
self::assertSame(800_000, $this->balance($ownerA, $recordA));
self::assertSame(800_000, $this->balance($ownerB, $recordB));
}
/**
* ⚠️ مرزی: ردیف بی‌محیط (ثبت‌شده پیش از این تفکیک) در همهٔ محیط‌ها دیده می‌شود.
* پنهان‌کردنش تاریخچهٔ موجودِ یک بیمار را ناپدید می‌کرد.
*/
public function testUnattributedLegacyRowsRemainVisibleEverywhere(): void
{
[$ownerA, $recordA, $ownerB, $recordB] = $this->sharedPatientInTwoEnvironments();
// کاربر بعد از درخواست‌های API از EntityManager جدا شده؛ دوباره از همین EM.
$patient = $this->em->find(User::class, $recordA->getUser()->getId());
$legacy = new WalletTransaction($patient, 100_000, WalletTransaction::TYPE_CREDIT, 900_000);
$legacy->setDescription('شارژ قدیمی بدون محیط');
$this->em->persist($legacy);
$this->em->flush();
self::assertContains('شارژ قدیمی بدون محیط', $this->ledger($ownerA, $recordA));
self::assertContains('شارژ قدیمی بدون محیط', $this->ledger($ownerB, $recordB));
}
/** ❌ خلاصهٔ کیف پول هم همان قاعده را دارد؛ فقط موجودی سراسری می‌ماند. */
public function testTheWalletSummaryOnlyShowsThisEnvironmentsRecentEntries(): void
{
[$ownerA, $recordA] = $this->sharedPatientInTwoEnvironments();
$res = $this->authJson('GET', '/api/v1/patient/' . $recordA->getUuid() . '/wallet', $ownerA);
self::assertSame(200, $this->responseCode());
self::assertSame(800_000, $res['data']['balance_rials'], 'موجودی سراسری است');
self::assertSame(
['شارژ نزد الف'],
array_column($res['data']['recent_transactions'], 'description'),
);
}
/** پرداختِ مراجعه از کیف پول، محیطش را از پروندهٔ همان مراجعه می‌گیرد. */
public function testAVisitPaidFromTheWalletIsAttributedToTheRecordsEnvironment(): void
{
[$ownerA, $recordA, $ownerB, $recordB] = $this->sharedPatientInTwoEnvironments();
$session = $this->authJson('POST', '/api/v1/patient/' . $recordA->getUuid() . '/session', $ownerA, [
'visit_price_rials' => 200_000,
]);
self::assertSame(201, $this->responseCode());
$this->authJson('POST', '/api/v1/session/' . $session['data']['uuid'] . '/payments', $ownerA, [
'amount_rials' => 200_000,
'method' => 'wallet',
]);
self::assertSame(201, $this->responseCode(), 'پرداخت از کیف پول باید ثبت شود');
self::assertNotSame([], array_filter(
$this->ledger($ownerA, $recordA),
static fn (string $d) => str_starts_with($d, 'پرداخت سرویس'),
));
self::assertSame([], array_filter(
$this->ledger($ownerB, $recordB),
static fn (string $d) => str_starts_with($d, 'پرداخت سرویس'),
), 'محیط دیگر نباید پرداختِ مراجعهٔ این محیط را ببیند');
}
}