- 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.
24 KiB
SMS API
Prefix:
/api/v1/sms
Provider: همیشهkavenegar(پیشفرض و تنها گزینه فعال).
All send operations are dispatched asynchronously via Symfony Messenger → Redis queue.
سیاست lookup-only (مهم): همهی پیامکها فقط از طریق الگوی تأییدشدهی کاوهنگار (
verify/lookup.json) ارسال میشوند. ارسال متن آزاد باsms/send.jsonغیرفعال است (در ایران برای پیام خدماتی فیلتر میشود). هر پیامی که الگو/کد VerifyLookup نداشته باشد ارسال نمیشود؛ درSmsLogباsuccess=falseثبت و در لاگ برنامه خطا میخورد. هر رکوردSmsLogنام الگوی کاوهنگارِ استفادهشده را در فیلدtemplate_codeنگه میدارد (در پاسخGET /api/v1/admin/sms/logsبا کلیدtemplate)؛ برای پیامکهای ردشدهnullاست.
Configuration
- کلید API کاوهنگار فقط از متغیر محیطی
KAVENEGAR_API_KEYخوانده میشود — نه از دیتابیس و نه از پنل. در پنل ادمین فقط وضعیت read-only «تنظیمشده/نشده» نمایش داده میشود. - شماره فرستنده تنظیم نمیشود؛ کاوهنگار از خط پیشفرض حساب استفاده میکند.
- endpoint
GET /api/v1/admin/settingsیک فیلد read-only به نامsms_api_key_configured(boolean) برمیگرداند. PATCH /api/v1/admin/settingsکلیدهایsms_provider،kavenegar_api_key،kavenegar_sender،rangineh_api_key،rangineh_senderرا نمیپذیرد (ازALLOWED_KEYSحذف شدهاند).- ترابری (transport) کاوهنگار: همهی فراخوانیهای
KavehNegarProviderبه کاوهنگار بهصورت GET با query string ارسال میشوند (مطابق مستند رسمی) — هیچ درخواستPOST/bodyساخته نمیشود. GET بدنه ندارد پس curl هدرExpect: 100-continueنمیفرستد؛ این جلویIdle timeoutرا میگیرد. هر درخواستtimeout=15s،max_duration=30s، بایپس proxy محیطی (proxy=null) و تا ۳ بار retry با backoff فقط برای خطاهای گذرای شبکه (TransportException) دارد؛ خطاهای HTTP کاوهنگار (4xx/5xx، مثل431) دائماند و retry نمیشوند. - سقف طول توکن (
431guard): مقدار طولانی در توکنها (مثل نام کلینیک فارسی که هر کاراکترش ۹ بایت URL-encode میشود) query string را بزرگ میکند و کاوهنگار431 Request Header Fields Too Largeبرمیگرداند. provider هر مقدار توکن را بهMAX_TOKEN_LEN = 60کاراکتر cap میکند. اگر با این حال کاوهنگار خطای HTTP بدهد، بهجایerrorپرسروصدا فقط یکwarningکوتاه با کد وضعیت لاگ میشود (چون خطای دائم است، نه transient). - قیمت هر پیامک از کلید تنظیمات
sms_price_rialsخوانده میشود (قابل ویرایش در/admin/settings→ بخش پیامک، و از طریقPATCH /api/v1/admin/settings). اگر تنظیم نشده باشد، مقدار پیشفرضSmsWalletController::SMS_PRICE_RIALS = 500ریال بهعنوان fallback استفاده میشود.GET /api/v1/sms/wallet/balanceاین مقدار را درsms_price_rialsو تعداد تخمینی پیامک را درestimated_sms_countبرمیگرداند.
POST /api/v1/sms/send — ⛔ غیرفعال (Deprecated)
ارسال متن آزاد دیگر مجاز نیست (سیاست lookup-only). این endpoint اکنون همیشه 422
برمیگرداند و هیچ پیامکی ارسال نمیکند. برای ارسال دستی از
POST /api/v1/sms/send-template با یک تمپلیت تأییدشده
که کد VerifyLookup کاوهنگار دارد استفاده کنید.
Permission: ROLE_ADMIN
Response 422 (همیشه)
{
"success": false,
"errors": [
{ "code": "ERR_VALIDATION_001", "message": "ارسال متن آزاد مجاز نیست؛ از تمپلیت تأییدشده (VerifyLookup) استفاده کنید" }
]
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_VALIDATION_001 |
422 | ارسال متن آزاد غیرفعال است (همیشه) |
POST /api/v1/sms/send-template
Send an SMS using an approved template.
Permission: ROLE_ADMIN
Request Body (application/json)
{
"mobile": "09123456789",
"template_uuid": "tmpl-uuid-...",
"vars": {
"name": "علی احمدی",
"date": "۱۵ خرداد ۱۴۰۴"
},
"provider": "kavenegar"
}
| Field | Type | Required | Description |
|---|---|---|---|
mobile |
string | ✅ | Recipient mobile |
template_uuid |
string (UUID) | ✅ | UUID of an approved template |
vars |
object | ❌ | Key-value substitutions for template placeholders |
provider |
string | ❌ | Override provider |
Response 200
{
"success": true,
"data": { "message": "پیامک با موفقیت ارسال شد" }
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Template not found |
ERR_VALIDATION_001 |
422 | Template not approved |
ERR_VALIDATION_001 |
422 | تمپلیت کد VerifyLookup کاوهنگار ندارد و قابل ارسال نیست (provider_code خالی) |
POST /api/v1/sms/template
Create a new SMS template.
Permission: ROLE_ADMIN
Request Body (application/json)
{
"name": "تأیید نوبت",
"body": "دکتر گرامی ${name}، نوبت شما در تاریخ ${date} تأیید شد.",
"variables": ["name", "date"]
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✅ | Template display name |
body |
string | ✅ | Template text with ${variable} placeholders |
variables |
string[] | ❌ | List of expected variable names |
Response 201
{
"success": true,
"data": {
"uuid": "tmpl-uuid-...",
"name": "تأیید نوبت",
"body": "دکتر گرامی ${name}...",
"variables": ["name", "date"],
"status": "draft",
"created_at": 1717000000
}
}
Template Status Values:
| Value | Description |
|---|---|
draft |
Created, not submitted |
pending |
Submitted for review |
approved |
Ready to use |
rejected |
Rejected |
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_VALIDATION_002 |
422 | Missing required field |
GET /api/v1/sms/template/{uuid}
Get template detail.
Permission: AUTH
Response 200
Template object.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_NOT_FOUND_001 |
404 | Template not found |
PATCH /api/v1/sms/template/{uuid}
Update a template (only allowed in draft or rejected status).
Permission: ROLE_ADMIN
Request Body (application/json)
{
"name": "تأیید نوبت - ویرایش",
"body": "نوبت شما در ${date} تأیید شد.",
"variables": ["date"]
}
All fields optional.
Response 200
Updated template object.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Template not found |
ERR_SMS_003 |
422 | Template already submitted/approved |
POST /api/v1/sms/template/{uuid}/submit
Submit template for admin review (moves status from draft to pending).
Permission: ROLE_ADMIN
Response 200
Updated template with status: "pending".
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Template not found |
ERR_SMS_003 |
422 | Already submitted |
DELETE /api/v1/sms/template/{uuid}
Delete a template.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "message": "قالب پیامک حذف شد" } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Template not found |
GET /api/v1/admin/sms/templates
List all templates (admin view with all statuses).
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"name": "تأیید نوبت",
"status": "approved",
"created_at": 1717000000
}
]
}
POST /api/v1/admin/sms/template/{uuid}/approve
Approve a pending template.
Permission: ROLE_ADMIN
Request Body (application/json)
{
"note": "تأیید شد",
"provider_code": "verify_appointment"
}
| Field | Type | Required | Description |
|---|---|---|---|
note |
string | ❌ | Admin note |
provider_code |
string | ❌ | Provider-side template code |
Response 200
Updated template with status: "approved".
POST /api/v1/admin/sms/template/{uuid}/reject
Reject a pending template.
Permission: ROLE_ADMIN
Request Body
{
"note": "متن قالب نامناسب است"
}
| Field | Type | Required |
|---|---|---|
note |
string | ✅ |
Response 200
Updated template with status: "rejected".
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_002 |
422 | Missing note |
SMS Wallet
کیف پیامکی — جدا از کیف مالی، فقط برای ارسال پیامک.
GET /api/v1/sms/wallet/balance
Permission: IS_AUTHENTICATED_FULLY
{
"success": true,
"data": {
"balance_rials": 15000,
"sms_price_rials": 500,
"estimated_sms_count": 30
}
}
POST /api/v1/sms/wallet/charge
شارژ کیف پیامکی از طریق درگاه پرداخت.
Permission: IS_AUTHENTICATED_FULLY
{
"gateway": "mellat",
"amount_rials": 50000,
"frontend_address": "https://example.com/sms-wallet"
}
Response 200:
{
"success": true,
"data": {
"payment_uuid": "...",
"pay_url": "{APP_BASE_URL}/api/v1/payment/pay/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).
Query params: page, limit
{
"success": true,
"data": [
{
"uuid": "...",
"type": "credit",
"amount_rials": 50000,
"description": "شارژ کیف پیامک",
"created_at": 1718000000
}
],
"meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
}
SMS Settings
GET /api/v1/sms/settings
تنظیمات پیامک entity جاری.
Permission: IS_AUTHENTICATED_FULLY
{
"success": true,
"data": {
"entity_type": "clinic",
"entity_id": 5,
"reminder_enabled": true,
"reminder_hours_before": 2,
"post_visit_enabled": false,
"post_visit_text": null,
"post_visit_text_pending": null,
"post_visit_text_status": "none",
"post_visit_text_reject_reason": null,
"updated_at": 1718000000
}
}
PATCH /api/v1/sms/settings
Permission: IS_AUTHENTICATED_FULLY
{
"reminder_enabled": true,
"reminder_hours_before": 3,
"post_visit_enabled": true,
"post_visit_text": "از مراجعه شما سپاسگزاریم"
}
تغییر رفتار post_visit_text: متن ارسالشده مستقیماً اعمال نمیشود — در فیلد post_visit_text_pending ذخیره میشود و وضعیت post_visit_text_status به pending تغییر میکند. پس از تأیید ادمین، به post_visit_text منتقل میشود.
مقادیر post_visit_text_status: none | pending | approved | rejected
Admin Endpoints
GET /api/v1/admin/sms/settings/review
Permission: ROLE_ADMIN — لیست تنظیمات SMS بر اساس وضعیت متن ویزیت.
Query params:
| پارامتر | مقدار | پیشفرض | توضیح |
|---|---|---|---|
status |
pending | approved |
pending |
فیلتر بر اساس post_visit_text_status. مقدار نامعتبر → pending. |
برای تب «در انتظار تأیید» با status=pending و برای تب «پیامکهای تأییدشده» با status=approved فراخوانی میشود.
{
"success": true,
"data": {
"data": [
{
"id": 3,
"entity_type": "doctor",
"entity_id": 7,
"entity_name": "دکتر محمد محمدی",
"post_visit_text_pending": "متن در انتظار تأیید",
"post_visit_text_status": "pending",
...
}
]
}
}
POST /api/v1/admin/sms/settings/{id}/approve
Permission: ROLE_ADMIN — تأیید متن پیامک. post_visit_text_pending به post_visit_text منتقل میشود.
POST /api/v1/admin/sms/settings/{id}/reject
Permission: ROLE_ADMIN — رد متن پیامک.
{ "reason": "متن نامناسب است" }
GET /api/v1/admin/sms/wallet-report
Permission: ROLE_ADMIN — لیست همه کیفهای پیامکی (paginated)
متن ویرایشپذیر پیامکهای سیستمی
متن پیامکهای سیستمی از پنل قابل ویرایش است و بر اساس تگ کلیددار میشود. هر متن placeholderهای مجاز خود را دارد (مثل {code}، {doctor}، {date}).
همهی پیامکهای سیستمی از طریق Kavenegar VerifyLookup (verify/lookup.json) ارسال میشوند (نه متنآزاد sms/send). هر تمپلت دو فیلد اضافه دارد:
kavenegar_template: نام تمپلت مصوب در پنل کاوهنگار. متن واقعیِ ارسالی از همین تمپلت پنل میآید، نه ازbodyدیتابیس؛bodyفقط برای پیشنمایش ادمین و رندرِ رکوردSmsLogاستفاده میشود و باید دستی با تمپلت پنل همراستا نگه داشته شود.token_map: نگاشت متغیر منطقی → جایگاه کاوهنگار. قانون کاوهنگار: هر الگو حتماً باید%tokenداشته باشد (token1 اجباری است)؛ پس token_map هر تگ یک مقدار بهtokenمیدهد. قانون فاصله:token/token2/token3فاصله نمیپذیرند — provider (KavehNegarProvider) فاصلههای مقدارِ این جایگاهها را خودکار با نیمفاصله (ZWNJ) جایگزین میکند؛token10/token20فاصله را بدون تغییر میپذیرند. پس ترجیحاً مقادیر بدونفاصله (کد/تاریخ/ساعت/username/لینک) درtoken/token2/token3و مقادیر دارای فاصله (نام دکتر/بیمار/کلینیک) درtoken10/token20. مقدار هر جایگاه بهMAX_TOKEN_LEN = 60کاراکتر cap میشود تا از خطای431جلوگیری شود.
SmsService::dispatchTemplate(tag, mobile, vars) نقطهی واحدِ ارسال است: kavenegar_template و token_map را از رکورد DB (یا SmsMessageTemplate::DEFAULTS) میخواند، vars را به جایگاهها نگاشت میکند و با VerifyLookup میفرستد. اگر kavenegar_template تعریف نشده باشد → fallback به ارسال متنآزاد با body رندرشده.
نگاشت پیشفرض هر تگ (نام تمپلت پنل + token_map):
| تگ | kavenegar_template | token_map |
|---|---|---|
otp |
clinicpro-otp |
code→token, site→token10 |
notification_mobile |
clinicpro-notify-code |
code→token |
payment |
clinicpro-payment |
date→token, time→token2, doctor→token10 |
clinic_invitation |
clinicpro-clinic-invite |
link→token, clinic→token10 |
pre_registration |
clinicpro-pre-register |
username→token, password→token2, link→token3 |
secretary |
clinicpro-secretary |
username→token, link→token3, owner→token10 |
doctor_appointment |
clinicpro-doctor-appt |
date→token, time→token2, patient→token10 |
welcome |
clinicpro-welcome |
name→token, site→token10 |
پیشنیاز: این تمپلتها باید در پنل کاوهنگار ساخته و تأیید شوند (نیازمند اشتراک advanced). تمپلتهای دارای
{link}باید با تأیید لینک ساخته شوند.
تگها:
otp،payment،clinic_invitation،pre_registration،notification_mobile،welcome،secretary،doctor_appointment. (پیامک قالبیِ کاربر با تگuser_templateجداگانه از طریقPOST /api/v1/sms/templateمدیریت میشود.)تگ
otp: کد تأیید ورود. مقدارsiteاز فیلدdomainدرPOST /api/v1/user/send-codeگرفته میشود:site_nameشهرِ متناظر در جدولcities؛ اگرdomainنیامد یا شهر پیدا نشد → «کلینیک پرو».تگ
otp: کد تأیید ورود از طریق تمپلتclinicpro-otp(VerifyLookup). نام تمپلت از رکورد DB خوانده میشود (متغیر محیطیKAVENEGAR_OTP_TEMPLATEحذف شده). token_map:code→token،site→token10.تگ
welcome: پیامک خوشآمد که هنگام افزودن پزشک/کلینیک توسط نماینده (POST /api/v1/representation/doctor|clinic) بهصورت async به موبایل پزشک/مالک ارسال میشود. از تمپلتclinicpro-welcome(name→token10,site→token20).تگ
secretary: پیامک خوشآمد که هنگام تعریف منشی جدید (POST /api/v1/secretary) بهصورت async به موبایل منشی ارسال میشود. placeholderها:{owner}(نام دکتر یا کلینیک)،{username}(موبایل منشی)،{link}(لینک ورود). متن از قالب DB میآید (fallback به پیشفرضSmsMessageTemplate::DEFAULTS).تگ
doctor_appointment: اعلانِ «نوبت جدید» به شمارهٔ اعلان دکتر (Doctor.notificationMobileکه در/admin/profileست میشود). فقط برای نوبتهای پرداختشدهٔ سایت ارسال میشود — درPaymentManager::handleAppointmentConfirmationکه تنها پس از verify موفقِ پرداخت اجرا میشود؛ نوبتهای ثبتشده توسط منشی (بدون پرداخت) این پیامک را نمیگیرند. placeholderها:{patient}(نام بیمار)،{date}(تاریخ شمسی)،{time}(ساعتHH:MM). اگرnotificationMobileخالی باشد ارسال نمیشود. متن از قالب DB (fallback بهDEFAULTS).
GET /api/v1/admin/sms/messages
لیست همهی متنهای سیستمی (تگهایی که هنوز رکورد ندارند با مقدار پیشفرض برگردانده میشوند).
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"data": [
{
"tag": "otp",
"title": "کد تأیید ورود",
"body": "کد تأیید شما: {code}",
"variables": ["code", "site"],
"kavenegar_template": "clinicpro-otp",
"token_map": { "code": "token", "site": "token10" },
"updated_at": 1718000000
}
]
}
}
PATCH /api/v1/admin/sms/messages/{tag}
ویرایش متن یک پیامک سیستمی.
Permission: ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
tag |
string | یکی از تگهای سیستمی |
Request Body
{ "body": "کد ورود شما: {code}", "kavenegar_template": "clinicpro-otp" }
| Field | Type | Required | Description |
|---|---|---|---|
body |
string | ✅ | متن جدید (پیشنمایش/لاگ)؛ فقط placeholderهای مجازِ همان تگ پذیرفته میشود |
kavenegar_template |
string | ❌ | نام تمپلت مصوب پنل کاوهنگار؛ رشتهی خالی → null |
Response 200
{ "success": true, "data": { "data": { "tag": "otp", "title": "...", "body": "...", "variables": ["code"], "updated_at": 1718000123 } } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | تگ ناشناخته |
ERR_VALIDATION_002 |
422 | متن خالی |
ERR_VALIDATION_001 |
422 | placeholder نامعتبر (خارج از متغیرهای مجاز تگ) |
Command:
php bin/console app:seed-sms-message-templatesرکوردهای پیشفرض را برای تگهایی که هنوز ندارند میسازد.