# LIS Patient Loyalty Points — خطة معروضة بانتظار اعتماد المالك (2026-07-10)

**الخطة الكاملة (المرجع الحاكم):** `https://moonui.elbaset.com/his-analysis/lis-loyalty-program-plan.html` (`/home/moonui/public_html/his-analysis/lis-loyalty-program-plan.html`)

## الجوهر
- الكسب **عند الدفع الفعلي** per-payment (`PostLabPayment` hook، أحداث best-effort لا تكسر الدفع) · claw-back عبر `VoidLabPayment` بفرعيه.
- **الاستبدال = طريقة دفع `loyalty_points`** (`is_cash=false` + حساب GL) — صفر تعديل على كور الدفع. ينفّذه الكاشير بصلاحية `lis.loyalty.redeem` المنفصلة.
- Schema: `lab_loyalty_transactions` (FIFO lots + UNIQUE(company,payment,type)) + `lab_patients.loyalty_points` cached.
- محاسبة v1: expense-at-redemption (لا JE عند الكسب) + تقرير liability.
- بورتال المريض (موجود: `patient-portal-public`): بادج في `me()`/هيدر + تاب «نقاطي» + شارة انتهاء.
- إعدادات `lis.loyalty_*` (group `lis_loyalty`) · مستثنون: B2B patients + لا نقاط-على-نقاط · انتهاء بالشهور per-lot + مهمة 03:00 chunked.
- **المرجع البرمجي:** WebStore loyalty على **main فقط** `cdffe11dd` (اقرأه بـ`git show`) — ننسخ التصميم ونصلّح 4 فجوات فيه (idempotency DB-unique · block policy enforced · notify_days مستهلك · expiry chunked).
- v1 add-ons: welcome bonus + expiry-soon chip. WPs: BE-1..9 + FE-1..5 (5 منهم [FIN] بمراجعة Fable).

## قرارات المالك المثبتة
دفع فعلي · خصم-على-طلب v1 (محفظة v2) · الكاشير ينفّذ (بورتال قراءة) · انتهاء إعداد.

## ⏳ 3 قرارات معروضة في الـHTML بتوصيات (حساب مصروف مخصّص / allow_negative / welcome bonus في v1)
## ⛔ التنفيذ يبدأ بعد اعتماد المالك («ابدأ بالاختيارات الموصى بها» أو بتعديلاته).

## 🧙 عقد Fable المالي المُلزِم (APPROVE-WITH-CHANGES، 2026-07-10) — لـBE-2/BE-3
**BLOCKERS (إلزامية):**
1. **الاستبدال sync/atomic:** فرع في `LabPaymentController::store()` لما `payment_method==='loyalty_points'` يلفّ **كله في outer DB::transaction**: قفل لوتات المريض `FOR UPDATE` بترتيب **`expires_at ASC, id ASC` (FEFO)** → تحقّق النقاط ≤ الرصيد المقفول (422) → صفوف الاستهلاك + إنقاص `lab_patients.loyalty_points` → إنشاء الدفعة → `PostLabPayment::execute()`. أي throw = rollback الكل. **الاستبدال مش listener.** الكسب فقط best-effort.
2. **حارس الكسب:** earn فقط لو `invoice.invoice_type ∈ {Standard, PatientInvoice}` (فواتير حصة المريض insurance_amount=0؛ `PostLabPayment::resolveReceivableAccount` **بيقيّد ذمم تأمين** لو insurance_amount>0 — الافتراض الأصلي غلط) AND `payment_method!=='loyalty_points'` AND `patient.external_lab_id IS NULL`. (B2B آمن بنيويًا — RecordExternalLabPayment مايعملش LabPayment. المُنشئان الوحيدان لـCompleted LabPayment = store() + factory.)
3. **السيرفر يفرض:** على فرع loyalty يتجاهل `receiving_account_id` المُرسَل ويقرأ الحساب من صف `loyalty_points` في lab_payment_methods (422 لو null)؛ الـAPI تاخد `points` (صحيح)، تحسب `amount = points × redeem_rate` سيرفر-سايد (نقاط كاملة → decimal(12,3) مضبوط). حارس `amount > balance_due` الموجود يفضل.
**SHOULDs:** 4. أمر `lis:loyalty-reconcile` (يمسح Completed earn-eligible بلا صف earn ويعيد الكسب — idempotent بالـunique key؛ يتخطّى voided) **يُشحن مع listener الكسب**، مجدول يومي. 5. redemption-void refund lot: `expires_at = MAX(انتهاء اللوتات المستهلكة)`. earn-void→سالب مقبول. 6. الاستهلاك FEFO.
**APPROVED كما هو:** PostLabPayment JE يشتغل بلا تغيير (Dr expense / Cr receivable، متوازن، is_cash=false مايدخلش cash-box routing؛ expected_cash يعدّ 'cash' الحرفي فقط فمافيش phantom variance). الكسب على gross (VAT immaterial). expense-at-redemption مقبول v1.
**NOTE للتقارير:** redemptions تظهر في «المحصّل» — أي KPI كاش-إن يخصم طريقة loyalty_points.

---
## 🔨 تقدّم التنفيذ (SDD)
- **BE-1** ✅ `6cdfba21d` — جداول + إعدادات + صلاحيات (migrated dev).
- **BE-2/3** ✅ القلب المالي [FIN] — `a5f1db619` (service+events+payment method).
  - 🧙 **Fable review التنفيذ → SHIP-WITH-FIXES** (الذرّية والقفل مؤكّدان). ٦ تسريبات ثانوية.
  - **fix-wave `4a337aaac`** (Opus نفّذها بنفسه): SHOULD-2 debt-gate under patient lock (min(lotSum,cached)، ترتيب قفل موحّد) · SHOULD-1 reconcile fence (--from/--all) · SHOULD-3 isUniqueViolation (1062/UNIQUE فقط، FK يطلع) · SHOULD-4 reconcile يعيد clawback المبلوع · SHOULD-5 floor(round) · SHOULD-6 invoice refresh بعد القفل · NOTE-1 block policy مُنفّذة فعليًا (reverse=min(earned,unspent)، invariant cached==SUM محفوظ) · **+TZ fix** (meta expiry بتوقيت التطبيق بدل UTC — كان ينزاح ٣ساعات/يوم).
  - **21 تست/1 skipped (+5 جديدة: rollback crux, debt gate, block, fence, clawback replay)؛ LabPaymentApiTest 15/15.** ⏳ Fable re-verify للـfix commit.
- **BE-4/5/6** 📝 brief جاهز (`scratchpad/loyalty-be456-brief.md`): API + expiry job + portal. بينتظر تأكيد Fable للكور.
- **BE-7** (تقارير [FIN]) · **BE-8** (bonus/notify) · **BE-9** (tests [FIN]) · **FE-1..5** ⏳.
- ⚠️ **NOTE-3 (Fable):** manualAdjust السالب بلا حارس رصيد → لازم حارس عند endpoint BE-4 (`abs(points) ≤ balance` أو override). مُضاف للـbrief.

- **BE-4/5/6** ✅ مكتملة+مراجعة+مدفوعة — BE-4 `a0674911f` (API: summary/ledger/preview/adjust+NOTE-3/settings) · BE-5 `c63fdaa92` (مهمة انتهاء ليلية chunked 03:00؛ تشمل كل أنواع اللوطات Earn/ManualAdd/WelcomeBonus لأن scopeAvailableLots كده — **بيحل Fable NOTE-2**) · BE-6 `e1a990ee0` (بورتال me() loyalty block + me/loyalty-history scoped للتوكن).
  - مراجع مستقل (sonnet) → **NEEDS-FIXES** بنقطتين، مُصلحتان `654d20c63`: **I1** UpdateLoyaltySettingsRequest بيتحقّق نوع/enum كل مفتاح (withValidator لأن المفاتيح dotted) — بدل coercion صامت يصفّر point_value · **I2** حارس NOTE-3 كان TOCTOU → داخل DB::transaction بقفل صف المريض أولًا (manualAdjust يعيد القفل savepoint). +3 تست، 16 نجاح.
  - **مِنورز للمراجعة النهائية (triage):** (a) TYPE_LABELS_AR const مكرّر بين LoyaltyController+PatientPortalController · (b) 404 vs 422 غير متّسق cross-company (cosmetic) · (c) findPatient re-check ميت في preview · (d) ExpireLoyaltyPoints::enabledCompanyIds full-scan بلا index (لو الليدجر كبر) · (e) **BE-6 404-when-disabled = قرار منتج** (me() بيخفي التاب فمايتنادى أصلًا؛ مقبول v1، يتأكّد وقت الـFE).
- **BE-7** (تقارير [FIN]) + **BE-8** (bonus/notify) 📝 brief جاهز (`scratchpad/loyalty-be78-brief.md`) — قيد الإطلاق.

- **BE-7/8** ✅ مكتملة+مراجعة+مدفوعة `1f6a5a369` (+fix `e7fd8cece`).
  - BE-7 [FIN] تقارير: `LoyaltyReportController` (balances/movements/redemptions/liability). liability=SUM(remaining) للّوطات المتاحة (السلطوي) + دلتا الكاش تشخيص + buckets. redemptions=redeem فقط ويستثني refund_reversal/manual_deduct.
  - BE-8: welcome bonus (`LabPatientCreated` event على LabPatient::created + `GrantWelcomeBonus` listener — best-effort/B2B-excluded/idempotent بقفل+وجود/**بيتجنّب تسميم كاش SettingsService** بقراءة خام) + `expiryNotify` (بيانات النافذة، التسليم مؤجّل لا gateway).
  - ⚠️ **Fable وقع (session limit resets 1:20am UTC)** → عملت **native [FIN] review** بديلًا (القاعدة: بديل لما Fable يقع). اصطاد: **redemptions كان بيعدّ استخدامات لدفعات ملغاة** (قيدها انعكس → يضخّم مطابقة GL) → مُصلح `whereNotExists(voided)` + تست. **المنح الجماعي عند الاستيراد = غير وارد** (PatientImportSeeder يستخدم raw insert بلا events فالـhook مايولّعش؛ بس الإنشاء التفاعلي يمنح).
  - **72 نجاح** (11 reports + welcome + expiry + API regression + patient-create + barcode cache guard). الكور المجمّد ماتلمسش.
- 🎯 **BE-1..8 كلها ✅.** التالي: BE-9 (تدقيق فجوات التستات — مغطّى أساسًا عبر تستات كل WP) → FE-1..5 → نشر.
- 🧙 **ذيل لـFable لما يرجع:** يراجع BE-7/8 [FIN] (راجعته native فقط) — منخفض الخطر (تقارير read-only + welcome bonus متحقّق).

---
## ✅✅✅ اللويالتي مكتملة ومنشورة (2026-07-11) — بانتظار تست المالك
- **الباك إند BE-1..9:** كامل، 78 تست لويالتي، صفر regression، على hazemdev BE (آخر `3e7707970` = CHANGELOG). القلب المالي BE-2/3 = **Fable SHIP** (مراجعتان). BE-4/5/6 مراجع مستقل→مُصلح (I1 typing/I2 TOCTOU). BE-7/8 **native [FIN] review** (Fable وقع session-limit) → أصلح استثناء الاستخدامات الملغاة. لايف تست رحلة شاملة ✓.
- **الفرونت FE-0..5:** كامل على moon-erp hazemdev (`bc4c319`). `LisLoyaltyService` + إعدادات(تاب Lab Info) + دفع[FIN](quick-collect: بادج+بانل معاينة حيّة+Collect مقفول على صحة السيرفر، صفر amount من العميل) + تاب ملف المريض(+manual adjust) + تقارير(4 تابات+export) + بورتال(بادج+صفحة نقاطي). أصلحت sort الأرصدة (asc|desc).
- **النشر:** `ng build --base-href /app/` نجح → `/app` (`main-TJHS77NW.js`، 581 ملف)، config.json سليم، chowned moonui. مسارات BE لايف (401 مش 404).
- **CHANGELOG:** 3 إدخالات مجزّأة → إدخال واحد شامل ثنائي اللغة (أغلق وعود «لسه مفيش شاشات»).
- ⛔ **المالك:** merge hazemdev→main (BE+FE) + ship للعملاء عبر MoonStack + **تست وظيفي**. migrations اللويالتي (BE-1) اتشغّلت على moonui_dev_be.
- 🧙 **ذيل اختياري:** Fable يراجع BE-7/8 + FE-2[FIN] لما يرجع (1:20am UTC) — راجعتهم native، منخفض الخطر.
- **مؤجّلات:** المحفظة v2 · insurer-settlement guard · إشعار انتهاء فعلي (لا gateway) · welcome-bonus fence لو أي مسار Eloquent-bulk اتضاف مستقبلًا (حاليًا raw insert فآمن).
