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
+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)