feat: port payment management tab from tauri to admin dashboard

Add per-clinic payment methods (bank accounts + POS/card-reader devices)
under the "مدیریت پرداخت" settings tab at /admin/my-financial, ported from
clinic-pro-tauri's mock-only PaymentManagement tab into a real persisted
feature. These records are referenceable (by uuid) from patient invoices to
record which method a service payment was made with.

Backend (new src/PaymentMethod domain):
- BankAccount + Pos entities, repositories, PaymentMethodService (validation,
  ownership scoping, create/update/toggle logic).
- Thin PaymentMethodController exposing /api/v1/my/payment-methods/{bank-accounts,pos}
  (GET/POST/PUT + PATCH .../status), guarded to clinic/doctor/secretary/admin.
- Migration for bank_accounts + pos_devices tables.
- Functional tests (success + validation/404/403 + empty boundaries).
- docs/api/payment-method.md.

Frontend:
- Replace MyFinancialPage content with the payment-management UI (two tabs,
  tables, add/edit modals, status toggle) using the admin design system.
- usePaymentMethods hook (TanStack Query) + presentational components.
- Update page test to cover tabs, data, empty state and the add modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 11:39:32 +03:30
co-authored by Claude Opus 4.8
parent cac2e8b46a
commit b459d082a4
19 changed files with 1631 additions and 82 deletions
@@ -0,0 +1,109 @@
<?php
namespace App\PaymentMethod\Controller;
use App\Auth\Entity\User;
use App\PaymentMethod\Service\PaymentMethodService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
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.
*/
#[OA\Tag(name: 'Payment Methods')]
#[Route('/api/v1/my/payment-methods')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PaymentMethodController extends BaseController
{
private const ALLOWED_ROLES = ['ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_ADMIN'];
public function __construct(
private readonly PaymentMethodService $service,
) {}
// ---- Bank accounts -----------------------------------------------------
#[Route('/bank-accounts', methods: ['GET'])]
public function listBankAccounts(#[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
return $this->success($this->service->listBankAccounts($user));
}
#[Route('/bank-accounts', methods: ['POST'])]
public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createBankAccount($user, $data), 201);
}
#[Route('/bank-accounts/{uuid}', methods: ['PUT'])]
public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updateBankAccount($user, $uuid, $data));
}
#[Route('/bank-accounts/{uuid}/status', methods: ['PATCH'])]
public function toggleBankAccountStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
return $this->success($this->service->toggleBankAccountStatus($user, $uuid));
}
// ---- POS devices -------------------------------------------------------
#[Route('/pos', methods: ['GET'])]
public function listPos(#[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
return $this->success($this->service->listPos($user));
}
#[Route('/pos', methods: ['POST'])]
public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createPos($user, $data), 201);
}
#[Route('/pos/{uuid}', methods: ['PUT'])]
public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updatePos($user, $uuid, $data));
}
#[Route('/pos/{uuid}/status', methods: ['PATCH'])]
public function togglePosStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
return $this->success($this->service->togglePosStatus($user, $uuid));
}
private function assertRole(User $user): void
{
if (!array_intersect(self::ALLOWED_ROLES, $user->getRoles())) {
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی ندارید', 403);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\PaymentMethod\Entity;
use App\Auth\Entity\User;
use App\PaymentMethod\Repository\BankAccountRepository;
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.
*/
#[ORM\Entity(repositoryClass: BankAccountRepository::class)]
#[ORM\Table(name: 'bank_accounts')]
#[ORM\Index(columns: ['user_id'], name: 'idx_bank_accounts_user')]
class BankAccount
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private User $user;
#[ORM\Column(name: 'bank_name', type: 'string', length: 100)]
private string $bankName;
#[ORM\Column(name: 'card_number', type: 'string', length: 32, nullable: true)]
private ?string $cardNumber = null;
#[ORM\Column(name: 'account_number', type: 'string', length: 64)]
private string $accountNumber;
#[ORM\Column(name: 'shaba_number', type: 'string', length: 34, nullable: true)]
private ?string $shabaNumber = null;
#[ORM\Column(name: 'is_active', type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(
User $user,
string $bankName,
string $accountNumber,
?string $cardNumber = null,
?string $shabaNumber = null,
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->bankName = $bankName;
$this->accountNumber = $accountNumber;
$this->cardNumber = $cardNumber ?: null;
$this->shabaNumber = $shabaNumber ?: null;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getBankName(): string { return $this->bankName; }
public function getCardNumber(): ?string { return $this->cardNumber; }
public function getAccountNumber(): string { return $this->accountNumber; }
public function getShabaNumber(): ?string { return $this->shabaNumber; }
public function isActive(): bool { return $this->isActive; }
public function setBankName(string $v): self { $this->bankName = $v; $this->touch(); return $this; }
public function setCardNumber(?string $v): self { $this->cardNumber = $v ?: null; $this->touch(); return $this; }
public function setAccountNumber(string $v): self { $this->accountNumber = $v; $this->touch(); return $this; }
public function setShabaNumber(?string $v): self { $this->shabaNumber = $v ?: null; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'bank_name' => $this->bankName,
'card_number' => $this->cardNumber,
'account_number' => $this->accountNumber,
'shaba_number' => $this->shabaNumber,
'is_active' => $this->isActive,
'created_at' => $this->createdAt,
];
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\PaymentMethod\Entity;
use App\Auth\Entity\User;
use App\PaymentMethod\Repository\PosRepository;
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.
*/
#[ORM\Entity(repositoryClass: PosRepository::class)]
#[ORM\Table(name: 'pos_devices')]
#[ORM\Index(columns: ['user_id'], name: 'idx_pos_devices_user')]
class Pos
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private User $user;
#[ORM\Column(name: 'bank_name', type: 'string', length: 100)]
private string $bankName;
#[ORM\Column(name: 'serial_number', type: 'string', length: 64, nullable: true)]
private ?string $serialNumber = null;
#[ORM\Column(name: 'terminal_number', type: 'string', length: 64)]
private string $terminalNumber;
#[ORM\Column(name: 'account_number', type: 'string', length: 64, nullable: true)]
private ?string $accountNumber = null;
#[ORM\Column(name: 'is_active', type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(
User $user,
string $bankName,
string $terminalNumber,
?string $serialNumber = null,
?string $accountNumber = null,
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->bankName = $bankName;
$this->terminalNumber = $terminalNumber;
$this->serialNumber = $serialNumber ?: null;
$this->accountNumber = $accountNumber ?: null;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getBankName(): string { return $this->bankName; }
public function getSerialNumber(): ?string { return $this->serialNumber; }
public function getTerminalNumber(): string { return $this->terminalNumber; }
public function getAccountNumber(): ?string { return $this->accountNumber; }
public function isActive(): bool { return $this->isActive; }
public function setBankName(string $v): self { $this->bankName = $v; $this->touch(); return $this; }
public function setSerialNumber(?string $v): self { $this->serialNumber = $v ?: null; $this->touch(); return $this; }
public function setTerminalNumber(string $v): self { $this->terminalNumber = $v; $this->touch(); return $this; }
public function setAccountNumber(?string $v): self { $this->accountNumber = $v ?: null; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; }
private function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'bank_name' => $this->bankName,
'serial_number' => $this->serialNumber,
'terminal_number' => $this->terminalNumber,
'account_number' => $this->accountNumber,
'is_active' => $this->isActive,
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\PaymentMethod\Repository;
use App\Auth\Entity\User;
use App\PaymentMethod\Entity\BankAccount;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class BankAccountRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BankAccount::class);
}
public function findByUuid(string $uuid): ?BankAccount
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return BankAccount[] */
public function findByUser(User $user): array
{
return $this->createQueryBuilder('b')
->where('b.user = :user')->setParameter('user', $user)
->orderBy('b.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(BankAccount $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\PaymentMethod\Repository;
use App\Auth\Entity\User;
use App\PaymentMethod\Entity\Pos;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PosRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Pos::class);
}
public function findByUuid(string $uuid): ?Pos
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return Pos[] */
public function findByUser(User $user): array
{
return $this->createQueryBuilder('p')
->where('p.user = :user')->setParameter('user', $user)
->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(Pos $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
if ($flush) $this->getEntityManager()->flush();
}
}
@@ -0,0 +1,184 @@
<?php
namespace App\PaymentMethod\Service;
use App\Auth\Entity\User;
use App\PaymentMethod\Entity\BankAccount;
use App\PaymentMethod\Entity\Pos;
use App\PaymentMethod\Repository\BankAccountRepository;
use App\PaymentMethod\Repository\PosRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* 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.
*/
class PaymentMethodService
{
public function __construct(
private readonly BankAccountRepository $bankRepo,
private readonly PosRepository $posRepo,
) {}
// ---- Bank accounts -----------------------------------------------------
/** @return array<int, array<string, mixed>> */
public function listBankAccounts(User $user): array
{
return array_map(
static fn (BankAccount $b) => $b->toArray(),
$this->bankRepo->findByUser($user),
);
}
public function createBankAccount(User $user, array $data): array
{
$bankName = trim((string) ($data['bank_name'] ?? ''));
$accountNumber = trim((string) ($data['account_number'] ?? ''));
$cardNumber = trim((string) ($data['card_number'] ?? ''));
$shabaNumber = trim((string) ($data['shaba_number'] ?? ''));
if ($bankName === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name');
}
if ($accountNumber === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number');
}
$account = new BankAccount($user, $bankName, $accountNumber, $cardNumber, $shabaNumber);
$this->bankRepo->save($account);
return $account->toArray();
}
public function updateBankAccount(User $user, string $uuid, array $data): array
{
$account = $this->ownedBankAccount($user, $uuid);
if (array_key_exists('bank_name', $data)) {
$bankName = trim((string) $data['bank_name']);
if ($bankName === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name');
}
$account->setBankName($bankName);
}
if (array_key_exists('account_number', $data)) {
$accountNumber = trim((string) $data['account_number']);
if ($accountNumber === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره حساب الزامی است', 422, 'account_number');
}
$account->setAccountNumber($accountNumber);
}
if (array_key_exists('card_number', $data)) {
$account->setCardNumber(trim((string) $data['card_number']));
}
if (array_key_exists('shaba_number', $data)) {
$account->setShabaNumber(trim((string) $data['shaba_number']));
}
$this->bankRepo->save($account);
return $account->toArray();
}
public function toggleBankAccountStatus(User $user, string $uuid): array
{
$account = $this->ownedBankAccount($user, $uuid);
$account->setActive(!$account->isActive());
$this->bankRepo->save($account);
return $account->toArray();
}
private function ownedBankAccount(User $user, string $uuid): BankAccount
{
$account = $this->bankRepo->findByUuid($uuid);
if ($account === null || $account->getUser()->getId() !== $user->getId()) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'حساب بانکی یافت نشد', 404);
}
return $account;
}
// ---- POS devices -------------------------------------------------------
/** @return array<int, array<string, mixed>> */
public function listPos(User $user): array
{
return array_map(
static fn (Pos $p) => $p->toArray(),
$this->posRepo->findByUser($user),
);
}
public function createPos(User $user, array $data): array
{
$bankName = trim((string) ($data['bank_name'] ?? ''));
$terminalNumber = trim((string) ($data['terminal_number'] ?? ''));
$serialNumber = trim((string) ($data['serial_number'] ?? ''));
$accountNumber = trim((string) ($data['account_number'] ?? ''));
if ($bankName === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name');
}
if ($terminalNumber === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number');
}
$pos = new Pos($user, $bankName, $terminalNumber, $serialNumber, $accountNumber);
$this->posRepo->save($pos);
return $pos->toArray();
}
public function updatePos(User $user, string $uuid, array $data): array
{
$pos = $this->ownedPos($user, $uuid);
if (array_key_exists('bank_name', $data)) {
$bankName = trim((string) $data['bank_name']);
if ($bankName === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام بانک الزامی است', 422, 'bank_name');
}
$pos->setBankName($bankName);
}
if (array_key_exists('terminal_number', $data)) {
$terminalNumber = trim((string) $data['terminal_number']);
if ($terminalNumber === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'شماره ترمینال الزامی است', 422, 'terminal_number');
}
$pos->setTerminalNumber($terminalNumber);
}
if (array_key_exists('serial_number', $data)) {
$pos->setSerialNumber(trim((string) $data['serial_number']));
}
if (array_key_exists('account_number', $data)) {
$pos->setAccountNumber(trim((string) $data['account_number']));
}
$this->posRepo->save($pos);
return $pos->toArray();
}
public function togglePosStatus(User $user, string $uuid): array
{
$pos = $this->ownedPos($user, $uuid);
$pos->setActive(!$pos->isActive());
$this->posRepo->save($pos);
return $pos->toArray();
}
private function ownedPos(User $user, string $uuid): Pos
{
$pos = $this->posRepo->findByUuid($uuid);
if ($pos === null || $pos->getUser()->getId() !== $user->getId()) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کارت خوان یافت نشد', 404);
}
return $pos;
}
}