Files
clinicpro/.claude/prompt/representation-commission-tax-engine.md
T
hamed 148d033114 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.
2026-06-24 13:06:17 +03:30

331 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# موتور مالی نمایندگی: پورسانت نوبت/ارتقاء + مالیات بر ارزش افزوده + هزینه پنل پیامک
## پروژه
`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/گزارش نشان بده — مثلاً نوبت ۲٬۰۰۰٬۰۰۰ ریال، پیامک ۱٬۵۰۰٬۰۰۰، مالیات ۱۰٪، پورسانت ۲۰٪ → خالص پس از پیامک ۵۰۰٬۰۰۰ → مالیات استخراجی ۴۵٬۴۵۵ → خالص ۴۵۴٬۵۴۵ → سهم نماینده ۹۰٬۹۰۹، سهم سیستم مابقی.