# 01 — مواصفات الباك إند (نتيجة الدفع + المستندات + الـ PDF + الـ QR)

> يعتمد على: `invoice-data-contract.json` · `invoice-template-config.json` · `payment-result-states.json` · `fixtures.json`  
> بيتبني فوق الموجود: `App.Api` (`/orders`, `/payments/{n}/session|verify`, `/me/orders`, `/settings/public`, `/content/texts`, `/ops/orders/{n}/status`) و`OrderLifecycle`.

## 1. نتيجة الدفع (Payment result)

### 1.1 الـ endpoint
`GET /v1/payments/{number}/result?token=…` (وبـ auth للعميلة المسجلة). **للقراءة فقط** — مصدر الحقيقة يفضل `POST /payments/{n}/verify`.

```json
{
  "state": "declined",                    // أحد الـ 15 مفتاح في payment-result-states.json
  "group": "failed",                       // success | pending | failed | needs_review
  "order": { "number": "TNG-24117", "status": "payment_failed" },
  "support_code": "05 · Do not honor",     // يتعرض للعميلة للدعم فقط
  "amount_charged": 0,                     // مهم: 0 = "مفيش فلوس اتخصمت" (الواجهة تعتمد عليه)
  "bag_hold_until": "2026-10-29T15:02:00+02:00",
  "retry": { "allowed": true, "alternatives": ["card", "wallet", "cod"] },
  "pending": { "type": "instapay", "hold_until": "…", "instapay": { "handle": "…", "reference": "TNG-24117" } },
  "documents": [ { "number": "INV-26-024117", "type": "invoice", "payment_state": "paid", "languages": ["ar", "en"] } ],
  "texts": { "title": "…", "body": "…", "reasons": ["…"] }   // مدموجة من الإعدادات (انظر 1.4)
}
```
**قاعدة صارمة**: `amount_charged > 0` ممنوع مع أي حالة `failed`. لو مش متأكدين → `needs_review`، **مش** `failed`.

### 1.2 الـ state machine (مختصر)
```
placed ─► payment_pending ─► paid ─► (fulfillment 0→4 كما هو)
              │   │              └─► refunded(جزئي/كلي)
              │   ├─► failed(reason)            ← مفيش خصم، الشنطة محفوظة
              │   ├─► needs_review ─► paid | failed | refunded
              │   └─► expired (InstaPay: انتهى الحجز → ملغي)
 COD:  placed ─► confirmed ─► … delivered ─► collected(paid)  |  refused_at_door → voided
```

### 1.3 Mapping أكواد البوابة → حالة (MPGS/CIB)
| ناتج البوابة | الحالة | ملاحظات |
|---|---|---|
| APPROVED + verify ✅ | `card` / `apple` / `walletp` | يتصدر الـ INV فورًا |
| response `05` Do not honor، `14/54/57` (كارت غلط/منتهي/غير مسموح) | `declined` | نص الأسباب يغطي الكارت المنتهي |
| `51` Insufficient funds | `funds` | |
| 3-D Secure `N`/`R`/`U` أو كود OTP غلط/منتهي | `tds` | |
| العميلة قفلت نافذة الدفع / `USER_CANCELLED` | `cancelled` | مفيش خصم، السلة تتسجل «متروكة» |
| session timeout عندنا | `session` | |
| `5xx`/timeout من البوابة **بدون** نتيجة مؤكدة | `needs_review` (`review`) | ممنوع نعرضها كفشل |
| `5xx` **قبل** إنشاء العملية (مفيش transaction id) | `tech` | |
| المخزون خلص بين الـ quote والدفع | `stock` | **قبل** الشحن على البوابة؛ لو اكتشفناه بعد → استرداد تلقائي + `needs_review` |
| InstaPay: عدّى الـ hold | `reserve` | يتلغي الأوردر ويرجّع المخزون |
| محفظة: عدّى الـ TTL | `walletw`→ resend | |

أي كود غير معروف ⇒ `needs_review` + alert للفريق (مش `failed`).

### 1.4 النصوص ديناميك
كل نص في `payment-result-states.json` هو **افتراضي**. ينزل من `GET /content/texts` (نفس آلية app-texts الحالية) بمفاتيح:
`payment.result.<state>.title` · `.body` · `.reasons.<i>` · `.cta.<key>` — مع placeholders `{order_number} {amount} {eta} {last4} {payment_ref} {phone_masked}`. ما يتحطش أي نص ثابت في الكلاينت. الـ API يرجّع `texts` جاهزة ومدموجة.

### 1.5 الـ reconciliation (للحالة needs_review)
- Job كل 10 دقايق: يعيد استعلام البوابة (`RETRIEVE ORDER`) لكل `needs_review`.
- لو اتأكد الخصم → `paid` + إصدار الفاتورة + رسالة تأكيد. لو اتأكد عدم الخصم → `failed` + رسالة. لو عدّى `payment.review.followup_minutes` بدون حسم → ticket للإدارة (لا استرداد تلقائي بدون قرار بشري إلا لو الخصم **متأكد**).
- كل نتيجة تتسجل في `payment_attempts` (انظر 2).

## 2. نموذج البيانات (مقترح — ظبّطوه على الـ schema الحالي)

| جدول | أعمدة أساسية |
|---|---|
| `payment_attempts` | `id, order_id, method, state, gateway_ref, reason_code, amount_authorized, amount_captured, started_at, finished_at, raw_response(json, redacted)` — **صف لكل محاولة** (الحالي بيخزن حالة واحدة فقط) |
| `documents` | `id(uuid), type(invoice/proforma/credit_note), number(unique), order_id, against_document_id?, status(issued/voided/superseded), payment_state, issued_at, snapshot(json — DocumentSnapshot), pdf_ar_key?, pdf_en_key?, verify_sig, created_by(system/user)` |
| `document_sequences` | `type, year, last_value` — تحديث `UPDATE … RETURNING` داخل transaction (gap-free) |
| `document_deliveries` | `id, document_id, channel(whatsapp/email/app), to, status(queued/sent/delivered/failed), provider_msg_id, attempts, last_error, at` |
| `settings_versions` | نسخة لكل حفظ لإعدادات القالب: `version, values(json), changed_by, at` — الـ snapshot يخزّن `version` |
| `document_audit` | `document_id, action(view/download/resend/reissue/void/credit), actor(user/customer/system), ip, at, note` |

**قيود**: `documents.number` unique · لا `DELETE` على documents أبدًا (void فقط) · `snapshot` immutable (trigger أو application-level).

## 3. الترقيم
- الصيغة: `{prefix}-{yy}-{seq padded 6}` — البادئات من الإعدادات (`INV/PF/CN`)، والـ padding من الإعدادات.
- تسلسل **لكل نوع** (INV منفصل عن CN منفصل عن PF)، يكمّل بين الويب والموبايل (تسلسل واحد) — **للتأكيد مع العميل**.
- gap-free: الرقم بيتحجز داخل نفس transaction اللي بيحفظ المستند. لو الـ PDF فشل بعد الحفظ → الرقم يفضل، والـ PDF يتعاد توليده (retry)، **مش** رقم جديد.
- Idempotent: إصدار نفس (order, type) مرتين يرجّع نفس المستند.

## 4. الحسابات (مرجعها `fixtures.json`)
- الأسعار **شاملة 14%**. `vat = round_half_up( total / 1.14 × 0.14 , 2 )`, `net = total − vat`.
- `subtotal = Σ (unit_price × qty)` · `total = subtotal − coupon − glow_points + shipping`.
- كوبون/نقاط Glow بتتوزّع تناسبيًا على الأسطر **للمرتجع فقط** (علشان قيمة الإشعار الدائن).
- **إشعار الدائن لمرتجع جزئي**: `refund = qty_returned × unit_price × (1 − (coupon+points)/subtotal)`؛ الشحن ما يتردّش إلا لو الاسترجاع كلي أو بقرار.
- الفاتورة ما تتغيّرش بعد الإصدار. تعديل الأوردر بعد الإصدار ⇒ `void` + فاتورة جديدة (قبل الشحن) أو إشعار دائن/فاتورة تكميلية (بعده).
- Fixtures المتوقعة: `std=935` (vat 114.82) · `ship=510` (62.63) · `large=4276` (525.12) · `cod=564` · `old=801`.

## 5. توليد الـ PDF
- **المدخل الوحيد**: `DocumentSnapshot` (مفيش query لقاعدة البيانات وقت الـ render).
- الطريقة المجرّبة في البروتوتايب: HTML → PDF بـ Chromium headless (`tools/make_pdfs.py`، وتقدروا تنقلوا `assets/invoice.js` كـ template). بدائل مقبولة بس لازم تتطابق مع الـ PDF المرجعية: QuestPDF/… **تأكدوا من RTL والـ shaping العربي**.
- الخطوط: **Alexandria** (عربي/لاتيني) + **Bricolage Grotesque** للأرقام. **لازم تتضمّن (embed)**. الملفات المرجعية حجمها ~400–600KB لأن الخط variable؛ استخدموا static instances/subset علشان توصلوا ~100KB.
- A4 (210×297mm)، margin صفر، `print_background`. صفحة واحدة لو ≤ 4 أسطر؛ غير كده: 6 أسطر في الصفحة الأولى، 11 في الوسطى، والإجماليات في آخر صفحة (≤ 7 أسطر معاها). كل صفحة فيها «n / N» وهيدر مصغّر من التانية.
- النصوص الطويلة: اسم المنتج يلف سطرين كحد أقصى ثم `…`. اسم العميلة طويل يلف. الأرقام `nowrap`.
- اسم الملف: `{number}-{lang}.pdf` (مثال: `INV-26-024117-ar.pdf`) مع `Content-Disposition: attachment; filename*=UTF-8''…`.
- التخزين: object storage بمفتاح `documents/{yy}/{number}-{lang}.pdf`. يتولّد **عند الطلب أول مرة** ويتكاش (مش لازم الاتنين لغات مقدمًا). إعادة التوليد ممنوعة إلا بـ `reissue` (نسخة جديدة بنفس الرقم و`template.version` جديد مسجّل في الـ audit).
- الأمان: بدون روابط عامة دائمة؛ التحميل بعد auth أو `token` للضيف، أو signed URL قصير العمر (≤ 10 دقايق). Rate limit على التحميل.

## 6. الـ API

| Method | Path | من يستخدمه | ملاحظات |
|---|---|---|---|
| GET | `/v1/payments/{n}/result` | موقع/تطبيق | انظر 1.1 |
| GET | `/v1/me/orders/{n}/documents` | حسابي/تطبيق | قايمة المستندات (فاتورة + إشعارات دائن) + `languages` |
| GET | `/v1/orders/{n}/documents?token=` | ضيف | نفس القايمة بالـ order token |
| GET | `/v1/documents/{number}/pdf?lang=ar\|en` | الكل | `200 application/pdf` · `403` لو مش بتاعتها · `404` · `409` لو لسه ما اتصدرتش (مبدئية/معلقة) |
| GET | `/v1/me/documents.zip?from=&to=` | حسابي | تحميل كل الفواتير |
| POST | `/v1/me/orders/{n}/company-invoice` | حسابي | `{ company_name, tax_no }` → يصدر نسخة جديدة `superseded` للقديمة (لو داخل `window_days`) |
| GET | `/v/{number}?s=` | أي حد (HTML) | صفحة التحقق (قسم 7) |
| GET | `/v1/verify/{number}?s=` | صفحة التحقق | JSON بدون PII |
| POST | `/ops/orders/{n}/documents/reissue` | داشبورد | `{ type, reason }` — reason إجباري |
| POST | `/ops/orders/{n}/credit-note` | داشبورد | `{ lines:[{sku,qty}], reason, refund_to }` |
| POST | `/ops/documents/{number}/resend` | داشبورد | `{ channel: whatsapp\|email }` |
| POST | `/ops/documents/zip` | داشبورد | `{ numbers:[…] }` → job + رابط (حتى 200 مستند) |
| POST | `/ops/orders/{n}/payment/resolve` | داشبورد | `{ action: mark_paid\|start_refund\|mark_failed, note }` لـ needs_review |
| GET/PUT | `/ops/settings/invoice` | داشبورد | يقرأ/يكتب `invoice-template-config.json` ويحفظ `settings_versions` |
| POST | `/ops/settings/invoice/preview` | داشبورد | `{ values, sample: std\|large… }` → PDF/HTML preview بدون إصدار |

أخطاء موحّدة: `{ "error": { "code": "document_not_ready", "message_ar": "…" } }`.

## 7. الـ QR وصفحة التحقق

**الـ payload** (نص واحد): `{verify_base_url}/{docNo}?s={sig}` — مثال حقيقي مفكوك من الـ PDF: `https://lotusorganics.store/v/INV-26-024117?s=7272b786b4`.

- `sig = hex( HMAC-SHA256(secret, "{docNo}|{total:0.00}|{issueDate:dd/MM/yyyy}") )[0..10]`. (البروتوتايب بيستخدم hash تجريبي في `TNG.sig` — **استبدلوه بالـ HMAC**.) المقارنة constant-time.
- **الصفحة تعرض الحالة الحالية** لا حالة وقت الإصدار: `valid` / `cod` (مستحقة) / `refunded` (فيه إشعار دائن) / `credit` / `invalid`.
- **ممنوع PII**: تعرض فقط رقم المستند، النوع، التاريخ، الإجمالي، الحالة، الاسم مقنّع (`س••• أ•••`). **مفيش** تليفون/عنوان/منتجات/إيميل.
- Rate limit قوي (توكن sig 40 bit = لازم نمنع التخمين) + `noindex` + بدون cookies.
- `invalid` يرجّع نفس الشكل لـ "مش موجود" و"توقيع غلط" (لا تفرّقوا في الرسالة للخارج).
- مواصفات الكود: **ECC level M**، quiet zone ≥ 4 modules، byte mode، الحجم المطبوع 25mm على الأقل. مفيش لوجو فوق الكود.
- المولّد المرجعي: `assets/qr.js` (نفس الملف بيشتغل Node/المتصفح). اتجرّب بقارئ OpenCV على 7 حالات (نسخ 1→9، عربي UTF-8) و**على الـ 15 PDF**: كل الأكواد اتفكت وطابقت `TNG.docs`. للـ C# ممكن QRCoder، ولـ Dart `qr`/`qr_flutter` — **نفس الـ payload والـ ECC**.
- **اختبار القبول**: امسحوا كل PDF مرجعي بموبايل فعلي → يفتح الرابط → الصفحة ترد بالحالة الصح.
- الفاتورة المبدئية **مفيهاش QR** (بتتبدّل برسالة).

## 8. الإرسال (واتساب / إيميل / التطبيق)

| الحدث | واتساب | إيميل | in-app |
|---|---|---|---|
| `paid` (كارت/Apple/محفظة) | قالب + document header (INV) | موضوع `delivery.email.subject_ar` + مرفق | إشعار + الفاتورة في الأوردر |
| COD متأكد | قالب + INV مستحقة | نفس | نفس |
| InstaPay pending | قالب + PF + بيانات التحويل | نفس | — |
| InstaPay اتأكد | قالب + INV النهائية | نفس | نفس |
| `failed` (قابل للحل) | بعد `delay_minutes` لو مفعّل: رابط الشنطة | اختياري | — |
| `needs_review` | فورًا: كود المرجع + "ما تدفعيش تاني" | اختياري | — |
| إشعار دائن | قالب + CN | نفس | نفس |

- قالب واتساب (Meta-approved) فيه `document` header + variables: الاسم، رقم الأوردر، الإجمالي، الـ ETA. الاسم من `delivery.whatsapp.template_invoice`.
- كل إرسال صف في `document_deliveries` مع retry (3 مرات، backoff) و`failed` يظهر في الداشبورد. الإرسال **async** (queue) مش جوّه request الدفع.
- لو الرقم أو الإيميل ناقص → تخطّي + سجّل السبب. احترم opt-out.
- **الأحداث** اللي لازم الباك إند يطلقها: `payment.state_changed`, `document.issued`, `document.voided`, `document.delivery_failed`, `payment.needs_review`.

## 9. الحالات الحدّية (checklist)
- [ ] أوردر بـ 11+ سطر → صفحتين، الإجماليات في الأخيرة (`large`).
- [ ] شحن مدفوع من غير كوبون (`ship`)؛ كوبون + نقاط Glow (`large`).
- [ ] مرتجع جزئي ثم مرتجع تاني → **CN منفصل** لكل واحد، مجموعهم ≤ قيمة الفاتورة.
- [ ] إلغاء بعد الدفع قبل الشحن → CN كلي (مش void) لأن في فلوس اتحصّلت.
- [ ] COD اترفض عند الباب → `voided` (مفيش CN لأن مفيش تحصيل). COD اتحصّل → نفس الفاتورة تتحوّل `paid`.
- [ ] InstaPay: التحويل وصل متأخر بعد انتهاء الحجز → مراجعة يدوية (تفعيل الأوردر أو استرداد).
- [ ] اسم منتج/عميلة طويل جدًا، حروف خاصة، RTL/LTR مختلط (أرقام داخل نص عربي).
- [ ] عميلة من غير إيميل / غير تليفون واتساب.
- [ ] تغيّر الرقم الضريبي أو الاسم **بعد** إصدار فواتير → الفواتير القديمة ثابتة (snapshot).
- [ ] فشل توليد PDF → retry، والرقم محجوز، والـ API يرجع `409 document_not_ready` مش 500.
- [ ] تحميل بالتزامن (race) من واتساب والموقع → نفس الملف.
- [ ] المنطقة الزمنية: Africa/Cairo دايمًا في الـ PDF والصفحات.
- [ ] تسلسل الترقيم تحت ضغط (50 إصدار متزامن) بدون تكرار ولا فجوات.
- [ ] فاتورة باسم شركة: الأصلية `superseded`، الجديدة بنفس الأوردر ورقم جديد، الاتنين ظاهرين.

## 10. اختبارات القبول (الحد الأدنى)
1. Unit: كل fixture في `fixtures.json` يطلع نفس الإجماليات والضريبة بالسنت.
2. Contract: أي `DocumentSnapshot` يعدّي JSON Schema في `invoice-data-contract.json`.
3. Visual: الـ PDF المتولّد من الـ snapshot المرجعي يطابق `../samples/*.pdf` (تحمّل فروقات الخط).
4. QR: فك كل PDF بقارئ → URL → `GET /v/{no}?s=` يرجّع الحالة المتوقعة.
5. State: كل مفتاح من الـ 15 حالة يرجع من `result` بنفس `group` والسلوك (`payment-result-states.json`).
6. Idempotency + concurrency للترقيم والإصدار.
