- {txns.map((t) => {
+ {shown.map((t) => {
const credit = t.type === 'credit';
return (
diff --git a/docs/api/patient.md b/docs/api/patient.md
index 26e5d3a7..8cbc5488 100644
--- a/docs/api/patient.md
+++ b/docs/api/patient.md
@@ -600,6 +600,13 @@ Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amoun
```
`amount_rials` باید > 0 باشد وگرنه `422`. Response `201`: `{ success, data: { transaction, balance_rials } }`
+### POST `/api/v1/patient/{uuid}/wallet/withdraw`
+برداشت دستی از کیفپول (مثلاً عودت وجه حضوری). یک تراکنش `debit` برای کاربرِ صاحب رکورد میسازد.
+```json
+{ "amount_rials": 200000, "description": "عودت (اختیاری، پیشفرض «برداشت از کیف پول»)" }
+```
+`amount_rials` باید > 0 باشد وگرنه `422`. اگر مبلغ از موجودی فعلی بیشتر باشد `422` با کد `ERR_WALLET_INSUFFICIENT`. Response `201`: `{ success, data: { transaction, balance_rials } }`
+
### GET `/api/v1/patient/{uuid}/wallet/transactions`
دفترِ کاملِ تراکنشهای کیفپول (paginated). Query: `page`, `limit` (≤100).
Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }`
@@ -608,6 +615,8 @@ Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_af
| HTTP | Code | Description |
|------|------|-------------|
| 404 | `ERR_PATIENT_001` | رکورد یافت نشد یا متعلق به مالک دیگر |
+| 422 | `ERR_VALIDATION_001` | مبلغ شارژ/برداشت ≤ 0 |
+| 422 | `ERR_WALLET_INSUFFICIENT` | مبلغ برداشت از موجودی کیفپول بیشتر است |
---
diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php
index daf96a62..846d3a9d 100644
--- a/src/Patient/Controller/PatientController.php
+++ b/src/Patient/Controller/PatientController.php
@@ -168,6 +168,46 @@ class PatientController extends BaseController
], 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.
+ */
+ #[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])]
+ public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
+ {
+ [$entityType, $entityId] = $this->resolveEntity($user);
+ $record = $this->recordRepo->findByUuid($uuid);
+ if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
+ return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
+ }
+
+ $data = json_decode($request->getContent(), true) ?? [];
+ $amount = (int) ($data['amount_rials'] ?? 0);
+ if ($amount <= 0) {
+ return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بزرگتر از صفر باشد', 422, 'amount_rials');
+ }
+
+ $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);
+
+ return $this->success([
+ 'transaction' => $txn->toArray(),
+ 'balance_rials' => $balance,
+ ], 201);
+ }
+
// ── Call center (کال سنتر) ─────────────────────────────────────────────────
/** List the patient's call log (newest first, optional ?outcome=success|missed). */
diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php
index 1c85f0b4..3727f425 100644
--- a/src/Shared/Constant/ErrorCodes.php
+++ b/src/Shared/Constant/ErrorCodes.php
@@ -70,6 +70,9 @@ class ErrorCodes
// SMS Wallet
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
+ // Patient Wallet
+ public const ERR_WALLET_INSUFFICIENT = 'ERR_WALLET_INSUFFICIENT';
+
// Rate Limit
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
@@ -144,6 +147,7 @@ class ErrorCodes
self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است',
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
+ self::ERR_WALLET_INSUFFICIENT => 'موجودی کیف پول کافی نیست',
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
self::ERR_EXTERNAL_001 => 'خطا در استعلام. لطفاً بعداً تلاش کنید',
self::ERR_EXTERNAL_NOT_CONFIGURED => 'سرویس استعلام پیکربندی نشده است',
diff --git a/tests/Patient/PatientFinancialsTest.php b/tests/Patient/PatientFinancialsTest.php
index 5e6a9231..5904dbfd 100644
--- a/tests/Patient/PatientFinancialsTest.php
+++ b/tests/Patient/PatientFinancialsTest.php
@@ -100,6 +100,61 @@ class PatientFinancialsTest extends ApiTestCase
self::assertSame(422, $this->responseCode());
}
+ public function testWithdrawWalletCreatesDebitAndReducesBalance(): void
+ {
+ [$owner, $record, $patient] = $this->recordFor();
+
+ $this->em->persist(new WalletTransaction($patient, 500000, 'credit', 500000));
+ $this->em->flush();
+
+ $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
+ 'amount_rials' => 200000, 'description' => 'عودت وجه',
+ ]);
+ self::assertSame(201, $this->responseCode());
+ self::assertSame(300000, $res['data']['balance_rials']);
+ self::assertSame('debit', $res['data']['transaction']['type']);
+ self::assertSame('عودت وجه', $res['data']['transaction']['description']);
+
+ $wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
+ self::assertSame(300000, $wallet['data']['balance_rials']);
+ }
+
+ public function testWithdrawWalletDefaultsDescription(): void
+ {
+ [$owner, $record, $patient] = $this->recordFor();
+
+ $this->em->persist(new WalletTransaction($patient, 400000, 'credit', 400000));
+ $this->em->flush();
+
+ $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
+ 'amount_rials' => 400000,
+ ]);
+ self::assertSame(201, $this->responseCode());
+ self::assertSame(0, $res['data']['balance_rials']);
+ self::assertSame('برداشت از کیف پول', $res['data']['transaction']['description']);
+ }
+
+ public function testWithdrawWalletRejectsAmountAboveBalance(): void
+ {
+ [$owner, $record, $patient] = $this->recordFor();
+
+ $this->em->persist(new WalletTransaction($patient, 100000, 'credit', 100000));
+ $this->em->flush();
+
+ $res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
+ 'amount_rials' => 150000,
+ ]);
+ self::assertSame(422, $this->responseCode());
+ self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']);
+ }
+
+ public function testWithdrawWalletRejectsNonPositiveAmount(): void
+ {
+ [$owner, $record] = $this->recordFor();
+ $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, ['amount_rials' => 0]);
+ self::assertSame(422, $this->responseCode());
+ }
+
public function testFinancialsAreOwnershipScoped(): void
{
[, $record] = $this->recordFor();
@@ -110,6 +165,8 @@ class PatientFinancialsTest extends ApiTestCase
self::assertSame(404, $this->responseCode());
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $other, ['amount_rials' => 1000]);
self::assertSame(404, $this->responseCode());
+ $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $other, ['amount_rials' => 1000]);
+ self::assertSame(404, $this->responseCode());
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $other);
self::assertSame(404, $this->responseCode());
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $other);