- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments. - Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements. - Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`. - Implemented `SecretaryEarning` entity and repository for managing secretary earnings. - Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments. - Added `UserIbanResolver` service to handle user IBAN retrieval and management. - Created `HasIbansTrait` for entities to manage IBANs in a JSON format. - Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
344 lines
12 KiB
Markdown
344 lines
12 KiB
Markdown
# Settlement & Wallet API
|
||
|
||
> **Prefix:** `/api/v1/wallet`, `/api/v1/settlement`
|
||
|
||
---
|
||
|
||
## GET `/api/v1/wallet/balance`
|
||
|
||
Get authenticated user's wallet balance.
|
||
|
||
**Permission:** `AUTH`
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"balance_rials": 2500000,
|
||
"recent_transactions": [
|
||
{
|
||
"uuid": "...",
|
||
"type": "credit",
|
||
"amount_rials": 500000,
|
||
"balance_after": 2500000,
|
||
"description": "دریافت از نوبت شماره ...",
|
||
"created_at": 1717000000
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/wallet/transactions`
|
||
|
||
Get wallet transaction history for the authenticated user (paginated, newest first).
|
||
|
||
**Permission:** `AUTH`
|
||
|
||
### Query Parameters
|
||
| پارامتر | نوع | توضیح |
|
||
|---------|-----|-------|
|
||
| `page` | int | شماره صفحه (پیشفرض ۱) |
|
||
| `limit` | int | تعداد در هر صفحه (پیشفرض ۵۰، حداکثر ۱۰۰) |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": [
|
||
{
|
||
"uuid": "...",
|
||
"type": "credit",
|
||
"amount_rials": 500000,
|
||
"balance_after": 2500000,
|
||
"description": "دریافت از نوبت",
|
||
"created_at": 1717000000
|
||
},
|
||
{
|
||
"uuid": "...",
|
||
"type": "debit",
|
||
"amount_rials": 200000,
|
||
"balance_after": 2300000,
|
||
"description": "تسویهحساب",
|
||
"created_at": 1716900000
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 124, "totalPages": 3, "currentPage": 1 }
|
||
}
|
||
}
|
||
```
|
||
|
||
**Transaction Type Values:**
|
||
| Value | Description |
|
||
|-------|-------------|
|
||
| `credit` | Money added to wallet |
|
||
| `debit` | Money removed from wallet |
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/settlement`
|
||
|
||
Request a settlement (withdrawal from wallet to bank account).
|
||
|
||
**Permission:** `AUTH`
|
||
|
||
### Request Body (`application/json`)
|
||
```json
|
||
{
|
||
"amount_rials": 1000000,
|
||
"iban_id": "iban-uuid-..."
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `amount_rials` | integer | ✅ | Amount to withdraw (must be ≤ wallet balance) |
|
||
| `iban_id` | string | ✅ | شناسهی یکی از شباهای **تأییدشدهی همان کاربر** |
|
||
|
||
**شبا از کجا خوانده میشود؟** تسویه دیگر مخصوص نماینده نیست: `UserIbanResolver` ابتدا شبای
|
||
نماینده (`GET /api/v1/representation/me` → `bank_account[].id`) و در نبودش شبای پروفایل
|
||
کاربر (`GET /api/v1/secretary/me` → `bank_account[].id`) را بررسی میکند. بنابراین هر نقشی
|
||
که موجودی کیف پول دارد — از جمله **منشی** با سهم نوبتهای آنلاین — میتواند برداشت کند.
|
||
|
||
> شبای انتخابی بهصورت snapshot (`iban`, `bank_name`, `owner_name`) داخل خود رکورد تسویه ذخیره میشود؛ حذف بعدی شبا در پروفایل، این رکورد را تغییر نمیدهد. مبلغ همان لحظهی ثبت از کیفپول کسر (debit) میشود.
|
||
|
||
### Response `201`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "settle-uuid-...",
|
||
"amount_rials": 1000000,
|
||
"status": "pending",
|
||
"bank_account": {
|
||
"iban": "IR...",
|
||
"bank_name": "بانک ملت",
|
||
"owner_name": "علی احمدی"
|
||
},
|
||
"created_at": 1717000000
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_VALIDATION_001` | 422 | مبلغ نامعتبر/بیش از موجودی، یا شبا تأییدنشده/نامعتبر (`field: iban_id`) |
|
||
| `ERR_VALIDATION_002` | 422 | `iban_id` ارسال نشده (`field: iban_id`) |
|
||
| `ERR_CONFLICT_001` | 422 | موجودی کافی نیست |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/settlement`
|
||
|
||
Get authenticated user's settlement requests (paginated, newest first).
|
||
|
||
**Permission:** `AUTH`
|
||
|
||
> **صفحهبندی:** `?page` و `?limit` (پیشفرض ۵۰، حداکثر ۱۰۰). پاسخ علاوه بر `data.data` یک `data.meta` (`totalRecords`/`totalPages`/`currentPage`) دارد؛ پاکت قبلی دستنخورده است.
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...",
|
||
"amount_rials": 1000000,
|
||
"status": "pending",
|
||
"note": null,
|
||
"created_at": 1717000000,
|
||
"processed_at": null
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Settlement Status Values:**
|
||
| Value | Description |
|
||
|-------|-------------|
|
||
| `pending` | Awaiting admin review |
|
||
| `approved` | Approved, payment sent |
|
||
| `rejected` | Rejected by admin |
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/settlement/{uuid}`
|
||
|
||
Get a single settlement.
|
||
|
||
**Permission:** `AUTH` — must be the owner or `ROLE_ADMIN`
|
||
|
||
### Response `200`
|
||
Settlement object with full details including `bank_account`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
|
||
| `ERR_NOT_FOUND_001` | 404 | Settlement not found |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/settlement/{uuid}/approve`
|
||
|
||
Approve a settlement request. Marks it as paid and debits the wallet.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `uuid` | string (UUID) | Settlement UUID |
|
||
|
||
### Request Body (`application/json`)
|
||
```json
|
||
{
|
||
"note": "پرداخت شد — شناسه پیگیری: 123456"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required |
|
||
|-------|------|----------|
|
||
| `note` | string | ❌ |
|
||
|
||
### Response `200`
|
||
Updated settlement object with `status: "approved"`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Settlement not found |
|
||
| `ERR_VALIDATION_001` | 422 | Already processed |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/settlement/{uuid}/reject`
|
||
|
||
Reject a settlement request. Returns the amount back to wallet.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Request Body
|
||
```json
|
||
{
|
||
"note": "حساب بانکی نادرست است"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required |
|
||
|-------|------|----------|
|
||
| `note` | string | ✅ | Rejection reason (required) |
|
||
|
||
### Response `200`
|
||
Updated settlement object with `status: "rejected"`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Settlement not found |
|
||
| `ERR_VALIDATION_002` | 422 | Missing note |
|
||
|
||
---
|
||
|
||
## FinancialBreakdown (لاگ مالی)
|
||
|
||
علاوه بر تسویهحساب دستی، کیفپول نماینده بهصورت خودکار از طریق `CommissionService` هنگام پرداخت موفقِ نوبت/اشتراک شارژ میشود (`WalletTransaction` credit). هر واریز یک ردیف `FinancialBreakdown` ثبت میکند که تفکیک کامل تراکنش (ناخالص، هزینه پیامک، مالیات، خالص، درصد و سهم پورسانت، **سهم منشی**، سهم سیستم) را نگه میدارد. ثبت idempotent است (بر اساس `payment_id`). گزارشها از طریق `GET /api/v1/admin/financial-breakdowns` و `GET /api/v1/admin/financial-summary` در دسترساند — جزئیات در `docs/api/admin.md`.
|
||
|
||
### ترتیب تقسیم و سهم منشی
|
||
|
||
```
|
||
۱) هزینهٔ پنل پیامک ← از ناخالص کم میشود
|
||
۲) مالیات ← استخراجی از باقیمانده: tax = amount × p/(100+p)
|
||
۳) سهمها، همه از «خالصِ پس از مالیات»:
|
||
پورسانت نماینده = netAfterTax × commission_percent / 100
|
||
سهم هر منشی = netAfterTax × online_share_percent / 100
|
||
سهم سیستم = ناخالص − پیامک − مالیات − پورسانت − مجموع سهم منشیها
|
||
```
|
||
|
||
- سهم منشی **مستقل از نماینده** است: نوبتِ بدون نمایندهٔ منطبق هم اگر منشیِ سهمبر داشته باشد، تفکیک مالی میسازد.
|
||
- `financial_breakdowns.secretary_share_rials` مجموع سهم منشیهای همان پرداخت است؛ تفکیک هر منشی در جدول `secretary_earnings` (با `share_percent` و `relation_uuid`) ذخیره میشود و گزارش پنل منشی از همان خوانده میشود ([secretary.md](secretary.md)).
|
||
- اگر مجموع درصدها (پورسانت + سهم منشیها) از ۱۰۰ بگذرد، به نسبت کلیپ و یک هشدار با `payment_uuid` لاگ میشود تا سهم سیستم منفی نشود.
|
||
|
||
---
|
||
|
||
## رسید پرداخت و ثبت پرداخت نهایی (ROLE_ADMIN)
|
||
|
||
> منطق کیفپول: مبلغ هنگام **ثبت درخواست** از کیفپول نماینده کسر (reserve/debit) میشود؛ **رد** آن را برمیگرداند (credit). تأیید و پرداختِ نهایی کیفپول را دوباره دست نمیزنند (جلوگیری از double-debit).
|
||
|
||
### POST `/file/upload/clinic_pro/settlement/receipt`
|
||
|
||
آپلود تصویر رسید پرداخت. بدنه = محتوای خام فایل؛ هدر `Content-Disposition: filename="..."`. **Permission:** `ROLE_ADMIN`
|
||
|
||
**Response `200`:**
|
||
```json
|
||
{ "success": true, "data": { "uuid": "...", "url": "/uploads/settlements/receipts/2026-06/...", "filename": "...", "filemime": "image/jpeg" } }
|
||
```
|
||
|
||
### POST `/api/v1/settlement/{uuid}/receipt`
|
||
|
||
ذخیرهی رسید پرداخت **بدون** نهاییسازی. فقط روی تسویهی **approved**؛ وضعیت تغییر نمیکند (همچنان `approved`). پس از این مرحله دکمهی «تکمیل» در پنل فعال میشود. **Permission:** `ROLE_ADMIN`
|
||
|
||
**Request Body:**
|
||
```json
|
||
{ "receipt": "/uploads/settlements/receipts/2026-06/..." }
|
||
```
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `receipt` | string | ✅ | URL رسیدِ آپلودشده |
|
||
|
||
**Response `200`:** آبجکت تسویه (با `receipt` ذخیرهشده، `status: "approved"`).
|
||
|
||
**Errors:**
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | درخواست یافت نشد |
|
||
| `ERR_VALIDATION_001` | 422 | تسویه approved نیست |
|
||
| `ERR_VALIDATION_002` | 422 | `receipt` خالی (`field: receipt`) |
|
||
|
||
### POST `/api/v1/settlement/{uuid}/paid`
|
||
|
||
**«تکمیل»** — ثبت پرداخت نهایی. فقط روی تسویهی **approved**. وضعیت → `paid` و `receipt` ذخیره میشود. کیفپول تغییر نمیکند (مبلغ هنگام ثبت درخواست کسر شده). پس از تکمیل، آن مبلغ دیگر قابل درخواست تسویه نیست. **Permission:** `ROLE_ADMIN`
|
||
|
||
**Request Body:**
|
||
```json
|
||
{ "receipt": "/uploads/settlements/receipts/2026-06/..." }
|
||
```
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `receipt` | string | ✅ | URL رسیدِ آپلودشده |
|
||
|
||
**Response `200`:** آبجکت تسویه (شامل `receipt` و `status: "paid"`).
|
||
|
||
**Errors:**
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | درخواست یافت نشد |
|
||
| `ERR_VALIDATION_001` | 422 | تسویه approved نیست |
|
||
| `ERR_VALIDATION_002` | 422 | `receipt` خالی (`field: receipt`) |
|