=== Laundry Management Suite ===
Contributors: srijani
Requires at least: 6.4
Tested up to: 6.7
Requires PHP: 8.0
WC requires at least: 8.0
Stable tag: 1.10.0
License: Proprietary

Multi-location laundry service management application, built on WordPress and WooCommerce.

== Description ==

Role-based frontend portal (no wp-admin access for any store role) covering:
store groups and stores, an item/process/pricing master, the inward -> QR tag ->
invoice workflow, status tracking, WhatsApp/email/print delivery, role-based
dashboards, and reporting. Free and Premium tiers, licensed via sitss.in.

See the customer presentation and development plan for the full feature set
and the phase-by-phase build order.

== Changelog ==

= 1.10.0 (New: automatic update delivery via sitss.in) =
* Added LMS_Plugin_Updater — the same shared update-delivery system used
  by the other plugins sold through the sitss.in License Manager Server.
  Hooks into WordPress's normal update system, so a new release shows the
  standard "Update available" row on the Plugins page with a "View
  version details" changelog popup, and updates with one click straight
  from sitss.in — no manual zip upload needed once a release is published
  there.
* No license key is required for the update check itself — every
  install, free or premium, receives the same update, since Free/Premium
  is gated at runtime inside the plugin (LM_License::is_premium()), not
  by which version is installed. This matches how the license system
  already works here: licensing and update delivery are deliberately
  separate concerns.
* The updater file was reconstructed from the original conversation where
  it was built for the other plugins (the file wasn't actually attached
  to the message that requested this), rather than written fresh — kept
  identical to the shared version so every plugin on this system behaves
  the same way. Verified end to end against a mocked license-server
  response before shipping: confirmed an available update is correctly
  flagged when the server's version is newer, correctly NOT flagged when
  already current, and that the plugin-info popup only responds for this
  plugin's own slug and passes through untouched for any other plugin's
  update check running on the same site.
* Adds a "Check for updates" link under the plugin's row on the Plugins
  screen for an on-demand check, bypassing WordPress's normal 12-hour
  update cache.
* Requires this plugin to be registered on sitss.in under License
  Manager -> Plugins with the slug `laundry-management-suite` (same
  outstanding step already needed for licensing) before update checks
  will find anything.

= 1.9.2 (Fix: "Add process"/"Add item" silently did nothing on a near-duplicate name) =
* Real bug: adding a process or item was a plain INSERT with no existence
  check at all. If the name (processes) or name+category (items) already
  existed — even only differing by case or stray spacing, e.g. typing
  "steam press" when "Steam Press" already existed from an earlier CSV
  import — the database's own unique constraint silently rejected the
  insert, and nothing checked whether it had actually succeeded. From the
  portal, clicking "Add" just appeared to do nothing. Since process and
  service are the same thing in this plugin, this is exactly what made
  prices look "unaligned" too: the process someone thought they'd just
  added didn't exist, so nothing they priced against it could show up.
* Fixed with a proper check before inserting: an exact or case/whitespace-
  different match that's already active is left alone (and now says so);
  a match that was soft-deleted earlier gets reactivated instead of
  colliding with the unique constraint; only a genuinely new name gets
  created. Every outcome now shows a message on the page — "Add" is never
  silent again, success or otherwise.
* Caught a real bug in this fix itself before shipping, exactly the kind
  of thing testing against more than the easy case is for: the first
  version only trimmed the stored database value for comparison, not the
  typed-in value being compared against it, so a name with leading/
  trailing spaces still didn't match. Fixed by trimming both sides in
  PHP before the query, and re-verified all 4 outcomes (new, exact
  duplicate, whitespace/case duplicate, reactivate-a-deleted-one) against
  a real database before shipping.

= 1.9.1 (Fix: process/item name matching depended on database collation) =
* Real bug: "process" and "service" are the same concept in this plugin
  (the importer maps the CSV/xlsx "Service" column straight to a
  process) — but matching an uploaded row against an existing process
  used a plain SQL `name = %s` comparison, which only works case-
  insensitively if the database's collation happens to be
  case-insensitive. That's common but not guaranteed. On a server where
  it isn't, uploading "Premium Laundry Kg" when "Premium Laundry KG"
  already existed would silently create a second, separate process
  instead of matching the first — splitting that service's pricing
  across two different records, which is exactly the kind of thing that
  makes prices look like they "aren't reflecting" even though they were
  saved.
* Fixed by comparing explicitly with LOWER(TRIM(...)) on both sides for
  both item and process matching in the bulk importer, instead of
  depending on the table's collation. Same fix applied to items, which
  had the identical risk.
* Added a "Merge duplicate items/processes" cleanup button (Master Admin)
  for anyone who already hit this on an earlier version: finds
  processes (and items, matched by name + category together) that only
  differ by case or stray whitespace, folds their pricing onto the
  oldest/original entry, and repoints any invoice and tag history so
  past invoices keep displaying correctly. Where the original already
  has a price for the same item, the original's price is kept and the
  duplicate's conflicting one is dropped, rather than either one
  silently overwriting the other.
* Verified against a real SQLite-backed test, not just read through:
  confirmed a genuine pricing conflict between the canonical and
  duplicate process resolves correctly (existing price wins, conflicting
  duplicate price is dropped, non-conflicting prices move over cleanly),
  and that invoice/tag history is correctly repointed — then separately
  confirmed the item-merge query correctly treats "Shirt" under Mens and
  "Shirt" under Womens as two different items, not a false match.

= 1.9.0 (Bulk import now accepts a real supplier price-list format) =
* The bulk pricing importer now accepts a second header format matching
  a real supplier rate-card export: City, Service, Category, ItemName,
  Price, Currency, Unit — alongside the original simple format (item,
  process, store_code, price). Detected automatically from whichever
  headers are present; City/Currency are read but not stored (single
  currency, and a city name alone doesn't map to one specific store).
  Unit ("Per Kg" / "Per Pc") sets a newly created process's per-kg/
  per-piece field automatically.
* Real bug found and fixed by testing against an actual 742-row supplier
  file rather than synthetic samples: the same garment name legitimately
  prices differently by category — e.g. "Hats" under Mens (Rs 129) vs
  Womens (Rs 99) in the real data, 61 such items in one file alone.
  Items were previously unique by name only, so importing this file as-is
  would have silently overwritten one gender's price with the other's on
  every collision. Fixed by making items unique by (name, category)
  together instead of name alone — the same garment name can now
  correctly hold a different price per category. Migrated safely: an
  explicit, idempotent index change (dbDelta doesn't reliably alter an
  existing UNIQUE KEY's column list) rather than assumed to "just work".
* Verified end to end against the real file via a SQLite-backed test
  harness exercising the actual import code, not a mock: all 742 rows
  imported, 0 skipped, 384 items created across the real category set,
  process units auto-detected correctly, and the Mens/Womens Hats
  conflict confirmed preserved (both prices intact) rather than
  collapsed into one.
* Also fixed a UTF-8 BOM handling bug in the CSV reader (a BOM at the
  start of a file — common when saved from Excel — would have made the
  first header cell fail to match, silently rejecting every row);
  re-verified correct after finding my first attempt at the fix hadn't
  actually been exercised by a real BOM byte sequence.
* CSV template and export downloads updated to the new format
  (Service/Category/ItemName/Price/Unit/store_code) so round-tripping
  (export, edit, re-upload) stays consistent with what's now the
  primary format.
* IMPORTANT: this version changes a database index (items unique key).
  Migrated automatically and safely on next load — existing data was
  already guaranteed unique under the stricter old constraint, so the
  new one can only ever be satisfied more easily, never violated by it.

= 1.8.0 (New: starter item catalog with Men's/Women's/Household categories) =
* Checked a live UClean pricing page for the requested Men/Women/Household
  category breakdown — the itemized price list there loads via JavaScript
  after picking a store, which this plugin's fetch tooling can't execute,
  so the exact per-item numbers weren't retrievable. Found and used a
  real, published laundry rate-card structure instead (men's wear,
  women's wear, woolens, household items) as the category basis, rather
  than guessing at a structure — same standard of not inventing data that
  applied to the earlier pricing-page starter content.
* New "Load starter items" button (Master Admin, next to the Items list)
  seeds a ready-made catalog across five categories — Men's Wear, Women's
  Wear, Household, Woolens, Kids Wear — about three dozen common garments
  in total. Prices are left blank; only the item names and categories are
  seeded, since rates vary by store.
* Idempotent and non-destructive, verified against a real database before
  shipping: running it again never duplicates an item, and it only fills
  in a category for items that don't already have one — an item you've
  already categorized yourself is never overwritten.
* Works directly with the category dropdown and scroll/DOM fixes from
  1.7.0/1.7.1 — the seeded catalog gives that filtering something
  meaningful to group by immediately instead of one flat list.

= 1.7.1 (Fix: matrix still heavy enough to freeze scrolling even with categories) =
* The v1.7.0 category fix reduced how many items render at once, but each
  visible cell was still its own complete <form>, with its own nonce
  field, hidden fields, and button — a 30-item x 8-process category is
  still 240 full forms (well over 1,000 DOM nodes) on one page, heavy
  enough to make scrolling janky or unresponsive on a real device even
  after the category split.
* Rebuilt the matrix around a single form for the entire table: every
  price cell is now a plain, unwrapped <input>, and one "Save prices"
  button at the bottom submits everything changed at once. Measured the
  actual difference on a 40 x 8 render rather than assuming: 320
  individual forms collapsed to 1, verified in a real rendered page, not
  just estimated.
* Added a live, client-side item filter box above the matrix (typing
  narrows visible rows instantly, no reload) so a large category doesn't
  need scrolling through in the first place.
* Blank cells are left untouched on save rather than being zeroed out —
  only cells you actually typed a number into get written. Same
  permission checks as before (global = Master Admin only, store-scoped
  = that store must be in the saving user's own scope), now shared
  between the single-price and bulk-save code paths instead of
  duplicated, and re-verified with 4 fixture tests after the refactor.

= 1.7.0 (Fix: item catalog crashing the page; item/process categories; bulk delete) =
* Real bug fixed: with a large item catalog, the Items/Processes/Pricing
  screen rendered every single item — plus a full price-edit form per
  process column, per item — in one page load. On a catalog of any real
  size this is thousands of form elements and nonce fields, enough to
  crash or freeze the browser. This is also very likely why the price
  update controls "disappeared" — the page was too heavy to finish
  rendering or respond at all, not that the controls were actually gone.
* Fixed by adding item categories (new optional field when adding an
  item, with autocomplete from existing categories) and a category
  dropdown that replaces the flat list — only items in the selected
  category are ever rendered on the Items list and the pricing matrix at
  once. Verified the underlying category queries against a real database
  (SQLite), including the NULL-vs-empty-string "Uncategorized" edge case
  and that soft-deleted items are correctly excluded, not just read
  through the code.
* Bulk delete for both Items and Processes — select multiple with
  checkboxes, "Select all", one confirmation, one request. Same
  permission split as single delete: Master Admin or Group Admin only,
  Store Manager/Executive excluded. Built as a small reusable script
  (assets/js/bulk-select.js) so more than one independent bulk-select
  list can live on the same page without interfering with each other —
  verified with a two-group test (Items and Processes selecting
  independently) before shipping.
* IMPORTANT: adds a new database column (item category) — created
  automatically on next load, no action needed.

= 1.6.1 (Fix: store-scoped prices were invisible; new item/process delete) =
* Real bug, found and fixed: the pricing matrix screen has, since it was
  built, only ever shown and edited the GLOBAL price (store_id null) — it
  never had a store selector. Any store-scoped price (the only kind a
  Group Admin can ever set, and anything a CSV/xlsx import wrote with a
  store_code filled in) was being saved to the database correctly the
  whole time, just never displayed or editable on this screen. This is
  why an uploaded CSV with per-store pricing looked like it "didn't
  reflect" — it did, invisibly.
* Fixed with a store selector on the pricing matrix ("Showing prices
  for: Global / [store name]"). Master Admin sees Global plus every
  store; Group Admin sees only their own store(s), defaulting to the
  first one — no Global option, since they were never able to set that
  anyway.
* Fixed a second, related bug the same investigation turned up: the
  per-cell price edit form only ever rendered for Master Admin — Group
  Admin had no way to edit a price from this screen at all, only view
  the (also-broken) global price. Now editable whenever the signed-in
  user can manage the selected view (global for Master Admin, their own
  store for Group Admin).
* Fixed a related security gap while in this code: the single-price save
  handler checked that the user held the pricing capability in general,
  but never checked that the specific store_id in the submitted form was
  one they were actually scoped to — a tampered hidden field could have
  let a Group Admin set pricing for a store outside their group. Fixed
  and verified with 5 fixture tests, including the tampered-field case.
* New: delete Items and Processes from the frontend portal (Master Admin
  or Group Admin — never Store Manager/Executive, matching the existing
  capability split exactly). Soft delete (marked inactive): removed from
  new inward entries, the pricing matrix, and the public pricing page,
  but every past invoice that already referenced the item/process still
  displays correctly, since invoice history is looked up by ID, not by
  the active list.

= 1.6.0 (New: bulk pricing upload/update via CSV or .xlsx) =
* New "Bulk upload or update pricing" section on the Items/Processes/
  Pricing screen — upload a .csv or .xlsx with columns item, process,
  store_code, price (store_code blank = the global price). Matching
  item/process/store combinations update the existing price; new ones
  are inserted. Optional "create new items/processes if not found"
  checkbox for Master Admin.
* .xlsx support is read directly with PHP's own zip/XML extensions — no
  external library vendored for it, since an .xlsx is just a zip of XML
  files. Built and tested against real files generated by LibreOffice,
  not hand-crafted XML: caught and fixed a real bug in the process where
  a row with a blank trailing cell (e.g. an empty price) silently lost
  that column entirely, because spreadsheets omit truly-empty trailing
  cells from the file rather than writing them out empty.
* Same permission boundaries as the rest of pricing, verified with 11
  fixture tests: Master Admin can set global prices and create new
  items/processes; a store-scoped upload (Group Admin/Store Manager with
  pricing access) can only touch stores they actually manage, and a
  global-price row or an out-of-scope store code in their file is
  rejected per-row with a reason shown after upload — never silently
  applied and never failing the whole file.
* "Download CSV template" and "Download current pricing as CSV" links
  alongside the upload form, so editing in a spreadsheet and re-uploading
  is a normal round trip.
* Every skipped row is reported by row number and reason after upload
  (unknown item, unknown store code, non-numeric price, out-of-scope
  store, etc.) rather than failing silently or aborting the whole import.

= 1.5.0 (New: public customer pricing page) =
* New public, no-login pricing/services page for customers
  (LM_Public_Pricing), styled after the category rate-card layout the site
  owner shared (icon, title, feature bullets, turnaround pill, starting
  price pill) — reachable at /laundry-portal/?lm_route=pricing.
* Processes now carry customer-facing marketing fields (unit — per piece
  or per kg, turnaround text, starting price, feature bullets, icon,
  visibility, display order), editable from Master Data > "Customer
  pricing page". This is separate from the existing operational
  item x process x store pricing used for actual invoicing — the public
  page shows curated category-level "starting from" pricing, not every
  itemized combination.
* One-click "Load starter pricing" seeds five ready-made categories (Dry
  Cleaning, Premium Laundry KG, Wash & Fold, Wash & Iron, Steam Press)
  matching a real published rate-card structure, editable afterward.
  Note: the live itemized numbers on the referenced site load via
  JavaScript after selecting a store, which this plugin's fetch tooling
  can't execute — the seeded figures come from the category-level pricing
  visible on the page itself, not scraped per-item Kolkata data. Treat
  them as a starting point to edit, not verified live rates.
* Fixed two real CSS bugs found while building this: a specificity
  conflict that made "Call us"-style buttons render with invisible dark
  text on a dark background, and a plugin-wide gap where .lm-btn never
  reset text-decoration — every <a> styled as a button (Download PDF,
  Open in WhatsApp, etc., not just the new page) has had a stray
  underline since Phase 2. Both confirmed fixed via computed-style checks
  in a real browser, not just visual inspection.
* This is a marketing/informational page, not a booking cart — the
  checkboxes mirror the reference design's look, but the call to action
  is call/WhatsApp, not an online checkout. A full booking flow would be
  a separate, larger piece of work.

= 1.4.0 (New: Staff screen — store/group assignment for Master and Group Admin) =
* Real gap, not a bug: the roles and the lm_store_id/lm_group_ids fields
  they read from have existed since Phase 1, but no screen ever let an
  admin actually set them — there was no way to assign a store to anyone.
  Added a new Staff screen (LM_Staff) that does this properly:
  - Master Admin can create or reassign Group Admin, Store Manager, and
    Store Executive accounts, for any store or group.
  - Group Admin can create or reassign Store Manager and Store Executive
    accounts, scoped to their own group's stores only — verified with 5
    permission-boundary tests, including the critical case of a Group
    Admin being correctly blocked from touching a staff account at a
    store outside their scope.
  - A new email gets a real WordPress account with a password-setup link
    (wp_new_user_notification, core's own flow — no custom email code).
    An existing email is switched to the chosen role and assignment
    instead of erroring.
  - "Remove access" strips every plugin role and clears their store/group
    assignment without touching the underlying WordPress account.
  - Master Admin accounts are intentionally not editable from this screen
    (avoids a dropdown that doesn't list "Master Admin" as an option
    silently downgrading one) — noted in the UI, manage those from
    WordPress Users directly.
* New Staff link in the sidebar for anyone who can manage users.

= 1.3.0 (Fixed layout bug, editable brand name, invoice settings moved, real charts) =
* Fixed a real layout bug reported with screenshots: the tag-printing screen
  wrapped the sidebar in an extra container that broke the shared flex
  layout, making the sidebar collapse into a floating shape overlapping the
  page content. Rebuilt to use the exact same structure as every other
  screen.
* Brand name is now editable (Settings -> Branding) — changes the sidebar
  name, login screen title, and browser tab title everywhere, so this
  plugin can be relabeled and reused for a different customer without
  touching code.
* Invoice template selection moved into Settings under its own "Invoice
  templates" section (previously only reachable via a collapsed row on the
  Stores screen, which is why it wasn't found). Now visible to Group Admin
  as well as Master Admin, scoped to the stores each can manage — Group
  Admin gained frontend access to Settings for this purpose.
* Dashboards now include real graphical output: a donut chart of item
  status breakdown and a horizontal bar chart comparing revenue across
  stores/groups, plus colored icon badges on the stat cards. Rendered as
  dependency-free inline SVG (same approach as the QR codes — no charting
  library, no external request). Verified with actual rendered pixel colors
  in both light and dark mode before shipping, not just template review.
* New LM_Charts class (donut + horizontal bar) and LM_Reports::
  status_breakdown() backing the new charts.

= 1.2.0 (Sidebar nav, working QR scan, bulk updates, no-API WhatsApp, per-store invoice templates) =
* Fixed dark/light mode for real: the previous CSS declared its light-mode
  colors directly on .lm-portal-shell, which sat INSIDE the dark-mode
  wrapper — so its own light values were silently shadowing the inherited
  dark ones every time, meaning the toggle never visibly did anything.
  Rebuilt around :root as the single source of truth, with the dark
  override applied directly to <body>. Verified with an actual browser
  test (computed styles, not just reading the CSS) before shipping.
* Navigation rebuilt as a left sidebar (desktop) with a hamburger menu and
  slide-in overlay (mobile) — replaces the old horizontal top bar, which
  wrapped awkwardly. Active route is now highlighted.
* General mobile pass: forms collapse to one column, stat cards reflow,
  wide tables scroll horizontally in their own container instead of
  overflowing the page, and content padding adjusts so it isn't hidden
  under the mobile hamburger button.
* QR scanning is now an actual feature, not just an encoded link:
  - Desktop: an autofocus text field a USB/Bluetooth scanner types
    straight into, submitting automatically.
  - Any device with a camera: a live "Scan with camera" view (vendored
    jsQR, Apache-2.0, no external service) that decodes in the browser.
  - Verified end to end — confirmed jsQR correctly decodes this plugin's
    own generated QR codes in a real browser before wiring it into the UI.
  - Both paths land on the same lookup, whether the QR holds a bare
    tracking number or a full URL.
* Bulk status update: select multiple pending items and apply one status
  to all of them in a single submission. Every update — single, scanned,
  or bulk — now also re-checks that the tag actually belongs to a store
  the requesting user can access, closing a gap where the store-ownership
  check was missing on status changes.
* WhatsApp delivery without any API: a wa.me link opens WhatsApp (web or
  app) with the invoice message pre-filled — staff taps Send themselves.
  No credentials, no business account, no template approval, available on
  both tiers immediately. The Cloud API path is still there as an optional
  "send automatically" alternative for stores that configure it.
* Invoice PDFs are now store-configurable, with three selectable layouts
  (Simple, Tally-style tax invoice, Classic letterhead), each stamped with
  that store's own name, address, phone, GSTIN, and a custom footer note —
  every store can look completely different. Editable from Stores and
  Groups. Verified by generating and text-extracting all three templates
  against fixture data before shipping, not just lint-checked.
* IMPORTANT: this version adds new database columns (store invoice
  branding) — no action needed, they're added automatically on next load,
  but if upgrading from a much older version, deactivate/reactivate once
  to be sure.

= 1.1.0 (Portal is now a true standalone page, not a shortcode) =
* The portal previously rendered via a [laundry_portal] shortcode placed on
  an ordinary WordPress Page — which meant the active theme's header, nav,
  and admin toolbar always wrapped around it, and the content area was
  squeezed into the theme's normal column width. Reported as broken UI
  (nav wrapping oddly, low-contrast links, everything squeezed into a
  narrow column) — root cause was theme CSS bleeding into the portal.
* Rebuilt as a genuinely standalone page: /laundry-portal/ is now a rewrite
  rule intercepted at 'template_redirect', before any theme template loads.
  It outputs its own complete <html>/<head>/<body> document — no
  wp_head()/wp_footer(), so the theme's stylesheet, nav, and admin bar
  never load here at all. Only this plugin's own portal.css is enqueued.
* The old auto-created "Laundry Portal" WordPress Page (and its now-dead
  shortcode) is automatically removed on activation/upgrade — it's no
  longer used for anything.
* Added a baseline CSS reset (html/body margin, box-sizing, default font)
  since the page no longer inherits any of that from a theme.
* Nav bar made to wrap gracefully and use the full page width now that it's
  no longer squeezed into a theme's content column.
* IMPORTANT: after installing this version, resave Permalinks once
  (Settings -> Permalinks -> Save Changes) so the new rewrite rule takes
  effect — same convention as our other plugins.

= 1.0.2 (Fix: no account could reach the portal after install) =
* Real bug: activation never assigned the Master Admin role to anyone, so
  every account — including the installing site admin — hit "Your account
  is not set up for the laundry portal" with no way in.
* Fixed with a self-healing bootstrap (LM_Roles::maybe_bootstrap_first_
  master_admin()): the first real WordPress admin (manage_options) to load
  any page after installing or upgrading is automatically granted Master
  Admin, once, as long as no one holds that role yet. Runs immediately on
  activation and again on 'init' as a safety net for sites already running
  an earlier version. Verified against 4 fixture scenarios: fresh install,
  a repeat visit after the flag is set, an install where a master admin was
  already assigned some other way, and a non-admin account (correctly never
  granted).
* Fixed a related lockout risk this uncovered: block_wp_admin() now exempts
  any user who also holds manage_options, so granting a plugin role to the
  site owner (or anyone doubling as both) can never lock them out of
  wp-admin. Only accounts that are purely plugin staff — no site-wide admin
  capability — get redirected to the portal.
* Author changed to Srijani.

= 1.0.1 (License Manager Server compatibility fix) =
* LM_License rewritten to match the REAL sitss.in License Manager Server
  contract, not the assumed one v1.0.0 shipped with. Confirmed against the
  server's actual client integrations (the same pattern used by the Celeste
  homestay booking plugin and School Management Suite):
  - Endpoint base is `{SERVER_URL}/wp-json/license-manager/v1/{activate|
    validate|deactivate}`, not the placeholder `/lms/v1/` path.
  - Requests are form-encoded (`wp_remote_post`'s plain `body` array), not
    JSON — the server doesn't read a JSON-encoded body.
  - The SERVER determines active/grace/expired status, not the client. This
    plugin no longer runs its own 30-day grace calculation — it stores
    whatever status the server returns and only falls back to computing
    from cached dates if the server has been unreachable for more than 5
    days (LM_LICENSE_OFFLINE_BUFFER_DAYS), so one missed daily check can
    never wrongly flip a store to Free.
  - Response field names corrected: `expires_at` and `grace_ends_at` are
    server-formatted date strings, not timestamps — the Settings screen's
    date formatting was fixed to match.
  - A 403/404 response is still read for its JSON body (invalid-key
    messages come back this way) rather than treated as a bare network
    failure.
* Verified with 6 fixture scenarios before shipping: successful activation,
  a server-reported grace period, a server-reported expiry, an invalid-key
  activation failure, and both offline-buffer fallback outcomes (still
  within the cached term vs. past the cached grace window) — all confirmed
  correct against the rewritten client, not just lint-checked.
* Before this connects to anything real: register this plugin in
  License Manager -> Plugins on sitss.in with the product slug
  `laundry-management-suite` (must match exactly) — the same one-time
  step already done for the other plugins on that server.
* Nothing outside LM_License changed — every Free/Premium gate elsewhere in
  the plugin still just calls LM_License::is_premium() and the other public
  helpers, unaffected by this fix.

= 1.0.0 (Phase 5 — Licensing and packaging; first production release) =
* Real sitss.in License Manager Server integration, replacing the Phase
  1-4 stub: activate/deactivate a license key from Settings, a daily cron
  validates status against the server, and every result is cached so a
  brief server outage never flips a store back to Free mid-check.
* 30-day grace period implemented exactly as specified: if the last
  confirmed check was active, Premium stays on for 30 days past the
  license's expiry even if the site can't reach the server or the term has
  lapsed — verified with 5 scenario tests (active, in-grace, past-grace,
  never-activated, invalid) before shipping.
* LM_License::is_premium() is still the one gate every other class calls —
  Phases 2-4's QR-scan status updates, WhatsApp PDF attachment, tag roll
  width, and CSV reports now run against the real check with no changes
  needed anywhere else in the codebase.
* Light/dark mode: a personal, per-user toggle in the nav bar, free for
  everyone, saved to user meta — independent of any store's branding.
* Premium accent color theming: Master Admin sets a site-wide default,
  Group Admin can override it for their own group; resolved with group
  color taking priority, then the site default, then the built-in teal —
  verified end to end with a fixture including the group-color override,
  the Premium/Free gate, and the color-darkening math for the nav bar.
* Every brand color used across the portal (nav, buttons, table headers,
  cards, stat tones) now runs through CSS custom properties instead of
  hardcoded hex values, so both dark mode and a custom accent color apply
  everywhere at once rather than needing per-page overrides.
* This is the first production-ready release. From v1.0.0 onward the
  plugin follows semantic versioning (patch/minor/major) rather than the
  phase-numbered 0.x releases used during initial development.

= 0.4.0 (Phase 4 — Dashboards and reports) =
* Store-wise, group-wise, and master dashboards, all sharing one template so
  they stay visually consistent: stat cards (revenue, items inward, pending,
  dues), a drill-down list, and a "needs attention" section (items not
  delivered after 3 days, invoices with dues outstanding after 15 days).
* Master Admin drills group -> store; Group Admin's stores are flattened
  directly (no intermediate group list, since a group admin is usually
  scoped to one region); Store Manager/Executive land straight on their own
  store — matching the confirmed mobile and desktop mockups.
* The bare portal page (no route) now renders the signed-in role's real
  dashboard directly — replaces the Phase 1 placeholder screen entirely.
* Every stat is computed live from the existing invoices/tags/payments
  tables — no separate rollup table to keep in sync, so dashboard numbers
  can never drift out of date with the underlying records.
* New Reports screen, Premium only (Free shows a plain upsell notice, no
  data): CSV export for sales-by-store, pending/overdue item aging, and
  payments due — each scoped to only the stores the requesting user can
  already see, not the whole business.
* CSV export runs through a dedicated streaming endpoint (LM_Report_Download,
  same pattern as the Phase 3 PDF download) with its own nonce and a
  license check that rejects a Free-tier request outright rather than
  serving a truncated file.

= 0.3.0 (Phase 3 — Delivery and payments) =
* Email delivery via wp_mail (no extra credentials needed) — an HTML invoice
  summary with line items, subtotal, and every tracking number, plus a link
  back to the portal.
* WhatsApp delivery via the Meta WhatsApp Cloud API (credentials entered by
  Master Admin under Settings). Free always sends a text update; Premium adds
  a PDF-only or text+PDF option (LM_License::whatsapp_pdf_attachment_allowed())
  — a Premium-only mode submitted while on Free is silently downgraded to text
  rather than trusted at face value, same pattern as the QR-scan status gate.
* Invoice PDF generation: vendored the permissively-licensed, dependency-free
  FPDF library (plus its core font metrics) — no GD/Imagick, no external PDF
  service. Verified end to end with a real fixture invoice (6 tags, 3 line
  items): generated, rendered to an image, and visually confirmed correct
  before shipping, not just lint-checked.
* "Download PDF" is a dedicated binary-response endpoint (LM_PDF_Download)
  outside the shortcode's HTML page, nonce-protected and scoped to the same
  store-access check as every other invoice action.
* Payment recording: Free is cash-only and rejects a partial amount outright
  (records the full due or nothing, so a store never ends up with a
  confusing partial balance it can't act on); Premium adds UPI/card/online
  gateway methods and true partial payments (LM_Payments).
* Invoice payment_status (unpaid/partial/paid) recalculates automatically
  after every payment; a running "due" figure is shown on the invoice screen
  ready for Phase 4's dashboards to roll up across stores.
* New LM_Settings screen (Master Admin only) for the WhatsApp Cloud API
  token and phone number ID.

= 0.2.0 (Phase 2 — Core workflow) =
* Inward entry screen: dynamic line-item builder (item + process + qty), live
  price lookup against the store's pricing matrix, skips any combination with
  no price set rather than guessing.
* Saving an inward entry creates the invoice (INV-00001 style number) and, in
  the same request, one tag per physical unit — an invoice line with qty 3
  produces 3 tags, each with its own unique tracking number, matching the
  confirmed requirement that every tag's number also appears on the invoice.
* QR code generation is fully self-contained: vendored the MIT-licensed,
  dependency-free kazuhikoarase/qrcode-generator PHP library (no GD/Imagick,
  no network call) and render straight to inline SVG so tags scale cleanly to
  any roll width. Verified end to end with a decode round-trip (zbarimg) —
  the first draft was missing the mandatory quiet-zone margin around the QR,
  which silently fails most scanners even though the encoded data is correct;
  fixed and re-verified before shipping.
* Each QR encodes a direct URL into the status-update screen with that tag
  pre-filled — a phone camera scan (Premium) opens straight to its update
  buttons, no app needed.
* Printable tag sheet: configurable thermal roll width. Free is fixed to 2
  inch; Premium can choose 2", 3", and the layout auto-scales to whichever is
  selected, via a real CSS @page size rather than a fixed image.
* Status pipeline: Inward -> In process -> Quality check -> Ready ->
  Delivered. Manual dropdown update always available; QR/barcode-scan update
  is gated behind the new LM_License::qr_scan_status_update_allowed() so a
  scan submitted while on the free tier is downgraded to a manual log entry
  rather than trusted at face value.
* Every status change is written to a status log (who, when, from/to,
  method) for the audit trail, and rolls the invoice's own status up to the
  least-progressed tag automatically.
* New LM_License class centralizes every Free/Premium gate (tag roll width,
  QR-scan status updates, WhatsApp PDF attachment) so Phase 5's real sitss.in
  license check only has to replace one method.

= 0.1.0 (Phase 1 — Foundation) =
* Four custom roles: Master Admin, Group Admin, Store Manager, Store Executive —
  each with a version-gated capability set that re-syncs on load, no
  deactivate/reactivate needed for a permissions change (LM_CAPS_VERSION).
* No plugin role can reach wp-admin under any circumstance — enforced on
  every admin_init request, not just by hiding menu items.
* Custom frontend login (not wp-login.php) with role-based dashboard redirect.
  OTP/2FA extension points are wired in (`lm_login_requires_otp`,
  `lm_send_login_otp`) for Phase 3 to fill in.
* Data model: store groups, stores (each optionally in one group), items,
  processes, and an item x process pricing matrix with an optional per-store
  price override.
* Store groups: Master Admin only. Stores: Master Admin (any group) or Group
  Admin (their own assigned group(s) only).
* Item/process master has no count limit in either Free or Premium, per the
  confirmed plan.
* Global pricing: Master Admin only. Per-store price override: gated behind
  `lm_manage_pricing` (Premium).
* Optional "sync to WooCommerce" toggle per price row — mirrors the service as
  a hidden WooCommerce simple product for catalog/reporting only. Never a
  dependency for taking payment; payment (Phase 3) goes through a WooCommerce
  order created directly against the invoice.
* Single frontend portal page (`[laundry_portal]`, auto-created on activation
  at /laundry-portal/) routes every screen — nothing lives outside it.

Planned next (see development plan):
* Phase 2 — Inward entry, invoice generation, QR tag printing (2"/3"/custom
  roll width, free fixed to 2"), status pipeline (manual in Free, QR/barcode
  scan in Premium).
* Phase 3 — Email/WhatsApp (text always available; PDF attachment choice in
  Premium)/print delivery, payment recording, dues tracking.
* Phase 4 — Store-wise, group-wise, and master dashboards with drill-down;
  exportable reports.
* Phase 5 — sitss.in License Manager Server integration, Free/Premium
  gating, light/dark mode (free) and custom accent color (premium) theming.
