Files
clinicpro/src/Settlement/Service/CommissionService.php
T
hamed 8d2b0d908a feat: Add online share functionality for secretaries
- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments.
- Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements.
- Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`.
- Implemented `SecretaryEarning` entity and repository for managing secretary earnings.
- Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments.
- Added `UserIbanResolver` service to handle user IBAN retrieval and management.
- Created `HasIbansTrait` for entities to manage IBANs in a JSON format.
- Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
2026-07-25 18:34:18 +03:30

225 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Settlement\Service;
use App\Auth\Entity\User;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
use App\Secretary\Entity\SecretaryEarning;
use App\Secretary\Repository\SecretaryEarningRepository;
use App\Secretary\Service\SecretaryShareResolver;
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;
use Psr\Log\LoggerInterface;
/**
* موتور تقسیم مالی پس از پرداخت موفق.
* ترتیب ثابت: ۱) کسر هزینه پنل پیامک ۲) کسر مالیات از باقی‌مانده ۳) پورسانت نماینده از خالصِ پس از مالیات.
*/
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 SecretaryShareResolver $secretaryShares,
private readonly SecretaryEarningRepository $earningRepo,
private readonly LoggerInterface $logger,
private readonly EntityManagerInterface $em,
) {}
/**
* تقسیم مالی نوبت آنلاین: پورسانت نماینده (اگر گاردِ دامنه برقرار باشد) و سهم
* منشی‌های همان پزشک/کلینیک — هر کدام مستقل. سهم منشی به وجود نماینده گره نیست.
*/
public function processAppointment(Payment $payment, ?int $doctorRepId, ?int $bookingRepId, ?int $doctorId): void
{
// پرداخت دوبار پردازش نشود — قبل از هر اعتبارِ کیف پول.
if ($this->breakdownRepo->existsForPayment($payment)) return;
$rep = $this->eligibleAppointmentRep($doctorRepId, $bookingRepId);
$secretaries = $this->secretaryShares->for($payment);
if ($rep === null && $secretaries === []) return;
$this->settle(
$payment,
FinancialBreakdown::SOURCE_APPOINTMENT,
$rep !== null ? (float) $rep->getCommissionPercent() : 0.0,
$rep,
$doctorId,
null,
$secretaries,
);
}
/**
* نمایندهٔ واجد شرط برای پورسانت نوبت. گاردِ دامنه: پزشک باید متعلق به نماینده
* باشد و نوبت هم از دامنهٔ همان نماینده ثبت شده باشد.
*/
private function eligibleAppointmentRep(?int $doctorRepId, ?int $bookingRepId): ?Representation
{
if ($this->configRepo->get('appointment_commission_enabled') !== '1') return null;
if ($doctorRepId === null || $bookingRepId === null || $doctorRepId !== $bookingRepId) return null;
return $this->resolveRep($doctorRepId);
}
/**
* پورسانت ارتقاء اشتراک: درصد سراسری = upgrade_commission_percent.
* گاردِ دامنه (مثل نوبت): مالک پزشک/کلینیک و نماینده‌ی دامنه‌ی خرید باید یکی باشند.
*/
public function processSubscription(Payment $payment, ?int $ownerRepId, ?int $bookingRepId, ?int $doctorId, ?int $clinicId): void
{
if ($this->configRepo->get('upgrade_commission_enabled') !== '1') return;
if ($ownerRepId === null || $bookingRepId === null || $ownerRepId !== $bookingRepId) return;
$rep = $this->resolveRep($ownerRepId);
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;
}
/**
* @param list<array{user: User, percent: float, relation_uuid: string}> $secretaries
*/
private function settle(
Payment $payment,
string $source,
float $commissionPercent,
?Representation $rep,
?int $doctorId,
?int $clinicId,
array $secretaries,
): 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;
// مرحله ۳: سهم‌ها، همه از «خالصِ پس از مالیات» — نه از مبلغ کل و نه از
// باقی‌ماندهٔ سهم دیگری، تا ترتیب اجرا روی مبالغ اثر نگذارد.
[$commissionPercent, $secretaries] = $this->clipPercents($payment, $commissionPercent, $secretaries);
$repShare = (int) round($netAfterTax * $commissionPercent / 100);
$secretaryTotal = 0;
$secretaryRows = [];
foreach ($secretaries as $secretary) {
$share = (int) round($netAfterTax * $secretary['percent'] / 100);
if ($share <= 0) continue;
$secretaryTotal += $share;
$secretaryRows[] = $secretary + ['share' => $share];
}
$systemShare = $gross - $smsFee - $taxRials - $repShare - $secretaryTotal;
if ($rep !== null && $repShare > 0) {
$this->credit($rep->getUser(), $repShare, $payment, sprintf('پورسانت %s %s', $source, $payment->getOrderId()));
}
$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,
);
$breakdown->setSecretaryShareRials($secretaryTotal);
$this->breakdownRepo->save($breakdown, false);
foreach ($secretaryRows as $row) {
$this->credit($row['user'], $row['share'], $payment, sprintf('سهم نوبت آنلاین %s', $payment->getOrderId()));
$this->earningRepo->save(
new SecretaryEarning($breakdown, $row['user'], $row['relation_uuid'], $row['percent'], $row['share']),
false,
);
}
$this->em->flush();
}
private function credit(User $user, int $amountRials, Payment $payment, string $description): void
{
$balance = $this->settlementRepo->getWalletBalance($user);
$tx = new WalletTransaction($user, $amountRials, WalletTransaction::TYPE_CREDIT, $balance + $amountRials);
$tx->setPayment($payment);
$tx->setDescription($description);
$this->walletRepo->save($tx, false);
}
/**
* سهم سیستم نباید منفی شود: اگر مجموع درصدها از ۱۰۰ بگذرد، به نسبت کلیپ می‌شود و
* هشدار ثبت می‌گردد (سکوت نمی‌کنیم — تنظیمِ اشتباه باید دیده شود).
*
* @param list<array{user: User, percent: float, relation_uuid: string}> $secretaries
* @return array{0: float, 1: list<array{user: User, percent: float, relation_uuid: string}>}
*/
private function clipPercents(Payment $payment, float $commissionPercent, array $secretaries): array
{
$total = $commissionPercent + array_sum(array_column($secretaries, 'percent'));
if ($total <= 100.0 || $total <= 0.0) {
return [$commissionPercent, $secretaries];
}
$this->logger->warning('Commission + secretary shares exceed 100% — clipping proportionally', [
'payment_uuid' => $payment->getUuid(),
'total_percent' => $total,
]);
$factor = 100.0 / $total;
$clipped = [];
foreach ($secretaries as $secretary) {
$secretary['percent'] *= $factor;
$clipped[] = $secretary;
}
return [$commissionPercent * $factor, $clipped];
}
}