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
+26
View File
@@ -8,6 +8,7 @@ use App\Appointment\Entity\WeeklySchedule;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Shared\Context\EntityContext;
use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
@@ -25,6 +26,12 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
*/
abstract class ApiTestCase extends WebTestCase
{
/**
* محیطِ رکوردهایی که موضوعِ تست، محیطشان نیست. عمداً یک ثابت است تا هیچ تستی
* تصادفاً با محیطِ واقعیِ تستِ دیگری برخورد نکند.
*/
protected const TENANTLESS_TEST_ENTITY_ID = 1;
protected KernelBrowser $client;
protected EntityManagerInterface $em;
@@ -134,6 +141,25 @@ abstract class ApiTestCase extends WebTestCase
return $schedule;
}
/**
* محیط پرداخت را می‌گذارد: از نوبتش اگر داشته باشد، وگرنه محیطِ ثابتی که
* موضوع تست نیست. مثل بقیهٔ جدول‌های محیط‌دار، ستون‌ها NOT NULL‌اند و پرداختِ
* بی‌محیط سرِ flush می‌شکند — همان رفتاری که کد واقعی هم دارد.
*/
protected function stampTenant(Payment $payment): Payment
{
$appointment = $payment->getAppointment();
if ($appointment !== null) {
$payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic()));
return $payment;
}
$payment->assignTenantPair(EntityContext::TYPE_DOCTOR, self::TENANTLESS_TEST_ENTITY_ID);
return $payment;
}
protected function newDateOverride(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null): DateOverride
{
$this->flushIfNew($doctor, $clinic);
@@ -34,6 +34,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$payment = new Payment($patient, 100_000, 'mellat', 'appointment');
$payment->setAppointment($appt);
$this->stampTenant($payment);
$this->em->persist($payment);
$appointments[] = [$appt, $payment];
+3 -3
View File
@@ -29,9 +29,9 @@ class UniqueConstraintsTest extends ApiTestCase
public function testDuplicateGatewayTokenRejected(): void
{
$token = 'tok-' . bin2hex(random_bytes(6));
$a = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION);
$a = $this->stampTenant(new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION));
$a->setGatewayToken($token);
$b = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION);
$b = $this->stampTenant(new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION));
$b->setGatewayToken($token);
$this->em->persist($a);
$this->em->persist($b);
@@ -57,7 +57,7 @@ class UniqueConstraintsTest extends ApiTestCase
public function testDuplicateBreakdownPaymentSourceRejected(): void
{
$user = $this->createUser();
$payment = new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT);
$payment = $this->stampTenant(new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT));
$this->em->persist($payment);
$this->em->flush();
+1 -1
View File
@@ -34,7 +34,7 @@ class PatientFinancialsTest extends ApiTestCase
{
[$owner, $record, $patient] = $this->recordFor();
$payment = new Payment($patient, 250000, 'mellat', 'appointment');
$payment = $this->stampTenant(new Payment($patient, 250000, 'mellat', 'appointment'));
$payment->setStatus(Payment::STATUS_SUCCESS);
$this->em->persist($payment);
$this->em->flush();
@@ -47,6 +47,7 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
$payment = new Payment($appointment->getUser(), 50_000, 'mock', Payment::TYPE_APPOINTMENT);
$payment->setAppointment($appointment);
$this->stampTenant($payment);
$this->em->persist($payment);
$this->em->flush();
+1 -1
View File
@@ -28,7 +28,7 @@ class PaymentCallbackAmountTest extends ApiTestCase
private function makePayment(int $amountRials): Payment
{
$user = $this->createUser();
$payment = new Payment($user, $amountRials, 'mock', Payment::TYPE_SMS_WALLET);
$payment = $this->stampTenant(new Payment($user, $amountRials, 'mock', Payment::TYPE_SMS_WALLET));
$this->em->persist($payment);
$this->em->flush();
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Tests\Payment;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Tenant\TenantFilter;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* پرداخت به محیطِ **گیرنده** تعلق دارد، نه به پرداخت‌کننده: نوبت → محیط همان نوبت،
* اشتراک → محیطی که خریدار صاحبش است، شارژ پیامک → محیطِ همان کیف پول.
*
* بیمار در هیچ محیطی نیست، پس TenantFilter برایش خاموش می‌ماند و پرداخت خودش را
* می‌بیند — همان دلیلی که فاز ۴ فیلتر را فقط روی «محیط انتخاب‌شده» روشن کرد.
*/
class PaymentTenantTest extends ApiTestCase
{
private function em(): EntityManagerInterface
{
return static::getContainer()->get(EntityManagerInterface::class);
}
protected function tearDown(): void
{
$filters = $this->em()->getFilters();
if ($filters->isEnabled(TenantFilter::NAME)) {
$filters->disable(TenantFilter::NAME);
}
parent::tearDown();
}
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر پرداخت');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeClinic(): Clinic
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک پرداخت');
$this->em->persist($clinic);
$this->em->flush();
return $clinic;
}
/** پرداخت را از دیتابیس می‌خواند، نه از پاسخ — پاسخ محیط را برنمی‌گرداند. */
private function storedPayment(string $uuid): Payment
{
$this->em->clear();
$payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $uuid]);
self::assertNotNull($payment, 'پرداخت باید ذخیره شده باشد');
return $payment;
}
private function payForAppointment(?Clinic $clinic): Payment
{
$doctor = $this->makeDoctor();
$patient = $this->createUser(['ROLE_USER']);
$start = strtotime('+10 days') + random_int(0, 500_000) * 7;
$appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$this->em->persist($appointment);
$this->em->flush();
$res = $this->authJson('POST', '/api/v1/payment/appointment', $patient, [
'appointment_uuid' => $appointment->getUuid(),
'gateway' => 'mellat',
]);
self::assertSame(200, $this->responseCode(), 'شروع پرداخت نوبت باید موفق باشد');
return $this->storedPayment($res['data']['payment_uuid']);
}
/** ✅ پرداخت نوبتِ یک کلینیک به همان کلینیک می‌نشیند. */
public function testAppointmentPaymentBelongsToTheClinicOfTheAppointment(): void
{
$clinic = $this->makeClinic();
$payment = $this->payForAppointment($clinic);
self::assertSame('clinic', $payment->getEntityType());
self::assertSame($clinic->getId(), $payment->getEntityId());
}
/** ✅ نوبتِ مطب شخصی به خودِ پزشک. */
public function testAppointmentPaymentOfAPersonalPracticeBelongsToTheDoctor(): void
{
$payment = $this->payForAppointment(null);
self::assertSame('doctor', $payment->getEntityType());
}
/** ✅ اشتراک به محیطی که خریدار صاحبش است. */
public function testSubscriptionPaymentBelongsToTheEnvironmentTheBuyerOwns(): void
{
$doctor = $this->makeDoctor();
$res = $this->authJson('POST', '/api/v1/subscription-payment', $doctor->getUser(), [
'gateway' => 'mellat',
'amount_rials' => 1_000_000,
]);
self::assertSame(200, $this->responseCode());
$payment = $this->storedPayment($res['data']['payment_uuid']);
self::assertSame('doctor', $payment->getEntityType());
self::assertSame($doctor->getId(), $payment->getEntityId());
}
/**
* ❌ کاربری که نه پزشک است نه کلینیک، اشتراک برای هیچ محیطی نمی‌خرد. بدون این
* گارد، ردیفی با محیطِ نامعتبر ساخته می‌شد یا flush بی‌پیام می‌شکست.
*/
public function testSubscriptionPaymentWithoutAnOwnedEnvironmentIsRejected(): void
{
$res = $this->authJson('POST', '/api/v1/subscription-payment', $this->createUser(['ROLE_USER']), [
'gateway' => 'mellat',
'amount_rials' => 1_000_000,
]);
self::assertSame(422, $this->responseCode());
self::assertSame(ErrorCodes::ERR_PAYMENT_004, $res['errors'][0]['code']);
}
/** ⚠️ مرزی: بیمار محیطی ندارد، پس فیلتر خاموش است و پرداخت خودش را می‌بیند. */
public function testThePayingPatientStillSeesTheirOwnPayment(): void
{
$clinic = $this->makeClinic();
$payment = $this->payForAppointment($clinic);
$patient = $payment->getUser();
$res = $this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $patient);
self::assertSame(200, $this->responseCode(), 'بیمار باید پرداخت خودش را ببیند');
self::assertSame($payment->getUuid(), $res['data']['uuid']);
}
/** ⚠️ محیط دیگر همان پرداخت را اصلاً نمی‌بیند — تور ایمنیِ TenantFilter. */
public function testAnotherEnvironmentDoesNotSeeThePaymentAtAll(): void
{
$clinic = $this->makeClinic();
$payment = $this->payForAppointment($clinic);
$uuid = $payment->getUuid();
$this->em->clear();
$this->em()->getFilters()
->enable(TenantFilter::NAME)
->setParameter(TenantFilter::PARAM_TYPE, 'clinic', 'string')
->setParameter(TenantFilter::PARAM_ID, $clinic->getId() + 1000, 'integer');
self::assertNull(
$this->em->getRepository(Payment::class)->findOneBy(['uuid' => $uuid]),
'پرداخت محیط دیگر نباید دیده شود',
);
}
}
@@ -0,0 +1,200 @@
<?php
namespace App\Tests\PaymentMethod;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\PaymentMethod\Entity\BankAccount;
use App\PaymentMethod\Entity\Pos;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
/**
* کارت و کارتخوان مالِ **محیط**اند نه کاربر. پزشکی که هم مطب شخصی دارد و هم
* کلینیک، در هر محیط فقط کارت‌های همان محیط را می‌بیند.
*
* ردیف‌های بازمانده از پیش از فاز ۶ محیط تهی دارند — عمداً، چون هیچ ستونی نمی‌گفت
* کارتِ کاربرِ چندمحیطی مال کدام محیط است و حدس زدنش یعنی پول به حساب اشتباه.
* چنین ردیفی در هیچ محیطی «متعلق» نیست، ولی مالکش باید ببیندش و بتواند تعیینش کند.
*/
class PaymentMethodTenantTest extends ApiTestCase
{
/** پزشکی که کلینیک هم دارد، با محیط فعالِ مشخص. */
private function multiEnvironmentUser(): array
{
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
$doctor = new Doctor($user, 'دکتر دو محیطه');
$this->em->persist($doctor);
$clinic = new Clinic($user);
$clinic->setName('کلینیک همان شخص');
$this->em->persist($clinic);
$this->em->flush();
return [$user, $doctor, $clinic];
}
private function switchTo(User $user, string $type, string $uuid): void
{
// هر درخواستِ API ممکن است EntityManager را پاک کند، پس کاربر دوباره از
// همین EM گرفته می‌شود تا UserActiveContext به نمونهٔ جداشده وصل نشود.
$managed = $this->em->find(User::class, $user->getId());
$existing = $this->em->getRepository(UserActiveContext::class)->findOneBy(['user' => $managed]);
if ($existing !== null) {
$this->em->remove($existing);
$this->em->flush();
}
$this->em->persist(new UserActiveContext($managed, $uuid, $type));
$this->em->flush();
}
private function createBankAccount(User $user, string $bankName): array
{
$res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'bank_name' => $bankName,
'account_number' => (string) random_int(1_000_000, 9_999_999),
]);
self::assertSame(201, $this->responseCode());
return $res['data'];
}
private function listBankAccounts(User $user): array
{
$res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
self::assertSame(200, $this->responseCode());
return $res['data'];
}
/** ✅ همان شخص، دو محیط، دو دستهٔ جدا از کارت‌ها. */
public function testTheSamePersonSeesDifferentCardsInEachEnvironment(): void
{
[$user, $doctor, $clinic] = $this->multiEnvironmentUser();
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
$personal = $this->createBankAccount($user, 'کارت مطب');
$this->switchTo($user, EntityContext::TYPE_CLINIC, $clinic->getUuid());
$clinical = $this->createBankAccount($user, 'کارت کلینیک');
self::assertSame(['کارت کلینیک'], array_column($this->listBankAccounts($user), 'bank_name'));
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
self::assertSame(['کارت مطب'], array_column($this->listBankAccounts($user), 'bank_name'));
self::assertNotSame($personal['uuid'], $clinical['uuid']);
}
/** ❌ کارتِ محیط دیگر حتی برای همان شخص قابل ویرایش نیست. */
public function testACardOfTheOtherEnvironmentCannotBeEditedEvenByItsOwner(): void
{
[$user, $doctor, $clinic] = $this->multiEnvironmentUser();
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
$personal = $this->createBankAccount($user, 'کارت مطب');
$this->switchTo($user, EntityContext::TYPE_CLINIC, $clinic->getUuid());
$this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/' . $personal['uuid'] . '/status', $user);
self::assertSame(404, $this->responseCode());
}
/**
* ⚠️ مرزی: کارتِ بی‌محیط (بازماندهٔ پیش از فاز ۶) در فهرست می‌آید با نشانهٔ
* `entity_type: null`، ولی تا وقتی محیطش تعیین نشده قابل ویرایش نیست.
*/
public function testAnUnassignedCardIsListedButNotEditable(): void
{
[$user, $doctor] = $this->multiEnvironmentUser();
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
$orphan = new BankAccount($user, 'کارت بی‌محیط', '555000', '', '');
$this->em->persist($orphan);
$this->em->flush();
$listed = $this->listBankAccounts($user);
self::assertSame(['کارت بی‌محیط'], array_column($listed, 'bank_name'));
self::assertNull($listed[0]['entity_type'], 'باید با نشانهٔ «محیط تعیین‌نشده» بیاید');
$this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/' . $orphan->getUuid() . '/status', $user);
self::assertSame(404, $this->responseCode(), 'تا تعیین محیط، ویرایش‌پذیر نیست');
}
/** ✅ انتساب، کارتِ بی‌محیط را به محیط فعال می‌چسباند و ویرایش‌پذیرش می‌کند. */
public function testAssigningAnEnvironmentMakesTheCardUsable(): void
{
[$user, $doctor] = $this->multiEnvironmentUser();
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
$orphan = new BankAccount($user, 'کارت بی‌محیط', '555000', '', '');
$this->em->persist($orphan);
$this->em->flush();
$uuid = $orphan->getUuid();
$res = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/environment", $user);
self::assertSame(200, $this->responseCode());
self::assertSame('doctor', $res['data']['entity_type']);
$this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $user);
self::assertSame(200, $this->responseCode(), 'بعد از انتساب باید ویرایش‌پذیر باشد');
}
/** ❌ انتساب دوباره روی کارتی که محیط دارد، بی‌اثر است — نه ربودن کارت محیط دیگر. */
public function testAssigningAnAlreadyAssignedCardIsRejected(): void
{
[$user, $doctor, $clinic] = $this->multiEnvironmentUser();
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
$personal = $this->createBankAccount($user, 'کارت مطب');
$this->switchTo($user, EntityContext::TYPE_CLINIC, $clinic->getUuid());
$this->authJson(
'PATCH',
'/api/v1/my/payment-methods/bank-accounts/' . $personal['uuid'] . '/environment',
$user,
);
self::assertSame(404, $this->responseCode());
}
/** ❌ کارتِ بی‌محیطِ شخص دیگر با انتساب هم به دست نمی‌آید. */
public function testAnotherPersonsUnassignedCardCannotBeClaimed(): void
{
[$user, $doctor] = $this->multiEnvironmentUser();
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
[$stranger] = $this->multiEnvironmentUser();
$orphan = new Pos($stranger, 'کارتخوان بیگانه', '900900');
$this->em->persist($orphan);
$this->em->flush();
$this->authJson('PATCH', '/api/v1/my/payment-methods/pos/' . $orphan->getUuid() . '/environment', $user);
self::assertSame(404, $this->responseCode());
}
/** شکل ردیفِ بی‌محیط باید دقیقاً همان شکل ردیفِ محیط‌دار باشد، وگرنه پنل می‌شکند. */
public function testUnassignedRowsHaveTheSameShapeAsAssignedOnes(): void
{
[$user, $doctor] = $this->multiEnvironmentUser();
$this->switchTo($user, EntityContext::TYPE_DOCTOR, $doctor->getUuid());
$assigned = $this->createBankAccount($user, 'کارت محیط‌دار');
$orphan = new BankAccount($user, 'کارت بی‌محیط', '555000', '', '');
$this->em->persist($orphan);
$this->em->flush();
$rows = $this->listBankAccounts($user);
self::assertCount(2, $rows);
$keys = array_map(static fn (array $row) => array_keys($row), $rows);
self::assertSame($keys[0], $keys[1], 'کلیدهای ردیف بی‌محیط با ردیف محیط‌دار یکی نیست');
self::assertSame(array_keys($assigned), $keys[0]);
}
}
+64 -34
View File
@@ -2,21 +2,49 @@
namespace App\Tests\PaymentMethod;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
/**
* Functional coverage for the per-clinic payment methods API
* Functional coverage for the per-environment payment methods API
* (bank accounts + POS devices). Success, error and boundary cases.
*
* اسکوپ از فاز ۶ به بعد **محیط** است نه کاربر، پس هر تست به یک محیط واقعی
* (پزشک یا کلینیک) نیاز دارد؛ کاربری با نقش کلینیک ولی بدون کلینیک، محیطی ندارد.
*/
class PaymentMethodTest extends ApiTestCase
{
/** کاربری با نقش کلینیک و یک کلینیک واقعی — یعنی محیط دارد. */
private function clinicOwner(): User
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک روش پرداخت');
$this->em->persist($clinic);
$this->em->flush();
return $owner;
}
private function doctorOwner(): User
{
$user = $this->createUser(['ROLE_DOCTOR']);
$this->em->persist(new Doctor($user, 'دکتر روش پرداخت'));
$this->em->flush();
return $user;
}
// ---- Bank accounts -----------------------------------------------------
public function testEmptyBankAccountListForNewClinic(): void
{
$user = $this->createUser(['ROLE_CLINIC']);
$res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
$res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner());
$this->assertSame(200, $this->responseCode());
$this->assertTrue($res['success']);
@@ -25,7 +53,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreateAndListBankAccount(): void
{
$user = $this->createUser(['ROLE_CLINIC']);
$user = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'bank_name' => 'ملی',
@@ -38,6 +66,7 @@ class PaymentMethodTest extends ApiTestCase
$this->assertSame('ملی', $created['data']['bank_name']);
$this->assertTrue($created['data']['is_active']);
$this->assertNotEmpty($created['data']['uuid']);
$this->assertSame('clinic', $created['data']['entity_type'], 'حساب باید به محیط فعال بچسبد');
$list = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
$this->assertCount(1, $list['data']);
@@ -46,9 +75,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreateBankAccountValidationErrorWhenBankNameMissing(): void
{
$user = $this->createUser(['ROLE_CLINIC']);
$res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
$res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner(), [
'account_number' => '0101234567890',
]);
@@ -59,7 +86,7 @@ class PaymentMethodTest extends ApiTestCase
public function testUpdateBankAccount(): void
{
$user = $this->createUser(['ROLE_CLINIC']);
$user = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'bank_name' => 'ملی',
'account_number' => '0101234567890',
@@ -78,7 +105,7 @@ class PaymentMethodTest extends ApiTestCase
public function testToggleBankAccountStatus(): void
{
$user = $this->createUser(['ROLE_CLINIC']);
$user = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
'bank_name' => 'ملی',
'account_number' => '0101234567890',
@@ -94,9 +121,11 @@ class PaymentMethodTest extends ApiTestCase
public function testToggleUnknownBankAccountReturns404(): void
{
$user = $this->createUser(['ROLE_CLINIC']);
$res = $this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/does-not-exist/status', $user);
$res = $this->authJson(
'PATCH',
'/api/v1/my/payment-methods/bank-accounts/does-not-exist/status',
$this->clinicOwner(),
);
$this->assertSame(404, $this->responseCode());
$this->assertFalse($res['success']);
@@ -104,8 +133,8 @@ class PaymentMethodTest extends ApiTestCase
public function testCannotTouchAnotherClinicsBankAccount(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$other = $this->createUser(['ROLE_CLINIC']);
$owner = $this->clinicOwner();
$other = $this->clinicOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [
'bank_name' => 'ملی',
'account_number' => '0101234567890',
@@ -120,9 +149,15 @@ class PaymentMethodTest extends ApiTestCase
public function testBankAccountForbiddenForPlainUser(): void
{
$user = $this->createUser(['ROLE_USER']);
$this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->createUser(['ROLE_USER']));
$this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
$this->assertSame(403, $this->responseCode());
}
/** ❌ نقشِ کلینیک بدون کلینیکِ واقعی محیطی ندارد، پس کارتی هم ندارد. */
public function testRoleWithoutAnActualEnvironmentIsRejected(): void
{
$this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->createUser(['ROLE_CLINIC']));
$this->assertSame(403, $this->responseCode());
}
@@ -131,7 +166,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreateAndListPos(): void
{
$user = $this->createUser(['ROLE_DOCTOR']);
$user = $this->doctorOwner();
$created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
'bank_name' => 'ملت',
@@ -143,6 +178,7 @@ class PaymentMethodTest extends ApiTestCase
$this->assertSame('ملت', $created['data']['bank_name']);
$this->assertSame('123456', $created['data']['terminal_number']);
$this->assertTrue($created['data']['is_active']);
$this->assertSame('doctor', $created['data']['entity_type']);
$list = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $user);
$this->assertCount(1, $list['data']);
@@ -150,9 +186,7 @@ class PaymentMethodTest extends ApiTestCase
public function testCreatePosValidationErrorWhenTerminalMissing(): void
{
$user = $this->createUser(['ROLE_DOCTOR']);
$res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
$res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $this->doctorOwner(), [
'bank_name' => 'ملت',
]);
@@ -163,7 +197,7 @@ class PaymentMethodTest extends ApiTestCase
public function testTogglePosStatus(): void
{
// منشی به روش‌های پرداخت فقط با مجوز payments از طریق رابطهٔ فعال دسترسی دارد.
$user = $this->createSecretaryWithPayments(['view' => true, 'create' => true, 'update' => true]);
$user = $this->createSecretaryWithPayments(['view' => true, 'create' => true, 'update' => true]);
$created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
'bank_name' => 'تجارت',
'terminal_number' => '345678',
@@ -189,34 +223,30 @@ class PaymentMethodTest extends ApiTestCase
}
/** منشی با رابطهٔ فعالِ کلینیک + context + مجوز payments مشخص. */
private function createSecretaryWithPayments(array $payments): \App\Auth\Entity\User
private function createSecretaryWithPayments(array $payments): User
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new \App\Clinic\Entity\Clinic($owner);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctor = new \App\Doctor\Entity\Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$rel = new \App\Secretary\Entity\DoctorSecretary($doctor, $secretary, $clinic);
$rel = new DoctorSecretary($doctor, $secretary, $clinic);
$rel->mergePermissions(['resources' => ['payments' => $payments]]);
$this->em->persist($rel);
$this->em->persist(new \App\Auth\Entity\UserActiveContext(
$secretary,
$clinic->getUuid(),
\App\Shared\Context\EntityContext::TYPE_CLINIC,
));
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), EntityContext::TYPE_CLINIC));
$this->em->flush();
return $secretary;
}
public function testPosListIsolatedPerUser(): void
public function testPosListIsolatedPerEnvironment(): void
{
$a = $this->createUser(['ROLE_CLINIC']);
$b = $this->createUser(['ROLE_CLINIC']);
$a = $this->clinicOwner();
$b = $this->clinicOwner();
$this->authJson('POST', '/api/v1/my/payment-methods/pos', $a, [
'bank_name' => 'صادرات',
'terminal_number' => '901234',
@@ -33,7 +33,7 @@ class DomainCommissionTest extends ApiTestCase
private function makePayment(string $frontendAddress): Payment
{
$payment = new Payment($this->createUser(), 2_000_000, 'mock', Payment::TYPE_APPOINTMENT, $frontendAddress);
$payment = $this->stampTenant(new Payment($this->createUser(), 2_000_000, 'mock', Payment::TYPE_APPOINTMENT, $frontendAddress));
$this->em->persist($payment);
$this->em->flush();
@@ -62,6 +62,7 @@ class SecretaryOnlineShareTest extends ApiTestCase
$payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, '');
$payment->setAppointment($appointment);
$this->stampTenant($payment);
$this->em->persist($payment);
$this->em->flush();
@@ -17,7 +17,7 @@ class FinancialBreakdownIntegrityTest extends ApiTestCase
public function testDeletingPaymentWithBreakdownIsRestricted(): void
{
$user = $this->createUser();
$payment = new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT);
$payment = $this->stampTenant(new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT));
$this->em->persist($payment);
$breakdown = new FinancialBreakdown(
@@ -0,0 +1,143 @@
<?php
namespace App\Tests\Settlement;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Secretary\Entity\SecretaryEarning;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Settlement\Entity\FinancialBreakdown;
use App\Settlement\Entity\WalletTransaction;
use App\Settlement\Repository\WalletTransactionRepository;
use App\Shared\Tenant\TenantFilter;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* زنجیرهٔ مالی ستون محیط ندارد و آن را از `payments` به ارث می‌برد:
*
* Payment ─┬─ PaymentLog (payment_id اسکالر)
* └─ FinancialBreakdown ── SecretaryEarning
*
* تضمین فقط تا جایی است که کوئری به ریشه لنگر بزند؛ `reportFor` این کار را با
* join('b.payment','p') می‌کند و همان join است که TenantFilter رویش می‌نشیند.
*
* کیف پول عمداً بیرون این زنجیره است: مالِ شخص است نه محیط، و همین‌جا پین می‌شود
* تا اگر روزی کسی ستون محیط رویش گذاشت، این تصمیم دوباره دیده شود.
*/
class FinancialChainTenantTest extends ApiTestCase
{
private function em(): EntityManagerInterface
{
return static::getContainer()->get(EntityManagerInterface::class);
}
protected function tearDown(): void
{
$filters = $this->em()->getFilters();
if ($filters->isEnabled(TenantFilter::NAME)) {
$filters->disable(TenantFilter::NAME);
}
parent::tearDown();
}
private function enableFilterFor(string $type, int $id): void
{
$this->em()->getFilters()
->enable(TenantFilter::NAME)
->setParameter(TenantFilter::PARAM_TYPE, $type, 'string')
->setParameter(TenantFilter::PARAM_ID, $id, 'integer');
}
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر زنجیره');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
/** یک سهم منشی از پرداختِ محیطِ داده‌شده، با کل زنجیره‌اش. */
private function earningFor(Doctor $doctor, User $secretary): SecretaryEarning
{
$payment = new Payment($this->createUser(), 1_000_000, 'mock', Payment::TYPE_APPOINTMENT);
$payment->assignTenantPair('doctor', $doctor->getId());
$this->em->persist($payment);
$breakdown = new FinancialBreakdown(
$payment, FinancialBreakdown::SOURCE_APPOINTMENT, $secretary,
1_000_000, 0, '0.00', 0, 1_000_000, '10.00', 100_000, 900_000, null, null, null,
);
$this->em->persist($breakdown);
$earning = new SecretaryEarning($breakdown, $secretary, 'rel-' . bin2hex(random_bytes(4)), 5.0, 50_000);
$this->em->persist($earning);
$this->em->flush();
return $earning;
}
/** @return string[] */
private function reportedUuids(User $secretary): array
{
$repo = static::getContainer()->get(SecretaryEarningRepository::class);
$report = $repo->reportFor($secretary, 1, 50);
return array_column($report['items'] ?? [], 'uuid');
}
/** ✅ سهمِ محیط خودی از راه لنگر به پرداخت دیده می‌شود. */
public function testAnEarningIsVisibleInsideItsOwnEnvironment(): void
{
$doctor = $this->makeDoctor();
$secretary = $this->createUser(['ROLE_SECRETARY']);
$earning = $this->earningFor($doctor, $secretary);
$this->em->clear();
$this->enableFilterFor('doctor', $doctor->getId());
self::assertContains($earning->getUuid(), $this->reportedUuids($secretary));
}
/**
* ❌ همان سهم در محیط دیگر ناپدید می‌شود — نه به‌خاطر شرط دستی، بلکه چون
* کوئری به `payments` لنگر زده و فیلتر روی همان join نشسته است.
*/
public function testTheSameEarningDisappearsInAnotherEnvironment(): void
{
$doctor = $this->makeDoctor();
$secretary = $this->createUser(['ROLE_SECRETARY']);
$earning = $this->earningFor($doctor, $secretary);
$this->em->clear();
$this->enableFilterFor('doctor', $doctor->getId() + 1000);
self::assertNotContains($earning->getUuid(), $this->reportedUuids($secretary));
}
/**
* ⚠️ مرزی: کیف پول شخص محیط ندارد و در هر محیطی دیده می‌شود. این نشتی نیست،
* تصمیم است — موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و تفکیکش
* به محیط، خودِ موجودی را بی‌معنا می‌کند.
*/
public function testTheUserWalletStaysGlobalAcrossEnvironments(): void
{
$patient = $this->createUser(['ROLE_USER']);
$txn = new WalletTransaction($patient, 500_000, WalletTransaction::TYPE_CREDIT, 500_000);
$this->em->persist($txn);
$this->em->flush();
$uuid = $txn->getUuid();
$this->em->clear();
$this->enableFilterFor('clinic', 987_654);
$repo = static::getContainer()->get(WalletTransactionRepository::class);
self::assertNotNull(
$repo->findOneBy(['uuid' => $uuid]),
'کیف پول شخص نباید به محیط قفل شود',
);
}
}
+7 -7
View File
@@ -126,15 +126,15 @@ class TenantSchemaCoverageTest extends ApiTestCase
}
/**
* بدهی باید کوچک شود نه بزرگ. اگر کلاسی به DEFERRED اضافه شد، این عدد هم باید
* عمداً بالا برود — یعنی تصمیم دیده می‌شود، نه اینکه بی‌صدا بگذرد.
* فاز ۶ بدهی را صفر کرد. از «رشد نکن» به «صفر بمان»: افزودن دوباره یعنی جدولی
* بیرون از هر تضمینی مانده، و باید تصمیم آگاهانه باشد نه یک ردشدنِ بی‌صدا.
*/
public function testDeferredDebtDoesNotGrow(): void
public function testThereIsNoUnclassifiedDebtLeft(): void
{
self::assertLessThanOrEqual(
8,
count(GlobalTables::DEFERRED),
'جدول‌های مالی طبقه‌بندی‌نشده بیشتر شدند؛ فهرست DEFERRED باید کوچک شود',
self::assertSame(
[],
GlobalTables::DEFERRED,
'بدهی طبقه‌بندی باید صفر بماند؛ هر کلاس یا جفت محیط می‌گیرد یا با دلیل سراسری/فرزند aggregate می‌شود',
);
}
}