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
+10 -3
View File
@@ -106,7 +106,8 @@
"appointment_fee_rials": 150000,
"gateways": [
{ "name": "mellat", "label": "بانک ملت" }
]
],
"tax_percent": 10
}
}
```
@@ -116,6 +117,7 @@
| `test_mode` | boolean | `true` = درگاه آزمایشی فعال است — backend از MockGateway استفاده می‌کند و پول واقعی کسر نمی‌شود |
| `appointment_fee_rials` | integer | مبلغ هر نوبت به ریال (از تنظیمات سایت، کلید `appointment_fee_rials`). frontend برای نمایش «مبلغ قابل پرداخت» از این می‌خواند؛ مبلغِ واقعیِ تراکنش هم در backend از همین کلید خوانده می‌شود (نه از client) |
| `gateways` | array | فقط درگاه‌های **فعال** (اعتبارنامه‌شان در تنظیمات سایت یا env ست شده). هر عضو: `{ name, label }`. frontend فقط همین‌ها را برای انتخاب نمایش می‌دهد. در `test_mode` تنها `[{ "name": "mellat", "label": "بانک ملت (آزمایشی)" }]` برمی‌گردد. اگر هیچ درگاهی فعال نباشد آرایه خالی است و frontend باید پرداخت را غیرفعال کند. |
| `tax_percent` | number | نرخ مالیات بر ارزش افزوده برای **اشتراک** و **شارژ کیف پول پیامک**. صفر یعنی مالیات خاموش است (`tax_enabled=0`). frontend با این عدد جمع کل را پیش از ارسال درخواست نشان می‌دهد؛ مبلغ نهایی همیشه در backend دوباره حساب می‌شود. نوبت این نرخ را به این شکل به‌کار نمی‌برد — آنجا مبلغ شامل مالیات است. |
فعال‌بودن هر درگاه با `PaymentGatewayInterface::isConfigured()` **و** کلید فعال‌سازی در تنظیمات سایت تعیین می‌شود: `mellat` نیازمند `mellat_terminal_id` + `mellat_username` + `mellat_password`؛ `sep` نیازمند `sep_terminal_id`. علاوه بر این، اگر ادمین درگاه را در تنظیمات غیرفعال کند (`mellat_enabled` / `sep_enabled` = `"0"`)، آن درگاه از این لیست حذف می‌شود و در `initiate` نیز رد می‌شود (خطای ۴۲۲: «درگاه پرداخت نامعتبر یا غیرفعال است»). کلید تنظیم‌نشده = فعال (پیش‌فرض).
@@ -341,7 +343,7 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm
```json
{
"gateway": "mellat",
"amount_rials": 1000000,
"period_uuid": "uuid-of-subscription-period",
"frontend_address": "https://myapp.com/wallet/result"
}
```
@@ -349,9 +351,14 @@ Initiate a subscription / wallet top-up payment (not tied to a specific appointm
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `gateway` | string | ✅ | `"mellat"` or `"sep"` |
| `amount_rials` | integer | ✅ | Amount in Rials (min: 10,000) |
| `period_uuid` | string | ✅ | دورهٔ اشتراک؛ مبلغ از آن محاسبه می‌شود |
| `frontend_address` | string | ❌ | Redirect URL after payment |
> `amount_rials` دیگر خوانده نمی‌شود. مبلغ = `price_rials + tax_rials` همان دوره، محاسبه‌شده در
> `SubscriptionTaxCalculator`. قیمت دوره خالص است و مالیات رویش اضافه می‌شود — جزئیات در
> [subscription.md](subscription.md#مالیات-دورهها). پاسخ هم هر چهار عدد را برمی‌گرداند:
> `price_rials`، `tax_percent`، `tax_rials`، `payable_rials`.
### Response `200`
```json
{
+25 -1
View File
@@ -349,13 +349,37 @@ Updated template with `status: "rejected"`.
"data": {
"payment_uuid": "...",
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/ORD-...",
"order_id": "ORD-..."
"order_id": "ORD-...",
"net_rials": 50000,
"tax_percent": 10,
"tax_rials": 5000,
"payable_rials": 55000
}
}
```
> این endpoint فقط `Payment` (type=`sms_wallet`) می‌سازد و `pay_url` می‌دهد؛ **ارتباط با بانک اینجا انجام نمی‌شود** و از flow واحد پرداخت (`GET /payment/pay/{orderId}` → callback → `PaymentManager`) عبور می‌کند. کلاینت باید مرورگر را به `pay_url` هدایت کند. پس از پرداخت موفق، `PaymentManager` موجودی کیف را خودکار شارژ می‌کند.
#### مالیات
`amount_rials` ورودی **خالص** است — همان اعتباری که به کیف پول می‌نشیند. مالیات رویش
**اضافه** می‌شود و مبلغی که به بانک می‌رود `payable_rials` است.
| فیلد | معنی |
|------|------|
| `net_rials` | اعتباری که بعد از پرداخت موفق به کیف پول اضافه می‌شود |
| `tax_percent` | درصد مؤثر؛ با `tax_enabled=0` برابر `0` |
| `tax_rials` | `round(net_rials × tax_percent / 100)` |
| `payable_rials` | `net_rials + tax_rials` — مبلغ رکورد `Payment` و مبلغ درگاه |
نرخ از همان کلیدهای سراسری `tax_enabled` / `tax_percent` می‌آید؛ محاسبه در
`App\Payment\Service\PaymentTaxCalculator`.
**اعتبار کیف پول هرگز شامل مالیات نیست.** مقدار خالص در `metadata.net_rials` رکورد پرداخت
ذخیره می‌شود و `PaymentManager::handleSmsWalletCharge` همان را شارژ می‌کند — نه
`amount_rials` را. استرداد هم قرینهٔ همین است. پرداخت‌های قدیمی که `net_rials` ندارند به
مبلغ کلشان fallback می‌کنند.
### GET /api/v1/sms/wallet/logs
تراکنش‌های کیف پیامک (paginated).
+32 -4
View File
@@ -12,6 +12,25 @@
> `max_resources` سقف منابع محیط است. مقدار `-1` یعنی نامحدود. مقادیر شیپ‌شده: `free` = ۱، `basic` = ۳، `professional` = `-1`. اجرای این سقف در `POST /api/v1/resource` است — [resource.md](resource.md).
### مالیات دوره‌ها
`price_rials` هر دوره **خالص** است و مالیات رویش **اضافه** می‌شود. این برعکسِ نوبت است؛ آنجا
مبلغ شامل مالیات است و `CommissionService` مالیات را از دلش استخراج می‌کند.
نرخ از همان کلیدهای سراسری `tax_enabled` و `tax_percent` در SiteConfig می‌آید — کلید جداگانه‌ای
برای اشتراک وجود ندارد. محاسبه در `App\Subscription\Service\SubscriptionTaxCalculator`.
هر دوره سه فیلد محاسبه‌شدهٔ اضافه دارد. `price_rials` دست‌نخورده می‌ماند تا کلاینت قدیمی نشکند:
| فیلد | معنی |
|------|------|
| `price_rials` | قیمت خالص، بدون مالیات — همان چیزی که ادمین وارد می‌کند |
| `tax_percent` | درصد مؤثر؛ با `tax_enabled=0` برابر `0` |
| `tax_rials` | `round(price_rials × tax_percent / 100)` |
| `payable_rials` | `price_rials + tax_rials` — مبلغی که واقعاً پرداخت می‌شود |
دورهٔ رایگان یا تریال (`price_rials = 0`) مالیات نمی‌گیرد.
**Response 200:**
```json
{
@@ -42,6 +61,9 @@
"label": "یک ماهه",
"duration_months": 1,
"price_rials": 290000,
"tax_percent": 10,
"tax_rials": 29000,
"payable_rials": 319000,
"is_trial": false,
"active": true,
"sort_order": 1
@@ -141,7 +163,6 @@
```json
{
"gateway": "mellat",
"amount_rials": 290000,
"period_uuid": "uuid-of-subscription-period",
"frontend_address": "https://example.com/payment-result"
}
@@ -150,18 +171,25 @@
| فیلد | نوع | الزامی |
|------|-----|--------|
| gateway | string (mellat\|sep) | ✅ |
| amount_rials | integer | ✅ |
| period_uuid | string (UUID) | ✅ |
| frontend_address | string (URL) | ❌ |
> **`amount_rials` دیگر پذیرفته نمی‌شود.** مبلغ سمت سرور از دورهٔ اشتراک محاسبه می‌شود:
> `price_rials + tax_rials`. اگر کلاینت آن را بفرستد نادیده گرفته می‌شود. دلیلش بستنِ راهِ
> دستکاری قیمت است. دورهٔ ناموجود یا غیرفعال → `422 ERR_VALIDATION_001` روی فیلد `period_uuid`.
**Response 200:**
```json
{
"success": true,
"data": {
"payment_uuid": "...",
"redirect_url": "https://gateway.shaparak.ir/...",
"order_id": "ORD-XXXXXXXXXXXXXXXX"
"pay_url": "https://clinic-pro.ir/api/v1/payment/pay/ORD-XXXXXXXXXXXXXXXX",
"order_id": "ORD-XXXXXXXXXXXXXXXX",
"price_rials": 290000,
"tax_percent": 10,
"tax_rials": 29000,
"payable_rials": 319000
}
}
```