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:
@@ -34,6 +34,18 @@ class PaymentTenantTest extends ApiTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function makePricedPeriod(int $priceRials): \App\Subscription\Entity\SubscriptionPeriod
|
||||
{
|
||||
$plan = new \App\Subscription\Entity\SubscriptionPlan('plan-' . bin2hex(random_bytes(4)), 5, 1, []);
|
||||
$this->em->persist($plan);
|
||||
|
||||
$period = new \App\Subscription\Entity\SubscriptionPeriod($plan, 'یک ماهه', 1, $priceRials);
|
||||
$this->em->persist($period);
|
||||
$this->em->flush();
|
||||
|
||||
return $period;
|
||||
}
|
||||
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر پرداخت');
|
||||
@@ -105,9 +117,10 @@ class PaymentTenantTest extends ApiTestCase
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
|
||||
// مبلغ از خودِ دوره میآید (قیمت خالص + مالیات)، نه از بدنهٔ درخواست.
|
||||
$res = $this->authJson('POST', '/api/v1/subscription-payment', $doctor->getUser(), [
|
||||
'gateway' => 'mellat',
|
||||
'amount_rials' => 1_000_000,
|
||||
'gateway' => 'mellat',
|
||||
'period_uuid' => $this->makePricedPeriod(1_000_000)->getUuid(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
@@ -123,8 +136,8 @@ class PaymentTenantTest extends ApiTestCase
|
||||
public function testSubscriptionPaymentWithoutAnOwnedEnvironmentIsRejected(): void
|
||||
{
|
||||
$res = $this->authJson('POST', '/api/v1/subscription-payment', $this->createUser(['ROLE_USER']), [
|
||||
'gateway' => 'mellat',
|
||||
'amount_rials' => 1_000_000,
|
||||
'gateway' => 'mellat',
|
||||
'period_uuid' => $this->makePricedPeriod(1_000_000)->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Sms;
|
||||
|
||||
use App\Config\Entity\SiteConfig;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* شارژ کیف پول پیامک مثل اشتراک است: مبلغ درخواستی خالص است و مالیات رویش اضافه
|
||||
* میشود. نکتهٔ اصلی این است که **اعتبارِ کیف پول همان مبلغ خالص میماند** — اگر
|
||||
* مالیات هم اعتبار میشد، کاربر آن را بهصورت پیامک پس میگرفت.
|
||||
*/
|
||||
class SmsWalletTaxTest extends ApiTestCase
|
||||
{
|
||||
private function setConfig(string $key, string $value): void
|
||||
{
|
||||
$cfg = $this->em->getRepository(SiteConfig::class)->findOneBy(['configKey' => $key]);
|
||||
if ($cfg === null) {
|
||||
$this->em->persist(new SiteConfig($key, $value));
|
||||
} else {
|
||||
$cfg->setValue($value);
|
||||
}
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function enableTax(string $percent = '10'): void
|
||||
{
|
||||
$this->setConfig('tax_enabled', '1');
|
||||
$this->setConfig('tax_percent', $percent);
|
||||
}
|
||||
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر کیف پول');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function charge(Doctor $doctor, int $netRials): array
|
||||
{
|
||||
$res = $this->authJson('POST', '/api/v1/sms/wallet/charge', $doctor->getUser(), [
|
||||
'gateway' => 'mellat',
|
||||
'amount_rials' => $netRials,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
return $res['data'];
|
||||
}
|
||||
|
||||
public function testChargeResponseSplitsNetAndTax(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
|
||||
$data = $this->charge($this->makeDoctor(), 1_000_000);
|
||||
|
||||
self::assertSame(1_000_000, $data['net_rials']);
|
||||
// JSON عدد اعشاریِ ۱۰٫۰ را ۱۰ سریالایز میکند، پس مقایسه با نوعِ شل.
|
||||
self::assertEquals(10, $data['tax_percent']);
|
||||
self::assertSame(100_000, $data['tax_rials']);
|
||||
self::assertSame(1_100_000, $data['payable_rials']);
|
||||
}
|
||||
|
||||
public function testThePaymentRowCarriesTheTaxedAmount(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
|
||||
$data = $this->charge($this->makeDoctor(), 1_000_000);
|
||||
$payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $data['payment_uuid']]);
|
||||
|
||||
self::assertSame(1_100_000, $payment->getAmountRials(), 'مبلغ بانک باید شامل مالیات باشد');
|
||||
self::assertSame(1_000_000, $payment->getMetadata()['net_rials']);
|
||||
}
|
||||
|
||||
public function testWalletIsCreditedWithTheNetAmountNotTheTaxedOne(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
$doctor = $this->makeDoctor();
|
||||
$wallets = static::getContainer()->get(SmsWalletService::class);
|
||||
$before = $wallets->getBalance('doctor', $doctor->getId());
|
||||
|
||||
$data = $this->charge($doctor, 1_000_000);
|
||||
$payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $data['payment_uuid']]);
|
||||
|
||||
$this->client->request('POST', \App\Payment\Service\PaymentManager::CALLBACK_PATH . '?' . http_build_query([
|
||||
'gateway' => 'mellat',
|
||||
'order_id' => $payment->getOrderId(),
|
||||
'mock' => '1',
|
||||
'ResCode' => '0',
|
||||
'mock_amount' => '1100000',
|
||||
]));
|
||||
|
||||
$this->em->clear();
|
||||
self::assertSame(
|
||||
$before + 1_000_000,
|
||||
$wallets->getBalance('doctor', $doctor->getId()),
|
||||
'مالیات نباید به اعتبار پیامک تبدیل شود',
|
||||
);
|
||||
}
|
||||
|
||||
public function testDisabledTaxKeepsChargeAmountUnchanged(): void
|
||||
{
|
||||
$this->setConfig('tax_enabled', '0');
|
||||
$this->setConfig('tax_percent', '10');
|
||||
|
||||
$data = $this->charge($this->makeDoctor(), 1_000_000);
|
||||
|
||||
self::assertSame(0, $data['tax_rials']);
|
||||
self::assertSame(1_000_000, $data['payable_rials']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Subscription;
|
||||
|
||||
use App\Config\Entity\SiteConfig;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Subscription\Entity\SubscriptionPeriod;
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use App\Subscription\Service\SubscriptionTaxCalculator;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* قیمت دورهٔ اشتراک خالص است و مالیات رویش اضافه میشود — برخلاف نوبت که مبلغش
|
||||
* شامل مالیات است. پس مبلغِ پرداختی همیشه ≥ قیمت پلن است، و مبلغ از سمت سرور
|
||||
* محاسبه میشود نه از بدنهٔ درخواست.
|
||||
*/
|
||||
class SubscriptionTaxTest extends ApiTestCase
|
||||
{
|
||||
private function setConfig(string $key, string $value): void
|
||||
{
|
||||
$cfg = $this->em->getRepository(SiteConfig::class)->findOneBy(['configKey' => $key]);
|
||||
if ($cfg === null) {
|
||||
$this->em->persist(new SiteConfig($key, $value));
|
||||
} else {
|
||||
$cfg->setValue($value);
|
||||
}
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function enableTax(string $percent = '10'): void
|
||||
{
|
||||
$this->setConfig('tax_enabled', '1');
|
||||
$this->setConfig('tax_percent', $percent);
|
||||
}
|
||||
|
||||
private function makePeriod(int $priceRials): SubscriptionPeriod
|
||||
{
|
||||
$plan = new SubscriptionPlan('plan-' . bin2hex(random_bytes(4)), 5, 1, []);
|
||||
$this->em->persist($plan);
|
||||
|
||||
$period = new SubscriptionPeriod($plan, 'یک ماهه', 1, $priceRials);
|
||||
$this->em->persist($period);
|
||||
$this->em->flush();
|
||||
|
||||
return $period;
|
||||
}
|
||||
|
||||
private function calculator(): SubscriptionTaxCalculator
|
||||
{
|
||||
return static::getContainer()->get(SubscriptionTaxCalculator::class);
|
||||
}
|
||||
|
||||
public function testTaxIsAddedOnTopOfTheNetPrice(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
$calc = $this->calculator();
|
||||
|
||||
self::assertSame(29_000, $calc->taxOf(290_000));
|
||||
self::assertSame(319_000, $calc->payableOf(290_000), 'مالیات باید اضافه شود، نه استخراج');
|
||||
}
|
||||
|
||||
public function testDisabledTaxLeavesThePriceUntouched(): void
|
||||
{
|
||||
$this->setConfig('tax_enabled', '0');
|
||||
$this->setConfig('tax_percent', '10');
|
||||
$calc = $this->calculator();
|
||||
|
||||
self::assertSame(0.0, $calc->percent());
|
||||
self::assertSame(0, $calc->taxOf(290_000));
|
||||
self::assertSame(290_000, $calc->payableOf(290_000));
|
||||
}
|
||||
|
||||
public function testFreePeriodStaysFree(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
|
||||
self::assertSame(0, $this->calculator()->payableOf(0));
|
||||
}
|
||||
|
||||
public function testPublicPlanListExposesTaxPerPeriod(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
$this->makePeriod(290_000);
|
||||
|
||||
// این روت پشت firewall نشسته (بر خلاف چیزی که docs میگوید)، پس با توکن زده میشود.
|
||||
$this->authJson('GET', '/api/v1/subscription/plans', $this->createUser());
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$periods = [];
|
||||
foreach (json_decode((string) $this->client->getResponse()->getContent(), true)['data'] as $plan) {
|
||||
foreach ($plan['periods'] ?? [] as $period) {
|
||||
$periods[] = $period;
|
||||
}
|
||||
}
|
||||
|
||||
self::assertNotEmpty($periods, 'حداقل یک دوره باید برگردد');
|
||||
foreach ($periods as $period) {
|
||||
self::assertArrayHasKey('tax_percent', $period);
|
||||
self::assertArrayHasKey('tax_rials', $period);
|
||||
self::assertArrayHasKey('payable_rials', $period);
|
||||
self::assertSame(
|
||||
$period['price_rials'] + $period['tax_rials'],
|
||||
$period['payable_rials'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testPaymentChargesPricePlusTaxAndIgnoresTheClientAmount(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
$period = $this->makePeriod(290_000);
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new \App\Doctor\Entity\Doctor($user, 'دکتر مالیات');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/subscription-payment', $user, [
|
||||
'gateway' => 'mellat',
|
||||
'period_uuid' => $period->getUuid(),
|
||||
// مبلغِ دستکاریشده باید نادیده گرفته شود.
|
||||
'amount_rials' => 1_000,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$body = json_decode((string) $this->client->getResponse()->getContent(), true)['data'];
|
||||
self::assertSame(290_000, $body['price_rials']);
|
||||
self::assertSame(29_000, $body['tax_rials']);
|
||||
self::assertSame(319_000, $body['payable_rials']);
|
||||
|
||||
$payment = $this->em->getRepository(Payment::class)->findOneBy(['uuid' => $body['payment_uuid']]);
|
||||
self::assertSame(319_000, $payment->getAmountRials(), 'مبلغ ذخیرهشده باید شامل مالیات باشد');
|
||||
}
|
||||
|
||||
public function testPaymentRejectsAnUnknownPeriod(): void
|
||||
{
|
||||
$this->enableTax('10');
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new \App\Doctor\Entity\Doctor($user, 'دکتر بدون دوره');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/subscription-payment', $user, [
|
||||
'gateway' => 'mellat',
|
||||
'period_uuid' => 'does-not-exist',
|
||||
'amount_rials' => 290_000,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user