# موتور مالی نمایندگی: پورسانت نوبت/ارتقاء + مالیات بر ارزش افزوده + هزینه پنل پیامک ## پروژه `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` ```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); } ``` ```php 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`) ```php $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) ```php // 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` این کلیدها اضافه شود (همه به‌صورت رشته ذخیره می‌شوند): ```php // 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 صحیح): ```php #[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`. ```php 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` شدن: ```php $doctor = $appointment->getDoctor(); $this->commissionService->processAppointment( $payment, $doctor->getRepresentationId(), $doctor->getId(), ); ``` در `handleSubscriptionActivation` بعد از فعال‌سازی اشتراک، با تشخیص doctor/clinic: ```php // مسیر 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/گزارش نشان بده — مثلاً نوبت ۲٬۰۰۰٬۰۰۰ ریال، پیامک ۱٬۵۰۰٬۰۰۰، مالیات ۱۰٪، پورسانت ۲۰٪ → خالص پس از پیامک ۵۰۰٬۰۰۰ → مالیات استخراجی ۴۵٬۴۵۵ → خالص ۴۵۴٬۵۴۵ → سهم نماینده ۹۰٬۹۰۹، سهم سیستم مابقی.