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:
hamed
2026-08-09 16:51:22 +03:30
parent 2471c90cbb
commit 7716b40f6a
18 changed files with 762 additions and 45 deletions
+28 -13
View File
@@ -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(),
]);
}
+7 -2
View File
@@ -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),
];
}
}
+16 -4
View File
@@ -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);
}
}