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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user