feat(settings): add appointment fee configuration and update payment logic

This commit is contained in:
hamed
2026-06-24 20:25:58 +03:30
parent 93fa0fc2c9
commit 763ad82b69
6 changed files with 27 additions and 4 deletions
+14
View File
@@ -29,6 +29,7 @@ const schema = z.object({
tax_enabled: z.string(),
tax_percent: z.string(),
sms_panel_fee_rials: z.string(),
appointment_fee_rials: z.string(),
// payment gateways
payment_test_mode: z.string(),
mellat_terminal_id: z.string(),
@@ -55,6 +56,7 @@ interface Settings {
tax_enabled: string;
tax_percent: string;
sms_panel_fee_rials: string;
appointment_fee_rials: string;
payment_test_mode: string;
mellat_terminal_id: string;
mellat_username: string;
@@ -112,6 +114,7 @@ export default function SettingsPage() {
tax_enabled: settings.tax_enabled ?? '0',
tax_percent: settings.tax_percent ?? '10',
sms_panel_fee_rials: settings.sms_panel_fee_rials ?? '1500000',
appointment_fee_rials: settings.appointment_fee_rials ?? '150000',
payment_test_mode: settings.payment_test_mode ?? '0',
mellat_terminal_id: settings.mellat_terminal_id ?? '',
mellat_username: settings.mellat_username ?? '',
@@ -313,6 +316,16 @@ export default function SettingsPage() {
)}
</div>
{/* مبلغ هر نوبت */}
<div style={{ maxWidth: 320 }}>
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>مبلغ هر نوبت (ریال)</label>
<input {...register('appointment_fee_rials')} type="number" min={0} dir="ltr" placeholder="150000"
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }} />
<p className="muted" style={{ fontSize: 12, marginTop: 6 }}>
مبلغی که بیمار هنگام رزرو نوبت آنلاین پرداخت میکند. ۱۵۰٬۰۰۰ ریال = ۱۵٬۰۰۰ تومان.
</p>
</div>
{/* هزینه پنل پیامک */}
<div style={{ maxWidth: 320 }}>
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>هزینه ثابت پنل پیامک (ریال)</label>
@@ -522,6 +535,7 @@ export default function SettingsPage() {
tax_enabled: settings.tax_enabled,
tax_percent: settings.tax_percent,
sms_panel_fee_rials: settings.sms_panel_fee_rials,
appointment_fee_rials: settings.appointment_fee_rials,
payment_test_mode: settings.payment_test_mode,
mellat_terminal_id: settings.mellat_terminal_id,
mellat_username: settings.mellat_username,
+1
View File
@@ -981,6 +981,7 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
| `tax_enabled` | `0` | فعال‌سازی مالیات بر ارزش افزوده |
| `tax_percent` | `10` | درصد مالیات |
| `sms_panel_fee_rials` | `1500000` | هزینه ثابت پنل پیامک به ریال (از نوبت و اشتراک کسر می‌شود) |
| `appointment_fee_rials` | `150000` | مبلغ هر نوبت به ریال؛ مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند. backend از همین کلید می‌خواند و در `GET /api/v1/payment/config` expose می‌شود |
**ترتیب محاسبه** (در `CommissionService`): ۱) کسر `sms_panel_fee_rials` ۲) مالیاتِ استخراجی `afterSms × tax/(100+tax)` ۳) پورسانت = `netAfterTax × percent/100`. سهم نماینده به کیف‌پولش (`WalletTransaction` credit) واریز و یک ردیف `FinancialBreakdown` ثبت می‌شود (idempotent بر اساس `payment_id`).
+5 -1
View File
@@ -16,7 +16,8 @@
{
"success": true,
"data": {
"test_mode": true
"test_mode": true,
"appointment_fee_rials": 150000
}
}
```
@@ -24,6 +25,7 @@
| Field | Type | Description |
|-------|------|-------------|
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود |
| `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این می‌خواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده می‌شود (نه از client) |
**نکته:** این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمی‌کند. تنها یک boolean برای مصرف frontend است.
@@ -105,6 +107,8 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
}
```
> **مبلغ نوبت:** مبلغِ پرداخت از کلید `appointment_fee_rials` تنظیمات سایت خوانده می‌شود (نه از client و نه hardcode). برای تغییر، در `/admin/settings` ویرایش کنید.
>
> **On successful callback** for an appointment payment, the booking is transitioned `pending → confirmed` (its 15-minute `expires_at` is cleared) and a confirmation SMS is dispatched to the patient's mobile. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band.
>
> **پورسانت نماینده:** اگر پزشک نوبت `representation_id` داشته باشد و `appointment_commission_enabled=1` باشد، پس از confirm شدن `CommissionService` هزینه پنل پیامک و مالیات را کسر و سهم نماینده را به کیف‌پولش واریز می‌کند (ردیف `FinancialBreakdown` ثبت می‌شود). برای پرداخت اشتراک هم اگر `upgrade_commission_enabled=1` و پزشک/کلینیک `representation_id` داشته باشد همین منطق با درصد `upgrade_commission_percent` اعمال می‌شود. کلیدهای تنظیمات و ترتیب محاسبه در `docs/api/admin.md`.
@@ -23,6 +23,7 @@ class SiteConfigController extends BaseController
'tax_enabled',
'tax_percent',
'sms_panel_fee_rials',
'appointment_fee_rials',
'site_name',
'support_phone',
'max_cancel_hours_before',
@@ -16,7 +16,8 @@ class SiteConfigRepository extends ServiceEntityRepository
'upgrade_commission_percent' => '20',
'tax_enabled' => '0',
'tax_percent' => '10',
'sms_panel_fee_rials' => '1500000',
'sms_panel_fee_rials' => '150',
'appointment_fee_rials' => '15000',
'site_name' => 'ClinicPro',
'support_phone' => '',
'max_cancel_hours_before' => '24',
+4 -2
View File
@@ -186,7 +186,8 @@ class PaymentController extends BaseController
return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503);
}
$payment = new Payment($user, 150000, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress);
$feeRials = (int) $this->configRepo->get('appointment_fee_rials');
$payment = new Payment($user, $feeRials, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress);
$payment->setAppointment($appointment);
$this->paymentRepo->save($payment);
@@ -519,7 +520,8 @@ class PaymentController extends BaseController
public function config(): JsonResponse
{
return $this->success([
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
'test_mode' => $this->configRepo->get('payment_test_mode') === '1',
'appointment_fee_rials' => (int) $this->configRepo->get('appointment_fee_rials'),
]);
}