# Final Production Hardening + Product Deep Links — Report

This covers the 25-part prompt (stock reservation lifecycle, inactive-product
enforcement, discount atomicity edge case, digital fulfillment retry-safety,
ZarinPal unknown-state handling, wallet top-up failure cleanup, and product deep
links). For the earlier round (Zarinpal idempotency, wallet ledger, CSRF/webhook
secret/login lockout, admin audit log, etc.) see `HARDENING_REPORT.md`.

Same environment constraint as last time: **no PHP interpreter or MySQL was
available in this sandbox**, so nothing here was actually executed — everything
below was produced by careful manual tracing plus an automated brace/quote-balance
pass on every touched file. Section 4 and the Final Status are honest about that.

---

## 1. Files Changed

**`handlers/repo_products.php`**
- `reserve_stock()`: added `AND is_active = 1` to the atomic UPDATE — this is the
  single final gate that makes an inactive product unpurchasable no matter which
  entry point was used (Part 2).
- New: `reserve_stock_batch()`, `release_stock_reservation()`,
  `consume_stock_reservation()`, `list_expired_reservation_keys()` — the stock
  reservation lifecycle (Part 1).

**`handlers/repo_cart.php`**
- New: `cart_remove_inactive_items()` — removes cart rows for products deactivated
  since they were added, returns their names for a user-facing notice (Part 2).

**`handlers/repo_orders.php`**
- `mark_order_item_fulfilled()`: now also stores `digital_content_type`.
- New: `mark_order_item_delivered()`, `increment_order_item_delivery_attempts()` —
  split "asset claimed" from "successfully sent to user" (Part 4).

**`handlers/checkout.php`**
- `start_add_to_cart()`, `handle_quantity_text()`: added `is_active` checks (Part 2).
- `show_cart()`, `handle_cart_checkout()`: call `cart_remove_inactive_items()` and
  notify the user which items were dropped (Part 2).
- `handle_confirm()`: re-checks `is_active` one more time (final gate before
  reserving), then reserves stock via `reserve_stock_batch()` instead of a raw
  per-item loop, storing the batch's `reservation_key` in user state (Part 1, 2).
- `ask_card_payment()`: missing-card-config failure now releases the reservation
  batch instead of a raw per-item `restore_stock()` loop (Part 1).
- New: `release_reserved_cart()`, `consume_reserved_cart()` — thin wrappers reading
  `reservation_key` from user state (Part 1).
- `handle_pay_wallet()`: wallet debit + order creation + discount consumption stay
  in one transaction as before, but now **also rolls back and releases the stock
  reservation** if `increment_discount_usage_atomic()` reports the discount ran out
  at the last second (Part 3) — previously the order was created anyway and the
  discount silently went uncounted. Consumes the reservation on success, releases
  it on any failure path.
- `handle_pay_zarinpal()`: releases the reservation if the ZarinPal payment-request
  call itself fails (previously stock stayed locked with no recovery path — Part 1);
  consumes the reservation once the `payment_sessions` row is created.
- `handle_receipt_photo()`: wrapped in try/catch; releases the reservation and
  informs the user on failure instead of silently leaving stock locked with no
  order (previously had no error handling at all); consumes it on success.

**`handlers/router.php`**
- `/start` parsing: now recognizes `/start product_<id>` deep links via a strict
  regex, while the bare `/start` behavior is untouched (Part 7–17).
- `StoreCancel`: now calls `release_reserved_cart()` instead of the old
  `stock_reserved` boolean + raw restore loop.

**`handlers/store.php`**
- New: `handle_start_deep_link()` — parses the `/start` parameter, calls the
  **existing** `show_product_detail()` for a valid `product_<id>`, otherwise falls
  back to the normal store home (never a raw error for garbage input).
- `show_product_detail()`: now **always** sends a message on invalid/inactive
  product (previously did nothing at all when called without an `editMessageId` —
  which is exactly the deep-link entry case). Distinguishes "not found" vs
  "no longer available" wording (Part 13).

**`handlers/wallet.php`**
- `handle_pay_topup_zarinpal()`: marks the `wallet_topups` row `failed` if the
  ZarinPal payment-request call fails, instead of leaving it `pending_payment`
  forever with no receipt and no session (Part 6).

**`handlers/admin_orders.php`**
- `fulfill_paid_order()`: rewritten into the two-phase Claim/Deliver model (Part 4)
  — see the "digital delivery" note below.
- `process_order_approval()`: captures `increment_discount_usage_atomic()`'s return
  value and appends a note to the admin's response if the discount had already run
  out (previously silently ignored — Part 3, admin-approval path).

**`zarinpal.php`**
- `zarinpal_verify_payment()`: now returns `['status' => 'paid'|'failed'|'unknown',
  'ref_id' => ...]` instead of a simple bool. A network/timeout/cURL failure, or an
  unrecognized ZarinPal response code, is `'unknown'` — never `'failed'`. Only a
  short, explicit list of ZarinPal's own "definitely failed" codes (-11, -21, -50,
  -51, -54) produces `'failed'` (Part 5).

**`handlers/zarinpal_processing.php`** (NEW)
- Shared logic used by both the callback and the retry cron, so there is exactly
  one code path that turns a verified ZarinPal payment into an order/top-up —
  no parallel implementation (Part 5, Part 11's spirit applied to payments too):
  `claim_payment_session_for_processing()`, `release_cart_stock_from_session()`,
  `finalize_verified_zarinpal_payment()`.

**`zarinpal_callback.php`**
- Rewritten to use `handlers/zarinpal_processing.php`. Adds the `'unknown'` branch:
  never marks the session `failed` or restores stock on a verify timeout; shows the
  user an honest "we're checking" message instead of "payment failed" (Part 5).

**`cron_cleanup_payments.php`**
- Now does three things instead of one: (1) the existing stale-`pending`-session
  sweep, (2) **new** — retries `'unknown'` ZarinPal sessions (max 10 attempts, ≥5min
  apart) via the shared `finalize_verified_zarinpal_payment()`, alerting admins if
  still unresolved after 10 tries, (3) **new** — sweeps expired `stock_reservations`
  batches nobody claimed/released (Part 1, Part 5).

**`functions.php`**
- New: `get_bot_username()` (via `getMe`, cached in `settings`, never hardcoded —
  Part 9) and `product_deep_link()` (Part 18: link is generated on demand, never
  stored in the database).

**`admin/products.php`**
- Product list table: new "لینک مستقیم" column with a direct `t.me/...` link per
  product. Edit-product form: a readonly, click-to-select link field plus an "open"
  link (Part 8).

**`db/schema.sql`** and **`db/migration_v9.sql`** — see Database section below.

---

## 2. Functions Changed

**`reserve_stock(productId, quantity)`**
- Old: reserved stock regardless of the product's `is_active` flag.
- New: fails (returns `false`) for an inactive product, even if stock is available.
  This is the actual fix for Part 2 — every purchase path (wallet, ZarinPal,
  card-to-card) funnels through this one function via `handle_confirm()`.

**`show_product_detail(chatId, userId, productId, editMessageId = null)`**
- Old: silently did nothing when the product was missing/inactive **and** no
  `editMessageId` was given (i.e. exactly the deep-link case — a fresh message, not
  an edit).
- New: always responds, with distinct wording for "not found" vs "no longer
  available".

**`zarinpal_verify_payment(amount, authority)`**
- Old: returned `[bool $success, ?string $refId]`; a cURL/network failure was
  silently treated the same as a real payment failure.
- New: returns `['status' => 'paid'|'failed'|'unknown', 'ref_id' => ...]`. Callers
  (`zarinpal_callback.php`, the retry cron) must branch on all three states.

**`fulfill_paid_order(orderId, logPrefix)`**
- Old: claimed a digital asset and sent it in one step with no check on whether the
  Telegram send actually succeeded; `order_items.digital_content` was set (and thus
  treated as "already delivered" on any retry) even if the send silently failed.
- New: two phases. Phase 1 (claim) only runs if `digital_content` is still empty —
  unchanged in effect, still exactly-once. Phase 2 (deliver) runs on **every** call
  for any item whose `delivery_status != 'sent'`, always resending the
  already-stored content (never re-claiming), and only marks `'sent'` if the
  Telegram API call actually returned a result. After 3 failed delivery attempts on
  the same item, admins get a one-time alert.

**`handle_pay_wallet(chatId, userId)`**
- Old: ignored the return value of `increment_discount_usage_atomic()` — if the
  discount had just run out, the order was still created at the discounted price
  with no corresponding usage record.
- New: if the discount can't be consumed, the whole transaction (wallet debit +
  order) is rolled back, the stock reservation is released, and the user is told to
  retry without the code.

---

## 3. Database

Migration:
`db/migration_v9.sql`

Changes:
- `payment_sessions.status` ENUM: added `'unknown'`.
- `payment_sessions`: added `verify_attempts INT UNSIGNED DEFAULT 0`,
  `last_verify_at BIGINT NULL`.
- `wallet_topups.status` ENUM: added `'failed'`.
- `order_items`: added `digital_content_type ENUM('text','file') NULL`,
  `delivery_status ENUM('pending','sent','failed') DEFAULT 'pending'`,
  `delivery_attempts INT UNSIGNED DEFAULT 0`. Existing rows with a non-empty
  `digital_content` are backfilled to `delivery_status = 'sent'` (they were
  delivered under the old logic; this avoids the new code trying to resend
  everything that already went out historically).
- NEW TABLE `stock_reservations` — see Part 1 discussion below for why this
  couldn't be avoided.

All changes are additive (new columns/table, widened enums) — nothing existing is
renamed, dropped, or rewritten. Also merged into `db/schema.sql` so a **fresh**
install via `install.php` gets everything automatically; existing installs run
`migration_v9.sql` once.

**Why a new table was necessary (Part 1):** the prompt asked me to check whether
`payment_sessions` / `orders` / `order_items` already had `reserved_at`/`expires_at`
fields that could carry the reservation lifecycle before adding anything new. They
don't, and more importantly: stock is decremented in `handle_confirm()` **before**
either a `payment_sessions` row (ZarinPal) or an `orders` row (wallet/card-to-card)
exists — at that point in the flow there is nothing else in the schema that could
represent "this stock is provisionally held." The gap is narrow (it's resolved
synchronously, in the same request, the moment a session or order is created) but
real: if the user abandons the chat at exactly that point, nothing previously
tracked it. `stock_reservations` exists only to cover that narrow window; the
moment a session/order takes over, the reservation is marked `'consumed'` and the
table stops being involved — `payment_sessions`/`orders` remain the source of truth
for everything downstream, exactly as before.

---

## 4. Tests

- **PHP syntax: NOT RUN.** No `php` binary and no network access in this sandbox
  (same limitation as last round). In place of it: manual re-reading of every
  changed function, plus an automated brace/quote-balance script across all 16
  touched PHP files — all came back balanced. **Run the real check before
  deploying:** `find . -name "*.php" -print0 | xargs -0 -n1 php -l`
- **Database migration: NOT RUN.** No MySQL available here. `migration_v9.sql` was
  hand-reviewed for valid syntax, correct enum widening order, and FK/index
  correctness. Apply it to a staging copy of the database first.
- **Stock / Inactive product / Discount / Digital / ZarinPal / Wallet top-up /
  Deep link test scenarios (Part 19): NOT executed as automated tests** — no test
  harness, database, or ZarinPal sandbox credentials in this environment. Every
  scenario listed in Part 19 was traced by hand against the new code (see Section 1
  and 2 for which function handles which case) but none of it has been exercised
  against a live system. This is the main reason Final Status below is NOT READY.

---

## 5. Deep Link Example

Format (bot username is fetched once via `getMe` and cached — never hardcoded, per
Part 9):

```
https://t.me/BOT_USERNAME?start=product_123
```

Where to get it for a specific product: **admin panel → محصولات → لینک مستقیم**
column (or inside the edit-product form) — both render the live link using the
bot's real username and that product's real ID, generated on the fly, never stored.

---

## 6. Remaining Issues

**Issue:** No `php -l` / staging-DB / ZarinPal-sandbox test run was possible here.
**Impact:** Cannot certify the code is bug-free in a real PHP/MySQL environment.
**Reason:** Sandbox has no PHP interpreter, no MySQL, no network.
**Recommended next step:** Run the syntax check, apply `migration_v9.sql` to a
staging DB, and walk through the Part 19 test scenarios (especially: confirm a
`product_1_extra`/`product_-1`/`product_abc` deep link doesn't crash, confirm a
double-tapped "pay with ZarinPal" doesn't create two orders, confirm an admin
deactivating a product mid-checkout actually blocks the purchase) before deploying.

**Issue:** Per-user discount usage limit (Part 3 mentions
`UNIQUE(discount_id, user_id, order_id)` "if a per-user limit exists").
**Impact:** None currently — this feature doesn't exist anywhere in the codebase
(no column, no admin UI for it).
**Reason:** Adding a per-user limit would be a new feature, not a bug fix, and the
prompt's own Part 3 phrasing makes this constraint conditional on the feature
existing. The actual bug in scope — double-counting/racing on the *shared*
`max_uses` cap — is fixed via the existing `UNIQUE(discount_id, order_id)`.
**Recommended next step:** If you want a per-user cap, that's a new feature request
with its own admin UI (a "max uses per user" field) — happy to build it if wanted.

**Issue:** Legacy `order_items` rows created before `migration_v9.sql` have no
`digital_content_type`, so a retry on one of those (if it somehow needed one, which
it shouldn't since they're backfilled to `delivery_status='sent'`) would default to
treating it as `'text'`.
**Impact:** Cosmetic/low — only matters for a pre-migration order that somehow
still needs re-delivery, and defaults to the more common case (text) anyway.
**Reason:** The asset's original `content_type` wasn't captured on `order_items`
before this change.
**Recommended next step:** None needed unless you specifically find an old
`file`-type digital order stuck in a bad state — extremely unlikely given the
backfill marks all pre-existing delivered content as `'sent'`.

**Issue:** Gift code redemption's `max_uses` still has the same (milder) race noted
in the previous round's report — not touched this round since it wasn't in scope of
this prompt's 25 parts.
**Impact:** Low (documented previously).
**Reason:** Out of scope for this pass; carried over from `HARDENING_REPORT.md`.
**Recommended next step:** Same fix pattern as `increment_discount_usage_atomic()`
if you want to close it.

---

## 7. Final Status

```
Production Readiness:
NOT READY

Blocking Issues:
- PHP syntax has not been verified with a real interpreter (php -l) — only manual
  review + an automated brace-balance heuristic.
- migration_v9.sql has not been executed against any real database.
- None of the Part 19 test scenarios (stock races, inactive-product enforcement,
  discount concurrency, digital retry, ZarinPal unknown-state, wallet top-up
  failure, deep link edge cases) have been run against a live system.
```

None of this means the code is wrong — it means it hasn't been proven right yet in
an environment that can actually run it. Once you run the syntax check and the
migration on staging and click through the scenarios above (a ZarinPal sandbox
account makes Part 5 easy to test — force a timeout by pointing the callback URL at
something slow, confirm the session lands in `'unknown'` and self-resolves on the
next cron run), I'd expect this to be genuinely close to ready.

---

## پیگیری (Session 3) — رفع دو گپ باقی‌مونده از انتهای گزارش قبلی

**بدون دسترسی PHP/MySQL در این سندباکس هم (نه از قبل نصب بود، نه شبکه برای نصبش در دسترس بود)،
پس این پاس هم صرفاً با مرور دستیِ خط‌به‌خط انجام شده — قبل از دیپلوی حتماً `php -l` روی همه‌ی
فایل‌ها و اجرای واقعی migration لازمه.**

### ۱. تزریق Markdown از طریق اسم/توضیح محصول (رفع شد)
تابع `md_escape()` به `functions.php` اضافه شد (کاراکترهای `\ _ * `` [` رو برای Markdown
قدیمی Escape می‌کنه) و روی همه‌ی نقاطی که محتوای دیتابیسی (نه متن ثابت خود کد) داخل پیام‌های
`parse_mode=Markdown` قرار می‌گرفت اعمال شد:
- `handlers/store.php` — کارت نمایش محصول (اسم + توضیحات)
- `handlers/checkout.php` — پرسیدن تعداد، تایید افزودن به سبد، اقلام سبد خرید، خلاصه‌ی سفارش (۷ نقطه)
- `handlers/admin_orders.php` — پیام‌های تحویل دیجیتال به مشتری (از جمله خودِ محتوای دیجیتال که
  داخل بک‌تیک قرار می‌گیره — این یکی واقعاً باگ بود: اگه کد/لایسنس دیجیتال یه بک‌تیک یا آندرلاین
  داشت، ارسال با خطای «can't parse entities» از تلگرام رد می‌شد و مشتری اصلاً محصولش رو دریافت
  نمی‌کرد)، و خطوط لاگ ادمین
- `handlers/customer_orders.php` — لیست اقلام سفارش و کد رهگیری پستی
- `handlers/rewards.php` — پیام تبریک جایزه‌ی چرخ‌شانس (برچسب جایزه)

### ۲. Hard-delete در CRUD دسته‌بندی/تخفیف/کد هدیه/جایزه‌ی چرخ‌شانس (رفع شد)
هر ۴ جدول از قبل ستون `is_active` داشتن، پس نیازی به migration نبود — فقط تابع/handler حذف
هرکدوم از `DELETE FROM ...` به `UPDATE ... SET is_active = 0 ...` تغییر کرد:
- `handlers/repo_discounts.php::delete_discount()`
- `handlers/repo_giftcodes.php::delete_gift_code()`
- `handlers/repo_wheel.php::delete_wheel_prize()`
- `admin/categories.php` (اکشن delete)

دلیل مهم بودنش: `gift_code_redemptions.gift_code_id` و `discount_usages.discount_id` هر دو
`ON DELETE CASCADE` دارن — یعنی حذف واقعی یه کد تخفیف/هدیه، کل تاریخچه‌ی استفاده‌ازش (و آمار
گزارش‌گیری از سفارش‌های قبلی) رو هم پاک می‌کرد.

### ۳. پوشش ناقص لاگ ادیت (رفع شد)
این چهار صفحه‌ی ادمین قبلاً هیچ `audit_log()` نداشتن؛ الان دارن:
- `admin/categories.php` (ایجاد/تغییر وضعیت/غیرفعال‌سازی)
- `admin/wheel.php` (تغییر تنظیمات، ایجاد/تغییر وضعیت/غیرفعال‌سازی جایزه)
- `admin/texts.php` (تغییر متن‌های سفارشی — فقط کلیدهای تغییریافته لاگ می‌شه، نه خودِ متن‌ها)
- `admin/features.php` (روشن/خاموش قابلیت‌ها، تغییر آیدی پشتیبانی)

### ۴. جاروب XSS تکمیلی
همه‌ی فایل‌های `admin/*.php` از نظر `<?= $var ?>`ِ بدون `htmlspecialchars` بررسی شدن. موارد پیدا‌شده
(شماره صفحه‌ی pagination در audit_log.php، کلیدهای enum وضعیت سفارش/شارژ در orders.php و
topups.php) همه یا عدد صحیح هستن یا کلید ثابت از آرایه‌ی PHP — نه متن آزاد کاربر — پس ریسک واقعی
ندارن. چیزی برای اصلاح پیدا نشد.

### همچنان تأیید‌نشده
هیچ‌کدوم از موارد بالا (نه این پاس، نه پاس‌های قبلی) با `php -l` یا اجرای واقعی روی MySQL تست
نشدن. قبل از دیپلوی حتماً باید روی یه محیط واقعی (یا لوکال با PHP/MySQL نصب‌شده) تست بشن، مخصوصاً
مسیر تحویل دیجیتال بعد از تایید سفارش.

---

## پیگیری (Session 4) — Master Prompt: Final Bug Fix + E2E Hardening

**همچنان بدون PHP/MySQL/شبکه در این سندباکس — این پاس هم صرفاً با مرور دستیِ خط‌به‌خط + ردیابی
منطقی همه‌ی مسیرها (نه اجرای واقعی) انجام شده.**

### روش کار
طبق قانون پرامپت، قبل از هر تغییری کل فلوهای پرداخت/تخفیف/موجودی/دیجیتال/دیپ‌لینک بررسی شدن.
نتیجه: بیشتر بندهای پرامپت (۳ تا ۱۴) از قبل به‌درستی و به‌شکل Atomic/Idempotent پیاده‌سازی شده
بودن (رزرو موجودی با UPDATE شرطی، claim دارایی دیجیتال با `SELECT ... FOR UPDATE`، تحویل دیجیتال
Retry-Safe، Zarinpal با state `unknown` و قفل `pending→processing`، شارژ کیف پول Idempotent با
Ledger، دیپ‌لینک با Regex validate + Prepared Statement + یوزرنیم غیر-Hardcode). این موارد
دست‌نخورده باقی موندن (طبق قانون «قابلیت سالم رو بازنویسی نکن»).

### باگ‌های واقعی پیدا و رفع شده

**۱. (بند ۱ پرامپت) تایید سفارش کارت‌به‌کارت: سفارش paid می‌شد حتی اگه افزایش مصرف کد تخفیف Fail
می‌شد.**
`handlers/admin_orders.php :: process_order_approval()`
قبلاً: `update_order_status($orderId, 'paid')` بدون قید و شرط اجرا می‌شد، بعد تخفیف افزایش پیدا
می‌کرد؛ اگه افزایش Fail می‌شد فقط یه هشدار به ادمین می‌رفت ولی سفارش paid می‌موند و تحویل هم انجام
می‌شد. الان: کل عملیات (انتقال وضعیت + افزایش تخفیف) داخل یک Transaction هست؛ اگه افزایش تخفیف
Fail بشه، Rollback کامل می‌شه (سفارش pending_payment می‌مونه، تحویلی انجام نمی‌شه) و ادمین پیام خطای
واضح می‌گیره که بعد از رفع ظرفیت کد، دوباره تلاش کنه. این دقیقاً هم‌راستا با الگویی هست که خود
پروژه از قبل توی مسیر «پرداخت با کیف پول» (`handle_pay_wallet`) استفاده می‌کرد — فقط توی مسیر
کارت‌به‌کارت این الگو رعایت نشده بود.

**۲. (بند ۱ پرامپت، Duplicate Approval) تایید/رد سفارش Race-Safe نبود.**
همون فایل — `process_order_approval()` و `process_order_rejection()`
قبلاً چک وضعیت به شکل SELECT-then-UPDATE بود (چک می‌کرد `pending_payment` هست، بعد جدا آپدیت
می‌کرد) — بین این دو مرحله، دو کلیک هم‌زمان (یا دو ادمین) می‌تونستن هر دو رد کنن. الان با تابع جدید
`update_order_status_conditional()` (در `handlers/repo_orders.php`) این انتقال با یک UPDATE شرطی
اتمیک انجام می‌شه (`WHERE status IN (...)`) — دقیقاً همون الگویی که خود پروژه قبلاً برای
`approve_topup()` و `release_stock_reservation()` استفاده کرده بود.

**۳. (بند ۲ پرامپت) محدودیت «محصول دیجیتال حداکثر تعداد ۱» اصلاً وجود نداشت.**
قبلاً هیچ‌جا این محدودیت اعمال نمی‌شد — کاربر می‌تونست یه محصول دیجیتال رو با تعداد ۵ بخره، ولی
`fulfill_paid_order` فقط بابت هر Order Item (نه هر واحد Quantity) یک Asset Claim می‌کرد؛ یعنی
مشتری پول ۵ واحد رو می‌داد ولی فقط ۱ کد دیجیتال می‌گرفت. الان در ۳ لایه رفع شده:
- UI: `start_add_to_cart`/`ask_next_variant` برای محصول دیجیتال اصلاً سوال تعداد نمی‌پرسن، مستقیم
  با تعداد ۱ به سبد اضافه می‌کنن (`add_digital_to_cart`).
- Backend (خط دفاع اصلی): `cart_add()` در `handlers/repo_cart.php` صرف‌نظر از این‌که caller چی
  فرستاده، برای محصول دیجیتال همیشه quantity=1 ذخیره می‌کنه.
- Checkout: `handle_confirm()` قبل از رزرو موجودی، `cart_clamp_digital_quantities()` رو صدا می‌زنه
  که هر ردیف دیجیتالِ باقی‌مونده‌ی قدیمی با تعداد >۱ رو به ۱ اصلاح و به کاربر اطلاع می‌ده.
- `handle_quantity_text()` هم به‌عنوان لایه‌ی احتیاطی اضافه، اگه محصول دیجیتال با qty≠1 برسه رد
  می‌کنه.

### چیزهایی که بررسی شدن ولی نیاز به تغییر نداشتن (از قبل درست بودن)
- Digital Asset Claim: `claim_digital_asset()` با `SELECT ... FOR UPDATE` کاملاً Race-Safe و
  Retry-Safe هست (بند ۳).
- Stock Reservation: `reserve_stock()`/`release_stock_reservation()`/`consume_stock_reservation()`
  همگی با UPDATE شرطی اتمیک هستن، TTL هم توسط `cron_cleanup_payments.php` پوشش داده می‌شه (بند ۵).
- Zarinpal State Machine + Duplicate Callback: `zarinpal_callback.php` با الگوی
  `claim_payment_session_for_processing()` (انتقال اتمیک pending→processing) دقیقاً طبق مشخصات
  پرامپت پیاده شده — حالت `unknown` هیچ‌وقت `failed` نمی‌شه، Cron دوره‌ای دوباره Verify می‌کنه (بند
  ۶، ۷).
- Wallet Top-up: `approve_topup()` با UPDATE شرطی + Ledger یک‌بار-مصرف اتمیک هست (بند ۸).
- Deep Link: `handle_start_deep_link()` با Regex `^product_(\d+)$` ورودی رو Validate می‌کنه و از
  همون handler موجود `show_product_detail()` استفاده می‌کنه (نه یه فلو موازی)؛ یوزرنیم ربات هم از
  `getMe` گرفته و در settings کش می‌شه، هیچ‌جا Hardcode نیست (بندهای ۱۰ تا ۱۴).

### تفاوت عمدی نسبت به Zarinpal (طبق تصمیم موجود پروژه، دست‌نخورده رها شد)
توی `finalize_verified_zarinpal_payment()`، اگه افزایش تخفیف بعد از پرداخت آنلاین موفق Fail بشه،
سفارش paid می‌مونه (نه Rollback) — چون برخلاف کارت‌به‌کارت، اینجا پول واقعی از درگاه گرفته شده و
غیرقابل‌برگشت خودکاره؛ Rollback کردن سفارش یعنی پول گرفته شده ولی نه سفارشی نه امکان تلاش دوباره.
این رفتار از قبل درست بود و طبق قانون «فقط orphan state رو اصلاح کن، منطق موجود رو حفظ کن» دست
نخورد.

### Database Changes
هیچ Migration جدیدی لازم نشد — هیچ تغییر Schema‌ای انجام نشد.

### Files Changed
- `handlers/repo_orders.php` — تابع جدید `update_order_status_conditional()`
- `handlers/admin_orders.php` — بازنویسی `process_order_approval()` و `process_order_rejection()`
  با Transaction + انتقال وضعیت اتمیک
- `handlers/checkout.php` — `start_add_to_cart`، `ask_next_variant`، `handle_quantity_text`،
  `handle_confirm` برای اعمال محدودیت quantity=1 محصول دیجیتال
- `handlers/repo_cart.php` — `cart_add()` (enforce quantity=1 برای دیجیتال) + تابع جدید
  `cart_clamp_digital_quantities()`

### Tests
هیچ‌کدوم در محیط واقعی اجرا نشدن (نبود PHP/MySQL) — فقط با مرور منطقی/دستی بررسی شدن:
- تایید سفارش عادی (بدون تخفیف): NOT TESTED (منطقاً بدون تغییر رفتار)
- تایید سفارش با تخفیف، ظرفیت کافی: NOT TESTED
- تایید سفارش با تخفیف، ظرفیت تازه تمام‌شده: NOT TESTED (مسیر جدید Rollback)
- دو کلیک هم‌زمان تایید/رد روی یک سفارش: NOT TESTED (مسیر جدید Atomic Guard)
- افزودن محصول دیجیتال به سبد (UI نباید تعداد بپرسه): NOT TESTED
- تلاش دستی برای qty>1 دیجیتال از مسیرهای دیگه: NOT TESTED
- همه‌ی مسیرهای از قبل موجود (Zarinpal، کیف‌پول، رزرو موجودی، دیپ‌لینک): Regression بررسی نشده با
  اجرای واقعی، فقط با خواندن کد.

### Remaining Risks
- **هیچ‌کدوم از تغییرات این پاس با `php -l` یا دیتابیس واقعی تست نشدن** — قبل از دیپلوی حتماً لازمه.
- سناریوی رفتار جدید (Rollback تایید سفارش وقتی ظرفیت تخفیف تمام شده) برای ادمین جدیده — بهتره
  قبل از استفاده‌ی واقعی یک‌بار عمداً تکرار بشه (یه کد تخفیف max_uses=1 بساز، دو سفارش با اون کد ثبت
  کن، هر دو رو تایید کن) تا مطمئن بشیم پیام و Rollback درست کار می‌کنه.
- بقیه‌ی بندهای پرامپت (۱۵ تا ۲۵) عمدتاً مستندسازی/سیاست‌گذاری بودن، نه باگ مشخص؛ چیزی که در حین
  بررسی خلافشون دیده بشه پیدا نشد.

### Production Readiness
**NEEDS FIXES** — نه به این خاطر که باگ شناخته‌شده‌ی حل‌نشده باقی مونده، بلکه چون:
۱) هیچ تغییری (نه این پاس، نه پاس‌های قبلی) با اجرای واقعی PHP/MySQL تست نشده،
۲) `php -l` روی کل پروژه هنوز اجرا نشده.
قبل از استفاده‌ی واقعی این دو مورد باید روی یه محیط واقعی (لوکال یا Staging) انجام بشن.

---

## پیگیری (Session 5) — Master Prompt: Sellora v4 Final Payment + Stock Reservation Hardening

**همچنان بدون PHP/MySQL/شبکه در این سندباکس — فقط با Trace کامل دستیِ سه فلو (Cart→Reservation→
Payment→Order، Zarinpal، Discount) طبق الزام خود پرامپت.**

### ۱. Root Cause

**مشکل اول — `payment_sessions` INSERT بدون محافظت:**
در `handle_pay_zarinpal()`، بعد از گرفتن Authority از زرین‌پال، ردیف `payment_sessions` بدون
`try/catch` ساخته می‌شد. اگه این INSERT با خطا مواجه می‌شد (قطعی موقت DB و مشابه)، یک
`PDOException` رو کاتچ‌نشده بالا می‌رفت (فقط توسط try/catch عمومیِ `webhook.php` جذب می‌شد)،
`consume_reserved_cart()` هیچ‌وقت اجرا نمی‌شد، کاربر هیچ پیامی نمی‌گرفت، و رزرو موجودی به‌جای
آزادسازی فوری، تا انقضای TTL (Cron) قفل می‌موند — دقیقاً Scenario B پرامپت.

**مشکل دوم — `release_cart_stock_from_session()` کاملاً Blind بود:**
این تابع مستقیماً از روی آیتم‌های `cart_snapshot` موجودی رو Restore می‌کرد، بدون هیچ ارجاعی به
وضعیت واقعی `stock_reservations`. تا اینجا (چون همه‌ی ۴ نقطه‌ی صداکننده‌اش از قبل با یک انتقال
وضعیت اتمیک روی خودِ `payment_sessions` محافظت می‌شدن) عملاً Double-Restore رخ نمی‌داد، ولی هیچ
لایه‌ی دفاعی مستقلی روی سطح خودِ Reservation نداشت — یعنی اگه یه روز منطق caller عوض بشه یا باگ
دیگه‌ای پیش بیاد، هیچی جلوی Restore دوباره رو نمی‌گرفت. همچنین هیچ ارتباط مستقیمی بین
`payment_session` و `stock_reservations` (نه Schema، نه حتی داخل JSON) وجود نداشت.

**مشکل سوم — تخفیف بعد از پرداخت موفق زرین‌پال:** از قبل به شکل امن مدیریت می‌شد (سفارش Rollback
نمی‌شد، چون پول واقعاً گرفته شده) ولی رد‌گیری (Reconciliation) فقط یک پیام به تاپیک ادمین بود —
هیچ اثری روی خودِ سفارش (در پنل ادمین) باقی نمی‌موند.

### ۲. Files Changed
- `handlers/repo_products.php` — تابع جدید `release_consumed_reservation()`
- `handlers/checkout.php` — `handle_pay_zarinpal()`: try/catch دور INSERT، ذخیره‌ی
  `reservation_key` در `cart_snapshot`
- `handlers/zarinpal_processing.php` — بازنویسی `release_cart_stock_from_session()` (اتمیک، بر پایه‌ی
  reservation_key، با Fallback برای Sessionهای قدیمی) + `finalize_verified_zarinpal_payment()`:
  ثبت وضعیت قابل Reconciliation روی خودِ سفارش (admin_note) در صورت Fail شدن افزایش تخفیف

### ۳. Database Changes
هیچ Migration/تغییر Schema‌ای لازم نشد. `reservation_key` به‌جای ستون جدید، داخل همون فیلد متنی
موجود `payment_sessions.cart_snapshot` (که از قبل JSON آزاد بود) ذخیره می‌شه.

### ۴. Stock Lifecycle (نهایی)

```
CART
 ↓ (تایید سبد)
reserve_stock_batch() → stock_reservations: 'reserved'
 ↓
 ├─ Payment Session ساخته شد (زرین‌پال) یا سفارش مستقیم ساخته شد (کارت‌به‌کارت/کیف‌پول)
 │   → consume_stock_reservation(): 'reserved' → 'consumed'  [اتمیک، شرطی روی status='reserved']
 │   (موجودی products.stock از لحظه‌ی reserve کسر شده و کسرشده می‌مونه)
 │
 │   ├─ زرین‌پال موفق → سفارش paid، reservation همون 'consumed' می‌مونه (هرگز دیگه دست نمی‌خوره)
 │   ├─ زرین‌پال fail/expire → release_consumed_reservation(): 'consumed' → 'released' + Restore
 │   │   [اتمیک، شرطی روی status='consumed' — دوبار صدا زده بشه، بار دوم هیچ کاری نمی‌کنه]
 │   └─ کارت‌به‌کارت رد شد → موجودی از روی order_items برمی‌گرده (مسیر جدا، از قبل درست)
 │
 └─ ساخت Payment/سفارش خودش Fail شد (Authority نگرفتیم / INSERT خطا داد / Exception دیگه)
     → release_stock_reservation(): 'reserved' → 'released' + Restore  [چون هنوز consume نشده بود]

هیچ‌کس ادعا نکرد (کاربر بین «تایید سبد» و انتخاب روش پرداخت رهاش کرد)
 ↓
Cron (list_expired_reservation_keys, فقط status='reserved') → release_stock_reservation()
```

### ۵. Zarinpal Flow
- **Success:** `claim_payment_session_for_processing()` (pending→processing اتمیک) → Verify → paid
  → `finalize_verified_zarinpal_payment()` سفارش می‌سازه، `payment_sessions.status='paid'`،
  Reservation در 'consumed' باقی می‌مونه (دیگه لازم نیست دست بخوره).
- **Failure (قطعی):** Verify کد Fail قطعی برگردوند یا Status از زرین‌پال NOK بود →
  `payment_sessions.status='failed'` + `release_cart_stock_from_session()` (الان بر پایه‌ی
  reservation_key، اتمیک).
- **Unknown:** هرگز بلافاصله failed/موجودی Restore نمی‌شه؛ `status='unknown'`، Cron هر ۵ دقیقه (تا
  ۱۰ بار) دوباره Verify می‌کنه؛ بعد از ۱۰ بار هنوز نامشخص → هشدار دستی به ادمین. دست‌نخورده و درست
  بود، تغییری نکرد.
- **Duplicate Callback:** با انتقال اتمیک `pending→processing` قبل از هر پردازشی محافظت می‌شه؛
  Callback دوم/سوم فقط پیام «قبلاً پردازش شده» متناسب با وضعیت فعلی می‌بینه، هیچ اثر جانبی‌ای نداره.

### ۶. Discount Flow
- **افزایش مصرف:** `increment_discount_usage_atomic()` — UPDATE شرطی روی ظرفیت + Unique Constraint
  روی `(discount_id, order_id)` — همیشه دقیقاً یک‌بار برای هر سفارش، صرف‌نظر از چندبار صدا زده شدن.
- **کارت‌به‌کارت:** مصرف فقط لحظه‌ی تایید ادمین ثبت می‌شه (نه لحظه‌ی ثبت سفارش) — اگه رد بشه، ظرفیت
  کد اصلاً درگیر نشده. تایید هم الان (از Session قبل) کاملاً Transactional و Race-Safe هست.
- **کیف‌پول:** افزایش تخفیف داخل همون Transaction خرید هست؛ Fail بشه، کل خرید (کسر کیف‌پول + سفارش)
  Rollback می‌شه — چون هنوز پول واقعی به بیرون نرفته.
- **زرین‌پال:** چون پول واقعاً از درگاه گرفته شده، Rollback سفارش امکان/صحت نداره (Gateway=paid,
  Order=failed دقیقاً همون چیزیه که پرامپت گفت اجتناب بشه). به‌جاش الان وضعیت روی خودِ سفارش
  (`admin_note`) هم ثبت می‌شه، علاوه بر Log و پیام تاپیک ادمین — قابل‌مشاهده مستقیم توی پنل بدون
  نیاز به گشتن توی چت.

### ۷. Tests
هیچ‌کدوم روی PHP/MySQL واقعی اجرا نشدن — NOT TESTED برای همه‌ی سناریوهای بند ۲۴ پرامپت، از جمله:
Reserve/Consume/Release/Double Release/Expired Reservation، Zarinpal Success/Failure/Unknown/
Duplicate Callback/API Failure/Session Insert Failure، Discount Valid/Max Usage/Duplicate Callback/
Atomic Failure، و سناریوهای Combined. فقط با ردیابی منطقی کد بررسی شدن.

### ۸. Remaining Risks
- تغییرات این پاس با `php -l` یا دیتابیس واقعی تست نشدن — قبل از دیپلوی الزامیه.
- Fallback مسیر قدیمی (`release_cart_stock_from_session` بدون `reservation_key`) فقط برای
  Payment Sessionهایی که *قبل از این آپدیت* ساخته شدن و هنوز `pending`/`unknown` مونده لازمه؛ بعد
  از گذشت TTL طبیعی اونا، این مسیر عملاً بلااستفاده می‌مونه — نیازی به حذفش نیست ولی قابل‌حذفه بعد
  از اطمینان از خالی‌شدن صف.
- سناریوی فوق‌العاده کم‌احتمال (و این پاس عمداً برطرفش نکرد، چون نیازمند تغییر معماری Reservation
  بود که خلاف الزام «بدون تغییر ساختار» است): اگه TTL رزرو خیلی کوتاه تنظیم بشه و دقیقاً هم‌زمان با
  لحظه‌ی ساخت Payment Session منقضی بشه، به‌ندرت ممکنه Cron و `consume_reserved_cart` روی هم Race
  کنن. با مقدار پیش‌فرض TTL (دقایق) عملاً غیرممکنه؛ اگه لازم شد، راه‌حلش تغییر signature
  `consume_stock_reservation` برای برگردوندن Affected Rows و Abort کردن ساخت Session در صورت 0
  هست — خارج از scope این پاس نگه داشته شد.

---

## Session 6 — Final Stock Integrity + Reservation Lifecycle Hardening

پرامپت این جلسه (Master Prompt: Final Stock Integrity + Reservation Lifecycle Hardening) روی
`Sellora-v5.zip` اجرا شد. طبق روال، قبل از هر تغییری کل مسیر Stock → stock_reservations →
Card-to-Card → Rejection → Cron ردیابی شد؛ Session 5 در گزارش خودش مسیر رد کارت‌به‌کارت رو
«مسیر جدا، از قبل درست» فرض کرده بود — این جلسه دقیقاً همون فرض رو زیر سوال برد و دو باگ واقعی
(نه فرضی) پیدا کرد.

### ۱. Root Cause — `reserve_stock_batch()` Atomicity

**مشکل:** `reserve_stock()` (کاهش موجودی) و `INSERT INTO stock_reservations` دو عملیات دیتابیس
جدا بودن، بدون Transaction واقعی. اگه بین این دو یه خطای دیتابیس رخ می‌داد (مثلاً INSERT fail
می‌شد)، هیچ `try/catch`ای دورش نبود — Exception مستقیم به بیرون Throw می‌شد و موجودی برای همیشه
کم می‌موند بدون این‌که هیچ ردیف Reservationای وجودش رو نشون بده (دقیقاً سناریوی Test B پرامپت).

**مشکل دوم و پنهان‌تر:** برای Batch چندآیتمی، اگه آیتم سوم Fail می‌شد، کد قبلی موجودی آیتم‌های
اول و دوم رو با `restore_stock()` دستی برمی‌گردوند اما ردیف‌های `stock_reservations` که برای
همون آیتم‌ها قبلاً INSERT شده بودن (status='reserved') رو پاک/تغییر نمی‌داد. این ردیف‌ها یتیم
می‌موندن، و چون `expires_at` واقعی داشتن، Cron (`cron_cleanup_payments.php` →
`list_expired_reservation_keys` → `release_stock_reservation`) دیر یا زود دوباره روی همون
Productها `restore_stock()` صدا می‌زد — یعنی **Double Restore واقعی**، دقیقاً همون چیزی که بند
۸ پرامپت صراحتاً ممنوع کرده بود.

**فیکس:** کل حلقه‌ی `reserve_stock_batch()` (تک‌تک UPDATEهای کاهش موجودی + تک‌تک INSERTهای
Reservation) داخل یک `PDO` Transaction واحد قرار گرفت. هر Fail (چه شکست منطقی چون موجودی کافی
نبود، چه Exception دیتابیس) کل Transaction رو ROLLBACK می‌کنه — هیچ ردیف یتیمی توی
`stock_reservations` باقی نمی‌مونه و نیازی به Restore دستی/جبرانی هم نیست، چون خودِ ROLLBACK
کاهش موجودی رو هم برمی‌گردونه. Race Condition جلوگیری از Overselling دست‌نخورده موند (همون
شرط `WHERE stock >= ?` داخل UPDATE، که حالا زیر محافظت قفل ردیف تراکنش هم هست).

### ۲. Root Cause — Card-to-Card Rejection Lifecycle

**مشکل:** `process_order_rejection()` موجودی رو مستقیم از روی `order_items` (با یه حلقه‌ی
`restore_stock()`) برمی‌گردوند، کاملاً مستقل و بی‌اطلاع از جدول `stock_reservations`. در همون
حین، ردیف‌های Reservation مربوط به اون سفارش (که موقع ساخت سفارش با `consume_stock_reservation()`
به `status='consumed'` رفته بودن) برای همیشه در همون وضعیت `consumed` می‌موندن — یتیم، بدون
هیچ اشاره‌ای به این‌که موجودی‌شون واقعاً (از مسیر دیگه‌ای) برگشته. این یعنی منبع حقیقت برای
موجودی دو تیکه شده بود (هم `order_items`، هم `stock_reservations`) و این دو هیچ‌وقت با هم
Sync نمی‌شدن — دقیقاً همون چیزی که بند ۱۰ پرامپت («یک منبع حقیقت مشخص داشته باش») ممنوع کرده بود.
اگه در آینده جایی (حتی یه Migration بعدی یا یه Cron جدید) روی ردیف‌های `consumed` عملیاتی انجام
می‌شد، همین وضعیت یتیم می‌تونست به Double Restore بعدی منجر بشه.

**فیکس:**
- ستون جدید `orders.reservation_key` اضافه شد (فقط برای سفارش‌های کارت‌به‌کارت پر می‌شه) تا
  رابطه‌ی سفارش → Batch رزرو، طبق بند ۱۳ پرامپت، Traceable بمونه.
- `process_order_rejection()` بازنویسی شد: به‌جای Restore مستقیم، اول با
  `update_order_status_conditional()` (که از قبل درست بود) وضعیت سفارش رو اتمیک به `rejected`
  می‌بره، بعد با `reservation_key` خودِ سفارش، تابع (از قبل موجود و درست) `release_consumed_reservation()`
  رو صدا می‌زنه — که با یک `UPDATE ... WHERE status='consumed'` شرطی، هر ردیف رو دقیقاً یک‌بار
  `consumed → released` می‌کنه و فقط برای ردیف‌هایی که واقعاً خودش Claim کرده Restore انجام
  می‌ده. کل این توالی (تغییر وضعیت سفارش + `admin_note` + Release رزرو) داخل یک Transaction
  قرار گرفت؛ پیام‌های تلگرام (که نباید داخل Transaction بمونن) بیرون از اون اجرا می‌شن.
- برای سفارش‌های قدیمی‌تر (قبل از این Migration) که `reservation_key = NULL` دارن، رفتار قبلی
  (Restore مستقیم از `order_items`) به‌عنوان Fallback مستندشده حفظ شد — طبق بند ۱۴ پرامپت
  (بدون بررسی، Fallback Legacy حذف نشه).
- محافظت اصلی در برابر «دبل‌ریجکت ادمین» همون Transition اتمیک `pending_payment → rejected` بود
  که از قبل درست کار می‌کرد و دست‌نخورده موند؛ تلاش دوم اصلاً به بخش Restore نمی‌رسه.

### Files Changed
- `handlers/repo_products.php` — `reserve_stock_batch()` داخل یک Transaction واقعی بازنویسی شد.
- `handlers/repo_orders.php` — `create_order_from_items()` پارامتر اختیاری `$reservationKey`
  گرفت و در `INSERT INTO orders` ذخیره می‌کنه (Backward-compatible — هر سه Call Site قبلی که
  این پارامتر رو نمی‌فرستن دست‌نخورده کار می‌کنن).
- `handlers/checkout.php` — `handle_receipt_photo()` (مسیر کارت‌به‌کارت) الان `reservationKey`
  رو به `create_order_from_items()` پاس می‌ده؛ کامنت مسیر Reject هم به‌روز شد.
- `handlers/admin_orders.php` — `process_order_rejection()` بازنویسی شد: Transaction واحد +
  استفاده از `release_consumed_reservation()` روی `reservation_key` سفارش، با Fallback Legacy.
- `db/schema.sql` — ستون `reservation_key` و ایندکسش به جدول `orders` اضافه شد.
- `db/migration_v10.sql` — Migration مربوط به تغییر بالا برای نصب‌های موجود.
- `README.md` — بخش کوتاه «ارتقا به v10» اضافه شد.

### Database Changes
بله — Migration ایجاد شد (`db/migration_v10.sql`): فقط یک `ALTER TABLE orders ADD COLUMN
reservation_key VARCHAR(64) NULL` + ایندکس روش. دلیل: تنها راه Traceable و اتمیک برای پیداکردن
Reservation مرتبط با یک سفارش در لحظه‌ی Rejection، داشتن این کلید روی خودِ سفارشه (چون سفارش
هیچ ارتباط دیگه‌ای با `stock_reservations` نداشت). این تغییر Backward-Compatible کامله —
سفارش‌های قدیمی NULL می‌گیرن و مسیر Legacy رو طی می‌کنن، بدون نیاز به Backfill.

### Final Stock Lifecycle
```
reserved → consumed         (سفارش/Payment Session ساخته شد؛ مسئولیت موجودی منتقل شد)
reserved → released         (Reject نشده، Fail/Expire/Cancel قبل از ساخت سفارش)
consumed → released         (Card-to-Card رد شد [این جلسه فیکس شد] یا Zarinpal Session fail/expire شد [از Session 5])
```
هر سه انتقال از طریق UPDATE شرطی (`WHERE status = '<from>'`) انجام می‌شن — هر ردیف در کل عمرش
دقیقاً یک‌بار Claim می‌شه، صرف‌نظر از این‌که چند بار (Cron، دبل‌کلیک ادمین، Retry) صدا زده بشه.

### Tests
هیچ‌کدوم روی PHP/MySQL واقعی اجرا نشدن (Sandbox این جلسه هم دسترسی به PHP/شبکه نداشت) —
NOT TESTED برای تمام سناریوهای بند ۱۸ و ۱۹ پرامپت (A تا I)، از جمله Test B/C (که مستقیماً هدف
فیکس این جلسه بودن) و Test H/I (رد ساده + دبل‌ریجکت). فقط با ردیابی دقیق منطق کد و پیگیری
مسیر داده تایید شدن — قبل از دیپلوی روی هاست واقعی حتماً باید این سناریوها دستی یا با Test
Suite واقعی اجرا بشن؛ خصوصاً Test H (که مستقیماً وابسته به فیکس امروزه) و Test F (Double Cron)
که غیرمستقیم از فیکس `reserve_stock_batch` تاثیر می‌گیره.

### Remaining Risks
- تغییرات این پاس با `php -l` یا دیتابیس واقعی تست نشدن (محدودیت Sandbox) — الزامی قبل از دیپلوی.
- اگه نصب موجودی از قبل ردیف‌های یتیم `stock_reservations` (status='reserved' بدون سفارش/Session
  متناظر، ناشی از باگ قبلی بند ۱) داشته باشه، این‌ها با فیکس امروز خودشون پاک نمی‌شن — ولی چون
  `expires_at` واقعی دارن، Cron بالاخره `release_stock_reservation()` رو روشون اجرا و Restore
  می‌کنه؛ اگه مشکوکی که همچین ردیف‌هایی از قبل مونده، بهتره قبل از فعال‌کردن مجدد Cron یه بار
  دستی `SELECT` بگیری و مطمئن بشی هیچ‌کدوم به یه Restore قبلی که از جای دیگه (مثلاً همین فیکس)
  انجام شده اشاره نمی‌کنن.
- سناریوی حاشیه‌ای Session 5 (TTL رزرو دقیقاً هم‌زمان با ساخت Payment Session منقضی بشه) هنوز
  به همون دلیل قبلی (نیاز به تغییر معماری، خارج از Scope) به‌صورت مستندشده باقی مونده.
