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:
hamed
2026-06-24 13:06:17 +03:30
parent e0abaf5c0c
commit 148d033114
16 changed files with 1119 additions and 0 deletions
@@ -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();
}
}