feat: Implement financial engine for commission and tax calculations

- Added new configuration keys for appointment and upgrade commissions, tax settings, and SMS panel fee in SiteConfigController and SiteConfigRepository.
- Introduced CommissionService to handle commission calculations for appointments and subscriptions, including tax deductions and SMS fees.
- Created FinancialBreakdown entity and repository to log financial transactions.
- Updated PaymentController to process commissions upon successful payments for appointments and subscriptions.
- Developed FinancialReportPage in the admin panel to display financial breakdowns and summaries.
- Added database migration for the new financial_breakdowns table.
This commit is contained in:
hamed
2026-06-24 13:06:17 +03:30
parent e0abaf5c0c
commit 148d033114
16 changed files with 1119 additions and 0 deletions
+120
View File
@@ -19,6 +19,13 @@ const schema = z.object({
}, 'درصد باید بین ۰ تا ۱۰۰ باشد'),
max_cancel_hours_before: z.string(),
appointment_reminder_hours: z.string(),
// financial engine
appointment_commission_enabled: z.string(),
upgrade_commission_enabled: z.string(),
upgrade_commission_percent: z.string(),
tax_enabled: z.string(),
tax_percent: z.string(),
sms_panel_fee_rials: z.string(),
// payment gateways
payment_test_mode: z.string(),
mellat_terminal_id: z.string(),
@@ -41,6 +48,12 @@ interface Settings {
commission_percent: string;
max_cancel_hours_before: string;
appointment_reminder_hours: string;
appointment_commission_enabled: string;
upgrade_commission_enabled: string;
upgrade_commission_percent: string;
tax_enabled: string;
tax_percent: string;
sms_panel_fee_rials: string;
payment_test_mode: string;
mellat_terminal_id: string;
mellat_username: string;
@@ -86,6 +99,12 @@ export default function SettingsPage() {
commission_percent: settings.commission_percent ?? '0',
max_cancel_hours_before: settings.max_cancel_hours_before ?? '24',
appointment_reminder_hours: settings.appointment_reminder_hours ?? '2',
appointment_commission_enabled: settings.appointment_commission_enabled ?? '0',
upgrade_commission_enabled: settings.upgrade_commission_enabled ?? '0',
upgrade_commission_percent: settings.upgrade_commission_percent ?? '20',
tax_enabled: settings.tax_enabled ?? '0',
tax_percent: settings.tax_percent ?? '10',
sms_panel_fee_rials: settings.sms_panel_fee_rials ?? '1500000',
payment_test_mode: settings.payment_test_mode ?? '0',
mellat_terminal_id: settings.mellat_terminal_id ?? '',
mellat_username: settings.mellat_username ?? '',
@@ -109,6 +128,9 @@ export default function SettingsPage() {
const commissionEnabled = watch('commission_enabled') === '1';
const paymentTestMode = watch('payment_test_mode') === '1';
const apptCommissionEnabled = watch('appointment_commission_enabled') === '1';
const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1';
const taxEnabled = watch('tax_enabled') === '1';
const onSubmit = (values: FormValues) => {
mutation.mutate(values);
@@ -253,6 +275,98 @@ export default function SettingsPage() {
</div>
</div>
{/* موتور مالی نمایندگی */}
<div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
<div className="ico" style={{ background: 'var(--info-bg)', color: 'var(--info)', width: 36, height: 36, borderRadius: 10 }}>
<span style={{ fontSize: 16 }}>🧮</span>
</div>
<h3 style={{ fontSize: 15 }}>موتور مالی نمایندگی</h3>
</div>
<p className="muted" style={{ fontSize: 12, marginBottom: '1rem', lineHeight: 1.7 }}>
ترتیب کسرها: ابتدا هزینه پنل پیامک، سپس مالیات بر ارزش افزوده (استخراجی از مبلغ شامل مالیات)،
و در نهایت پورسانت نماینده از مبلغِ خالصِ پس از مالیات محاسبه میشود.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* پورسانت نوبت */}
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
<input type="hidden" {...register('appointment_commission_enabled')} />
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}
onClick={() => setValue('appointment_commission_enabled', apptCommissionEnabled ? '0' : '1', { shouldDirty: true })}>
<div style={{ width: 44, height: 24, borderRadius: 12, background: apptCommissionEnabled ? 'var(--primary)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: apptCommissionEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
</div>
</div>
<span style={{ fontSize: 14 }}>
پورسانت نوبت نمایندگان {apptCommissionEnabled ? 'فعال' : 'غیرفعال'} است
<span className="muted" style={{ fontSize: 12, marginRight: 6 }}>(درصد از پروفایل هر نماینده خوانده میشود)</span>
</span>
</label>
{/* پورسانت ارتقاء اشتراک */}
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', marginBottom: upgradeCommissionEnabled ? 12 : 0 }}>
<input type="hidden" {...register('upgrade_commission_enabled')} />
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}
onClick={() => setValue('upgrade_commission_enabled', upgradeCommissionEnabled ? '0' : '1', { shouldDirty: true })}>
<div style={{ width: 44, height: 24, borderRadius: 12, background: upgradeCommissionEnabled ? 'var(--primary)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: upgradeCommissionEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
</div>
</div>
<span style={{ fontSize: 14 }}>پورسانت ارتقاء اشتراک {upgradeCommissionEnabled ? 'فعال' : 'غیرفعال'} است</span>
</label>
{upgradeCommissionEnabled && (
<div style={{ maxWidth: 280, paddingRight: 56 }}>
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>درصد پورسانت ارتقاء (۰۱۰۰)</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input {...register('upgrade_commission_percent')} type="number" min={0} max={100}
style={{ width: 120, 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' }} />
<span className="muted" style={{ fontSize: 13 }}>درصد</span>
</div>
</div>
)}
</div>
{/* مالیات بر ارزش افزوده */}
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', marginBottom: taxEnabled ? 12 : 0 }}>
<input type="hidden" {...register('tax_enabled')} />
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}
onClick={() => setValue('tax_enabled', taxEnabled ? '0' : '1', { shouldDirty: true })}>
<div style={{ width: 44, height: 24, borderRadius: 12, background: taxEnabled ? 'var(--primary)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: taxEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
</div>
</div>
<span style={{ fontSize: 14 }}>مالیات بر ارزش افزوده {taxEnabled ? 'فعال' : 'غیرفعال'} است</span>
</label>
{taxEnabled && (
<div style={{ maxWidth: 280, paddingRight: 56 }}>
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>درصد مالیات (۰۱۰۰)</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input {...register('tax_percent')} type="number" min={0} max={100}
style={{ width: 120, 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' }} />
<span className="muted" style={{ fontSize: 13 }}>درصد</span>
</div>
</div>
)}
</div>
{/* هزینه پنل پیامک */}
<div style={{ maxWidth: 320 }}>
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>هزینه ثابت پنل پیامک (ریال)</label>
<input {...register('sms_panel_fee_rials')} type="number" min={0} dir="ltr" placeholder="1500000"
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>
</div>
{/* تنظیمات نوبت‌دهی */}
<div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
@@ -445,6 +559,12 @@ export default function SettingsPage() {
commission_percent: settings.commission_percent,
max_cancel_hours_before: settings.max_cancel_hours_before,
appointment_reminder_hours: settings.appointment_reminder_hours,
appointment_commission_enabled: settings.appointment_commission_enabled,
upgrade_commission_enabled: settings.upgrade_commission_enabled,
upgrade_commission_percent: settings.upgrade_commission_percent,
tax_enabled: settings.tax_enabled,
tax_percent: settings.tax_percent,
sms_panel_fee_rials: settings.sms_panel_fee_rials,
payment_test_mode: settings.payment_test_mode,
mellat_terminal_id: settings.mellat_terminal_id,
mellat_username: settings.mellat_username,