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

22 KiB
Raw Blame History

موتور مالی نمایندگی: پورسانت نوبت/ارتقاء + مالیات بر ارزش افزوده + هزینه پنل پیامک

پروژه

clinicpro (Backend Symfony + پنل ادمین React). کاملاً داخل همین پروژه است؛ cross-repo نیست. سایت عمومی nobat724_front فقط مصرف‌کننده‌ی مبلغِ نهایی نوبت است و در این تغییر دست نمی‌خورد (مبلغ پرداختی کاربر تغییر نمی‌کند؛ فقط تقسیمِ پس از پرداخت در بک‌اند اضافه می‌شود).

زمینه

هر Representation یک فیلد commission_percent دارد که در /admin/representations ذخیره و ویرایش می‌شود، اما در هیچ محاسبه‌ی مالی استفاده نمی‌شود — صرفاً نمایشی است. هر Doctor و هر Clinic فیلد representationId دارند (نماینده‌ای که آن‌ها را اضافه کرده). هنگام موفق‌شدن پرداخت، PaymentController فقط نوبت را confirmed یا اشتراک را فعال می‌کند و هیچ سهمی به کیف‌پول نماینده واریز نمی‌شود.

کیف‌پول نماینده با WalletTransaction (credit/debit + balance_after) و موجودی با SettlementRepository::getWalletBalance(User) مدیریت می‌شود؛ نماینده از طریق Settlement برداشت می‌کند. تنظیمات سراسری در SiteConfig (کلید/مقدار) با whitelist در SiteConfigController::ALLOWED_KEYS و defaults در SiteConfigRepository::DEFAULTS نگه‌داری می‌شوند. کلیدهای commission_enabled و commission_percent از قبل تعریف شده ولی بلااستفاده‌اند.

مشکل / هدف

۱. هنگام پرداخت موفقِ نوبت برای پزشکی که representationId دارد، سهم نماینده محاسبه و به کیف‌پولش واریز شود (درصد = commission_percent همان نماینده). ۲. هنگام پرداخت موفقِ ارتقاء/خرید اشتراک پزشک یا کلینیکی که representationId دارد، درصدِ ارتقاء (پیش‌فرض ۲۰٪، غیر هاردکد، از تنظیمات ادمین) به کیف‌پول نماینده واریز شود. ۳. مالیات بر ارزش افزوده: همه‌ی مبالغ شامل مالیات‌اند. درصد مالیات + فعال/غیرفعال + (اختیاری) تاریخچه از پنل ادمین کنترل شود. برای هر تراکنش مبلغ مالیات ذخیره شود. ۴. هزینه ثابت پنل پیامک: مبلغ ثابت ۱۵۰٬۰۰۰ تومان = ۱٬۵۰۰٬۰۰۰ ریال از مبلغ نوبت کسر شود (مبلغ از تنظیمات ادمین قابل کنترل). ۵. ترتیب محاسبه (بسیار مهم): ۱) کسر هزینه پنل پیامک، ۲) کسر مالیات از باقی‌مانده، ۳) پورسانت نماینده از مبلغ خالصِ پس از مالیات. ۶. ثبت لاگ کامل مالی برای هر تراکنش + مشاهده/گزارش‌گیری در پنل ادمین.

فایل‌های مرتبط

فایل نقش
clinicpro/src/Config/Controller/SiteConfigController.php افزودن کلیدهای مالی به ALLOWED_KEYS
clinicpro/src/Config/Repository/SiteConfigRepository.php افزودن DEFAULTS کلیدهای مالی
clinicpro/src/Settlement/Entity/FinancialBreakdown.php (جدید) Entity لاگ تفکیک مالی هر تراکنش
clinicpro/src/Settlement/Repository/FinancialBreakdownRepository.php (جدید) repository + کوئری گزارش‌ها
clinicpro/src/Settlement/Service/CommissionService.php (جدید) منطق محاسبه (ترتیب کسر) + واریز کیف‌پول + ثبت لاگ
clinicpro/src/Payment/Controller/PaymentController.php فراخوانی CommissionService در handleAppointmentConfirmation و handleSubscriptionActivation
clinicpro/src/Representation/Entity/Representation.php منبع commission_percent نوبتِ هر نماینده (موجود)
clinicpro/src/Doctor/Entity/Doctor.php / Clinic/Entity/Clinic.php getRepresentationId() (موجود)
clinicpro/src/Representation/Repository/RepresentationRepository.php افزودن find(int $id) برای یافتن نماینده از روی representationId
clinicpro/src/Settlement/Repository/SettlementRepository.php getWalletBalance(User) (موجود) برای محاسبه balance_after
clinicpro/src/Admin/Controller/AdminApiController.php endpointهای گزارش مالی (لیست breakdown + جمع‌ها)
clinicpro/assets/admin/pages/SettingsPage.tsx فرم تنظیمات مالی
clinicpro/assets/admin/pages/FinancialReportPage.tsx (جدید) جدول تراکنش‌ها + کارت‌های جمع
clinicpro/docs/api/admin.md، docs/api/settlement.md، docs/api/payment.md مستندسازی

وضعیت فعلی

الف) هوک پرداخت موفق — هیچ پورسانتی واریز نمی‌شود

clinicpro/src/Payment/Controller/PaymentController.php

$payment->setStatus(Payment::STATUS_SUCCESS);
$payment->setReferenceId($result->referenceId);
$this->paymentRepo->save($payment);

if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) {
    $this->handleSubscriptionActivation($payment);
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
    $this->handleSmsWalletCharge($payment);
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
    $this->handleAppointmentConfirmation($payment);
}
private function handleAppointmentConfirmation(Payment $payment): void
{
    $appointment = $payment->getAppointment();
    if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
        return;
    }
    $appointment->transitionTo(Appointment::STATUS_CONFIRMED);
    $this->appointmentRepo->save($appointment);
    // ... فقط SMS؛ هیچ پورسانت/مالیاتی نیست
}

private function handleSubscriptionActivation(Payment $payment): void
{
    $meta       = $payment->getMetadata() ?? [];
    $periodUuid = $meta['period_uuid'] ?? null;
    if ($periodUuid === null) return;

    $user   = $payment->getUser();
    $doctor = $this->doctorRepo->findByUser($user);
    if ($doctor !== null) {
        $this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
        return;
    }
    $clinic = $this->clinicRepo->findByUser($user);
    if ($clinic !== null) {
        $this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
    }
}

ب) commission_percent بلااستفاده

Representation::getCommissionPercent(): string (decimal 5,2، پیش‌فرض '10.00') فقط در RepresentationController و خروجی admin خوانده/نوشته می‌شود؛ در هیچ محاسبه‌ی مالی به‌کار نمی‌رود.

ج) الگوی واریز کیف‌پول (از SettlementController::reject)

$balance = $this->settlementRepo->getWalletBalance($settlement->getUser());
$tx = new WalletTransaction(
    $settlement->getUser(),
    $settlement->getAmountRials(),
    WalletTransaction::TYPE_CREDIT,
    $balance + $settlement->getAmountRials()
);
$tx->setDescription('...');
$this->walletRepo->save($tx);

د) تنظیمات ادمین (whitelist + defaults)

// SiteConfigController::ALLOWED_KEYS — فعلاً: commission_enabled, commission_percent, ...
// SiteConfigRepository::DEFAULTS — 'commission_enabled' => '0', 'commission_percent' => '0', ...
// GET/PATCH /api/v1/admin/settings از قبل کار می‌کند

وظایف

۱. کلیدهای تنظیمات مالی در SiteConfig

به SiteConfigController::ALLOWED_KEYS و SiteConfigRepository::DEFAULTS این کلیدها اضافه شود (همه به‌صورت رشته ذخیره می‌شوند):

// ALLOWED_KEYS + DEFAULTS
'appointment_commission_enabled' => '0',     // پورسانت نوبت فعال؟ (درصد از خودِ نماینده خوانده می‌شود)
'upgrade_commission_enabled'     => '0',      // پورسانت ارتقاء اشتراک فعال؟
'upgrade_commission_percent'     => '20',     // درصد پورسانت ارتقاء (غیر هاردکد)
'tax_enabled'                    => '0',      // مالیات بر ارزش افزوده فعال؟
'tax_percent'                    => '10',     // درصد مالیات بر ارزش افزوده
'sms_panel_fee_rials'            => '1500000',// هزینه ثابت پنل پیامک به ریال (۱۵۰٬۰۰۰ تومان)

توجه: commission_percent/commission_enabled قدیمی بلااستفاده‌اند؛ دست نزن یا در همین تسک منسوخ‌شان کن (در SettingsPage پنهان کن). کلید پورسانت نوبت اکنون appointment_commission_enabled + درصدِ هر نماینده است.

۲. Entity لاگ مالی FinancialBreakdown

فایل جدید clinicpro/src/Settlement/Entity/FinancialBreakdown.php. برای هر تراکنشی که مشمول قانون مالی می‌شود یک ردیف ثبت شود. فیلدها (همگی ریال، تاریخ‌ها Unix timestamp صحیح):

#[ORM\Entity]
#[ORM\Table(name: 'financial_breakdowns')]
class FinancialBreakdown
{
    public const SOURCE_APPOINTMENT  = 'appointment';
    public const SOURCE_SUBSCRIPTION = 'subscription';

    private ?int $id;
    private string $uuid;                 // Uuid::v4()->toRfc4122()
    #[ORM\ManyToOne(targetEntity: Payment::class)] private Payment $payment;
    private string $source;               // appointment | subscription
    private int $grossRials;              // مبلغ اولیه تراکنش
    private int $smsFeeRials;             // کسر پنل پیامک
    private string $taxPercent;           // decimal 5,2 — درصد مالیاتِ اعمال‌شده
    private int $taxRials;                // مبلغ مالیات
    private int $netAfterTaxRials;        // خالص پس از پیامک و مالیات
    private string $commissionPercent;    // decimal 5,2 — درصد پورسانتِ اعمال‌شده
    private int $representationShareRials;// سهم نماینده
    private int $systemShareRials;        // سهم سیستم
    private ?int $representationId = null;
    private ?int $doctorId = null;
    private ?int $clinicId = null;
    #[ORM\ManyToOne(targetEntity: User::class)] private User $user; // پرداخت‌کننده
    private int $createdAt;
    // getters + toArray()
}

toArray() همه‌ی این مبالغ + ids را برگرداند تا در گزارش ادمین نمایش داده شوند.

migration لازم است: ddev exec php bin/console doctrine:migrations:diff --no-interaction سپس migrate.

۳. سرویس محاسبه CommissionService

فایل جدید clinicpro/src/Settlement/Service/CommissionService.php. مسئول: محاسبه با ترتیب دقیق، واریز کیف‌پول نماینده، ثبت FinancialBreakdown.

class CommissionService
{
    public function __construct(
        private SiteConfigRepository $configRepo,
        private RepresentationRepository $representationRepo,
        private SettlementRepository $settlementRepo,        // getWalletBalance
        private WalletTransactionRepository $walletRepo,
        private FinancialBreakdownRepository $breakdownRepo,
        private EntityManagerInterface $em,
    ) {}

    /**
     * نوبت: درصد پورسانت = commission_percent همان نماینده.
     * هزینه پیامک فقط در مسیر نوبت کسر می‌شود.
     */
    public function processAppointment(Payment $payment, ?int $representationId, ?int $doctorId): void
    {
        if ($this->configRepo->get('appointment_commission_enabled') !== '1') return;
        $rep = $representationId ? $this->representationRepo->find($representationId) : null;
        if ($rep === null || !$rep->isActive()) return;

        $smsFee  = (int) $this->configRepo->get('sms_panel_fee_rials');
        $percent = (float) $rep->getCommissionPercent();
        $this->settle($payment, FinancialBreakdown::SOURCE_APPOINTMENT, $smsFee, $percent, $rep, $doctorId, null);
    }

    /**
     * اشتراک/ارتقاء: درصد = upgrade_commission_percent (سراسری). بدون کسر هزینه پیامک.
     */
    public function processSubscription(Payment $payment, ?int $representationId, ?int $doctorId, ?int $clinicId): void
    {
        if ($this->configRepo->get('upgrade_commission_enabled') !== '1') return;
        $rep = $representationId ? $this->representationRepo->find($representationId) : null;
        if ($rep === null || !$rep->isActive()) return;

        $percent = (float) $this->configRepo->get('upgrade_commission_percent');
        $this->settle($payment, FinancialBreakdown::SOURCE_SUBSCRIPTION, 0, $percent, $rep, $doctorId, $clinicId);
    }

    private function settle(
        Payment $payment, string $source, int $smsFee, float $commissionPercent,
        Representation $rep, ?int $doctorId, ?int $clinicId
    ): void {
        $gross = $payment->getAmountRials();

        // مرحله ۱: کسر هزینه ثابت پنل پیامک
        $afterSms = max(0, $gross - $smsFee);

        // مرحله ۲: محاسبه و کسر مالیات از باقی‌مانده
        $taxEnabled = $this->configRepo->get('tax_enabled') === '1';
        $taxPercent = $taxEnabled ? (float) $this->configRepo->get('tax_percent') : 0.0;
        // مبالغ شامل مالیات‌اند ⇒ مالیات از مبلغِ مشمول استخراج می‌شود.
        // در run-prompt با کاربر تأیید کن: «استخراج از مبلغِ شامل مالیات» یا «افزودن روی مبلغ».
        $taxRials   = $taxEnabled ? (int) round($afterSms * $taxPercent / (100 + $taxPercent)) : 0;
        $netAfterTax = $afterSms - $taxRials;

        // مرحله ۳: پورسانت نماینده از خالصِ پس از مالیات
        $repShare    = (int) round($netAfterTax * $commissionPercent / 100);
        $systemShare = $gross - $smsFee - $taxRials - $repShare;

        // واریز کیف‌پول نماینده (الگوی SettlementController)
        $repUser = $rep->getUser();
        $balance = $this->settlementRepo->getWalletBalance($repUser);
        $tx = new WalletTransaction($repUser, $repShare, WalletTransaction::TYPE_CREDIT, $balance + $repShare);
        $tx->setPayment($payment);
        $tx->setDescription(sprintf('پورسانت %s %s', $source, $payment->getOrderId()));
        $this->walletRepo->save($tx, false);

        // ثبت لاگ مالی
        $bd = new FinancialBreakdown(/* ... همه‌ی مبالغ، درصدها، ids ... */);
        $this->breakdownRepo->save($bd, false);

        $this->em->flush();
    }
}

نکات محاسبه:

  • اگر repShare <= 0 بود (مثلاً مبلغ پس از کسرها صفر شد) واریز کیف‌پول انجام نشود ولی لاگ با مقادیر صفر همچنان ثبت شود (برای گزارش).
  • همه‌ی مبالغ صحیح ریال؛ از round برای جلوگیری از خطای ممیز استفاده شود.

۴. اتصال به PaymentController

CommissionService به constructor تزریق شود و در دو هوک فراخوانی شود.

در handleAppointmentConfirmation بعد از confirmed شدن:

$doctor = $appointment->getDoctor();
$this->commissionService->processAppointment(
    $payment,
    $doctor->getRepresentationId(),
    $doctor->getId(),
);

در handleSubscriptionActivation بعد از فعال‌سازی اشتراک، با تشخیص doctor/clinic:

// مسیر doctor:
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null);
// مسیر clinic:
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId());

idempotency: مطمئن شو یک پرداخت دوبار پردازش نشود (verify فقط یک‌بار success می‌شود؛ ولی برای اطمینان می‌توان قبل از ثبت، نبودِ FinancialBreakdown با همان payment_id را بررسی کرد).

۵. RepresentationRepository::find(int $id)

متد یافتن نماینده از روی representationId (که روی Doctor/Clinic ذخیره است). ServiceEntityRepository به‌صورت پیش‌فرض find() دارد؛ فقط مطمئن شو در سرویس از ->find($id) استفاده می‌شود.

۶. endpointهای گزارش مالی در AdminApiController

طبق الگوی پروژه (getArrayResult() + $this->paginated() برای لیست، $this->success() برای جمع‌ها):

  • GET /api/v1/admin/financial-breakdowns (paginated): لیست تراکنش‌ها با تفکیک کامل + join نام نماینده/پزشک/کلینیک. فیلتر اختیاری representation_id, source, بازه‌ی تاریخ.
  • GET /api/v1/admin/financial-summary: جمع‌ها → total_representation_income, total_tax_collected, total_sms_fee, total_system_share, total_gross.

هر دو #[IsGranted('ROLE_ADMIN')].

۷. پنل ادمین — تنظیمات + گزارش

SettingsPage.tsx: بخش «تنظیمات مالی» با فیلدهای:

  • فعال/غیرفعال پورسانت نوبت (appointment_commission_enabled)
  • فعال/غیرفعال + درصد پورسانت ارتقاء (upgrade_commission_enabled, upgrade_commission_percent)
  • فعال/غیرفعال + درصد مالیات (tax_enabled, tax_percent)
  • هزینه پنل پیامک به ریال (sms_panel_fee_rials) — نمایش معادل تومان کمکی PATCH به /api/v1/admin/settings (الگوی موجود همان صفحه).

FinancialReportPage.tsx (جدید) + route در App.tsx (roles={['admin']}):

  • کارت‌های جمع از financial-summary
  • جدول DataTable + Pagination از financial-breakdowns (مبالغ با formatRial، تاریخ شمسی با formatDate)
  • لینک در Sidebar.tsx

الگوهای frontend: paginated → items از data?.data، total از data?.meta?.totalRecords؛ single (summary) → data?.data. JWT از localStorage['clinicpro-auth'].

۸. (اختیاری طبق درخواست) تاریخچه‌ی تغییرات مالیات

اگر در run-prompt لازم شد: یک Entity ساده‌ی TaxRateHistory (percent, enabled, changed_by, changed_at) که در SiteConfigController::patch هنگام تغییر tax_percent/tax_enabled یک ردیف ثبت کند + endpoint لیست. در نسخه‌ی اول می‌توان فقط روی updated_atِ SiteConfig اکتفا کرد و این را به فاز بعد سپرد — در run-prompt با کاربر تأیید شود.

نکات مهم

  • ترتیب کسرها ثابت و غیرقابل‌جابه‌جایی است: پیامک → مالیات → پورسانت. پورسانت حتماً روی netAfterTax نه gross.
  • «همه مبالغ شامل مالیات‌اند» ⇒ فرمول استخراجِ مالیات از مبلغِ ناخالص (amount × p/(100+p)) استفاده شده، نه افزودن روی آن. این تصمیم را در ابتدای run-prompt صریحاً با کاربر تأیید کن؛ اگر منظورش «افزودن مالیات روی مبلغ» باشد فرمول amount × p/100 می‌شود.
  • هزینه پنل پیامک فقط روی نوبت اعمال شده (طبق متن «از مبلغ نوبت کسر شود»). اگر باید روی اشتراک هم اعمال شود، در run-prompt تأیید بگیر.
  • درصد پورسانتِ نوبت از Representation::commission_percent (هر نماینده جدا) و درصد ارتقاء از کلید سراسری upgrade_commission_percent. این تفکیک عمدی است.
  • هیچ مبلغی نباید هاردکد شود؛ ۲۰٪ و ۱۵۰٬۰۰۰ تومان صرفاً defaults در SiteConfig هستند.
  • همه‌ی مبالغ ریال صحیح‌اند (۱۵۰٬۰۰۰ تومان = ۱٬۵۰۰٬۰۰۰ ریال). در UI با formatRial نمایش، در محاسبه با int.
  • تاریخ‌ها Unix timestamp صحیح‌اند (نه DateTime). نمایش شمسی با formatDate.
  • همه‌ی controllerها از BaseController و پاسخ‌ها با success/paginated/error.
  • idempotency: مطمئن شو واریز پورسانت برای یک پرداخت فقط یک‌بار رخ دهد.
  • بعد از تغییر Entity: doctrine:migrations:diff + migrate. بعد از تغییر API: docs/api/admin.md, settlement.md, payment.md به‌روز شوند.
  • بعد از پایان: ddev exec php vendor/bin/phpstan analyse و ddev exec yarn dev برای صحت TS، سپس graphify update ..
  • تست محاسبه: یک سناریوی عددی واقعی در PR/گزارش نشان بده — مثلاً نوبت ۲٬۰۰۰٬۰۰۰ ریال، پیامک ۱٬۵۰۰٬۰۰۰، مالیات ۱۰٪، پورسانت ۲۰٪ → خالص پس از پیامک ۵۰۰٬۰۰۰ → مالیات استخراجی ۴۵٬۴۵۵ → خالص ۴۵۴٬۵۴۵ → سهم نماینده ۹۰٬۹۰۹، سهم سیستم مابقی.