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:
@@ -28,6 +28,7 @@ import BlogFormPage from './pages/BlogFormPage';
|
||||
import SecretariesPage from './pages/SecretariesPage';
|
||||
import MyClinicPage from './pages/MyClinicPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import FinancialReportPage from './pages/FinancialReportPage';
|
||||
import DoctorProfilePage from './pages/DoctorProfilePage';
|
||||
import MyPatientsPage from './pages/MyPatientsPage';
|
||||
import NewSessionPage from './pages/NewSessionPage';
|
||||
@@ -144,6 +145,7 @@ export default function App() {
|
||||
<Route path="payments" element={<RoleRoute roles={['admin']}><PaymentsPage /></RoleRoute>} />
|
||||
<Route path="payments/:uuid" element={<RoleRoute roles={['admin']}><PaymentDetailPage /></RoleRoute>} />
|
||||
<Route path="settlements" element={<RoleRoute roles={['admin']}><SettlementsPage /></RoleRoute>} />
|
||||
<Route path="financial-report" element={<RoleRoute roles={['admin']}><FinancialReportPage /></RoleRoute>} />
|
||||
<Route path="representations" element={<RoleRoute roles={['admin']}><RepresentationsPage /></RoleRoute>} />
|
||||
<Route path="representations/:uuid" element={<RoleRoute roles={['admin']}><RepresentationDetailPage /></RoleRoute>} />
|
||||
<Route path="comments" element={<RoleRoute roles={['admin']}><CommentsPage /></RoleRoute>} />
|
||||
|
||||
@@ -98,6 +98,11 @@ function buildSections(
|
||||
icon: BanknotesIcon,
|
||||
label: "تسویهحساب",
|
||||
},
|
||||
{
|
||||
to: "/admin/financial-report",
|
||||
icon: BanknotesIcon,
|
||||
label: "گزارش مالی",
|
||||
},
|
||||
{
|
||||
to: "/admin/pre-registrations",
|
||||
icon: ClipboardDocumentCheckIcon,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatRial, formatDate } from '../lib/utils';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
interface Breakdown {
|
||||
uuid: string;
|
||||
order_id: string;
|
||||
source: 'appointment' | 'subscription';
|
||||
gross_rials: number;
|
||||
sms_fee_rials: number;
|
||||
tax_percent: number;
|
||||
tax_rials: number;
|
||||
net_after_tax_rials: number;
|
||||
commission_percent: number;
|
||||
representation_share_rials: number;
|
||||
system_share_rials: number;
|
||||
representation_id: number | null;
|
||||
representation_name: string | null;
|
||||
doctor_id: number | null;
|
||||
clinic_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
total_gross: number;
|
||||
total_representation_income: number;
|
||||
total_tax_collected: number;
|
||||
total_sms_fee: number;
|
||||
total_system_share: number;
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
appointment: 'نوبت',
|
||||
subscription: 'ارتقاء اشتراک',
|
||||
};
|
||||
|
||||
export default function FinancialReportPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [source, setSource] = useState('');
|
||||
const limit = 15;
|
||||
|
||||
const summaryQ = useQuery({
|
||||
queryKey: ['financial-summary'],
|
||||
queryFn: () => api.get<ApiResponse<Summary>>('/api/v1/admin/financial-summary'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const summary: Summary | undefined = (summaryQ.data?.data as any)?.data ?? summaryQ.data?.data;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['financial-breakdowns', page, source],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (source) params.set('source', source);
|
||||
return api.get<PaginatedResponse<Breakdown>>(`/api/v1/admin/financial-breakdowns?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const items: Breakdown[] = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
const cards = [
|
||||
{ label: 'مجموع ناخالص', value: summary?.total_gross, color: 'var(--text-2)', bg: 'var(--surface-3)' },
|
||||
{ label: 'درآمد نمایندگان', value: summary?.total_representation_income, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||||
{ label: 'مالیات دریافتشده', value: summary?.total_tax_collected, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'هزینه پنل پیامک', value: summary?.total_sms_fee, color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
||||
{ label: 'سهم سیستم', value: summary?.total_system_share, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
];
|
||||
|
||||
const columns: Column<Breakdown>[] = [
|
||||
{ key: 'order_id', header: 'شناسه سفارش', render: (r) => <span dir="ltr" style={{ fontSize: 12 }}>{r.order_id}</span> },
|
||||
{ key: 'source', header: 'نوع', render: (r) => SOURCE_LABEL[r.source] ?? r.source },
|
||||
{ key: 'representation_name', header: 'نماینده', render: (r) => r.representation_name ?? '—' },
|
||||
{ key: 'gross_rials', header: 'ناخالص', render: (r) => formatRial(r.gross_rials) },
|
||||
{ key: 'sms_fee_rials', header: 'پیامک', render: (r) => formatRial(r.sms_fee_rials) },
|
||||
{ key: 'tax_rials', header: 'مالیات', render: (r) => `${formatRial(r.tax_rials)} (${r.tax_percent}٪)` },
|
||||
{ key: 'representation_share_rials', header: 'سهم نماینده', render: (r) => `${formatRial(r.representation_share_rials)} (${r.commission_percent}٪)` },
|
||||
{ key: 'system_share_rials', header: 'سهم سیستم', render: (r) => formatRial(r.system_share_rials) },
|
||||
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">گزارش مالی</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>تفکیک پورسانت، مالیات و هزینه پنل پیامک هر تراکنش</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(5,1fr)', marginBottom: 'var(--gap)' }}>
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="stat" style={{ background: c.bg }}>
|
||||
<div className="stat-label">{c.label}</div>
|
||||
<div className="stat-value" style={{ color: c.color, fontSize: 15 }}>
|
||||
{c.value === undefined ? '—' : formatRial(c.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 'var(--gap)' }}>
|
||||
<button className={!source ? 'on' : ''} onClick={() => { setSource(''); setPage(1); }}>همه</button>
|
||||
<button className={source === 'appointment' ? 'on' : ''} onClick={() => { setSource('appointment'); setPage(1); }}>نوبت</button>
|
||||
<button className={source === 'subscription' ? 'on' : ''} onClick={() => { setSource('subscription'); setPage(1); }}>ارتقاء اشتراک</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
emptyMessage="هنوز تراکنش مالی ثبت نشده است"
|
||||
/>
|
||||
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user