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_enabled: z.string(),
tax_percent: z.string(), tax_percent: z.string(),
sms_panel_fee_rials: z.string(), sms_panel_fee_rials: z.string(),
appointment_fee_rials: z.string(),
// payment gateways // payment gateways
payment_test_mode: z.string(), payment_test_mode: z.string(),
mellat_terminal_id: z.string(), mellat_terminal_id: z.string(),
@@ -55,6 +56,7 @@ interface Settings {
tax_enabled: string; tax_enabled: string;
tax_percent: string; tax_percent: string;
sms_panel_fee_rials: string; sms_panel_fee_rials: string;
appointment_fee_rials: string;
payment_test_mode: string; payment_test_mode: string;
mellat_terminal_id: string; mellat_terminal_id: string;
mellat_username: string; mellat_username: string;
@@ -112,6 +114,7 @@ export default function SettingsPage() {
tax_enabled: settings.tax_enabled ?? '0', tax_enabled: settings.tax_enabled ?? '0',
tax_percent: settings.tax_percent ?? '10', tax_percent: settings.tax_percent ?? '10',
sms_panel_fee_rials: settings.sms_panel_fee_rials ?? '1500000', 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', payment_test_mode: settings.payment_test_mode ?? '0',
mellat_terminal_id: settings.mellat_terminal_id ?? '', mellat_terminal_id: settings.mellat_terminal_id ?? '',
mellat_username: settings.mellat_username ?? '', mellat_username: settings.mellat_username ?? '',
@@ -313,6 +316,16 @@ export default function SettingsPage() {
)} )}
</div> </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 }}> <div style={{ maxWidth: 320 }}>
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>هزینه ثابت پنل پیامک (ریال)</label> <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_enabled: settings.tax_enabled,
tax_percent: settings.tax_percent, tax_percent: settings.tax_percent,
sms_panel_fee_rials: settings.sms_panel_fee_rials, sms_panel_fee_rials: settings.sms_panel_fee_rials,
appointment_fee_rials: settings.appointment_fee_rials,
payment_test_mode: settings.payment_test_mode, payment_test_mode: settings.payment_test_mode,
mellat_terminal_id: settings.mellat_terminal_id, mellat_terminal_id: settings.mellat_terminal_id,
mellat_username: settings.mellat_username, mellat_username: settings.mellat_username,
+1
View File
@@ -981,6 +981,7 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
| `tax_enabled` | `0` | فعال‌سازی مالیات بر ارزش افزوده | | `tax_enabled` | `0` | فعال‌سازی مالیات بر ارزش افزوده |
| `tax_percent` | `10` | درصد مالیات | | `tax_percent` | `10` | درصد مالیات |
| `sms_panel_fee_rials` | `1500000` | هزینه ثابت پنل پیامک به ریال (از نوبت و اشتراک کسر می‌شود) | | `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`). **ترتیب محاسبه** (در `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, "success": true,
"data": { "data": {
"test_mode": true "test_mode": true,
"appointment_fee_rials": 150000
} }
} }
``` ```
@@ -24,6 +25,7 @@
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود | | `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود |
| `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این می‌خواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده می‌شود (نه از client) |
**نکته:** این endpoint هیچ اطلاعات حساسی (terminal_id، password، ...) را expose نمی‌کند. تنها یک boolean برای مصرف frontend است. **نکته:** این 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. > **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`. > **پورسانت نماینده:** اگر پزشک نوبت `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_enabled',
'tax_percent', 'tax_percent',
'sms_panel_fee_rials', 'sms_panel_fee_rials',
'appointment_fee_rials',
'site_name', 'site_name',
'support_phone', 'support_phone',
'max_cancel_hours_before', 'max_cancel_hours_before',
@@ -16,7 +16,8 @@ class SiteConfigRepository extends ServiceEntityRepository
'upgrade_commission_percent' => '20', 'upgrade_commission_percent' => '20',
'tax_enabled' => '0', 'tax_enabled' => '0',
'tax_percent' => '10', 'tax_percent' => '10',
'sms_panel_fee_rials' => '1500000', 'sms_panel_fee_rials' => '150',
'appointment_fee_rials' => '15000',
'site_name' => 'ClinicPro', 'site_name' => 'ClinicPro',
'support_phone' => '', 'support_phone' => '',
'max_cancel_hours_before' => '24', '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); 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); $payment->setAppointment($appointment);
$this->paymentRepo->save($payment); $this->paymentRepo->save($payment);
@@ -519,7 +520,8 @@ class PaymentController extends BaseController
public function config(): JsonResponse public function config(): JsonResponse
{ {
return $this->success([ 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'),
]); ]);
} }