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:
+10
-3
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user