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
@@ -12,6 +12,8 @@ use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\PaymentManager;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -38,6 +40,7 @@ class PaymentController extends BaseController
private readonly SiteConfigRepository $configRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly EntityContextResolver $contextResolver,
private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '',
) {}
@@ -118,6 +121,7 @@ class PaymentController extends BaseController
$feeRials = (int) $this->configRepo->get('appointment_fee_rials');
$payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress);
$payment->setAppointment($appointment);
$payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic()));
$this->paymentRepo->save($payment);
// مرورگر به این endpoint بک‌اند می‌رود؛ آنجا صلاحیت نهایی + ارتباط با بانک
@@ -201,6 +205,7 @@ class PaymentController extends BaseController
$feeRials = (int) $this->configRepo->get('appointment_fee_rials');
$payment = new Payment($appointment->getUser(), $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $return);
$payment->setAppointment($appointment);
$payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic()));
$this->paymentRepo->save($payment);
return $payment;
}
@@ -405,8 +410,16 @@ class PaymentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر یا غیرفعال است', 422, 'gateway');
}
// اشتراک به حساب محیطی می‌نشیند که کاربر صاحبش است؛ همان مرجعی که
// PaymentManager::handleSubscriptionActivation خودِ اشتراک را با آن می‌سازد.
$owner = $this->contextResolver->ownedEntity($user);
if (!$owner->isResolved()) {
return $this->error(ErrorCodes::ERR_PAYMENT_004, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_004), 422);
}
$periodUuid = trim($data['period_uuid'] ?? '');
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
$payment->assignTenant($owner);
if ($periodUuid !== '') {
$payment->setMetadata(['period_uuid' => $periodUuid]);
}
+9
View File
@@ -4,16 +4,25 @@ namespace App\Payment\Entity;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use App\Payment\Repository\PaymentRepository;
use Symfony\Component\Uid\Uuid;
/**
* پرداخت همیشه به محیطِ گیرنده تعلق دارد، نه به پرداخت‌کننده: نوبت → محیط همان
* نوبت، اشتراک و شارژ پیامک → محیطی که برایش خریداری شده. بیمار همچنان پرداخت
* خودش را می‌بیند چون TenantFilter برای کاربرِ بی‌محیط خاموش می‌ماند.
*/
#[ORM\Entity(repositoryClass: PaymentRepository::class)]
#[ORM\Table(name: 'payments')]
#[ORM\Index(columns: ['order_id'], name: 'idx_payments_order')]
#[ORM\Index(columns: ['user_id'], name: 'idx_payments_user')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'created_at'], name: 'idx_payments_entity_date')]
class Payment
{
use TenantOwnedTrait;
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
+16 -8
View File
@@ -14,6 +14,7 @@ use App\Payment\Repository\PaymentLogRepository;
use App\Payment\Repository\PaymentRepository;
use App\Representation\Service\JalaliDateService;
use App\Settlement\Service\CommissionService;
use App\Shared\Context\EntityContext;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use App\Sms\Service\SmsWalletService;
@@ -346,20 +347,27 @@ final class PaymentManager
return;
}
$user = $payment->getUser();
$doctor = $this->doctorRepo->findByUser($user);
// محیط از خودِ پرداخت خوانده می‌شود، نه دوباره از کاربر: اشتراک باید دقیقاً
// روی همان محیطی بنشیند که هنگام خرید پرداختش ثبت شد، حتی اگر کاربر بین
// خرید و بازگشت از درگاه محیط تازه‌ای پیدا کرده باشد.
$bookingRepId = $this->bookingRepresentationIdFor($payment);
$entityId = $payment->getEntityId();
if ($payment->getEntityType() === EntityContext::TYPE_DOCTOR) {
$doctor = $this->doctorRepo->find($entityId);
if ($doctor === null) {
return;
}
$this->subscriptionService->createFromPayment($payment, 'doctor', $entityId, $periodUuid);
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $bookingRepId, $entityId, null);
if ($doctor !== null) {
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $bookingRepId, $doctor->getId(), null);
return;
}
$clinic = $this->clinicRepo->findByUser($user);
$clinic = $this->clinicRepo->find($entityId);
if ($clinic !== null) {
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $clinic->getId());
$this->subscriptionService->createFromPayment($payment, 'clinic', $entityId, $periodUuid);
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $entityId);
}
}
@@ -6,6 +6,8 @@ use App\Auth\Entity\User;
use App\PaymentMethod\Service\PaymentMethodService;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -15,8 +17,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA;
/**
* Per-clinic payment methods: bank accounts and POS (card reader) devices.
* Scoped to the acting user; only clinic/doctor/secretary roles may manage them.
* Per-environment payment methods: bank accounts and POS (card reader) devices.
*
* اسکوپ محیط فعال است، نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک، بسته به
* محیط فعالش کارت‌های متفاوتی می‌بیند. کارت‌های بازمانده‌ای که هنوز محیطی ندارند
* با `entity_type: null` در همان فهرست می‌آیند و با endpoint انتساب به محیط فعال
* چسبانده می‌شوند.
*/
#[OA\Tag(name: 'Payment Methods')]
#[Route('/api/v1/my/payment-methods')]
@@ -29,14 +35,17 @@ class PaymentMethodController extends BaseController
private readonly PaymentMethodService $service,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly EntityContextResolver $contextResolver,
) {}
/** نقش مجاز + مجوز منشی روی منبع payments (روش‌های پرداخت زیرمجموعهٔ مالی است). */
private function guard(User $user, string $action): void
private function guard(User $user, string $action): EntityContext
{
$this->assertRole($user);
$this->secretaryAccess->denyUnlessGranted($user, 'payments', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', $action);
return $this->contextResolver->resolve($user);
}
// ---- Bank accounts -----------------------------------------------------
@@ -44,35 +53,43 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts', methods: ['GET'])]
public function listBankAccounts(#[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'view');
$context = $this->guard($user, 'view');
return $this->success($this->service->listBankAccounts($user));
return $this->success($this->service->listBankAccounts($context, $user));
}
#[Route('/bank-accounts', methods: ['POST'])]
public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
$context = $this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createBankAccount($user, $data), 201);
return $this->success($this->service->createBankAccount($context, $user, $data), 201);
}
#[Route('/bank-accounts/{uuid}', methods: ['PUT'])]
public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
$context = $this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updateBankAccount($user, $uuid, $data));
return $this->success($this->service->updateBankAccount($context, $uuid, $data));
}
#[Route('/bank-accounts/{uuid}/status', methods: ['PATCH'])]
public function toggleBankAccountStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'update');
$context = $this->guard($user, 'update');
return $this->success($this->service->toggleBankAccountStatus($user, $uuid));
return $this->success($this->service->toggleBankAccountStatus($context, $uuid));
}
#[Route('/bank-accounts/{uuid}/environment', methods: ['PATCH'])]
public function assignBankAccountEnvironment(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$context = $this->guard($user, 'update');
return $this->success($this->service->assignBankAccountEnvironment($context, $user, $uuid));
}
// ---- POS devices -------------------------------------------------------
@@ -80,35 +97,43 @@ class PaymentMethodController extends BaseController
#[Route('/pos', methods: ['GET'])]
public function listPos(#[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'view');
$context = $this->guard($user, 'view');
return $this->success($this->service->listPos($user));
return $this->success($this->service->listPos($context, $user));
}
#[Route('/pos', methods: ['POST'])]
public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
$context = $this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createPos($user, $data), 201);
return $this->success($this->service->createPos($context, $user, $data), 201);
}
#[Route('/pos/{uuid}', methods: ['PUT'])]
public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
$context = $this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updatePos($user, $uuid, $data));
return $this->success($this->service->updatePos($context, $uuid, $data));
}
#[Route('/pos/{uuid}/status', methods: ['PATCH'])]
public function togglePosStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->guard($user, 'update');
$context = $this->guard($user, 'update');
return $this->success($this->service->togglePosStatus($user, $uuid));
return $this->success($this->service->togglePosStatus($context, $uuid));
}
#[Route('/pos/{uuid}/environment', methods: ['PATCH'])]
public function assignPosEnvironment(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$context = $this->guard($user, 'update');
return $this->success($this->service->assignPosEnvironment($context, $user, $uuid));
}
private function assertRole(User $user): void
+14 -3
View File
@@ -4,19 +4,28 @@ namespace App\PaymentMethod\Entity;
use App\Auth\Entity\User;
use App\PaymentMethod\Repository\BankAccountRepository;
use App\Shared\Tenant\NullableTenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A clinic's bank account used as a payment method. Referenced from patient
* invoices to record which account a service payment was made to. This entity
* only stores the account info; the payment linkage lives on the invoice side.
* A bank account used as a payment method. Referenced from patient invoices to
* record which account a service payment was made to. This entity only stores
* the account info; the payment linkage lives on the invoice side.
*
* حساب مالِ **محیط** است، نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک،
* حساب‌هایشان جداست. `user_id` می‌ماند تا بدانیم چه کسی ثبتش کرده، ولی اسکوپِ
* خواندن و ویرایش، محیط است. حساب‌هایی که از پیش از فاز ۶ مانده‌اند و مالکشان
* چند محیط دارد، محیطشان تهی است تا خودش تعیین کند.
*/
#[ORM\Entity(repositoryClass: BankAccountRepository::class)]
#[ORM\Table(name: 'bank_accounts')]
#[ORM\Index(columns: ['user_id'], name: 'idx_bank_accounts_user')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_bank_accounts_entity')]
class BankAccount
{
use NullableTenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -94,6 +103,8 @@ class BankAccount
'shaba_number' => $this->shabaNumber,
'is_active' => $this->isActive,
'created_at' => $this->createdAt,
// تهی یعنی «محیطش هنوز تعیین نشده»؛ پنل با همین نشانه‌گذاری‌اش می‌کند.
'entity_type' => $this->entityType,
];
}
}
+11 -2
View File
@@ -4,18 +4,25 @@ namespace App\PaymentMethod\Entity;
use App\Auth\Entity\User;
use App\PaymentMethod\Repository\PosRepository;
use App\Shared\Tenant\NullableTenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A clinic's card reader (POS) device used as a payment method. Referenced from
* patient invoices to record which device a service payment was collected on.
* A card reader (POS) device used as a payment method. Referenced from patient
* invoices to record which device a service payment was collected on.
*
* قرینهٔ {@see BankAccount}: دستگاه مالِ محیط است نه کاربر، و ردیف‌های مبهمِ
* پیش از فاز ۶ محیط تهی دارند تا مالک خودش تعیین کند.
*/
#[ORM\Entity(repositoryClass: PosRepository::class)]
#[ORM\Table(name: 'pos_devices')]
#[ORM\Index(columns: ['user_id'], name: 'idx_pos_devices_user')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_pos_devices_entity')]
class Pos
{
use NullableTenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -93,6 +100,8 @@ class Pos
'account_number' => $this->accountNumber,
'is_active' => $this->isActive,
'created_at' => $this->createdAt,
// تهی یعنی «محیطش هنوز تعیین نشده»؛ پنل با همین نشانه‌گذاری‌اش می‌کند.
'entity_type' => $this->entityType,
];
}
}
@@ -20,15 +20,80 @@ class BankAccountRepository extends ServiceEntityRepository
}
/** @return BankAccount[] */
public function findByUser(User $user): array
public function findByEntity(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('b')
->where('b.user = :user')->setParameter('user', $user)
->where('b.entityType = :type')->setParameter('type', $entityType)
->andWhere('b.entityId = :id')->setParameter('id', $entityId)
->orderBy('b.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* حساب‌های بی‌محیطِ همین کاربر — بازمانده‌های پیش از فاز ۶ که مالکشان چند محیط
* داشت و انتسابشان حدس می‌خواست.
*
* عمداً DBAL است نه DQL: TenantFilter شرط تساوی می‌گذارد و NULL با هیچ مقداری
* برابر نیست، پس این ردیف‌ها از DQL هرگز برنمی‌گردند. به‌جای فیلتر، محدودیت
* مالکیت همین‌جا با user_id گذاشته شده.
*
* شکل خروجی باید با {@see BankAccount::toArray()} یکی بماند؛
* PaymentMethodTenantTest همین را می‌سنجد.
*
* @return array<int, array<string, mixed>>
*/
public function findUnassignedByUser(User $user): array
{
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative(
'SELECT uuid, bank_name, card_number, account_number, shaba_number, is_active, created_at
FROM bank_accounts
WHERE user_id = ? AND (entity_type IS NULL OR entity_id IS NULL)
ORDER BY created_at DESC',
[$user->getId()],
);
return array_map(static fn (array $r) => [
'uuid' => $r['uuid'],
'bank_name' => $r['bank_name'],
'card_number' => $r['card_number'],
'account_number' => $r['account_number'],
'shaba_number' => $r['shaba_number'],
'is_active' => (bool) $r['is_active'],
'created_at' => (int) $r['created_at'],
'entity_type' => null,
], $rows);
}
/**
* انتساب یک حساب بی‌محیط به یک محیط. شرط‌های مالکیت و بی‌محیط بودن داخل خودِ
* UPDATE‌اند تا دو درخواست هم‌زمان نتوانند یک حساب را به دو محیط بچسبانند.
*
* @return bool چیزی انتساب یافت؟ false یعنی یافت نشد یا از قبل محیط داشت.
*/
public function assignEntity(string $uuid, User $user, string $entityType, int $entityId): bool
{
return $this->getEntityManager()->getConnection()->executeStatement(
'UPDATE bank_accounts SET entity_type = ?, entity_id = ?, updated_at = ?
WHERE uuid = ? AND user_id = ? AND (entity_type IS NULL OR entity_id IS NULL)',
[$entityType, $entityId, time(), $uuid, $user->getId()],
) > 0;
}
/**
* پس از نوشتنِ DBAL، نمونهٔ داخل identity map هنوز مقدار قدیمی را دارد و
* findOneBy همان را برمی‌گرداند بی‌آنکه تازه‌اش کند. این متد صریحاً تازه می‌کند.
*/
public function reloadByUuid(string $uuid): ?BankAccount
{
$account = $this->findByUuid($uuid);
if ($account !== null) {
$this->getEntityManager()->refresh($account);
}
return $account;
}
public function save(BankAccount $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
+54 -2
View File
@@ -20,15 +20,67 @@ class PosRepository extends ServiceEntityRepository
}
/** @return Pos[] */
public function findByUser(User $user): array
public function findByEntity(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('p')
->where('p.user = :user')->setParameter('user', $user)
->where('p.entityType = :type')->setParameter('type', $entityType)
->andWhere('p.entityId = :id')->setParameter('id', $entityId)
->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* قرینهٔ {@see BankAccountRepository::findUnassignedByUser()} — و به همان دلیل
* DBAL است: ردیف بی‌محیط از DQL برنمی‌گردد چون فیلتر شرط تساوی می‌گذارد.
*
* شکل خروجی باید با {@see Pos::toArray()} یکی بماند.
*
* @return array<int, array<string, mixed>>
*/
public function findUnassignedByUser(User $user): array
{
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative(
'SELECT uuid, bank_name, serial_number, terminal_number, account_number, is_active, created_at
FROM pos_devices
WHERE user_id = ? AND (entity_type IS NULL OR entity_id IS NULL)
ORDER BY created_at DESC',
[$user->getId()],
);
return array_map(static fn (array $r) => [
'uuid' => $r['uuid'],
'bank_name' => $r['bank_name'],
'serial_number' => $r['serial_number'],
'terminal_number' => $r['terminal_number'],
'account_number' => $r['account_number'],
'is_active' => (bool) $r['is_active'],
'created_at' => (int) $r['created_at'],
'entity_type' => null,
], $rows);
}
/** @see BankAccountRepository::assignEntity() */
public function assignEntity(string $uuid, User $user, string $entityType, int $entityId): bool
{
return $this->getEntityManager()->getConnection()->executeStatement(
'UPDATE pos_devices SET entity_type = ?, entity_id = ?, updated_at = ?
WHERE uuid = ? AND user_id = ? AND (entity_type IS NULL OR entity_id IS NULL)',
[$entityType, $entityId, time(), $uuid, $user->getId()],
) > 0;
}
/** @see BankAccountRepository::reloadByUuid() */
public function reloadByUuid(string $uuid): ?Pos
{
$pos = $this->findByUuid($uuid);
if ($pos !== null) {
$this->getEntityManager()->refresh($pos);
}
return $pos;
}
public function save(Pos $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
@@ -8,32 +8,44 @@ use App\PaymentMethod\Entity\Pos;
use App\PaymentMethod\Repository\BankAccountRepository;
use App\PaymentMethod\Repository\PosRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
/**
* Business logic for a clinic's payment methods (bank accounts + POS devices).
* Every read/write is scoped to the acting user so one clinic can never touch
* another's records. Ported from clinic-pro-tauri PaymentManagement tab.
* Business logic for an environment's payment methods (bank accounts + POS
* devices). Ported from clinic-pro-tauri PaymentManagement tab.
*
* اسکوپ **محیط** است نه کاربر: پزشکی که هم مطب شخصی دارد و هم کلینیک، در هر محیط
* کارت‌های همان محیط را می‌بیند. `user_id` هنگام ساخت از کاربر جاری پر می‌شود، ولی
* فقط می‌گوید چه کسی ثبتش کرده.
*
* ردیف‌های بازمانده از پیش از فاز ۶ محیط تهی دارند و در هیچ محیطی دیده نمی‌شوند؛
* مالکشان آن‌ها را در فهرست «بی‌محیط» می‌بیند و با assign*Environment به محیط
* فعالش می‌چسباند.
*/
class PaymentMethodService
{
public function __construct(
private readonly BankAccountRepository $bankRepo,
private readonly PosRepository $posRepo,
private readonly BankAccountRepository $bankRepo,
private readonly PosRepository $posRepo,
private readonly TenantOwnershipChecker $tenantOwnership,
) {}
// ---- Bank accounts -----------------------------------------------------
/** @return array<int, array<string, mixed>> */
public function listBankAccounts(User $user): array
public function listBankAccounts(EntityContext $context, User $user): array
{
return array_map(
static fn (BankAccount $b) => $b->toArray(),
$this->bankRepo->findByUser($user),
[$type, $id] = $this->pair($context);
return array_merge(
array_map(static fn (BankAccount $b) => $b->toArray(), $this->bankRepo->findByEntity($type, $id)),
$this->bankRepo->findUnassignedByUser($user),
);
}
public function createBankAccount(User $user, array $data): array
public function createBankAccount(EntityContext $context, User $user, array $data): array
{
$bankName = trim((string) ($data['bank_name'] ?? ''));
$accountNumber = trim((string) ($data['account_number'] ?? ''));
@@ -47,15 +59,18 @@ class PaymentMethodService
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number');
}
$this->pair($context);
$account = new BankAccount($user, $bankName, $accountNumber, $cardNumber, $shabaNumber);
$account->assignTenant($context);
$this->bankRepo->save($account);
return $account->toArray();
}
public function updateBankAccount(User $user, string $uuid, array $data): array
public function updateBankAccount(EntityContext $context, string $uuid, array $data): array
{
$account = $this->ownedBankAccount($user, $uuid);
$account = $this->ownedBankAccount($context, $uuid);
if (array_key_exists('bank_name', $data)) {
$bankName = trim((string) $data['bank_name']);
@@ -83,19 +98,36 @@ class PaymentMethodService
return $account->toArray();
}
public function toggleBankAccountStatus(User $user, string $uuid): array
public function toggleBankAccountStatus(EntityContext $context, string $uuid): array
{
$account = $this->ownedBankAccount($user, $uuid);
$account = $this->ownedBankAccount($context, $uuid);
$account->setActive(!$account->isActive());
$this->bankRepo->save($account);
return $account->toArray();
}
private function ownedBankAccount(User $user, string $uuid): BankAccount
/** حسابِ بی‌محیطِ خودِ کاربر را به محیط فعال می‌چسباند. */
public function assignBankAccountEnvironment(EntityContext $context, User $user, string $uuid): array
{
[$type, $id] = $this->pair($context);
if (!$this->bankRepo->assignEntity($uuid, $user, $type, $id)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکیِ بدون محیط یافت نشد', 404);
}
$account = $this->bankRepo->reloadByUuid($uuid);
if (!$this->tenantOwnership->belongsTo($context, $account)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکی یافت نشد', 404);
}
return $account->toArray();
}
private function ownedBankAccount(EntityContext $context, string $uuid): BankAccount
{
$account = $this->bankRepo->findByUuid($uuid);
if ($account === null || $account->getUser()->getId() !== $user->getId()) {
if (!$this->tenantOwnership->belongsTo($context, $account)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکی یافت نشد', 404);
}
@@ -105,15 +137,17 @@ class PaymentMethodService
// ---- POS devices -------------------------------------------------------
/** @return array<int, array<string, mixed>> */
public function listPos(User $user): array
public function listPos(EntityContext $context, User $user): array
{
return array_map(
static fn (Pos $p) => $p->toArray(),
$this->posRepo->findByUser($user),
[$type, $id] = $this->pair($context);
return array_merge(
array_map(static fn (Pos $p) => $p->toArray(), $this->posRepo->findByEntity($type, $id)),
$this->posRepo->findUnassignedByUser($user),
);
}
public function createPos(User $user, array $data): array
public function createPos(EntityContext $context, User $user, array $data): array
{
$bankName = trim((string) ($data['bank_name'] ?? ''));
$terminalNumber = trim((string) ($data['terminal_number'] ?? ''));
@@ -127,15 +161,18 @@ class PaymentMethodService
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number');
}
$this->pair($context);
$pos = new Pos($user, $bankName, $terminalNumber, $serialNumber, $accountNumber);
$pos->assignTenant($context);
$this->posRepo->save($pos);
return $pos->toArray();
}
public function updatePos(User $user, string $uuid, array $data): array
public function updatePos(EntityContext $context, string $uuid, array $data): array
{
$pos = $this->ownedPos($user, $uuid);
$pos = $this->ownedPos($context, $uuid);
if (array_key_exists('bank_name', $data)) {
$bankName = trim((string) $data['bank_name']);
@@ -163,22 +200,56 @@ class PaymentMethodService
return $pos->toArray();
}
public function togglePosStatus(User $user, string $uuid): array
public function togglePosStatus(EntityContext $context, string $uuid): array
{
$pos = $this->ownedPos($user, $uuid);
$pos = $this->ownedPos($context, $uuid);
$pos->setActive(!$pos->isActive());
$this->posRepo->save($pos);
return $pos->toArray();
}
private function ownedPos(User $user, string $uuid): Pos
/** کارتخوانِ بی‌محیطِ خودِ کاربر را به محیط فعال می‌چسباند. */
public function assignPosEnvironment(EntityContext $context, User $user, string $uuid): array
{
[$type, $id] = $this->pair($context);
if (!$this->posRepo->assignEntity($uuid, $user, $type, $id)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوانِ بدون محیط یافت نشد', 404);
}
$pos = $this->posRepo->reloadByUuid($uuid);
if (!$this->tenantOwnership->belongsTo($context, $pos)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوان یافت نشد', 404);
}
return $pos->toArray();
}
private function ownedPos(EntityContext $context, string $uuid): Pos
{
$pos = $this->posRepo->findByUuid($uuid);
if ($pos === null || $pos->getUser()->getId() !== $user->getId()) {
if (!$this->tenantOwnership->belongsTo($context, $pos)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوان یافت نشد', 404);
}
return $pos;
}
/**
* محیطِ حل‌شده الزامی است: منشیِ بدون محیط فعال یا کاربری که هنوز پزشک/کلینیکی
* ندارد، نه کارتی برای دیدن دارد نه جایی برای ساختنش.
*
* @return array{0: string, 1: int}
*/
private function pair(EntityContext $context): array
{
if (!$context->isResolved()) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط فعالی برای روش‌های پرداخت انتخاب نشده است', 403);
}
[$type, $id] = $context->toEntityPair();
return [$type, (int) $id];
}
}
@@ -730,6 +730,7 @@ class SeedDemoDataCommand extends Command
$user = $this->em->getReference(\App\Auth\Entity\User::class, $patientIds[$i]);
$payment = new Payment($user, $fee, 'mock', Payment::TYPE_APPOINTMENT, 'https://' . $rep['domain'] . '/payment/result');
$payment->assignTenantPair('doctor', (int) $doc['id']);
$payment->setMetadata(['demo' => true, 'scenario' => 'match-service']);
$this->em->persist($payment);
$this->em->flush();
+2
View File
@@ -29,6 +29,7 @@ class ErrorCodes
public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001';
public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002';
public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003';
public const ERR_PAYMENT_004 = 'ERR_PAYMENT_004';
// Appointment
public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001';
@@ -127,6 +128,7 @@ class ErrorCodes
self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست',
self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است',
self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست',
self::ERR_PAYMENT_004 => 'محیط این پرداخت مشخص نیست',
self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست',
self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست',
self::ERR_FILE_001 => 'فرمت فایل مجاز نیست',
@@ -73,6 +73,25 @@ class EntityContextResolver
}
}
/**
* محیطی که کاربر **صاحبش** است، مستقل از محیط فعال و از نقش‌هایش.
*
* برای خریدهایی است که به حساب خودِ صاحب می‌نشیند (اشتراک): آنجا «کجا ایستاده‌ام»
* مهم نیست، «چه چیزی دارم» مهم است. تنها مرجعِ این پرسش همین متد است تا پرداختِ
* اشتراک و خودِ اشتراک هرگز روی دو محیط متفاوت ننشینند.
*/
public function ownedEntity(User $user): EntityContext
{
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor !== null) {
return EntityContext::forDoctor($doctor);
}
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
}
/** مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، یا منشیِ دارای رابطهٔ فعال در آن. */
public function canActInClinic(User $user, Clinic $clinic): bool
{
+14 -14
View File
@@ -66,6 +66,10 @@ final class GlobalTables
// استثنای مستندشده در فاز ۲
\App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیط‌ها»، نه «مطب شخصی» — جفت tenant این را نمی‌تواند بیان کند',
// کیف پولِ شخص — استثنای مستندشده در فاز ۶
\App\Settlement\Entity\WalletTransaction::class => 'کیف پول خودِ شخص است نه محیط: موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و payment_id تهی‌پذیر است، پس تفکیک به محیط، موجودی را بی‌معنا می‌کند',
\App\Settlement\Entity\Settlement::class => 'برداشت از همان کیف پولِ شخصی (SettlementController موجودی را با getWalletBalance(user) می‌سنجد)؛ محیط ندارد چون کیف پول ندارد',
];
/**
@@ -107,25 +111,21 @@ final class GlobalTables
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Sms\Entity\SmsWalletTransaction::class => \App\Sms\Entity\SmsWallet::class,
\App\Payment\Entity\PaymentLog::class => \App\Payment\Entity\Payment::class,
\App\Settlement\Entity\FinancialBreakdown::class => \App\Payment\Entity\Payment::class,
\App\Secretary\Entity\SecretaryEarning::class => \App\Settlement\Entity\FinancialBreakdown::class,
];
/**
* بدهی ثبت‌شده: مالکیتشان دوگانه است (پرداخت‌کننده در برابر دریافت‌کننده) و
* تصمیم درباره‌شان تحلیل جدا می‌خواهد. migration اشتباه روی دادهٔ مالی برگشت‌پذیر
* نیست، پس عمداً در این فاز دست نخوردند.
* بدهیِ طبقه‌بندی: کلاسی که هنوز تصمیمی درباره‌اش گرفته نشده.
*
* این فهرست باید کوچک شود، نه بزرگ.
* فاز ۶ آخرین هشت موردش را تعیین تکلیف کرد و اکنون خالی است. خالی بماند:
* هر افزوده‌ای یعنی جدولی بیرون از هر تضمینی مانده. اگر تصمیم واقعاً به تحلیل
* بیشتری نیاز دارد، همین‌جا با دلیل ثبتش کن — ولی TenantSchemaCoverageTest
* خالی‌بودن را اجبار می‌کند تا این کار بی‌صدا نگذرد.
*
* @var array<class-string, string>
*/
public const DEFERRED = [
\App\Payment\Entity\Payment::class => 'پرداخت بین بیمار و محیط؛ هر دو طرف باید ببینندش',
\App\Payment\Entity\PaymentLog::class => 'فرزند Payment؛ با همان تصمیم می‌رود',
\App\Settlement\Entity\Settlement::class => 'تسویهٔ سامانه با صاحب محیط',
\App\Settlement\Entity\FinancialBreakdown::class => 'تفکیک سهم‌ها بین چند طرف یک پرداخت',
\App\Settlement\Entity\WalletTransaction::class => 'کیف پول کاربر، نه محیط',
\App\Secretary\Entity\SecretaryEarning::class => 'سهم منشی از یک پرداخت',
\App\PaymentMethod\Entity\BankAccount::class => 'حساب بانکی روی User ثبت شده، نه روی محیط',
\App\PaymentMethod\Entity\Pos::class => 'دستگاه کارتخوان روی User ثبت شده، نه روی محیط',
];
public const DEFERRED = [];
}
@@ -0,0 +1,50 @@
<?php
namespace App\Shared\Tenant;
use App\Shared\Context\EntityContext;
use Doctrine\ORM\Mapping as ORM;
/**
* همان جفت محیطِ {@see TenantOwnedTrait}، ولی تهی‌پذیر — برای جدول‌هایی که پیش از
* نشانه‌گذاری وجود داشتند و مالکِ بعضی ردیف‌هایشان از روی داده قابل تشخیص نیست.
*
* تنها مصرفش حساب بانکی و کارتخوان است: تا فاز ۶ روی `User` ثبت می‌شدند و کاربری
* که چند محیط دارد، هیچ ستونی نمی‌گوید کدام کارتش مال کدام محیط است. حدس زدنش
* یعنی پول به حساب اشتباه؛ پس تهی می‌مانند تا مالک خودش تعیین کند.
*
* ⚠️ ردیفِ تهی در **هیچ** محیطی دیده نمی‌شود، چون TenantFilter شرط تساوی می‌گذارد
* و NULL با هیچ مقداری برابر نیست. این عمدی است ولی نقطهٔ ضعف است و در
* docs/architecture/tenancy.md ثبت شده: تا وقتی مالک محیط را تعیین نکند، کارتش
* از فهرست‌ها غایب است.
*/
trait NullableTenantOwnedTrait
{
#[ORM\Column(name: 'entity_type', type: 'string', length: 10, nullable: true)]
private ?string $entityType = null;
#[ORM\Column(name: 'entity_id', type: 'integer', nullable: true)]
private ?int $entityId = null;
public function getEntityType(): ?string { return $this->entityType; }
public function getEntityId(): ?int { return $this->entityId; }
public function hasTenant(): bool
{
return $this->entityType !== null && $this->entityId !== null;
}
/** @throws \InvalidArgumentException اگر محیط حل نشده باشد */
public function assignTenant(EntityContext $context): void
{
if (!$context->isResolved()) {
throw new \InvalidArgumentException(sprintf(
'Cannot assign an unresolved tenant context to %s.',
static::class,
));
}
[$this->entityType, $this->entityId] = $context->toEntityPair();
}
}
@@ -95,6 +95,7 @@ class SmsWalletController extends BaseController
$frontendAddress = trim($data['frontend_address'] ?? '');
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
$payment->assignTenantPair($entityType, $entityId);
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
$this->paymentRepo->save($payment);