feat: unify wallet with real payment methods, full transaction transparency, pay-session-from-wallet

Address wallet feedback: use the clinic's real payment infrastructure,
redesign the tab to match the admin panel, and make every wallet movement
fully auditable.

Backend:
- WalletTransaction: add createdBy (acting user) + createdByName, payment_method,
  reference, status; toArray exposes them (migration Version20260716083939).
- WalletService (Settlement): balance/charge/withdraw + settleSessionFromWallet,
  records actor/method/reason; insufficient balance throws ERR_WALLET_INSUFFICIENT.
- PatientController: charge/withdraw delegate to WalletService and accept
  payment_method/reference; PATCH /session/{uuid} with payment_method=wallet
  debits the patient's final share from the wallet (reference=session:{uuid}).
- docs/api/patient.md updated.

Frontend:
- Wallet modal redesigned to panel style (no gradient); payment method now uses
  the clinic's real bank accounts + POS devices (usePaymentMethods) plus cash.
- Wallet tab: panel balance card + DataTable ledger with columns مبلغ/نوع/روش/
  دلیل/ثبت‌کننده/تاریخ/ساعت/وضعیت + همه/واریزی/برداشت filters.
- Session card «تکمیل پرداخت» opens a payment-method chooser incl. کیف پول.

Tests: backend transparency + session-from-wallet (success/insufficient/cash);
frontend modal (real methods, toman→rials) + wallet tab + settle chooser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 12:22:37 +03:30
co-authored by Claude Opus 4.8
parent 22474653b5
commit ef97b2b249
12 changed files with 598 additions and 187 deletions
+39 -23
View File
@@ -58,6 +58,7 @@ class PatientController extends BaseController
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo,
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
private readonly \App\Settlement\Service\WalletService $walletService,
private readonly LoggerInterface $logger,
) {}
@@ -155,24 +156,24 @@ class PatientController extends BaseController
}
$patient = $record->getUser();
$balance = $this->settlementRepo->getWalletBalance($patient) + $amount;
$txn = new \App\Settlement\Entity\WalletTransaction($patient, $amount, 'credit', $balance);
$description = trim((string) ($data['description'] ?? ''));
$txn->setDescription($description !== '' ? $description : 'شارژ کیف پول');
$this->walletRepo->save($txn);
$txn = $this->walletService->charge(
$patient, $amount, $user,
trim((string) ($data['description'] ?? '')) ?: null,
$this->normalizeMethod($data['payment_method'] ?? null),
trim((string) ($data['reference'] ?? '')) ?: null,
);
return $this->success([
'transaction' => $txn->toArray(),
'balance_rials' => $balance,
'balance_rials' => $txn->getBalanceAfter(),
], 201);
}
/**
* Manual wallet withdrawal (برداشت از کیف پول) — e.g. a refund or cash
* hand-back at the desk. Creates a debit WalletTransaction for the record's
* owner User. Rejected (422) when the amount exceeds the current balance,
* mirroring the offline app's balance guard.
* owner User (with acting user + payment method recorded). Rejected (422)
* when the amount exceeds the current balance, mirroring the offline app.
*/
#[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])]
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -189,25 +190,28 @@ class PatientController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بزرگ‌تر از صفر باشد', 422, 'amount_rials');
}
// Insufficient balance → WalletService throws AppException (422), handled globally.
$patient = $record->getUser();
$current = $this->settlementRepo->getWalletBalance($patient);
if ($amount > $current) {
return $this->error(ErrorCodes::ERR_WALLET_INSUFFICIENT, ErrorCodes::message(ErrorCodes::ERR_WALLET_INSUFFICIENT), 422, 'amount_rials');
}
$balance = $current - $amount;
$txn = new \App\Settlement\Entity\WalletTransaction($patient, $amount, 'debit', $balance);
$description = trim((string) ($data['description'] ?? ''));
$txn->setDescription($description !== '' ? $description : 'برداشت از کیف پول');
$this->walletRepo->save($txn);
$txn = $this->walletService->withdraw(
$patient, $amount, $user,
trim((string) ($data['description'] ?? '')) ?: null,
$this->normalizeMethod($data['payment_method'] ?? null),
trim((string) ($data['reference'] ?? '')) ?: null,
);
return $this->success([
'transaction' => $txn->toArray(),
'balance_rials' => $balance,
'balance_rials' => $txn->getBalanceAfter(),
], 201);
}
/** روش پرداختِ مجاز برای کیف پول؛ ورودیِ ناشناخته نادیده گرفته می‌شود. */
private function normalizeMethod(mixed $method): ?string
{
$method = is_string($method) ? trim($method) : '';
return in_array($method, ['card', 'pos', 'cash', 'gateway', 'wallet'], true) ? $method : null;
}
// ── Call center (کال سنتر) ─────────────────────────────────────────────────
/** List the patient's call log (newest first, optional ?outcome=success|missed). */
@@ -958,8 +962,20 @@ class PatientController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
if (isset($data['payment_method'])) { $session->setPaymentMethod($data['payment_method']); }
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
if (isset($data['payment_method'])) {
$method = (string) $data['payment_method'];
// پرداخت از کیف پول: سهمِ بیمار را از موجودی کسر کن (فقط یک‌بار، اگر
// مراجعه هنوز تسویه نشده). موجودیِ ناکافی → AppException (۴۲۲).
if ($method === 'wallet'
&& $session->getPaymentMethod() === 'pending'
&& $session->getFinalPriceRials() > 0
) {
$this->walletService->settleSessionFromWallet($session, $user);
}
$session->setPaymentMethod($method);
}
$this->sessionRepo->save($session);