- 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.
22 KiB
موتور مالی نمایندگی: پورسانت نوبت/ارتقاء + مالیات بر ارزش افزوده + هزینه پنل پیامک
پروژه
clinicpro (Backend Symfony + پنل ادمین React). کاملاً داخل همین پروژه است؛ cross-repo نیست.
سایت عمومی nobat724_front فقط مصرفکنندهی مبلغِ نهایی نوبت است و در این تغییر دست نمیخورد (مبلغ پرداختی کاربر تغییر نمیکند؛ فقط تقسیمِ پس از پرداخت در بکاند اضافه میشود).
زمینه
هر Representation یک فیلد commission_percent دارد که در /admin/representations ذخیره و ویرایش میشود، اما در هیچ محاسبهی مالی استفاده نمیشود — صرفاً نمایشی است. هر Doctor و هر Clinic فیلد representationId دارند (نمایندهای که آنها را اضافه کرده). هنگام موفقشدن پرداخت، PaymentController فقط نوبت را confirmed یا اشتراک را فعال میکند و هیچ سهمی به کیفپول نماینده واریز نمیشود.
کیفپول نماینده با WalletTransaction (credit/debit + balance_after) و موجودی با SettlementRepository::getWalletBalance(User) مدیریت میشود؛ نماینده از طریق Settlement برداشت میکند. تنظیمات سراسری در SiteConfig (کلید/مقدار) با whitelist در SiteConfigController::ALLOWED_KEYS و defaults در SiteConfigRepository::DEFAULTS نگهداری میشوند. کلیدهای commission_enabled و commission_percent از قبل تعریف شده ولی بلااستفادهاند.
مشکل / هدف
۱. هنگام پرداخت موفقِ نوبت برای پزشکی که representationId دارد، سهم نماینده محاسبه و به کیفپولش واریز شود (درصد = commission_percent همان نماینده).
۲. هنگام پرداخت موفقِ ارتقاء/خرید اشتراک پزشک یا کلینیکی که representationId دارد، درصدِ ارتقاء (پیشفرض ۲۰٪، غیر هاردکد، از تنظیمات ادمین) به کیفپول نماینده واریز شود.
۳. مالیات بر ارزش افزوده: همهی مبالغ شامل مالیاتاند. درصد مالیات + فعال/غیرفعال + (اختیاری) تاریخچه از پنل ادمین کنترل شود. برای هر تراکنش مبلغ مالیات ذخیره شود.
۴. هزینه ثابت پنل پیامک: مبلغ ثابت ۱۵۰٬۰۰۰ تومان = ۱٬۵۰۰٬۰۰۰ ریال از مبلغ نوبت کسر شود (مبلغ از تنظیمات ادمین قابل کنترل).
۵. ترتیب محاسبه (بسیار مهم): ۱) کسر هزینه پنل پیامک، ۲) کسر مالیات از باقیمانده، ۳) پورسانت نماینده از مبلغ خالصِ پس از مالیات.
۶. ثبت لاگ کامل مالی برای هر تراکنش + مشاهده/گزارشگیری در پنل ادمین.
فایلهای مرتبط
| فایل | نقش |
|---|---|
clinicpro/src/Config/Controller/SiteConfigController.php |
افزودن کلیدهای مالی به ALLOWED_KEYS |
clinicpro/src/Config/Repository/SiteConfigRepository.php |
افزودن DEFAULTS کلیدهای مالی |
clinicpro/src/Settlement/Entity/FinancialBreakdown.php (جدید) |
Entity لاگ تفکیک مالی هر تراکنش |
clinicpro/src/Settlement/Repository/FinancialBreakdownRepository.php (جدید) |
repository + کوئری گزارشها |
clinicpro/src/Settlement/Service/CommissionService.php (جدید) |
منطق محاسبه (ترتیب کسر) + واریز کیفپول + ثبت لاگ |
clinicpro/src/Payment/Controller/PaymentController.php |
فراخوانی CommissionService در handleAppointmentConfirmation و handleSubscriptionActivation |
clinicpro/src/Representation/Entity/Representation.php |
منبع commission_percent نوبتِ هر نماینده (موجود) |
clinicpro/src/Doctor/Entity/Doctor.php / Clinic/Entity/Clinic.php |
getRepresentationId() (موجود) |
clinicpro/src/Representation/Repository/RepresentationRepository.php |
افزودن find(int $id) برای یافتن نماینده از روی representationId |
clinicpro/src/Settlement/Repository/SettlementRepository.php |
getWalletBalance(User) (موجود) برای محاسبه balance_after |
clinicpro/src/Admin/Controller/AdminApiController.php |
endpointهای گزارش مالی (لیست breakdown + جمعها) |
clinicpro/assets/admin/pages/SettingsPage.tsx |
فرم تنظیمات مالی |
clinicpro/assets/admin/pages/FinancialReportPage.tsx (جدید) |
جدول تراکنشها + کارتهای جمع |
clinicpro/docs/api/admin.md، docs/api/settlement.md، docs/api/payment.md |
مستندسازی |
وضعیت فعلی
الف) هوک پرداخت موفق — هیچ پورسانتی واریز نمیشود
clinicpro/src/Payment/Controller/PaymentController.php
$payment->setStatus(Payment::STATUS_SUCCESS);
$payment->setReferenceId($result->referenceId);
$this->paymentRepo->save($payment);
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) {
$this->handleSubscriptionActivation($payment);
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
$this->handleSmsWalletCharge($payment);
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
$this->handleAppointmentConfirmation($payment);
}
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return;
}
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->appointmentRepo->save($appointment);
// ... فقط SMS؛ هیچ پورسانت/مالیاتی نیست
}
private function handleSubscriptionActivation(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];
$periodUuid = $meta['period_uuid'] ?? null;
if ($periodUuid === null) return;
$user = $payment->getUser();
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor !== null) {
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
return;
}
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic !== null) {
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
}
}
ب) commission_percent بلااستفاده
Representation::getCommissionPercent(): string (decimal 5,2، پیشفرض '10.00') فقط در RepresentationController و خروجی admin خوانده/نوشته میشود؛ در هیچ محاسبهی مالی بهکار نمیرود.
ج) الگوی واریز کیفپول (از SettlementController::reject)
$balance = $this->settlementRepo->getWalletBalance($settlement->getUser());
$tx = new WalletTransaction(
$settlement->getUser(),
$settlement->getAmountRials(),
WalletTransaction::TYPE_CREDIT,
$balance + $settlement->getAmountRials()
);
$tx->setDescription('...');
$this->walletRepo->save($tx);
د) تنظیمات ادمین (whitelist + defaults)
// SiteConfigController::ALLOWED_KEYS — فعلاً: commission_enabled, commission_percent, ...
// SiteConfigRepository::DEFAULTS — 'commission_enabled' => '0', 'commission_percent' => '0', ...
// GET/PATCH /api/v1/admin/settings از قبل کار میکند
وظایف
۱. کلیدهای تنظیمات مالی در SiteConfig
به SiteConfigController::ALLOWED_KEYS و SiteConfigRepository::DEFAULTS این کلیدها اضافه شود (همه بهصورت رشته ذخیره میشوند):
// ALLOWED_KEYS + DEFAULTS
'appointment_commission_enabled' => '0', // پورسانت نوبت فعال؟ (درصد از خودِ نماینده خوانده میشود)
'upgrade_commission_enabled' => '0', // پورسانت ارتقاء اشتراک فعال؟
'upgrade_commission_percent' => '20', // درصد پورسانت ارتقاء (غیر هاردکد)
'tax_enabled' => '0', // مالیات بر ارزش افزوده فعال؟
'tax_percent' => '10', // درصد مالیات بر ارزش افزوده
'sms_panel_fee_rials' => '1500000',// هزینه ثابت پنل پیامک به ریال (۱۵۰٬۰۰۰ تومان)
توجه:
commission_percent/commission_enabledقدیمی بلااستفادهاند؛ دست نزن یا در همین تسک منسوخشان کن (درSettingsPageپنهان کن). کلید پورسانت نوبت اکنونappointment_commission_enabled+ درصدِ هر نماینده است.
۲. Entity لاگ مالی FinancialBreakdown
فایل جدید clinicpro/src/Settlement/Entity/FinancialBreakdown.php. برای هر تراکنشی که مشمول قانون مالی میشود یک ردیف ثبت شود. فیلدها (همگی ریال، تاریخها Unix timestamp صحیح):
#[ORM\Entity]
#[ORM\Table(name: 'financial_breakdowns')]
class FinancialBreakdown
{
public const SOURCE_APPOINTMENT = 'appointment';
public const SOURCE_SUBSCRIPTION = 'subscription';
private ?int $id;
private string $uuid; // Uuid::v4()->toRfc4122()
#[ORM\ManyToOne(targetEntity: Payment::class)] private Payment $payment;
private string $source; // appointment | subscription
private int $grossRials; // مبلغ اولیه تراکنش
private int $smsFeeRials; // کسر پنل پیامک
private string $taxPercent; // decimal 5,2 — درصد مالیاتِ اعمالشده
private int $taxRials; // مبلغ مالیات
private int $netAfterTaxRials; // خالص پس از پیامک و مالیات
private string $commissionPercent; // decimal 5,2 — درصد پورسانتِ اعمالشده
private int $representationShareRials;// سهم نماینده
private int $systemShareRials; // سهم سیستم
private ?int $representationId = null;
private ?int $doctorId = null;
private ?int $clinicId = null;
#[ORM\ManyToOne(targetEntity: User::class)] private User $user; // پرداختکننده
private int $createdAt;
// getters + toArray()
}
toArray() همهی این مبالغ + ids را برگرداند تا در گزارش ادمین نمایش داده شوند.
migration لازم است: ddev exec php bin/console doctrine:migrations:diff --no-interaction سپس migrate.
۳. سرویس محاسبه CommissionService
فایل جدید clinicpro/src/Settlement/Service/CommissionService.php. مسئول: محاسبه با ترتیب دقیق، واریز کیفپول نماینده، ثبت FinancialBreakdown.
class CommissionService
{
public function __construct(
private SiteConfigRepository $configRepo,
private RepresentationRepository $representationRepo,
private SettlementRepository $settlementRepo, // getWalletBalance
private WalletTransactionRepository $walletRepo,
private FinancialBreakdownRepository $breakdownRepo,
private EntityManagerInterface $em,
) {}
/**
* نوبت: درصد پورسانت = commission_percent همان نماینده.
* هزینه پیامک فقط در مسیر نوبت کسر میشود.
*/
public function processAppointment(Payment $payment, ?int $representationId, ?int $doctorId): void
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
$rep = $representationId ? $this->representationRepo->find($representationId) : null;
if ($rep === null || !$rep->isActive()) return;
$smsFee = (int) $this->configRepo->get('sms_panel_fee_rials');
$percent = (float) $rep->getCommissionPercent();
$this->settle($payment, FinancialBreakdown::SOURCE_APPOINTMENT, $smsFee, $percent, $rep, $doctorId, null);
}
/**
* اشتراک/ارتقاء: درصد = upgrade_commission_percent (سراسری). بدون کسر هزینه پیامک.
*/
public function processSubscription(Payment $payment, ?int $representationId, ?int $doctorId, ?int $clinicId): void
{
if ($this->configRepo->get('upgrade_commission_enabled') !== '1') return;
$rep = $representationId ? $this->representationRepo->find($representationId) : null;
if ($rep === null || !$rep->isActive()) return;
$percent = (float) $this->configRepo->get('upgrade_commission_percent');
$this->settle($payment, FinancialBreakdown::SOURCE_SUBSCRIPTION, 0, $percent, $rep, $doctorId, $clinicId);
}
private function settle(
Payment $payment, string $source, int $smsFee, float $commissionPercent,
Representation $rep, ?int $doctorId, ?int $clinicId
): void {
$gross = $payment->getAmountRials();
// مرحله ۱: کسر هزینه ثابت پنل پیامک
$afterSms = max(0, $gross - $smsFee);
// مرحله ۲: محاسبه و کسر مالیات از باقیمانده
$taxEnabled = $this->configRepo->get('tax_enabled') === '1';
$taxPercent = $taxEnabled ? (float) $this->configRepo->get('tax_percent') : 0.0;
// مبالغ شامل مالیاتاند ⇒ مالیات از مبلغِ مشمول استخراج میشود.
// در run-prompt با کاربر تأیید کن: «استخراج از مبلغِ شامل مالیات» یا «افزودن روی مبلغ».
$taxRials = $taxEnabled ? (int) round($afterSms * $taxPercent / (100 + $taxPercent)) : 0;
$netAfterTax = $afterSms - $taxRials;
// مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$systemShare = $gross - $smsFee - $taxRials - $repShare;
// واریز کیفپول نماینده (الگوی SettlementController)
$repUser = $rep->getUser();
$balance = $this->settlementRepo->getWalletBalance($repUser);
$tx = new WalletTransaction($repUser, $repShare, WalletTransaction::TYPE_CREDIT, $balance + $repShare);
$tx->setPayment($payment);
$tx->setDescription(sprintf('پورسانت %s %s', $source, $payment->getOrderId()));
$this->walletRepo->save($tx, false);
// ثبت لاگ مالی
$bd = new FinancialBreakdown(/* ... همهی مبالغ، درصدها، ids ... */);
$this->breakdownRepo->save($bd, false);
$this->em->flush();
}
}
نکات محاسبه:
- اگر
repShare <= 0بود (مثلاً مبلغ پس از کسرها صفر شد) واریز کیفپول انجام نشود ولی لاگ با مقادیر صفر همچنان ثبت شود (برای گزارش). - همهی مبالغ صحیح ریال؛ از
roundبرای جلوگیری از خطای ممیز استفاده شود.
۴. اتصال به PaymentController
CommissionService به constructor تزریق شود و در دو هوک فراخوانی شود.
در handleAppointmentConfirmation بعد از confirmed شدن:
$doctor = $appointment->getDoctor();
$this->commissionService->processAppointment(
$payment,
$doctor->getRepresentationId(),
$doctor->getId(),
);
در handleSubscriptionActivation بعد از فعالسازی اشتراک، با تشخیص doctor/clinic:
// مسیر doctor:
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null);
// مسیر clinic:
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId());
idempotency: مطمئن شو یک پرداخت دوبار پردازش نشود (verify فقط یکبار success میشود؛ ولی برای اطمینان میتوان قبل از ثبت، نبودِ
FinancialBreakdownبا همانpayment_idرا بررسی کرد).
۵. RepresentationRepository::find(int $id)
متد یافتن نماینده از روی representationId (که روی Doctor/Clinic ذخیره است). ServiceEntityRepository بهصورت پیشفرض find() دارد؛ فقط مطمئن شو در سرویس از ->find($id) استفاده میشود.
۶. endpointهای گزارش مالی در AdminApiController
طبق الگوی پروژه (getArrayResult() + $this->paginated() برای لیست، $this->success() برای جمعها):
GET /api/v1/admin/financial-breakdowns(paginated): لیست تراکنشها با تفکیک کامل + join نام نماینده/پزشک/کلینیک. فیلتر اختیاریrepresentation_id,source, بازهی تاریخ.GET /api/v1/admin/financial-summary: جمعها →total_representation_income,total_tax_collected,total_sms_fee,total_system_share,total_gross.
هر دو #[IsGranted('ROLE_ADMIN')].
۷. پنل ادمین — تنظیمات + گزارش
SettingsPage.tsx: بخش «تنظیمات مالی» با فیلدهای:
- فعال/غیرفعال پورسانت نوبت (
appointment_commission_enabled) - فعال/غیرفعال + درصد پورسانت ارتقاء (
upgrade_commission_enabled,upgrade_commission_percent) - فعال/غیرفعال + درصد مالیات (
tax_enabled,tax_percent) - هزینه پنل پیامک به ریال (
sms_panel_fee_rials) — نمایش معادل تومان کمکی PATCH به/api/v1/admin/settings(الگوی موجود همان صفحه).
FinancialReportPage.tsx (جدید) + route در App.tsx (roles={['admin']}):
- کارتهای جمع از
financial-summary - جدول
DataTable+Paginationازfinancial-breakdowns(مبالغ باformatRial، تاریخ شمسی باformatDate) - لینک در
Sidebar.tsx
الگوهای frontend: paginated → items از
data?.data، total ازdata?.meta?.totalRecords؛ single (summary) →data?.data. JWT ازlocalStorage['clinicpro-auth'].
۸. (اختیاری طبق درخواست) تاریخچهی تغییرات مالیات
اگر در run-prompt لازم شد: یک Entity سادهی TaxRateHistory (percent, enabled, changed_by, changed_at) که در SiteConfigController::patch هنگام تغییر tax_percent/tax_enabled یک ردیف ثبت کند + endpoint لیست. در نسخهی اول میتوان فقط روی updated_atِ SiteConfig اکتفا کرد و این را به فاز بعد سپرد — در run-prompt با کاربر تأیید شود.
نکات مهم
- ترتیب کسرها ثابت و غیرقابلجابهجایی است: پیامک → مالیات → پورسانت. پورسانت حتماً روی
netAfterTaxنهgross. - «همه مبالغ شامل مالیاتاند» ⇒ فرمول استخراجِ مالیات از مبلغِ ناخالص (
amount × p/(100+p)) استفاده شده، نه افزودن روی آن. این تصمیم را در ابتدای run-prompt صریحاً با کاربر تأیید کن؛ اگر منظورش «افزودن مالیات روی مبلغ» باشد فرمولamount × p/100میشود. - هزینه پنل پیامک فقط روی نوبت اعمال شده (طبق متن «از مبلغ نوبت کسر شود»). اگر باید روی اشتراک هم اعمال شود، در run-prompt تأیید بگیر.
- درصد پورسانتِ نوبت از
Representation::commission_percent(هر نماینده جدا) و درصد ارتقاء از کلید سراسریupgrade_commission_percent. این تفکیک عمدی است. - هیچ مبلغی نباید هاردکد شود؛ ۲۰٪ و ۱۵۰٬۰۰۰ تومان صرفاً defaults در
SiteConfigهستند. - همهی مبالغ ریال صحیحاند (۱۵۰٬۰۰۰ تومان = ۱٬۵۰۰٬۰۰۰ ریال). در UI با
formatRialنمایش، در محاسبه باint. - تاریخها Unix timestamp صحیحاند (نه DateTime). نمایش شمسی با
formatDate. - همهی controllerها از
BaseControllerو پاسخها باsuccess/paginated/error. - idempotency: مطمئن شو واریز پورسانت برای یک پرداخت فقط یکبار رخ دهد.
- بعد از تغییر Entity:
doctrine:migrations:diff+migrate. بعد از تغییر API:docs/api/admin.md,settlement.md,payment.mdبهروز شوند. - بعد از پایان:
ddev exec php vendor/bin/phpstan analyseوddev exec yarn devبرای صحت TS، سپسgraphify update .. - تست محاسبه: یک سناریوی عددی واقعی در PR/گزارش نشان بده — مثلاً نوبت ۲٬۰۰۰٬۰۰۰ ریال، پیامک ۱٬۵۰۰٬۰۰۰، مالیات ۱۰٪، پورسانت ۲۰٪ → خالص پس از پیامک ۵۰۰٬۰۰۰ → مالیات استخراجی ۴۵٬۴۵۵ → خالص ۴۵۴٬۵۴۵ → سهم نماینده ۹۰٬۹۰۹، سهم سیستم مابقی.