- 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.
225 lines
9.3 KiB
PHP
225 lines
9.3 KiB
PHP
<?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];
|
||
}
|
||
}
|