Files
clinicpro/tests/Patient/PatientWalletTenantTest.php
hamedandClaude Opus 5 6ab1eb6483 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>
2026-07-28 15:21:55 +03:30

178 lines
7.7 KiB
PHP

<?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, 'پرداخت سرویس'),
), 'محیط دیگر نباید پرداختِ مراجعهٔ این محیط را ببیند');
}
}