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>
260 lines
9.8 KiB
PHP
260 lines
9.8 KiB
PHP
<?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\Secretary\Entity\DoctorSecretary;
|
|
use App\Shared\Context\EntityContext;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner());
|
|
|
|
$this->assertSame(200, $this->responseCode());
|
|
$this->assertTrue($res['success']);
|
|
$this->assertSame([], $res['data']);
|
|
}
|
|
|
|
public function testCreateAndListBankAccount(): void
|
|
{
|
|
$user = $this->clinicOwner();
|
|
|
|
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
|
|
'bank_name' => 'ملی',
|
|
'card_number' => '6037991234567890',
|
|
'account_number' => '0101234567890',
|
|
'shaba_number' => 'IR820540102680020817909002',
|
|
]);
|
|
|
|
$this->assertSame(201, $this->responseCode());
|
|
$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']);
|
|
$this->assertSame('0101234567890', $list['data'][0]['account_number']);
|
|
}
|
|
|
|
public function testCreateBankAccountValidationErrorWhenBankNameMissing(): void
|
|
{
|
|
$res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $this->clinicOwner(), [
|
|
'account_number' => '0101234567890',
|
|
]);
|
|
|
|
$this->assertSame(422, $this->responseCode());
|
|
$this->assertFalse($res['success']);
|
|
$this->assertSame('bank_name', $res['errors'][0]['field']);
|
|
}
|
|
|
|
public function testUpdateBankAccount(): void
|
|
{
|
|
$user = $this->clinicOwner();
|
|
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
|
|
'bank_name' => 'ملی',
|
|
'account_number' => '0101234567890',
|
|
]);
|
|
$uuid = $created['data']['uuid'];
|
|
|
|
$updated = $this->authJson('PUT', "/api/v1/my/payment-methods/bank-accounts/$uuid", $user, [
|
|
'bank_name' => 'ملت',
|
|
'account_number' => '0209876543210',
|
|
]);
|
|
|
|
$this->assertSame(200, $this->responseCode());
|
|
$this->assertSame('ملت', $updated['data']['bank_name']);
|
|
$this->assertSame('0209876543210', $updated['data']['account_number']);
|
|
}
|
|
|
|
public function testToggleBankAccountStatus(): void
|
|
{
|
|
$user = $this->clinicOwner();
|
|
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
|
|
'bank_name' => 'ملی',
|
|
'account_number' => '0101234567890',
|
|
]);
|
|
$uuid = $created['data']['uuid'];
|
|
$this->assertTrue($created['data']['is_active']);
|
|
|
|
$toggled = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $user);
|
|
|
|
$this->assertSame(200, $this->responseCode());
|
|
$this->assertFalse($toggled['data']['is_active']);
|
|
}
|
|
|
|
public function testToggleUnknownBankAccountReturns404(): void
|
|
{
|
|
$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']);
|
|
}
|
|
|
|
public function testCannotTouchAnotherClinicsBankAccount(): void
|
|
{
|
|
$owner = $this->clinicOwner();
|
|
$other = $this->clinicOwner();
|
|
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [
|
|
'bank_name' => 'ملی',
|
|
'account_number' => '0101234567890',
|
|
]);
|
|
$uuid = $created['data']['uuid'];
|
|
|
|
$res = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $other);
|
|
|
|
$this->assertSame(404, $this->responseCode());
|
|
$this->assertFalse($res['success']);
|
|
}
|
|
|
|
public function testBankAccountForbiddenForPlainUser(): void
|
|
{
|
|
$this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $this->createUser(['ROLE_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());
|
|
}
|
|
|
|
// ---- POS devices -------------------------------------------------------
|
|
|
|
public function testCreateAndListPos(): void
|
|
{
|
|
$user = $this->doctorOwner();
|
|
|
|
$created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
|
|
'bank_name' => 'ملت',
|
|
'serial_number' => 'SN-98765',
|
|
'terminal_number' => '123456',
|
|
]);
|
|
|
|
$this->assertSame(201, $this->responseCode());
|
|
$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']);
|
|
}
|
|
|
|
public function testCreatePosValidationErrorWhenTerminalMissing(): void
|
|
{
|
|
$res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $this->doctorOwner(), [
|
|
'bank_name' => 'ملت',
|
|
]);
|
|
|
|
$this->assertSame(422, $this->responseCode());
|
|
$this->assertSame('terminal_number', $res['errors'][0]['field']);
|
|
}
|
|
|
|
public function testTogglePosStatus(): void
|
|
{
|
|
// منشی به روشهای پرداخت فقط با مجوز payments از طریق رابطهٔ فعال دسترسی دارد.
|
|
$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',
|
|
]);
|
|
$uuid = $created['data']['uuid'];
|
|
|
|
$toggled = $this->authJson('PATCH', "/api/v1/my/payment-methods/pos/$uuid/status", $user);
|
|
|
|
$this->assertSame(200, $this->responseCode());
|
|
$this->assertFalse($toggled['data']['is_active']);
|
|
}
|
|
|
|
public function testSecretaryWithoutPaymentsPermissionCannotManagePos(): void
|
|
{
|
|
$user = $this->createSecretaryWithPayments(['view' => false, 'create' => false, 'update' => false]);
|
|
|
|
$this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
|
|
'bank_name' => 'تجارت',
|
|
'terminal_number' => '345678',
|
|
]);
|
|
|
|
$this->assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
/** منشی با رابطهٔ فعالِ کلینیک + context + مجوز payments مشخص. */
|
|
private function createSecretaryWithPayments(array $payments): User
|
|
{
|
|
$owner = $this->createUser(['ROLE_CLINIC']);
|
|
$clinic = new Clinic($owner);
|
|
$this->em->persist($clinic);
|
|
|
|
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
|
$this->em->persist($doctor);
|
|
$clinic->getDoctors()->add($doctor);
|
|
|
|
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
|
$rel = new DoctorSecretary($doctor, $secretary, $clinic);
|
|
$rel->mergePermissions(['resources' => ['payments' => $payments]]);
|
|
$this->em->persist($rel);
|
|
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), EntityContext::TYPE_CLINIC));
|
|
$this->em->flush();
|
|
|
|
return $secretary;
|
|
}
|
|
|
|
public function testPosListIsolatedPerEnvironment(): void
|
|
{
|
|
$a = $this->clinicOwner();
|
|
$b = $this->clinicOwner();
|
|
$this->authJson('POST', '/api/v1/my/payment-methods/pos', $a, [
|
|
'bank_name' => 'صادرات',
|
|
'terminal_number' => '901234',
|
|
]);
|
|
|
|
$listB = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $b);
|
|
|
|
$this->assertSame([], $listB['data']);
|
|
}
|
|
}
|