# وب‌سرویس حداقل پیامک پلتفرم

وضعیت: پیاده‌سازی کد برای بازبینی؛ تست، نصب PostgreSQL و ارسال واقعی انجام نشده است.

## تصمیم‌های تأییدشده

- فعلاً یک حساب sms.ir متعلق به مهامیار داریم؛ خط اختصاصی کسب‌وکار هم زیر همین حساب است.
- OTP قالبی از `/send/verify` ارسال می‌شود. این درخواست انتخاب شماره خط ندارد؛ خط انتخابی کسب‌وکار برای ارسال متنی آینده است و روی OTP اعمال نمی‌شود.
- OTP نرخ ثابت ریالی دارد. پیامک متنی آینده بر اساس تعداد بخش‌ها قیمت‌گذاری می‌شود.
- برای کسب‌وکار منتخب، `free_otp=true` یعنی پرداخت هزینه توسط پلتفرم. محدودیت نرخ همچنان برقرار است.
- با اتمام اعتبار کسب‌وکار، OTP پولی ارسال نمی‌شود؛ پرداخت خودکار از حساب پلتفرم یا ایجاد بدهی نداریم.
- تمامی جدول‌ها مرکزی هستند؛ محل دیتابیس عملیاتی کسب‌وکار اثری بر این قرارداد ندارد.

## هویت و مجوز

پیشوند همه مسیرهای این سند `/api/v1/platform/sms` است. تمام مسیرها Passport، حساب فعال، توکن مستقل پلتفرم و مجوز صریح لازم دارند. توکن کسب‌وکار پذیرفته نمی‌شود. پاسخ‌ها `Cache-Control: no-store, private` دارند.

مجوزها در `InfrastructurePermissionSeeder` تعریف شده‌اند و به کاتالوگ نقش اولیه `platform_admin` افزوده شده‌اند:

| مجوز | کاربرد |
|---|---|
| `platform.sms.settings.view` | مشاهده تنظیمات، خطوط، تعرفه‌ها و سیاست کسب‌وکار؛ برآورد متن |
| `platform.sms.settings.manage` | تغییر اتصال، قالب ورود و خطوط |
| `platform.sms.billing.manage` | ثبت نسخه تعرفه، سیاست هزینه کسب‌وکار و شارژ مدیریتی |
| `platform.sms.logs.view` | مشاهده سوابق پیامک، دفتر اعتبار و تاریخچه مدیریت |
| `platform.sms.reconcile` | تعیین تکلیف انسانی نتیجه نامشخص |

تمام تغییرات علاوه بر مجوز، فیلد `current_password` و تأیید رمز جاری مدیر را لازم دارند. این فیلد هر بار بررسی می‌شود و توکن تأیید مجدد یا جدول جداگانه‌ای نمی‌سازد. محدودیت عمومی مدیر ۶۰ درخواست در دقیقه و محدودیت مشترک تغییرات حساس ۵ درخواست در دقیقه است.

سیدر فقط کاتالوگ و اتصال مجوز به نقش را می‌سازد؛ عضویت و تخصیص نقش به کاربر ایجاد نمی‌کند. نقش غیرفعال یا سابقه لغو مجوز از مسیر bootstrap خودکار احیا نمی‌شود. نصب مجوزها و تخصیص نقش باید با مسیر موجود پروژه انجام شود؛ در این تحویل هیچ سیدری اجرا نشده است.

## مسیرها

| روش و مسیر نسبی | ورودی اصلی | مجوز |
|---|---|---|
| `GET settings` | ـ | `settings.view` |
| `PUT settings` | تنظیمات اتصال و `current_password` | `settings.manage` |
| `GET lines` | `before_id` اختیاری | `settings.view` |
| `POST lines` | `number`، `business_uid` اختیاری | `settings.manage` |
| `PUT lines/{line_uid}` | `is_active` | `settings.manage` |
| `GET tariffs` | `before_id` اختیاری | `settings.view` |
| `POST tariffs` | `otp_price_rial`، `segment_price_rial` | `billing.manage` |
| `GET businesses/{business_uid}` | ـ | `settings.view` |
| `PUT businesses/{business_uid}` | `tariff_uid`، `free_otp`، `otp_hourly_limit`، `sender_line_uid` اختیاری | `billing.manage` |
| `POST businesses/{business_uid}/credits` | `operation_uid`، `amount_rial` | `billing.manage` |
| `GET businesses/{business_uid}/wallet-entries` | `before_id` اختیاری | `logs.view` |
| `GET messages` | `business_uid` و `before_uid` اختیاری | `logs.view` |
| `GET events` | `before_id` اختیاری | `logs.view` |
| `POST messages/{message_uid}/resolve` | `accepted`، `provider_checked=true`، شناسه سرویس‌دهنده برای پذیرش | `reconcile` |
| `POST estimate` | `text`، `tariff_uid` | `settings.view` |

نام کوتاه مجوزهای جدول، پسوند `platform.sms.` است. مسیرهای تغییر همگی `current_password` می‌خواهند. فهرست‌ها حداکثر ۵۰ ردیف دارند. برای فهرست‌های دارای `before_id`، شناسه آخرین ردیف صفحه در درخواست بعدی فرستاده شود؛ `messages` مقدار `next_before_uid` می‌دهد.

## تنظیم اتصال

فیلدهای قابل تنظیم: `base_url`، `api_key`، `is_active`، `default_sender_number`، `otp_template_id`، `otp_parameter_name` و `request_timeout`.

- نشانی مجاز فعلی فقط `https://api.sms.ir/v1` با اسلش اختیاری انتهایی است؛ تغییر مسیر HTTP دنبال نمی‌شود.
- `api_key` فقط ورودی است. حذف این فیلد از درخواست یعنی حفظ کلید؛ مقدار `null` یعنی پاک‌کردن کلید و مستلزم غیرفعال‌بودن حساب است. مقدار ذخیره‌شده Laravel-encrypted است و پاسخ فقط `api_key_configured` دارد.
- برای فعال‌سازی از API، کلید و شناسه قالب OTP لازم‌اند. شماره خط برای مسیر قالبی OTP لازم نیست.
- `otp_template_id` شناسه قالب تأییدشده در پنل sms.ir است؛ API مهامیار قالب را نزد سرویس‌دهنده ایجاد یا تأیید نمی‌کند. نام پارامتر پیش‌فرض `Code` است.
- با تغییر تنظیمات، `revision` افزایش می‌یابد و نسخه مورد استفاده کنار هر ارسال ثبت می‌شود. کلید قدیمی یا تصویر رمزگذاری‌شده آن در سابقه ارسال کپی نمی‌شود.
- تغییر تنظیمات در ممیزی مرکزی و تاریخچه ماژول ثبت می‌شود. مقادیر کلید، رمز مدیر، OTP و پاسخ خام سرویس‌دهنده در این سوابق نیستند.
- `max_retry_attempts` در مدیریت فعلی صفر می‌ماند. فیلد قدیمی `status_check_interval` هنوز مصرف‌کننده اجرایی ندارد؛ polling تحویل ساخته نشده است.

کلید واقعی را در چت، فایل نمونه، دستور خط فرمان یا گزارش قرار ندهید. رابط مدیریتی آینده باید کلید را فقط روی ارتباط امن بفرستد و بدنه درخواست حساس در ابزارهای ثبت درخواست ذخیره نشود. کلید Laravel و تنظیمات واقعی این تحویل تغییر نکرده‌اند.

## سیاست کسب‌وکار و اعتبار

قبل از ارسال، کسب‌وکار باید تنظیم پیامک و یک تعرفه صریح داشته باشد. نبود تنظیمات با `SMS_BUSINESS_NOT_CONFIGURED` متوقف می‌شود؛ تعرفه یا OTP رایگان خودکار انتخاب نمی‌شود.

خط ثبت‌شده می‌تواند عمومی (`business_id=NULL`) یا متعلق به یک کسب‌وکار باشد. انتخاب خط اختصاصی کسب‌وکار دیگر هم در کد و هم با trigger PostgreSQL منع شده است. مالک و شماره خط پس از ثبت تغییر نمی‌کنند؛ می‌توان خط را غیرفعال کرد. `sender_line_uid=null` یعنی انتخاب خط مشترک پیش‌فرض در مسیر ارسال متنی آینده. OTP از این فیلد استفاده نمی‌کند.

تعرفه‌ها غیرقابل تغییرند؛ تغییر قیمت با ثبت نسخه جدید و تخصیص آن به کسب‌وکار انجام می‌شود. قیمت هر واحد بین ۱ تا ۱٬۰۰۰٬۰۰۰٬۰۰۰ ریال است. تعرفه و مبلغ هر ارسال ثابت می‌مانند.

اعتبار به ریال است. `available_rial = balance_rial - reserved_rial`؛ رزرو هنگام آماده‌سازی ارسال انجام می‌شود. برداشت با پذیرش قطعی درخواست توسط سرویس‌دهنده است، نه اثبات تحویل به گوشی. رد قطعی باعث آزادسازی رزرو است. برای OTP رایگان، کیف کسب‌وکار تغییری نمی‌کند و پیام با پرداخت‌کننده `PLATFORM` و وضعیت `SPONSORED` ثبت می‌شود. از هزینه ارسال ناموفق نباید در گزارش هزینه پذیرفته‌شده جمع زده شود.

شارژ فعلی **اعطای اعتبار مدیریتی** است و اثبات پرداخت بانکی نیست. مدیر مجاز می‌تواند مبلغ را با `operation_uid` یکتا ثبت کند؛ تکرار همان شناسه و مبلغ برای همان کسب‌وکار اعتبار تازه نمی‌سازد. استفاده مجدد با مبلغ یا کسب‌وکار دیگر خطای تعارض می‌دهد. هر شارژ به ممیزی مرکزی وصل است. خرید آنلاین، اتصال فاکتور پرداخت‌شده، بازپرداخت و مانده حساب sms.ir در این مرحله ساخته نشده‌اند. مصرف آتی پرداخت‌ها باید در هسته صورتحساب، پس از تأیید پرداخت و با کلید یکتای پرداخت به افزایش اعتبار وصل شود.

## تاریخچه تغییر سیاست هزینه

در هر ثبت یا تغییر سیاست کسب‌وکار، مقادیر `tariff_uid`، `sender_line_uid`، `free_otp` و `otp_hourly_limit` پیش و پس از تغییر، در همان تراکنش تغییر سیاست ثبت می‌شوند. کلید API، رمز مدیر و OTP جزو این داده‌ها نیستند. ثبت سیاست جدید بدون نسخه بعد از تغییر، در سطح دیتابیس نیز رد می‌شود.

`GET events` علاوه بر نوع رویداد و زمان، `actor_user_id`، `actor_membership_id`، `business_uid`، `previous_values`، `new_values` و `has_policy_snapshot` را برمی‌گرداند. نسخه‌ها به‌صورت شیء JSON هستند. در نخستین تنظیم، `previous_values=null` است. رویدادهای قدیمی پیش از نصب این اصلاحیه بازنویسی نمی‌شوند و `has_policy_snapshot=false` دارند؛ سابقه مجهول با مقدار فعلی پر نمی‌شود. تاریخچه همچنان تغییرناپذیر است.

## روند OTP و نتیجه نامشخص

مسیرهای ورود قبلی باقی‌اند:

- `POST /api/v1/tenant/auth/otp/request`
- `POST /api/v1/tenant/auth/otp/verify`

Identity محدودیت‌ها را بررسی و چالش را با هش رمز ثبت می‌کند و تراکنش را می‌بندد. SMS در تراکنش کوتاه دیگری، اعتبار و قفل چالش را از قرارداد عمومی `Identity/PublicContracts/LoginOtpDispatch` می‌گیرد و سیاست کسب‌وکار، سابقه بدون متن رمز و رزرو مبلغ را مدیریت می‌کند. SMS جدول چالش را مستقیماً نمی‌خواند. درخواست شبکه بعد از commit و بدون تراکنش باز انجام می‌شود. سپس نتیجه در تراکنش جدید ثبت و اعتبار تسویه می‌شود.

| نتیجه | وضعیت پیام | اعتبار کسب‌وکار |
|---|---|---|
| پاسخ پذیرش معتبر همراه شناسه پیام | `SENT` | `CHARGED` |
| پاسخ صریح عدم پذیرش | `FAILED` | `RELEASED` |
| timeout، پاسخ نامعتبر یا نتیجه مبهم | `UNKNOWN` | `RESERVED` |
| توقف فرایند پس از آماده‌سازی یا خطای ثبت نتیجه | ممکن است `SENDING` بماند | `RESERVED` |

در دو حالت آخر ارسال مجدد خودکار انجام نمی‌شود. `sms:pending --limit=50` فقط شناسه‌های تعیین‌تکلیف‌نشده با قدمت حداقل ۱۸۰ ثانیه را می‌خواند؛ گیرنده و رمز را نمایش نمی‌دهد. فیلد `resolution_available` فقط برای `UNKNOWN` درست است. این فرمان عملیات اصلاح، تماس با سرویس‌دهنده یا ارسال ندارد.

مدیر دارای مجوز مستقل، پس از بررسی پنل sms.ir، با `resolve` فقط نتیجه `UNKNOWN` را تأیید می‌کند. پذیرش نیازمند شناسه پیام است؛ رد یعنی مدیر عدم پذیرش واقعی را احراز کرده است. این مسیر به اعتماد و مسئولیت مدیر مجاز متکی است و استعلام خودکار ارائه‌دهنده ندارد. این عملیات هم حداقل ۱۸۰ ثانیه انتظار، رمز فعلی و ثبت تاریخچه دارد. نتیجه قطعی دوباره قابل تسویه نیست. برای رد قطعی، `provider_message_id` نفرستید.

`SENDING` با هر قدمتی توسط API قابل تسویه نیست و خطای ۴۰۹ با کد `SMS_DISPATCH_NOT_FINALIZED` می‌دهد. گذشت زمان اثبات پایان فرایند ارسال نیست؛ فرایند معلق ممکن است هنوز ادامه پیدا کند. این وضعیت و رزرو آن برای بررسی عملیاتی حفظ می‌شوند؛ تبدیل خودکار یا دستور تبدیل دستی آن به `UNKNOWN` در این نسخه وجود ندارد. بازیابی چنین موردی نیازمند احراز توقف قطعی فرایند و مسیر اصلاح کنترل‌شده جداگانه است؛ ویرایش مستقیم وضعیت یا آزادسازی رزرو، مسیر پشتیبانی این API نیست.

چالش OTP که به‌دلیل شکست یا نتیجه مبهم باطل شده با تعیین تکلیف مالی دوباره فعال نمی‌شود. رمز جدید فقط با درخواست جدید و محدودیت ارسال مجدد ایجاد می‌شود. اگر ارسال پذیرفته شده ولی چالش هنگام پاسخ منقضی یا باطل شده باشد، `SMS_CHALLENGE_EXPIRED` برمی‌گردد؛ هزینه پذیرش ثبت‌شده باقی است.

## محدودیت‌ها و نگهداری امن

تنظیمات واحد در `Modules/SMS/config/config.php` است:

| محدودیت | پیش‌فرض |
|---|---|
| فاصله درخواست مجدد برای شماره، در کل پلتفرم | ۶۰ ثانیه |
| درخواست هر شماره، در کل پلتفرم | ۵ در ساعت |
| درخواست هر IP | ۲۰ در ساعت |
| درخواست هر کسب‌وکار | ۱۰۰ در ساعت |
| تلاش تأیید OTP هر IP، شامل چالش نامعتبر | ۶۰ در ساعت |
| عمر رمز | ۳۰۰ ثانیه |
| خطای تأیید هر چالش | ۵ بار |

قفل‌های تراکنشی PostgreSQL شمارنده‌های درخواست را سری می‌کنند. سقف اختصاصی کسب‌وکار نیز پیش از آماده‌سازی ارسال بررسی می‌شود؛ سقف مؤثر محدودترِ تنظیم عمومی و اختصاصی است. محدودکننده قبلی گروه مسیرهای ورود نیز برقرار است. عمر قابل تنظیم از ۳۰ تا ۳۰۰ ثانیه و حداکثر خطا از ۱ تا ۵ است تا با قیود امنیتی دیتابیس سازگار بماند.

OTP، پارامترهای ارسال و کلید در job یا سابقه پیام ذخیره نمی‌شوند. هش OTP فقط در جدول چالش Identity است. سابقه ارسال شماره واقعی را برای عملیات داخلی دارد؛ پاسخ API آن را با چهار رقم پایانی نمایش می‌دهد. پاسخ خام، متن خطای سرویس‌دهنده و استثنای شبکه ذخیره نمی‌شوند؛ فقط کد عددی و خطای داخلی مجاز ثبت می‌شوند. حذف خودکار تاریخچه مالی/مدیریتی در این نسخه نداریم.

`provider_cost` عدد اعلامی سرویس‌دهنده با واحد خود سرویس‌دهنده است؛ به‌عنوان ریال یا قیمت فروش مهامیار تفسیر نمی‌شود و ورودی برداشت از کیف نیست.

## برآورد پیامک متنی آینده

`estimate` فقط برآورد می‌کند و ارسال یا برداشت ندارد. محاسبه GSM-7 با سقف ۱۶۰ واحد برای پیام تکی و ۱۵۳ برای هر بخش چندتکه، و متن Unicode با ۷۰ و ۶۷ واحد UTF-16 انجام می‌شود. نویسه‌های توسعه‌یافته GSM و زوج جانشین UTF-16 هنگام تقسیم بین بخش‌ها شکسته نمی‌شوند. نتیجه `estimated=true` دارد؛ تطبیق با رفتار واقعی sms.ir و پشتیبانی کاراکترها هنوز بررسی عملی نشده است. نرخ ثابت OTP از طول قالب یا این برآورد محاسبه نمی‌شود.

## خطاهای اصلی

قالب پاسخ خطا همان `error.code / message / fields` پروژه است.

- `SMS_BUSINESS_NOT_CONFIGURED`، `SMS_UNAVAILABLE` و `SMS_OUTCOME_UNKNOWN`: وضعیت ۵۰۳.
- `SMS_INSUFFICIENT_CREDIT`، `SMS_CREDIT_OPERATION_CONFLICT`، `SMS_MESSAGE_NOT_RECONCILABLE`، `SMS_DISPATCH_NOT_FINALIZED` و `SMS_CHALLENGE_EXPIRED`: وضعیت ۴۰۹.
- `SMS_BUSINESS_RATE_LIMITED`: وضعیت ۴۲۹؛ محدودیت‌های عمومی ورود نیز ۴۲۹ دارند.
- خطای ورودی: ۴۲۲؛ نبود هویت یا مجوز: ۴۰۱ یا ۴۰۳.

## مرجع تطبیق سرویس‌دهنده

قرارداد OTP و تفاوت آن با ارسال دارای شماره خط، از [SDK منتشرشده ارائه‌دهنده](https://github.com/IPeCompany/SmsPanelV2.DotNet#sends) مبنا گرفته شده است. لینک‌های معرفی و مستندات مستقیم sms.ir در بررسی قبلی قابل دریافت نبودند. در این مرحله هیچ تماس واقعی با حساب سرویس‌دهنده انجام نشده است.

اصلاح پاسخ درخواست OTP: پس از ثبت چالش، API بدون انتظار برای سرویس پیامک پاسخ می‌دهد. برای همه شماره‌ها یک کار در صف مرکزی identity-otp ثبت می‌شود؛ صلاحیت، انقضا و اتصال هویت در worker بررسی می‌شود. پاسخ پذیرش به معنی ارسال قطعی نیست. زمان اعتبار از زمان صدور چالش است و با انتظار صف تمدید نمی‌شود. خرابی ثبت صف باعث بازگشت تراکنش چالش می‌شود. صف اختصاصی identity_otp از نوع database، روی اتصال central و جدول jobs است و به QUEUE_CONNECTION یا sync وابسته نیست. فقط ciphertext رمز و شناسه چالش در payload قرار می‌گیرد؛ رمز با APP_KEY رمزگذاری می‌شود. نگهداری کلید برنامه برای پردازش کارهای موجود ضروری است.

دستور worker اختصاصی (باید با مدیر فرایند سرور دائماً فعال باشد):

```sh
php artisan queue:work identity_otp --queue=identity-otp --tries=1 --timeout=150
```

retry_after اتصال اختصاصی ۲۴۰ ثانیه است. کار منقضی یا باطل ارسال نمی‌شود. تلاش خودکار مجدد برای ارسال انجام نمی‌شود؛ پس از timeout ممکن است SENDING و رزرو برای بررسی عملیاتی باقی بمانند. کارهای صف قدیمی را پس از ارتقا با worker قدیمی پردازش نکنید؛ workerها باید کد جدید را بارگذاری کنند. برای این اصلاح تست، ارسال پیامک یا عملیات دیتابیس اجرا نشده است.
