\
{'# POS Compliance Audit

Reference: `POS-Test-Cases-for external-devs.pdf`  
Audit date: 2026-07-12 (re-verified)  
Method: repository-wide static review, PHP syntax validation, and full re-read of every cited file/line in the working tree (not just HEAD — 111 files differ from the last commit, including the EIS classes).

## Score

- **43/44 (97.7%)**
- Pass: **21/22**
- Partial: **1/22**
- Fail: **0/22**
- Certification: **Near-ready** (one regression to close; live MRA and hardware evidence still required)

Scoring: Pass = 2, Partial = 1, Fail = 0. A code-level Pass is implementation evidence, not proof of certification without live MRA and hardware execution.

## Scorecard

| # | Test | Result | Pts | Evidence / gap |
|---:|---|---|---:|---|
| 1 | EIS terminal activation | Pass | 2 | `activate()` posts `/onboarding/activate-terminal`, stores `mra_terminal_id`/`jwt_token`/`secret_key`; `confirm()` posts `/onboarding/terminal-activated-confirmation`: `classes/MraEisOnboarding.php:525`, `:576`. |
| 2 | Download site products | Pass | 2 | `syncSiteProducts()` posts `/utilities/get-terminal-site-products`, retries with refreshed token on 401, persists via `saveSiteProducts()`: `classes/MraEisOnboarding.php:637-723`. |
| 3 | B2B and B2C transactions | Pass | 2 | B2B forced online-only: blocked when offline/EIS disabled, else `validateOnlineB2BBuyer()` runs before any DB write; rollback on failure: `views/sales/create_sale.php:214-234`, `:339-427`; `classes/MraEisCompliance.php:1497-1531`. |
| 4 | Grouped tax rates A/B/E | Pass | 2 | EIS payload per-rate breakdown drives the receipt: extraction `sale_receipt.php:129-142`, one VAT row per rate group rendered `sale_receipt.php:1025-1031`. |
| 5 | VAT registration status | Pass | 2 | `terminalVatRegistrationStatus()` (`MraEisCompliance.php:2957`) consumed in `prepareOnlineSaleContext()` (:1549). Receipt: "Not VAT Registered" label + no VAT row (`sale_receipt.php:969-970`, `:1032-1036`), "TAX INVOICE" vs "SALE RECEIPT" (:962), Seller TPIN row (:971-973). |
| 6 | MRA server time | Pass | 2 | `getMraServerDateTime()` pings `/utilities/ping` (`MraEisCompliance.php:2915-2955`); used in `prepareOnlineSaleContext()` (:1542) and as `$saleDate` (`create_sale.php:119-121`). Receipt reads `invoiceHeader.invoiceDateTime` from outbox first (`sale_receipt.php:222-241`, `:1102`). |
| 7 | Insufficient stock prevention | Pass | 2 | Item existence/sold/wrong-branch checks before `createSale()` (`create_sale.php:362-397`), re-checked in `Sales.php:307-324`, rollback on failure (`create_sale.php:425-427`). |
| 8 | Percentage/fixed discounts | Pass | 2 | Both modes validated server-side: percentage bound 0-100%, fixed bound ≤ total (`create_sale.php:76-84`, `:176-190`). Receipt shows original total + discount row: `sale_receipt.php:1009-1017`. **Gap:** `classes/DiscountPolicy.php::getActiveRules()` is fetched at `create_sale.php:39-40` but never applied/enforced anywhere after — the admin-configured rule-based discount policy is dead code, not wired into validation. Doesn't break the base requirement but should be finished or removed. |
| 9 | VAT accuracy | Pass | 2 | **This row is stale on the rate value (superseded 2026-07-12):** 17.5% is now the canonical standard rate everywhere — `config/system_settings.php`, `classes/SystemSettings.php` default, `views/admin/settings.php` fallback, and every receipt/invoice/quotation view's `?? 17.5` fallback are all consistent (`grep -rn "16\.5\|17\.5"` across the tree, re-verified 2026-07-16). `SystemSettings::vatPercentage()`/`vatRate()` (backed by `system_configurations` via `Branding::settings()`) is the single source every page reads; `MraEisCompliance::updateVatFromMra()` writes it with `source='mra'` once MRA supplies a rate, which then makes the admin settings field read-only (`views/admin/settings.php:318-319,552,556-558`) — admin can no longer silently drift it out of sync with MRA. **Real accuracy bug found and fixed 2026-07-16:** `MraEisCompliance::saleSnapshot()` (feeds `prepareSale()`, which computes the VAT actually submitted to MRA) resolved each line's `tax_rate_id`/`levy_rate_id` via `COALESCE(msp.tax_rate_id, pcmsp.tax_rate_id, map.tax_rate_id, pcm.tax_rate_id, 'A')` — it never checked the item's own `items.tax_rate_id` column nor joined `mra_eis_site_products` via `items.eis_site_product_id` (`itemsp`), even though `resolveTaxRateId()` (used by `sale_receipt.php`'s displayed VAT breakdown and by `reliefAdjustedPrices()`) already did both. Items created via `MraEisOnboarding::syncWarehouseStock()` (branch-7 warehouse sync, see [[project_mra_eis]]) get `tax_rate_id`/`eis_site_product_id` stamped directly on the `items` row with no `mra_eis_item_product_map`/`mra_eis_product_code_map` entry — for those, `saleSnapshot()` silently fell through to the `'A'` (17.5%) default while the receipt shown to the customer (via `resolveTaxRateId()`) could correctly show zero-rated/exempt. That is: the printed receipt and the invoice actually submitted to MRA could disagree on whether VAT applied at all. Fixed by adding the `itemsp` join and putting `NULLIF(i.tax_rate_id,'')` and `itemsp.tax_rate_id`/`itemsp.levy_rate_id` first in the COALESCE, matching `resolveTaxRateId()`'s precedence (`MraEisCompliance.php:2557-2581`). |
| 10 | Tourism levy | Pass | 2 | Levy rate map keyed correctly (`MraEisCompliance.php:3001-3004`), `levyComponent()` (:3023), levy breakdown in payload (:353, :445-446). Receipt renders levy rows after VAT: `sale_receipt.php:145-155`, `:1044-1049`. |
| 11 | Offline sale and sync | Pass | 2 | `isOfflineMode()`/`assertOfflineSaleAllowed()` gate sales (`MraEisCompliance.php:1617-1672`), queued in `mra_eis_outbox`, synced via `syncOfflineSales()`/`submitOutbox()` (:1674-1706+). Offline signature: HMAC-SHA256 via terminal secret key (`generateOfflineSignature()`, :2899-2938). **Fixed 2026-07-15 (signing):** verified against MRA's published "Signing Offline Receipts" reference implementation — the HMAC was being computed over `"?TI=...&N=...&I=...&V=...&T=..."` (with a leading `?`), but MRA's reference signs the bare `"TI=...&N=...&I=...&V=...&T=..."` param string and only prepends `?` when assembling the validation URL. This mismatch meant every offline-signed receipt's signature would fail MRA's server-side HMAC recomputation. `generateOfflineSignature()` now signs the un-prefixed param string and prepends `?` only when building `receipt_data`. Julian-date/invoice-number construction (`Sales.php::generateMraEisInvoiceNumber()`/`base10ToMraBase64()`/`toJulianDate()`) was checked against MRA's `invoice_number_generation.htm` reference (which shows the actual `Base10ToBase64`/`ToJulianDate` bodies the signing page only references) and matches byte-for-byte. **Fixed 2026-07-15 (URL encoding):** that same alphabet is `ABC...xyz0-9+/`, so the TI/T segments can legitimately contain literal `+`/`/`. `receipt_data` embedded them raw in the query string; a literal `+` decodes as a space on any standard HTTP query parser, corrupting the value before MRA's server can recompute the signature — causing "invalid receipt" on the scanned QR whenever a taxpayer ID/position/Julian date/count happened to produce a `+` or `/` digit. `generateOfflineSignature()` now percent-encodes the TI/T values only when building the URL (`rawurlencode()`), while the HMAC continues to sign the raw un-encoded param string per spec. **Fixed 2026-07-16 (amount formatting):** MRA's C# sample interpolates the raw decimal (`$"I={request.InvoiceTotal}"`, no format specifier) — a .NET decimal keeps the scale it was computed with, and currency totals always carry the 2-decimal scale of their line items through arithmetic, so MRA's server reconstructs a fixed `"1500.00"`-style string. Our `formatNumber()` was stripping trailing zeros and the decimal point entirely for whole amounts (`"1500.00"` → `"1500"`), an unfounded guess with no doc backing that would break the signature on every whole-currency-amount receipt. Now always formats `I`/`V` with a fixed 2 decimal places. **Caveat:** any offline sale already queued in `mra_eis_outbox` before this series of fixes has its old (wrong) signature baked into its stored `payload_json` — `submitOutbox()`/the "recall" action resubmit that stored payload verbatim rather than regenerating it, so those specific rows will keep failing validation until they're re-prepared. Verify with a **newly created** offline sale, not a resend of a pre-existing queued one. |
| 12 | Offline time threshold | Pass | 2 | `offlineLimits()` reads `maxTransactionAgeInHours` from the activated terminal's stored config (`MraEisCompliance.php:2414-2425`), enforced in `assertOfflineSaleAllowed()` (:1631-1654). **Citation fix:** the prior audit's `classes/SystemSettings.php:61-69` reference was wrong — that file has no offline-threshold keys (only `eis_offline_mode`/`eis_offline_since` at :74-81); the real source is per-terminal config in `MraEisCompliance.php`. **Fixed 2026-07-15:** `create_sale.php`'s pre-checks only ran the threshold assertion when the page-load `$eisOfflineMode` snapshot was `true`; `MraEisCompliance::prepareSale()` re-checks live offline state internally and could throw an expiry exception that the generic catch block (`create_sale.php:677-689`) then downgraded to a non-blocking warning because it still trusted the stale snapshot — letting an expired-threshold sale commit anyway. The catch now re-probes live connection status via `offlineStatusForSale()` before deciding whether to block. **Note (this audit is stale on this row):** the threshold logic has since been extracted into its own `classes/MraEisOfflineThreshold.php` (untracked, not yet committed) with a DB-backed `offline_started_at`/`offline_time_threshold_hours` window and its own test (`tests/mra_offline_threshold_integration.php`); the line citations above no longer match current `MraEisCompliance.php` line numbers. **Fixed 2026-07-16:** `assertOfflineSaleAllowed()` compared the offline expiry window against the wall clock (`'now'`) even though `create_sale.php` lets a cashier enter/backdate the sale date (`$saleDate`, when `allowManualSaleDate` is on). The entered sale date was accepted as a parameter but silently discarded — `MraEisCompliance::assertOfflineSaleAllowed()`'s `$transactionDate` argument was dead. Now threaded through as `$referenceTime` into `MraEisOfflineThreshold::getStatus()`/`assertOfflineSaleAllowed()` (both gained an optional `$referenceTime = 'now'` parameter), and `create_sale.php` computes one normalized `$saleDateTime` (`create_sale.php:177`) used at all three enforcement call sites (`:331`, `:446`, `:633`) instead of a fresh `date('Y-m-d H:i:s')` at each site. This is also what makes the expiry-block testable without waiting out a real threshold window. |
| 13 | Offline amount threshold | Pass | 2 | `pendingOfflineAmount()` (`MraEisCompliance.php:2427`) + `maxCummulativeAmount` check in `assertOfflineSaleAllowed()` (:1656-1663). Note: MRA's test sheet marks this test case "Dropped, no longer required" — implementation is still present and harmless. |
| 14 | Configuration refresh | Pass | 2 | `applyConfigVersionsFromResponse()` persists config versions and calls `downloadLatestConfiguration()` when `shouldDownloadLatestConfig` is true: `MraEisCompliance.php:3038-3097`, `:3136-3170`. **Enhanced 2026-07-19:** configuration refresh no longer depends on a manual "Refresh from MRA" click or on a terminal happening to transact. `MraEisCompliance::syncAllTerminalConfigurations()` (`MraEisCompliance.php:3749-3803`) loops every activated terminal, skips ones synced within the last `AUTO_CONFIG_SYNC_INTERVAL_HOURS` (6h) using `mra_eis_configurations.mra_synced_at`, and re-downloads `/configuration/get-latest-configs` for the rest — so a config change reaches every terminal, not just the one that triggered it. `persistMraConfiguration()` now returns whether the downloaded config's `source_hash` actually changed; when it did, `notifyAdminsOfConfigChange()` raises a deduped Admin dashboard notification via the existing `Notifications` system (`MraEisCompliance.php:3805-3822`) and logs an `auto_config_sync_changed` row to `mra_eis_compliance_events`. Wired into both superadmin dashboards (`views/dashboard/admin.php`, `views/dashboard/2admin.php`) inside a try/catch so a sync failure can't blank the page — each dashboard load opportunistically syncs if due (no cron available on the InfinityFree host), and skips the HTTP calls entirely when nothing is stale. The manual "Refresh from MRA" button on `views/admin/mra_eis_configuration.php` still works for an on-demand pull. |
| 15 | Receipt QR | Pass | 2 | QR generated only when acknowledged with a valid validation URL: `sale_receipt.php:181-194`, rendered `:1097-1105`. |
| 16 | QR URL and timestamp | Pass | 2 | "MRA Invoice Time" shown next to QR from `invoiceHeader.invoiceDateTime`: `sale_receipt.php:222-241`, `:1101-1103`. |
| 17 | Invoice sequencing | Pass | 2 | `nextDailyTransactionSequence()` + `transactionIdentifierExists()` dedupe against `sold_items` and `mra_eis_outbox`: `classes/Sales.php:151-189`. |
| 18 | Invoice format | Pass | 2 | Format built as `base64(taxpayerId)-base64(terminalPosition)-base64(julianDate)-base64(sequence)`: `Sales.php:129-161`, `:261-298`. No separate "reject invalid format" validator exists, but none is needed — invoice numbers are always system-generated, never user-supplied. |
| 19 | Terminal blocking | Pass | 2 | `assertTerminalNotBlocked()` runs before every online sale (`:1541`), re-checks MRA via `fetchTerminalBlock()`/`resolveTerminalBlock()` (`MraEisCompliance.php:3172-3230`). `applyTerminalBlockFromResponse()` persists blocks from any submission response (:3099-3134). |
| 20 | Relief supply (TC-RS-015) | Pass | 2 | `reliefAdjustedPrices()` strips VAT only from standard-rated (A) items, B/E unchanged: `MraEisCompliance.php:1616-1638`. **Fixed 2026-07-14:** the function previously resolved each item's tax rate with a narrow query hitting only `mra_eis_item_product_map`/`mra_eis_site_products`, diverging from the authoritative `resolveTaxRateId()` used everywhere else (receipt, invoice submission), which also checks `items.tax_rate_id`, `items.eis_site_product_id`, and `mra_eis_product_code_map`. Any item resolved only through those other paths defaulted to `'A'`, so a mixed-rate relief-supply sale (16.5% + Exempt + Zero-rated) could strip VAT from the wrong lines. Now delegates to `resolveTaxRateId($itemId)` for a single source of truth. Receipt shows "RELIEF SUPPLY" label and "VAT (Relief — Exempt): MWK 0.00": `sale_receipt.php:962`, `:1039`. |
| 21 | VAT-5 validation | Pass | 2 | `validateVat5Certificate()` denies locally **before any API call** for empty cert number (`MraEisCompliance.php:1576-1580`), invoked pre-persistence from `create_sale.php:280-288`. Full cert details forwarded into the invoice payload (`MraEisCompliance.php:415-424`). **Fixed 2026-07-14:** the sale form had no Project Number field at all — only a certificate number input — and `projectNumber`/`quantity`/certificate dates were never persisted, only held in the request that validated the certificate. Any later re-preparation of the sale (`eis_sale_action.php`'s recall action, offline outbox sync `MraEisCompliance.php:1758`, scheduled retry `:256`) calls `prepareSale()` with an empty `$extraContext`, silently dropping the project number from the relief-supply invoice. Added a required "Project Number" field to `create_sale.php`, persisted the full cert details as JSON in new `sold_items.vat5_certificate_details` column (`Sales.php`, migration `2026_07_14_add_sale_vat5_certificate_details.sql`), and `prepareSale()` now falls back to the persisted value when `$extraContext` doesn't supply one (`MraEisCompliance.php`). |
| 22 | Devices/printers/cash drawer | **Partial** | 1 | Printer paper-width sizing (30/40/50/80mm) still works: selector `sale_receipt.php:1141-1146`, size map `:1154-1159`, `applyPrinterSize()`/`printReceipt()` (:1166-1328), handheld/mobile path (:1175-1183). **Regression:** the "Open Cash Drawer" Web Serial API button credited in the last audit is now **completely gone** — a repo-wide `grep -i "drawer\|serial"` across every `.php`/`.js` file and the full git history returns zero matches. This isn't a line-number drift; the feature was removed (or never committed) with no trace. Downgraded from Pass to Partial. |

## Repository checks

- All cited files pass `php -l`.
- Working tree currently differs from the last commit (`970f5c2`) in 111 files, including the core EIS classes (`MraEisCompliance.php` alone is +424/-7 lines vs HEAD) — this audit reflects the working tree, since that's what would actually run.
- A first-party automated test suite now exists (`tests/run.php`) — progress on the prior audit's recommendation — but it currently **fails with a fatal error** (`Call to a member function eisZeroRatedTaxIds() on null` at `MraEisCompliance.php:2864`): the test builds `MraEisCompliance` via `ReflectionClass::newInstanceWithoutConstructor()` and sets `vatPercentage` via reflection but never sets the `$settings` (SystemSettings) property that `vatComponent()` depends on. Needs a mock/instance injected into `settings` before assertions will run.
- Live MRA API, network-loss, mobile, printer, Bluetooth, and cash-drawer tests were not executed.

## Priority fixes (remaining)

1. **Restore cash-drawer support** (test 22) — re-add the Web Serial ESC/POS drawer-kick button that has disappeared from `sale_receipt.php`.
2. **Fix the new test suite** (`tests/run.php`) so it actually exercises `vatComponent()`/levy logic instead of fataling on a null `settings` property.
3. Finish or remove the dead `DiscountPolicy` wiring in `create_sale.php` (test 8) — rules are fetched but never enforced.
4. Clean up stale `?? 17.5` dead-code fallbacks and the "Defaults to 17.5%" help text in `views/admin/settings.php` and the invoice/quotation/service view files (test 9 hygiene).
5. Collect live MRA and hardware evidence for full certification.
