---
name: lis-discount
description: How the LIS request/invoice discount system works — price-list, insurance, package, and the manual whole-request discount (fixed/percentage) that was silently dropped and is now fixed.
updated: 2026-06-27
---

# LIS Discount System

## ✅ FIXED (2026-06-27) — manual whole-request/invoice discount (% or fixed) was silently dropped
**Status: SHIPPED on `hazemdev`+`main`, FE on `/app`. ⏳ elmadina via MoonStack update (migration `2026_06_27_140000`).** FE `e0633f3` · BE `506c59d2d` · CHANGELOG [Unreleased]. Diagnosis report: `public_html/lab-discount-report.html`.

**The bug (FE↔BE contract mismatch):** the wizard sent a request-level `discount_percentage`, but `StoreLabRequestRequest` only validated per-line `investigations.*.discount` → Laravel `validated()` stripped `discount_percentage`, and `CreateLabRequest` never read it. Per-line discount was never sent either → the discount was lost; request + auto-invoice saved at full price. Proven on elmadina: 0 of 7 requests had any discount (`discount_amount=0`, `net=total`).

**The fix (Option A — whole-document discount, owner's choice "خصم كامل على الفاتورة"):**
- **Columns** (`2026_06_27_140000`): `lab_requests.discount_percentage` + `manual_discount_amount`; `lab_invoices.manual_discount_amount` (additive, default 0).
- **`LabRequestService::recalculateTotals`** applies the manual discount to `net_amount` via shared static **`resolveManualDiscount(base, pct, fixed)`** — percentage wins over fixed, capped to `[0, base]`, rounded(3). `discount_amount` (derived) = line discounts + manual; identity `total − discount_amount − package_discount == net_amount` preserved. Clears `manual_discount_amount` when a percentage governs.
- **`LabInvoice::recalculateTotals`** folds `manual_discount_amount` into the invoice discount → **VAT is computed AFTER the discount** (ZATCA-correct). Reviewer proved: request `net_amount` == invoice `total` for the no-VAT standard path (same `resolveManualDiscount`, same base = `sum(net_price)`).
- **`CreateLabRequest`** + `Store/UpdateLabRequestRequest` + controller accept `discount_percentage` / `discount_amount` and persist to the right columns — **never** the DERIVED `lab_requests.discount_amount` (payload `discount_amount` is unset before the `...$data` spread and remapped to `manual_discount_amount`). %/fixed are mutually exclusive (setting one clears the other).
- **Invoice gen** (`InsuranceInvoiceService`): standard invoice gets the full discount; for an insured split it applies to the **patient** invoice only.
- **Wizard**: the discount field has a **%/amount toggle** (`setDiscountMode`); sends `discount_percentage` OR `discount_amount`; live summary + net reflect the mode; edit restores the saved mode.

**Guards (code-reviewer HIGH/MEDIUM applied):**
- 🔒 **A manual discount is BLOCKED when `insurance_contract_id` is set** (422, lang `discount_not_allowed_with_insurance`) — otherwise the request total (discount on total gross) diverges from the split invoices (discount on patient share only). Insured-discount is intentionally NOT supported.
- `update()` returns `meta.invoice_stale: true` when a discount edit leaves a live invoice un-regenerated.
- A 100% discount → `net_amount = 0` → no auto-invoice (guarded `net_amount > 0`); logged for audit.
- 5 phpunit tests (`LabRequestDiscountTest`): %, fixed, cap, no-discount regression, insurance-block — all green.

**⚠️ Limitations:** editing a request's discount AFTER its invoice exists does not auto-regenerate the invoice (use regenerate; `meta.invoice_stale` signals it). `discount_amount` stays in `LabRequest` `$fillable` because `recalculateTotals` mass-assigns it (both write paths already guard external writes).

## The full discount system (what applies where)
- ✅ **Price-list per-item discount** — baked into the `net_price` the wizard sends (`item.net_price`); flows through naturally.
- ✅ **Insurance contract pricing** — per-test approved price + patient/insurance shares (separate path; manual discount blocked here).
- ✅ **Package discount** — `recalculateTotals` allocates the package charge across owned rows; surfaces as `package_discount`.
- ✅ **Manual whole-request discount** — the fix above (% or fixed).
- Per-line `investigations.*.discount` is supported by the BE but the wizard doesn't send it (it uses the whole-request manual discount instead).

## ✅ B2B claim split by price list (2026-06-28, shipped 4.0.26)
Per-external-lab setting `lab_external_labs.claim_split_mode` (`combined` | `separate_sections` | `separate_files`, default combined). `claimDetail()` returns `groups[]` keyed by `price_list_id` (each its own subtotal+VAT+total) alongside the flat keys (backward-compatible). FE `LisB2bClaimService` reuses the EXACT current claim layout per price-list group (owner's rule: same format, only split). Setting in the external-lab edit form. FE `4edaa38`/BE `9c39dc549`. Decisions: separate_files = auto-download per list; separate_sections = new page per list + overall total. Migration `2026_06_27_180000`.

## ✅ Discount now shows in the B2B CLIENT PORTAL (2026-06-28, 4.0.28 + [Unreleased] follow-up)
**Bug:** the discount was stored correctly (`LabInvoice.discount_amount` incl. manual; exposed by `LabInvoiceResource`/`LabRequestResource`) and the printed tax invoice showed it — but the **B2B client portal never displayed any discount** (un-unified surfaces). Root causes: portal `show()` + `ExternalLabPortalBillingService::statement()` returned subtotal/VAT/total **without** discount, and the FE portal had no discount line/column. (The portal request CREATE path hardcodes discount=0 — that's correct: a client self-service request has no manual discount.) Also found: `claim-detail`/`price-lists`/`syncPriceLists` had **NO permission gate** (auth only) — sync is a write (see [[lis-permissions-granularity]]).
**Fix (single source = the invoice):** `show()` + `statement()` now return `discount_amount`; FE renders a Discount line (request detail) + column (statement), shown when >0. Portal invoice PRINT already reused `LisTaxInvoiceService` (reads `discount_amount`) — was already correct. BE `2d78ec392`. **Follow-up `643b9450d`:** a 100%-discount request nets to 0 → no invoice → portal `show()` now **falls back to the request's own totals** (`total_amount`/`discount_amount`/`net_amount`) so the discount still shows.
**Lesson:** every financial document must read the invoice's authoritative `subtotal/discount_amount/tax_amount/total` (single source) + show a discount line when >0 — never recompute or hardcode.

## ✅ Discount now shows in the PATIENT PORTAL too (2026-06-29, [Unreleased])
**Bug (same class, different surface — the patient portal never got the B2B treatment):** a PATIENT request with a manual whole-request discount showed NO discount in the patient portal. Confirmed on staging LR-2026-00016 (100% discount, `discount_amount=180`, `net_amount=0`, **`external_lab_id=NULL` → patient, not B2B**). Two root causes: (1) `PatientPortalController::mapRequest()` returned `invoice={invoice_number,total}` only, and only when an invoice existed — a 100%-discount request nets to 0 → no invoice → nothing shown; (2) 🔴 `buildInvoiceFromRequest()` (the printed receipt synth) summed **per-line `$ri->discount` only** — the wizard applies a **whole-request manual** discount (per-line=0) → the receipt printed full price, discount 0.
**Fix (`2ee83ded2` BE / `8d053a1` FE):** mapRequest now carries `discount_amount` + a **synthetic fallback to the request's own totals** (`net_amount`+`discount_amount`, `synthetic=true`) when no invoice; `buildInvoiceFromRequest` uses the request's **authoritative `net_amount`** (all discounts: line+manual+package) as the taxable base instead of recomputing from per-line; invoices eager-load selects `discount_amount`. FE `pp-request-detail` renders a Discount / Total-after-discount line + shows the 0 total (was hidden, 0 is falsy). **Verified on staging:** portal returns `{total:0, discount_amount:180, synthetic:true}`, receipt subtotal 180 / discount 180 / total 0. 26 PatientPortalApiTest green. **Same lesson confirmed: the B2B per-line-only synth would mis-handle a manual discount too — but B2B portal-created requests hardcode discount=0, so it never surfaced there.**
