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:
@@ -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