feat: implement tax calculations for subscription and SMS wallet payments
- Updated SubscriptionPeriod interface to include tax-related fields: tax_percent, tax_rials, and payable_rials. - Modified payment API documentation to reflect changes in tax handling for subscriptions and SMS wallet charges. - Adjusted PaymentController to calculate payment amounts based on subscription period details instead of client input. - Enhanced PaymentManager to handle net amounts for SMS wallet charges, ensuring tax is not credited to the wallet. - Created PaymentTaxCalculator and SubscriptionTaxCalculator services to manage tax calculations consistently across payment types. - Added tests for tax calculations in both subscription and SMS wallet contexts, ensuring correct behavior with and without tax enabled. - Updated frontend components to display tax information appropriately during payment processes.
This commit is contained in:
@@ -41,6 +41,9 @@ class PaymentController extends BaseController
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly \App\Subscription\Repository\SubscriptionPeriodRepository $subscriptionPeriodRepo,
|
||||
private readonly \App\Subscription\Service\SubscriptionTaxCalculator $subscriptionTax,
|
||||
private readonly \App\Payment\Service\PaymentTaxCalculator $paymentTax,
|
||||
private readonly string $appBaseUrl,
|
||||
private readonly string $allowedFrontendHosts = '',
|
||||
) {}
|
||||
@@ -413,11 +416,7 @@ class PaymentController extends BaseController
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$gatewayName = trim($data['gateway'] ?? 'mellat');
|
||||
$frontendAddress = trim($data['frontend_address'] ?? '');
|
||||
$amountRials = (int) ($data['amount_rials'] ?? 0);
|
||||
|
||||
if ($amountRials <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422);
|
||||
}
|
||||
$periodUuid = trim($data['period_uuid'] ?? '');
|
||||
|
||||
if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address');
|
||||
@@ -434,19 +433,32 @@ class PaymentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_004, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_004), 422);
|
||||
}
|
||||
|
||||
$periodUuid = trim($data['period_uuid'] ?? '');
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
||||
$payment->assignTenant($owner);
|
||||
if ($periodUuid !== '') {
|
||||
$payment->setMetadata(['period_uuid' => $periodUuid]);
|
||||
// مبلغ از دورهٔ اشتراک محاسبه میشود، نه از بدنهٔ درخواست: قیمت دوره خالص
|
||||
// است و مالیات رویش مینشیند، و کلاینت نباید بتواند مبلغ را تعیین کند.
|
||||
$period = $periodUuid !== '' ? $this->subscriptionPeriodRepo->findByUuid($periodUuid) : null;
|
||||
if ($period === null || !$period->isActive()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دورهٔ اشتراک نامعتبر است', 422, 'period_uuid');
|
||||
}
|
||||
|
||||
$amountRials = $this->subscriptionTax->payableForPeriod($period);
|
||||
if ($amountRials <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422);
|
||||
}
|
||||
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
||||
$payment->assignTenant($owner);
|
||||
$payment->setMetadata(['period_uuid' => $periodUuid]);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
// مثل appointment: ارتباط با بانک اینجا نیست؛ در GET /payment/pay انجام میشود.
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||
'order_id' => $payment->getOrderId(),
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||
'order_id' => $payment->getOrderId(),
|
||||
'price_rials' => $period->getPriceRials(),
|
||||
'tax_percent' => $this->subscriptionTax->percent(),
|
||||
'tax_rials' => $this->subscriptionTax->taxOf($period->getPriceRials()),
|
||||
'payable_rials' => $amountRials,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -502,6 +514,9 @@ class PaymentController extends BaseController
|
||||
'test_mode' => $this->gateways->isTestMode(),
|
||||
'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
|
||||
'gateways' => $this->gateways->activeGateways(),
|
||||
// نرخ مالیاتِ اشتراک و شارژ کیف پول، تا کلاینت بتواند جمع کل را پیش از
|
||||
// ارسال درخواست نشان دهد. صفر یعنی مالیات خاموش است.
|
||||
'tax_percent' => $this->paymentTax->percent(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -305,8 +305,10 @@ final class PaymentManager
|
||||
if ($entityType === null || $entityId === null) {
|
||||
return;
|
||||
}
|
||||
// قرینهٔ handleSmsWalletCharge: همان مبلغی که اعتبار شده بود پس گرفته میشود.
|
||||
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
|
||||
$this->smsWalletService->deduct($wallet, $payment->getAmountRials(), 'استرداد شارژ کیف پیامک');
|
||||
$credit = (int) ($meta['net_rials'] ?? $payment->getAmountRials());
|
||||
$this->smsWalletService->deduct($wallet, $credit, 'استرداد شارژ کیف پیامک');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,8 +388,11 @@ final class PaymentManager
|
||||
return;
|
||||
}
|
||||
|
||||
// اعتبار = مبلغ خالص، نه مبلغ پرداختی: مالیات سهم دولت است نه شارژ کاربر.
|
||||
// پرداختهای قدیمی `net_rials` ندارند و همان مبلغ کلشان اعتبار میشود.
|
||||
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
|
||||
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
|
||||
$credit = (int) ($meta['net_rials'] ?? $payment->getAmountRials());
|
||||
$this->smsWalletService->charge($wallet, $credit, $payment);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Service;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
|
||||
/**
|
||||
* مالیات پرداختهایی که مبلغشان **خالص** است و مالیات رویشان اضافه میشود:
|
||||
* اشتراک و شارژ کیف پول پیامک.
|
||||
*
|
||||
* نوبت از این کلاس استفاده نمیکند؛ آنجا مبلغ شامل مالیات است و
|
||||
* `CommissionService` مالیات را از دلش استخراج میکند — دو فرمول متفاوتاند.
|
||||
*
|
||||
* نرخ از همان کلیدهای سراسری `tax_enabled` / `tax_percent` میآید تا دو نرخ
|
||||
* موازی در سیستم نداشته باشیم.
|
||||
*/
|
||||
class PaymentTaxCalculator
|
||||
{
|
||||
public function __construct(private readonly SiteConfigRepository $configRepo) {}
|
||||
|
||||
/** درصد مؤثر؛ خاموشبودن مالیات یعنی صفر. */
|
||||
public function percent(): float
|
||||
{
|
||||
if ($this->configRepo->get('tax_enabled') !== '1') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return max(0.0, (float) $this->configRepo->get('tax_percent'));
|
||||
}
|
||||
|
||||
public function taxOf(int $netRials): int
|
||||
{
|
||||
$percent = $this->percent();
|
||||
if ($percent <= 0.0 || $netRials <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round($netRials * $percent / 100);
|
||||
}
|
||||
|
||||
/** مبلغی که کاربر واقعاً میپردازد: مبلغ خالص + مالیات. */
|
||||
public function payableOf(int $netRials): int
|
||||
{
|
||||
return $netRials + $this->taxOf($netRials);
|
||||
}
|
||||
|
||||
/**
|
||||
* تفکیک کامل، برای برگرداندن در پاسخ API.
|
||||
*
|
||||
* @return array{net_rials: int, tax_percent: float, tax_rials: int, payable_rials: int}
|
||||
*/
|
||||
public function breakdown(int $netRials): array
|
||||
{
|
||||
return [
|
||||
'net_rials' => $netRials,
|
||||
'tax_percent' => $this->percent(),
|
||||
'tax_rials' => $this->taxOf($netRials),
|
||||
'payable_rials' => $this->payableOf($netRials),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class SmsWalletController extends BaseController
|
||||
private readonly \App\Config\Repository\SiteConfigRepository $configRepo,
|
||||
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly \App\Payment\Service\PaymentTaxCalculator $tax,
|
||||
private readonly string $appBaseUrl,
|
||||
) {}
|
||||
|
||||
@@ -82,12 +83,18 @@ class SmsWalletController extends BaseController
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$gatewayName = trim($data['gateway'] ?? 'mellat');
|
||||
$amountRials = (int) ($data['amount_rials'] ?? 0);
|
||||
// `amount_rials` مبلغی است که به کیف پول مینشیند — خالص، بدون مالیات.
|
||||
$netRials = (int) ($data['amount_rials'] ?? 0);
|
||||
|
||||
if ($amountRials <= 0) {
|
||||
if ($netRials <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422);
|
||||
}
|
||||
|
||||
// مالیات روی مبلغ شارژ اضافه میشود؛ اعتبارِ کیف پول همان مبلغ خالص میماند،
|
||||
// وگرنه کاربر مالیات را هم بهصورت اعتبار پیامک پس میگرفت.
|
||||
$breakdown = $this->tax->breakdown($netRials);
|
||||
$amountRials = $breakdown['payable_rials'];
|
||||
|
||||
// فقط اعتبارسنجی درگاه؛ ارتباط با بانک در flow واحد (GET /payment/pay) انجام میشود.
|
||||
if ($this->gateways->resolve($gatewayName) === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422);
|
||||
@@ -96,14 +103,19 @@ class SmsWalletController extends BaseController
|
||||
$frontendAddress = trim($data['frontend_address'] ?? '');
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
|
||||
$payment->assignTenantPair($entityType, $entityId);
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$payment->setMetadata([
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
// اعتباری که بعد از پرداخت موفق به کیف پول مینشیند — بدون مالیات.
|
||||
'net_rials' => $netRials,
|
||||
]);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'pay_url' => $this->appBaseUrl . '/api/v1/payment/pay/' . $payment->getOrderId(),
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
] + $breakdown);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/wallet/logs', methods: ['GET'])]
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Subscription\Repository\SubscriptionPlanRepository;
|
||||
use App\Subscription\Repository\SubscriptionPeriodRepository;
|
||||
use App\Subscription\Repository\ClinicSubscriptionRepository;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use App\Subscription\Service\SubscriptionTaxCalculator;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -36,6 +37,7 @@ class SubscriptionController extends BaseController
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly SubscriptionTaxCalculator $tax,
|
||||
) {}
|
||||
|
||||
// ── Public ──────────────────────────────────────────────────────────────
|
||||
@@ -45,10 +47,10 @@ class SubscriptionController extends BaseController
|
||||
{
|
||||
$plans = $this->planRepo->findAllActive();
|
||||
|
||||
return $this->success(array_map(
|
||||
return $this->success($this->tax->decoratePlans(array_map(
|
||||
fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true),
|
||||
$plans
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Authenticated ────────────────────────────────────────────────────────
|
||||
@@ -123,7 +125,7 @@ class SubscriptionController extends BaseController
|
||||
$plans = $this->planRepo->findAllForAdmin();
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans),
|
||||
$this->tax->decoratePlans(array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans)),
|
||||
count($plans),
|
||||
1,
|
||||
100
|
||||
@@ -247,7 +249,7 @@ class SubscriptionController extends BaseController
|
||||
|
||||
$this->periodRepo->save($period);
|
||||
|
||||
return $this->success($period->toArray(), 201);
|
||||
return $this->success($this->tax->decoratePeriod($period->toArray()), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['PATCH'])]
|
||||
@@ -269,7 +271,7 @@ class SubscriptionController extends BaseController
|
||||
|
||||
$this->periodRepo->save($period);
|
||||
|
||||
return $this->success($period->toArray());
|
||||
return $this->success($this->tax->decoratePeriod($period->toArray()));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['DELETE'])]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Service;
|
||||
|
||||
use App\Payment\Service\PaymentTaxCalculator;
|
||||
use App\Subscription\Entity\SubscriptionPeriod;
|
||||
|
||||
/**
|
||||
* لایهٔ اشتراکیِ مالیات: حساب را به `PaymentTaxCalculator` میسپارد و فقط شکلِ
|
||||
* دوره/پلن را میشناسد.
|
||||
*
|
||||
* قیمت دوره (`price_rials`) خالص است و مالیات رویش اضافه میشود؛ پس مبلغ قابل
|
||||
* پرداخت از قیمت پلن بیشتر است.
|
||||
*/
|
||||
class SubscriptionTaxCalculator
|
||||
{
|
||||
public function __construct(private readonly PaymentTaxCalculator $tax) {}
|
||||
|
||||
public function percent(): float
|
||||
{
|
||||
return $this->tax->percent();
|
||||
}
|
||||
|
||||
public function taxOf(int $netRials): int
|
||||
{
|
||||
return $this->tax->taxOf($netRials);
|
||||
}
|
||||
|
||||
public function payableOf(int $netRials): int
|
||||
{
|
||||
return $this->tax->payableOf($netRials);
|
||||
}
|
||||
|
||||
public function payableForPeriod(SubscriptionPeriod $period): int
|
||||
{
|
||||
return $this->tax->payableOf($period->getPriceRials());
|
||||
}
|
||||
|
||||
/**
|
||||
* سه فیلد مالیاتی را کنار `price_rials` میگذارد. کلیدِ خودِ قیمت دست نمیخورد
|
||||
* تا کلاینتهای قدیمی نشکنند.
|
||||
*
|
||||
* @param array<string, mixed> $period خروجی `SubscriptionPeriod::toArray()`
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function decoratePeriod(array $period): array
|
||||
{
|
||||
$net = (int) ($period['price_rials'] ?? 0);
|
||||
|
||||
return $period + [
|
||||
'tax_percent' => $this->tax->percent(),
|
||||
'tax_rials' => $this->tax->taxOf($net),
|
||||
'payable_rials' => $this->tax->payableOf($net),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* همان کار را روی `periods` هر پلن انجام میدهد.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $plans خروجی `SubscriptionPlan::toArray(withPeriods: true)`
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function decoratePlans(array $plans): array
|
||||
{
|
||||
return array_map(function (array $plan): array {
|
||||
if (isset($plan['periods']) && is_array($plan['periods'])) {
|
||||
$plan['periods'] = array_map($this->decoratePeriod(...), $plan['periods']);
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}, $plans);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user