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:
@@ -0,0 +1,330 @@
|
||||
# موتور مالی نمایندگی: پورسانت نوبت/ارتقاء + مالیات بر ارزش افزوده + هزینه پنل پیامک
|
||||
|
||||
## پروژه
|
||||
|
||||
`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/گزارش نشان بده — مثلاً نوبت ۲٬۰۰۰٬۰۰۰ ریال، پیامک ۱٬۵۰۰٬۰۰۰، مالیات ۱۰٪، پورسانت ۲۰٪ → خالص پس از پیامک ۵۰۰٬۰۰۰ → مالیات استخراجی ۴۵٬۴۵۵ → خالص ۴۵۴٬۵۴۵ → سهم نماینده ۹۰٬۹۰۹، سهم سیستم مابقی.
|
||||
@@ -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,
|
||||
|
||||
@@ -966,3 +966,72 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
|
||||
```json
|
||||
{ "success": true, "data": { "message": "درخواست رد شد" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## موتور مالی نمایندگی
|
||||
|
||||
تنظیمات مالی از طریق `GET`/`PATCH /api/v1/admin/settings` کنترل میشوند (کلیدها در whitelist `SiteConfigController::ALLOWED_KEYS`):
|
||||
|
||||
| کلید | پیشفرض | شرح |
|
||||
|------|---------|-----|
|
||||
| `appointment_commission_enabled` | `0` | فعالسازی پورسانت نوبت (درصد از `Representation.commission_percent` هر نماینده) |
|
||||
| `upgrade_commission_enabled` | `0` | فعالسازی پورسانت ارتقاء اشتراک |
|
||||
| `upgrade_commission_percent` | `20` | درصد پورسانت ارتقاء (سراسری) |
|
||||
| `tax_enabled` | `0` | فعالسازی مالیات بر ارزش افزوده |
|
||||
| `tax_percent` | `10` | درصد مالیات |
|
||||
| `sms_panel_fee_rials` | `1500000` | هزینه ثابت پنل پیامک به ریال (از نوبت و اشتراک کسر میشود) |
|
||||
|
||||
**ترتیب محاسبه** (در `CommissionService`): ۱) کسر `sms_panel_fee_rials` ۲) مالیاتِ استخراجی `afterSms × tax/(100+tax)` ۳) پورسانت = `netAfterTax × percent/100`. سهم نماینده به کیفپولش (`WalletTransaction` credit) واریز و یک ردیف `FinancialBreakdown` ثبت میشود (idempotent بر اساس `payment_id`).
|
||||
|
||||
### GET `/api/v1/admin/financial-breakdowns`
|
||||
|
||||
لیست تفکیک مالی تراکنشها (paginated). **Permission:** `ROLE_ADMIN`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `page` | integer | پیشفرض 1 |
|
||||
| `limit` | integer | پیشفرض 15، حداکثر 100 |
|
||||
| `representation_id` | integer | فیلتر نماینده |
|
||||
| `source` | string | `appointment` یا `subscription` |
|
||||
| `from` | integer | Unix timestamp شروع بازه |
|
||||
| `to` | integer | Unix timestamp پایان بازه |
|
||||
|
||||
**Response `200` (paginated):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "…", "order_id": "ORD-…", "source": "appointment",
|
||||
"gross_rials": 2000000, "sms_fee_rials": 1500000,
|
||||
"tax_percent": 10, "tax_rials": 45455, "net_after_tax_rials": 454545,
|
||||
"commission_percent": 20, "representation_share_rials": 90909,
|
||||
"system_share_rials": 363636,
|
||||
"representation_id": 3, "representation_name": "نماینده یزد",
|
||||
"doctor_id": 12, "clinic_id": null, "created_at": "2026-06-24T…"
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/v1/admin/financial-summary`
|
||||
|
||||
جمع کل مبالغ. **Permission:** `ROLE_ADMIN`
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total_gross": 2000000,
|
||||
"total_representation_income": 90909,
|
||||
"total_tax_collected": 45455,
|
||||
"total_sms_fee": 1500000,
|
||||
"total_system_share": 363636
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -106,6 +106,8 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
|
||||
```
|
||||
|
||||
> **On successful callback** for an appointment payment, the booking is transitioned `pending → confirmed` (its 15-minute `expires_at` is cleared) and a confirmation SMS is dispatched to the patient's mobile. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band.
|
||||
>
|
||||
> **پورسانت نماینده:** اگر پزشک نوبت `representation_id` داشته باشد و `appointment_commission_enabled=1` باشد، پس از confirm شدن `CommissionService` هزینه پنل پیامک و مالیات را کسر و سهم نماینده را به کیفپولش واریز میکند (ردیف `FinancialBreakdown` ثبت میشود). برای پرداخت اشتراک هم اگر `upgrade_commission_enabled=1` و پزشک/کلینیک `representation_id` داشته باشد همین منطق با درصد `upgrade_commission_percent` اعمال میشود. کلیدهای تنظیمات و ترتیب محاسبه در `docs/api/admin.md`.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|
||||
@@ -252,3 +252,9 @@ Updated settlement object with `status: "rejected"`.
|
||||
| `ERR_AUTH_006` | 403 | Not admin |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Settlement not found |
|
||||
| `ERR_VALIDATION_002` | 422 | Missing note |
|
||||
|
||||
---
|
||||
|
||||
## FinancialBreakdown (لاگ مالی)
|
||||
|
||||
علاوه بر تسویهحساب دستی، کیفپول نماینده بهصورت خودکار از طریق `CommissionService` هنگام پرداخت موفقِ نوبت/اشتراک شارژ میشود (`WalletTransaction` credit). هر واریز یک ردیف `FinancialBreakdown` ثبت میکند که تفکیک کامل تراکنش (ناخالص، هزینه پیامک، مالیات، خالص، درصد و سهم پورسانت، سهم سیستم) را نگه میدارد. ثبت idempotent است (بر اساس `payment_id`). گزارشها از طریق `GET /api/v1/admin/financial-breakdowns` و `GET /api/v1/admin/financial-summary` در دسترساند — جزئیات در `docs/api/admin.md`.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260624092459 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE financial_breakdowns (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, source VARCHAR(20) NOT NULL, gross_rials INT NOT NULL, sms_fee_rials INT NOT NULL, tax_percent NUMERIC(5, 2) NOT NULL, tax_rials INT NOT NULL, net_after_tax_rials INT NOT NULL, commission_percent NUMERIC(5, 2) NOT NULL, representation_share_rials INT NOT NULL, system_share_rials INT NOT NULL, representation_id INT DEFAULT NULL, doctor_id INT DEFAULT NULL, clinic_id INT DEFAULT NULL, created_at INT NOT NULL, payment_id INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_D66D9FF4D17F50A6 (uuid), INDEX IDX_D66D9FF44C3A3BB (payment_id), INDEX IDX_D66D9FF4A76ED395 (user_id), INDEX idx_breakdown_rep_date (representation_id, created_at), INDEX idx_breakdown_source_date (source, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE financial_breakdowns ADD CONSTRAINT FK_D66D9FF44C3A3BB FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE financial_breakdowns ADD CONSTRAINT FK_D66D9FF4A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE financial_breakdowns DROP FOREIGN KEY FK_D66D9FF44C3A3BB');
|
||||
$this->addSql('ALTER TABLE financial_breakdowns DROP FOREIGN KEY FK_D66D9FF4A76ED395');
|
||||
$this->addSql('DROP TABLE financial_breakdowns');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Rating\Entity\Comment;
|
||||
use App\Rating\Entity\Rate;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Settlement\Entity\FinancialBreakdown;
|
||||
use App\Settlement\Entity\Settlement;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Entity\SmsTemplate;
|
||||
@@ -969,6 +970,113 @@ class AdminApiController extends BaseController
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
// ── Financial Breakdowns ──────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/financial-breakdowns',
|
||||
summary: 'لیست تفکیک مالی تراکنشها (paginated)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
|
||||
new OA\Parameter(name: 'representation_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'source', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['appointment', 'subscription'])),
|
||||
new OA\Parameter(name: 'from', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
|
||||
new OA\Parameter(name: 'to', in: 'query', required: false, schema: new OA\Schema(type: 'integer', description: 'Unix timestamp')),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'لیست تفکیک مالی')]
|
||||
)]
|
||||
#[Route('/api/v1/admin/financial-breakdowns', methods: ['GET'])]
|
||||
public function financialBreakdowns(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$repId = $request->query->get('representation_id');
|
||||
$source = trim((string) $request->query->get('source', ''));
|
||||
$from = $request->query->get('from');
|
||||
$to = $request->query->get('to');
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'b.uuid, b.source, b.grossRials, b.smsFeeRials, b.taxPercent, b.taxRials,
|
||||
b.netAfterTaxRials, b.commissionPercent, b.representationShareRials, b.systemShareRials,
|
||||
b.representationId, b.doctorId, b.clinicId, b.createdAt,
|
||||
p.orderId as order_id, r.fullName as representation_name'
|
||||
)
|
||||
->from(FinancialBreakdown::class, 'b')
|
||||
->join('b.payment', 'p')
|
||||
->leftJoin(Representation::class, 'r', 'WITH', 'r.id = b.representationId')
|
||||
->orderBy('b.createdAt', 'DESC');
|
||||
|
||||
if ($repId !== null && $repId !== '') {
|
||||
$qb->andWhere('b.representationId = :repId')->setParameter('repId', (int) $repId);
|
||||
}
|
||||
if ($source !== '') {
|
||||
$qb->andWhere('b.source = :source')->setParameter('source', $source);
|
||||
}
|
||||
if ($from !== null && $from !== '') {
|
||||
$qb->andWhere('b.createdAt >= :from')->setParameter('from', (int) $from);
|
||||
}
|
||||
if ($to !== null && $to !== '') {
|
||||
$qb->andWhere('b.createdAt <= :to')->setParameter('to', (int) $to);
|
||||
}
|
||||
|
||||
$total = (clone $qb)->select('COUNT(b.uuid)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$items = array_map(fn(array $b) => [
|
||||
'uuid' => $b['uuid'],
|
||||
'order_id' => $b['order_id'],
|
||||
'source' => $b['source'],
|
||||
'gross_rials' => (int) $b['grossRials'],
|
||||
'sms_fee_rials' => (int) $b['smsFeeRials'],
|
||||
'tax_percent' => (float) $b['taxPercent'],
|
||||
'tax_rials' => (int) $b['taxRials'],
|
||||
'net_after_tax_rials' => (int) $b['netAfterTaxRials'],
|
||||
'commission_percent' => (float) $b['commissionPercent'],
|
||||
'representation_share_rials' => (int) $b['representationShareRials'],
|
||||
'system_share_rials' => (int) $b['systemShareRials'],
|
||||
'representation_id' => $b['representationId'],
|
||||
'representation_name' => $b['representation_name'] ?? null,
|
||||
'doctor_id' => $b['doctorId'],
|
||||
'clinic_id' => $b['clinicId'],
|
||||
'created_at' => date('c', (int) $b['createdAt']),
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/financial-summary',
|
||||
summary: 'جمعِ کلِ سهم نماینده، مالیات، هزینه پیامک و سهم سیستم',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [new OA\Response(response: 200, description: 'جمعهای مالی')]
|
||||
)]
|
||||
#[Route('/api/v1/admin/financial-summary', methods: ['GET'])]
|
||||
public function financialSummary(): JsonResponse
|
||||
{
|
||||
$row = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'COALESCE(SUM(b.grossRials), 0) as gross,
|
||||
COALESCE(SUM(b.representationShareRials), 0) as rep_income,
|
||||
COALESCE(SUM(b.taxRials), 0) as tax,
|
||||
COALESCE(SUM(b.smsFeeRials), 0) as sms_fee,
|
||||
COALESCE(SUM(b.systemShareRials), 0) as system_share'
|
||||
)
|
||||
->from(FinancialBreakdown::class, 'b')
|
||||
->getQuery()->getSingleResult();
|
||||
|
||||
return $this->success([
|
||||
'total_gross' => (int) $row['gross'],
|
||||
'total_representation_income' => (int) $row['rep_income'],
|
||||
'total_tax_collected' => (int) $row['tax'],
|
||||
'total_sms_fee' => (int) $row['sms_fee'],
|
||||
'total_system_share' => (int) $row['system_share'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Secretaries ───────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -18,6 +18,13 @@ class SiteConfigController extends BaseController
|
||||
private const ALLOWED_KEYS = [
|
||||
'commission_enabled',
|
||||
'commission_percent',
|
||||
// financial engine
|
||||
'appointment_commission_enabled',
|
||||
'upgrade_commission_enabled',
|
||||
'upgrade_commission_percent',
|
||||
'tax_enabled',
|
||||
'tax_percent',
|
||||
'sms_panel_fee_rials',
|
||||
'site_name',
|
||||
'support_phone',
|
||||
'max_cancel_hours_before',
|
||||
|
||||
@@ -12,6 +12,13 @@ class SiteConfigRepository extends ServiceEntityRepository
|
||||
private const DEFAULTS = [
|
||||
'commission_enabled' => '0',
|
||||
'commission_percent' => '0',
|
||||
// financial engine
|
||||
'appointment_commission_enabled' => '0',
|
||||
'upgrade_commission_enabled' => '0',
|
||||
'upgrade_commission_percent' => '20',
|
||||
'tax_enabled' => '0',
|
||||
'tax_percent' => '10',
|
||||
'sms_panel_fee_rials' => '1500000',
|
||||
'site_name' => 'ClinicPro',
|
||||
'support_phone' => '',
|
||||
'max_cancel_hours_before' => '24',
|
||||
|
||||
@@ -50,6 +50,7 @@ class PaymentController extends BaseController
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly \App\Settlement\Service\CommissionService $commissionService,
|
||||
private readonly string $appBaseUrl,
|
||||
private readonly string $allowedFrontendHosts = '',
|
||||
) {}
|
||||
@@ -633,6 +634,13 @@ class PaymentController extends BaseController
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->appointmentRepo->save($appointment);
|
||||
|
||||
$doctor = $appointment->getDoctor();
|
||||
$this->commissionService->processAppointment(
|
||||
$payment,
|
||||
$doctor->getRepresentationId(),
|
||||
$doctor->getId(),
|
||||
);
|
||||
|
||||
$mobile = $appointment->getPatientMobile();
|
||||
if ($mobile) {
|
||||
$when = date('Y-m-d H:i', $appointment->getSlotStart());
|
||||
@@ -660,12 +668,14 @@ class PaymentController extends BaseController
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor !== null) {
|
||||
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
|
||||
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null);
|
||||
return;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic !== null) {
|
||||
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
|
||||
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Payment\Entity\Payment;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'financial_breakdowns')]
|
||||
#[ORM\Index(columns: ['representation_id', 'created_at'], name: 'idx_breakdown_rep_date')]
|
||||
#[ORM\Index(columns: ['source', 'created_at'], name: 'idx_breakdown_source_date')]
|
||||
class FinancialBreakdown
|
||||
{
|
||||
public const SOURCE_APPOINTMENT = 'appointment';
|
||||
public const SOURCE_SUBSCRIPTION = 'subscription';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Payment::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Payment $payment;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20)]
|
||||
private string $source;
|
||||
|
||||
#[ORM\Column(name: 'gross_rials', type: 'integer')]
|
||||
private int $grossRials;
|
||||
|
||||
#[ORM\Column(name: 'sms_fee_rials', type: 'integer')]
|
||||
private int $smsFeeRials;
|
||||
|
||||
#[ORM\Column(name: 'tax_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $taxPercent;
|
||||
|
||||
#[ORM\Column(name: 'tax_rials', type: 'integer')]
|
||||
private int $taxRials;
|
||||
|
||||
#[ORM\Column(name: 'net_after_tax_rials', type: 'integer')]
|
||||
private int $netAfterTaxRials;
|
||||
|
||||
#[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $commissionPercent;
|
||||
|
||||
#[ORM\Column(name: 'representation_share_rials', type: 'integer')]
|
||||
private int $representationShareRials;
|
||||
|
||||
#[ORM\Column(name: 'system_share_rials', type: 'integer')]
|
||||
private int $systemShareRials;
|
||||
|
||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $representationId = null;
|
||||
|
||||
#[ORM\Column(name: 'doctor_id', type: 'integer', nullable: true)]
|
||||
private ?int $doctorId = null;
|
||||
|
||||
#[ORM\Column(name: 'clinic_id', type: 'integer', nullable: true)]
|
||||
private ?int $clinicId = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(
|
||||
Payment $payment,
|
||||
string $source,
|
||||
User $user,
|
||||
int $grossRials,
|
||||
int $smsFeeRials,
|
||||
string $taxPercent,
|
||||
int $taxRials,
|
||||
int $netAfterTaxRials,
|
||||
string $commissionPercent,
|
||||
int $representationShareRials,
|
||||
int $systemShareRials,
|
||||
?int $representationId,
|
||||
?int $doctorId,
|
||||
?int $clinicId,
|
||||
) {
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->payment = $payment;
|
||||
$this->source = $source;
|
||||
$this->user = $user;
|
||||
$this->grossRials = $grossRials;
|
||||
$this->smsFeeRials = $smsFeeRials;
|
||||
$this->taxPercent = $taxPercent;
|
||||
$this->taxRials = $taxRials;
|
||||
$this->netAfterTaxRials = $netAfterTaxRials;
|
||||
$this->commissionPercent = $commissionPercent;
|
||||
$this->representationShareRials = $representationShareRials;
|
||||
$this->systemShareRials = $systemShareRials;
|
||||
$this->representationId = $representationId;
|
||||
$this->doctorId = $doctorId;
|
||||
$this->clinicId = $clinicId;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getPayment(): Payment { return $this->payment; }
|
||||
public function getSource(): string { return $this->source; }
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'payment_uuid' => $this->payment->getUuid(),
|
||||
'order_id' => $this->payment->getOrderId(),
|
||||
'source' => $this->source,
|
||||
'gross_rials' => $this->grossRials,
|
||||
'sms_fee_rials' => $this->smsFeeRials,
|
||||
'tax_percent' => $this->taxPercent,
|
||||
'tax_rials' => $this->taxRials,
|
||||
'net_after_tax_rials' => $this->netAfterTaxRials,
|
||||
'commission_percent' => $this->commissionPercent,
|
||||
'representation_share_rials' => $this->representationShareRials,
|
||||
'system_share_rials' => $this->systemShareRials,
|
||||
'representation_id' => $this->representationId,
|
||||
'doctor_id' => $this->doctorId,
|
||||
'clinic_id' => $this->clinicId,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Repository;
|
||||
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Settlement\Entity\FinancialBreakdown;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class FinancialBreakdownRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, FinancialBreakdown::class);
|
||||
}
|
||||
|
||||
public function existsForPayment(Payment $payment): bool
|
||||
{
|
||||
return $this->count(['payment' => $payment]) > 0;
|
||||
}
|
||||
|
||||
public function save(FinancialBreakdown $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) $this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settlement\Service;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Settlement\Entity\FinancialBreakdown;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Settlement\Repository\FinancialBreakdownRepository;
|
||||
use App\Settlement\Repository\SettlementRepository;
|
||||
use App\Settlement\Repository\WalletTransactionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* موتور تقسیم مالی پس از پرداخت موفق.
|
||||
* ترتیب ثابت: ۱) کسر هزینه پنل پیامک ۲) کسر مالیات از باقیمانده ۳) پورسانت نماینده از خالصِ پس از مالیات.
|
||||
*/
|
||||
class CommissionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly RepresentationRepository $representationRepo,
|
||||
private readonly SettlementRepository $settlementRepo,
|
||||
private readonly WalletTransactionRepository $walletRepo,
|
||||
private readonly FinancialBreakdownRepository $breakdownRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** پورسانت نوبت: درصد = commission_percent همان نماینده. */
|
||||
public function processAppointment(Payment $payment, ?int $representationId, ?int $doctorId): void
|
||||
{
|
||||
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
|
||||
|
||||
$rep = $this->resolveRep($representationId);
|
||||
if ($rep === null) return;
|
||||
|
||||
$this->settle(
|
||||
$payment,
|
||||
FinancialBreakdown::SOURCE_APPOINTMENT,
|
||||
(float) $rep->getCommissionPercent(),
|
||||
$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 = $this->resolveRep($representationId);
|
||||
if ($rep === null) return;
|
||||
|
||||
$this->settle(
|
||||
$payment,
|
||||
FinancialBreakdown::SOURCE_SUBSCRIPTION,
|
||||
(float) $this->configRepo->get('upgrade_commission_percent'),
|
||||
$rep,
|
||||
$doctorId,
|
||||
$clinicId,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveRep(?int $representationId): ?Representation
|
||||
{
|
||||
if ($representationId === null) return null;
|
||||
$rep = $this->representationRepo->find($representationId);
|
||||
return ($rep !== null && $rep->isActive()) ? $rep : null;
|
||||
}
|
||||
|
||||
private function settle(
|
||||
Payment $payment,
|
||||
string $source,
|
||||
float $commissionPercent,
|
||||
Representation $rep,
|
||||
?int $doctorId,
|
||||
?int $clinicId,
|
||||
): void {
|
||||
// پرداخت دوبار پردازش نشود.
|
||||
if ($this->breakdownRepo->existsForPayment($payment)) return;
|
||||
|
||||
$gross = $payment->getAmountRials();
|
||||
|
||||
// مرحله ۱: کسر هزینه ثابت پنل پیامک.
|
||||
$smsFee = (int) $this->configRepo->get('sms_panel_fee_rials');
|
||||
$afterSms = max(0, $gross - $smsFee);
|
||||
|
||||
// مرحله ۲: مالیاتِ استخراجی از مبلغِ شامل مالیات: tax = amount × p/(100+p).
|
||||
$taxEnabled = $this->configRepo->get('tax_enabled') === '1';
|
||||
$taxPercent = $taxEnabled ? (float) $this->configRepo->get('tax_percent') : 0.0;
|
||||
$taxRials = ($taxEnabled && $taxPercent > 0)
|
||||
? (int) round($afterSms * $taxPercent / (100 + $taxPercent))
|
||||
: 0;
|
||||
$netAfterTax = $afterSms - $taxRials;
|
||||
|
||||
// مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات.
|
||||
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
|
||||
$systemShare = $gross - $smsFee - $taxRials - $repShare;
|
||||
|
||||
$repUser = $rep->getUser();
|
||||
|
||||
if ($repShare > 0) {
|
||||
$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);
|
||||
}
|
||||
|
||||
$breakdown = new FinancialBreakdown(
|
||||
$payment,
|
||||
$source,
|
||||
$payment->getUser(),
|
||||
$gross,
|
||||
$smsFee,
|
||||
number_format($taxPercent, 2, '.', ''),
|
||||
$taxRials,
|
||||
$netAfterTax,
|
||||
number_format($commissionPercent, 2, '.', ''),
|
||||
$repShare,
|
||||
$systemShare,
|
||||
$rep->getId(),
|
||||
$doctorId,
|
||||
$clinicId,
|
||||
);
|
||||
$this->breakdownRepo->save($breakdown, false);
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user