---
name: POS settings infrastructure + Drug product
description: The 2026-08-01/02 work that turned POS settings into a definition-driven screen, made the terminal settings real, split the POS sale permission, and gave products a drug identity (active ingredients, dosage forms, per-unit pricing). Read before touching POS settings, Modules/Core product/lookups, or the products dialog.
updated: 2026-08-03
refs:
  - knowledge-base/plans/pos-settings-catalogue-and-drug-product.html   # the approved analysis (46-setting catalogue)
  - knowledge-base/plans/pos-pharmacy-fitness-analysis.html             # the pharmacy gap analysis that preceded it
  - knowledge-base/plans/pos-settings-drug-product/LEDGER.md            # per-WP execution record — the authoritative one
related:
  - pos-overhaul
---

# POS settings infrastructure + Drug product (2026-08-02)

**Status:** ✅ WP1–WP8 + C1/C2 + **D1** implemented on `hazemdev2`, deployed to moonui2 `/app` (`main-N7MTBNXM.js`) — `/fullpush` is the owner's step. WP9 (the coming-soon catalogue) is written and queued.

**D1 (2026-08-03) — «إضافة دواء»:** a PRESET over the existing product dialog, never a second form or a second save path. Toolbar button → same dialog with `is_drug` on, the «دواء» tab leading and active, the fields grouped identity → pharmacology → pack & price; «حفظ + دواء تاني» saves and blanks without closing; category, manufacturer and storage temperature carry to the next entry. **The preset follows the medicine, not the entry path** — opening any saved drug gets the same layout — but «حفظ + دواء تاني» is creation-only and sticky values never reach an existing record. **Sticky tax was dropped: `products` has no tax column and neither FormRequest declares one.** Backend untouched; verified by `e2e/specs/d1-drug-entry-preset.spec.ts` (8/8, API-mocked, captures the wire). See the LEDGER for the two-signal design and the CSS-`order` tab trap.

⚠️ **Where the analysis and the ledger disagree, the LEDGER is right.** The two HTML files are Phase-1 analysis written *before* implementation; execution overturned several of their claims (see below).

## Why it happened

The owner asked whether the POS screen could serve as a pharmacy till. The gap analysis said yes-with-additions, and he then chose to build **the settings machinery and the drug product master first**, deferring the 46 individual pharmacy toggles: *«أما إعداد إعداد هنشتغل عليه فيما بعد»*.

## The one discovery that reordered everything

**The settings machine already existed and was excellent — and the POS tab was the only screen outside it.**

`setting_definitions` carries `label_ar/label_en` **and `description_ar/description_en`**, plus `value_type`, `allowed_values`, `display_group`. Seven module tabs render themselves from it. The POS tab was three hand-written `p-select`s with no description slot at all.

⇒ The first WP was not a setting. It was converting the tab. After it, **a new POS setting costs one seeder entry + one reader**, and renders itself bilingually with its explanation. Before it, four hand edits and no explanation.

## The recurring defect family — four instances of one thing

Settings were **stored, validated, rendered — and never read**:

| # | Where | Found in |
|---|---|---|
| 1 | All **seven** terminal settings had zero backend readers. The screen was decoration; any direct API call bypassed it. | WP2 |
| 2 | `POSTerminal::getSettingAttribute()` was broken — no `setting` column, so it returned **only the defaults** and threw away each terminal's real configuration. Reading policy through it would have meant "allow everything, always". | WP2 |
| 3 | `POSSessionResource` never emitted `terminal.settings`, so `PosPolicyService` was **null on every till** — WP2's guards worked server-side but the screen could not pre-empt them; the cashier learned the rule only by being refused after pressing Pay. | WP3 (fixed by the orchestrator) |
| 4 | `pos.default_receiving_account_type` has **zero functional readers**. Still open — Phase C. | WP1 |

Plus the same shape in permissions: `pos.refunds.create` existed but was **never added to `PermissionCatalog`**, so it showed as a bare key on the roles screen.

## Locked decisions (do not re-litigate)

1. **Drug is `products.is_drug` + a 1:1 `product_drug_details` table — never a third `ProductType`.** A `ProductType::Drug` would have silently removed every drug from **POS index, POS search and Inventory Count**, all three of which hard-filter the literal `'product'`. Proven with live HTTP assertions in `ProductDrugDetailsApiTest`.
2. **Dosage form is a lookup table, not an enum** — the list is not closed across markets, and a table gives per-company Arabic naming. 18 forms seeded bilingually.
3. **Pack composition stays flat.** No `parent_unit_id`, no recursion in `UnitConversionService` — it is the primitive every stock movement, sale, purchase and valuation depends on. Nesting is a **UI helper** that multiplies and always displays the base-relative result.
4. **POS settings are company-scoped by default.** Only decisions taken inside `Modules/POS` controllers may be terminal-scoped: `PostSalesInvoice` / `StockService` / `LotAllocationService` / `ValidatesMinimumSalePrice` receive `company_id` and can never read a terminal.
5. **Settings the till must obey ride on the session payload**, not per-key `GET /core/settings/{key}` — that endpoint 403s for a cashier and the FE swallowed it into a silent default.
6. **Terminal `allow_negative_stock` and `default_tax_rate` were deleted, not fixed** — one home per concept. Negative stock belongs to `warehouses.allow_negative_stock` (now shown read-only on the terminal form); tax is a per-line `tax_rate_id`.

## Load-bearing facts for whoever comes next

- **`MergesSettingDefinitionRules` is why a new setting is cheap.** Any module settings FormRequest that uses it accepts every defined key automatically; without it `validated()` silently drops the key and the setting never saves. Pinned by a test that creates a rule-less definition and saves it.
- **`SettingsService::set()` is the single write choke point** for every module's settings screen. That is where WP9 puts the "not implemented yet" 422.
- **A newly granted permission is invisible until the user logs out and back in** — the FE caches the user (with permissions) in `localStorage['cached_user']`. Cost an hour of "nothing shows" debugging on 2026-08-02. Verify in this order: deployed chunk contains the code → endpoint returns 200 for that user → **then** say re-login.
- **`product_variants` has no `sale_price`** — only `additional_price`, and **nothing in the codebase applies it**. Any price-resolution chain that includes a "variant price" leg is wrong and will refuse legitimate sales.
- **Both product-unit FormRequests already validate `conversion_factor` `gt:0`** and already enforce unit-group consistency, and already accept `barcode`/prices/flags. The analysis claimed otherwise — it was wrong. WP8 was pure FE + wiring a `updateProductUnit()` that had been written with zero callers.
- **Manufacturers live in WebStore** and are unreachable from Core. WP8 added a **read-only** `GET /api/core/manufacturers` gated by `core.products.view` (not a new permission — a new one would leave every existing role unable to open the picker). Writes stay in WebStore.
- **`RolePermissionSeeder` is excluded from the MoonStack updater** — a permission added only there is dead on 100% of existing installs. `pos.sales.create` shipped with a bridge migration that grants it to every role holding `sales.invoices.create`; on dev it reached 6 roles including a hand-made `testrole` the seeder has never heard of.
- **Pest loads every test file into ONE process.** A duplicate top-level function is a fatal redeclare → exit 255, zero output, under every filter. **Fourth occurrence** hit this work: `makeOrder()` in WebStore vs Production had the entire suite dead before a single line was written. Prefix every helper with its file's subject.

## What shipped

| WP | Delivered |
|---|---|
| 1 | POS settings tab generated from definitions; bilingual label + **explanation** per setting; groups; `GET|PUT /pos/settings` |
| 2 | The 7 terminal settings enforced server-side (or deleted); `show_unit` formalized; 3 conflicting `require_customer` defaults unified to `false` |
| 3 | Settings delivered on the session payload; FE till gate switched to the new permission; `pos.refunds.create` cataloged; terminal policy blob now reaches the screen |
| 4 | `pos.sales.create` + bridge migration (the till was gated on `sales.invoices.create`) |
| 5 | `active_ingredients` + `dosage_forms` CRUD modules in Core, with FE screens and 18 seeded bilingual forms |
| 6 | `products.is_drug`, `product_drug_details`, `product_active_ingredient` (with per-ingredient strength), wired into Core's resource/requests/controller; `manufacturer_id` and `shelf_life_days` un-orphaned |
| 7 | The conditional «دواء» tab in the product dialog |
| 8 | Editable product-units grid (per-unit price/barcode/flags), the composition helper, the manufacturer picker |

## Numbers

| | Before | After |
|---|---|---|
| Scoped regression filter | 174 passed / 14 failed | **201 passed / 14 failed** (same 14) |
| `Modules/Core` | 671 / 10 | **696 / 10** (same 10) |
| `Modules/POS` | 212 / 0 | **226 / 0** |

The 14 and the 10 are pre-existing and documented in `plans/pos-settings-drug-product/baseline-failures.md`. Seven migrations ran on `moonui2_dev_be`, all forward-only and guarded.

## Deliberate leftovers

- `pos.default_receiving_account_type` — defined, zero readers. Give it a reader or retire it.
- `drug_details.notes` — in the BE contract, no FE control, and `syncDrugData` writes `notes => $payload['notes'] ?? null` unconditionally, so a save from the dialog would null a note set elsewhere. Latent, harmless today.
- Terminal-scoped catalogue settings (receipt paper width, auto-print, copies, unit-switch display) cannot enter `setting_definitions` — that mechanism has no terminal scope and the terminal blob has no description slot. Owner decision pending.
- Clients that saved a receiving account through the **old** picker hold a petty-cash id in a field the ledger reads as a GL account. On moonui2 the stored values were already correct GL accounts; other installs may need one re-pick.
