{
  "name": "Front of House for Event Espresso",
  "slug": "eea-foh",
  "version": "0.22.1",
  "download_url": "https://gitea.mulligan.casa/ee-extensions/foh-updates/raw/branch/main/eea-foh/eea-foh-0.22.1.zip",
  "homepage": "https://gitea.mulligan.casa/ee-extensions/eea-foh",
  "requires": "6.4",
  "tested": "7.0",
  "requires_php": "8.1",
  "last_updated": "2026-06-23 18:38:00",
  "icons": {
    "1x": "https://gitea.mulligan.casa/ee-extensions/foh-updates/raw/branch/main/eea-foh/icon-128x128.png",
    "2x": "https://gitea.mulligan.casa/ee-extensions/foh-updates/raw/branch/main/eea-foh/icon-256x256.png"
  },
  "author": "Mike Mulligan",
  "author_homepage": "https://gitea.mulligan.casa/mmulligan",
  "tags": [
    "event espresso",
    "point of sale",
    "box office",
    "front of house",
    "ticketing",
    "registration"
  ],
  "sections": {
    "description": "<p>Front-of-house event check-in for Event Espresso 4/5: tablet <strong>check-in stations</strong><br />\n(installable PWA, zero WP chrome), <strong>device trust</strong> with admin approval handshake,<br />\n<strong>clerk attribution</strong> via badge codes, and a full <strong>append-only audit trail</strong> — plus a<br />\nwp-admin \"default mode\" page that makes it a drop-in replacement for the official<br />\n(stalled) EE Barcode Scanner add-on.</p>\n<p><strong>Design contract: <a href=\"docs/SPEC.md\">docs/SPEC.md</a>.</strong> Read it before touching code.</p>\n<h2>Status</h2>\n<p>Pre-release scaffold (<code>0.1.0</code>). Schema and module skeleton in place; see the spec for<br />\nthe build plan.</p>\n<h2>Requirements</h2>\n<ul>\n<li>WordPress ≥ 6.4 (multisite supported, per-site activation)</li>\n<li>PHP ≥ 8.1</li>\n<li>Event Espresso ≥ 4.10 (tested against 5.0.17.p)</li>\n<li>Optional: <code>eea-ticketing</code> (enables camera/QR scanning of printed tickets)</li>\n</ul>\n<h2>Deploy</h2>\n<p>Copy the plugin directory into <code>wp-content/plugins/</code> — built front-end assets are<br />\ncommitted under <code>assets/app/</code>; nothing to build or install on the host. Activate<br />\nper-site. Deactivate <code>eea-barcode-scanner</code> first; this replaces it.</p>",
    "changelog": "<h2>[0.22.1] - 2026-06-23</h2>\n<h3>Changed</h3>\n<ul>\n<li>Release now flows through the shared manifest pipeline; the \"View details\" modal carries a description, changelog, and tags. No functional change to the plugin.</li>\n</ul>\n<h2>[0.22.0] - 2026-06-23</h2>\n<h3>Added</h3>\n<ul>\n<li>Color-coded plugin icon, published to the update channel by the release workflow.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>Declared WordPress 7.0 compatibility (Tested up to: 7.0).</li>\n</ul>\n<h2>[0.21.2] — 2026-06-22</h2>\n<h3>Changed</h3>\n<ul>\n<li><strong>Updates delegated to the Manifest Updater plugin.</strong> FOH no longer bundles its<br />\n  own self-hosted updater (<code>Support/Updater.php</code> removed + its instantiation). It<br />\n  now declares an <code>Update URI</code> header pointing at its <code>foh-updates</code> channel manifest<br />\n  with the <code>#manifest-updater.json</code> trigger, so the network-active <strong>Manifest<br />\n  Updater</strong> plugin surfaces + installs updates via WordPress's native update path.<br />\n  Functionally identical update delivery; the point is that FOH can now run<br />\n<strong>site-active</strong> instead of network-active — which removes the stray Return/<br />\n  Settlement payment-method rows on non-desk subsites at the source (the cause of<br />\n  the recurring \"automatically deactivated\" notice). Manifest Updater must be<br />\n  network-activated to receive update notifications.</li>\n</ul>\n<h2>[0.21.1] — 2026-06-22</h2>\n<h3>Fixed</h3>\n<ul>\n<li><strong>The recurring \"payment method Return/Settlement was automatically deactivated\"<br />\n  admin notice — root cause, surfaced on multisite.</strong> FOH is network-active (so its<br />\n  self-hosted updater runs network-wide), and it <strong>eagerly auto-activated</strong> its two<br />\n  internal payment methods (Return, Settlement) on EVERY subsite via<br />\n<code>Schema::install</code>, then <strong>proactively reactivated</strong> them on every admin/cron load.<br />\n  On subsites that never run a front desk, those stray active rows had no registered<br />\n  type in some request contexts (e.g. a network plugin update), so EE auto-deactivated<br />\n  them and filed a sticky per-site notice — and the proactive reactivation flipped<br />\n  them back on for EE to deactivate again, regenerating the notice in a loop. (Earlier<br />\n  patches also cleared the notice in the wrong site's context on a network.)</li>\n</ul>\n<p>The lifecycle is now <strong>use-driven</strong>, matching how EE treats every other payment<br />\n  method: the Return/Settlement rows are created/reactivated <strong>lazily on first actual<br />\n  use</strong> (<code>paymentMethodId()</code>), which only happens on the site that records a<br />\n  return/settlement — so no stray rows are planted elsewhere. The eager <code>Schema::install</code><br />\n  activation and the proactive reactivation are removed; the money path is unchanged<br />\n  (it already self-heals the row on use). The PMT <strong>types</strong> are still registered on<br />\n  every load (that is what stops EE deactivating the real active row on the desk site).<br />\n  The stale notice is now <strong>purged through EE's own notice collection</strong> (the<br />\n<code>...PersistentAdminNoticeManager__registerNotices</code> seam), which works in any<br />\n  admin/network-admin context and on any site — fixing the multisite mis-targeting of<br />\n  the previous attempts. New <code>EePersistentNotice::suppressAutoDeactivated()</code>.</p>\n<h2>[0.21.0] — 2026-06-22</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Per-method payment options end-to-end (payment-methods config Phase 3).</strong><br />\n  Settings ▸ Payment Methods now configures, per method: a <strong>display-label<br />\n  override</strong> (all methods), <strong>require a check number</strong> (check), and a <strong>card<br />\n  surcharge</strong> (card). The station honours all three — resolved label, check#-required<br />\n  capture, and surcharge display + charge.</li>\n<li><strong>Card surcharge as a REAL fee line item (the money model).</strong> A configured card<br />\n  surcharge is added to the transaction as a genuine, non-taxable EE fee line item<br />\n  when the card payment is taken: the total rises by it, it prints on the receipt,<br />\n  and it lands in EE's reports. The station launches the Collect reader with<br />\n<code>base + surcharge</code>; the server records the gateway-verified captured amount and<br />\n  adds the matching surcharge line (<code>LIN_code = foh-srcg-{ref}</code>, idempotent on<br />\n  retry), clearing the surcharge-inclusive balance to zero. A divergence between the<br />\n  charged amount and the configured surcharge is audited (<code>card_surcharge_mismatch</code>);<br />\n  a surcharge that cannot be applied fails the record loudly rather than overpaying.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li><strong>Card retired from FOH's built-in registry; now registered by the Collect<br />\n  add-on through <code>eea_foh_register_payment_methods</code>.</strong> FOH no longer carries a<br />\n  transitional <code>card</code> built-in or reads <code>eea_foh_collect_enabled</code>. <code>recordPayment</code><br />\n  resolves card via the registry filter (which foh-collect registers unconditionally<br />\n  at load), so the money path still resolves card on a pure REST request. <strong>Requires<br />\n  eea-foh-collect 0.2.0+</strong> (install it alongside this release — it supplies the card<br />\n  registration). FOH now owns enable/disable + label + surcharge for every method,<br />\n  including card, in one Settings section.</li>\n<li><strong><code>cash_refunds_enabled</code> forward-migrated and dropped</strong> (<code>EEA_FOH_SCHEMA_VERSION</code><br />\n  18→19). On upgrade, the legacy event-wide cash-refund setting is migrated into<br />\n<code>payment_methods.cash.options.allow_refunds</code> (idempotent, gated on the pre-upgrade<br />\n  version) and the legacy key is removed from all reads. An existing install keeps<br />\n  its cash-refund setting; a fresh install defaults off.</li>\n</ul>\n<h3>Hardening (adversarial money-path review)</h3>\n<ul>\n<li>Surcharge is <strong>clamped to the configured amount</strong> so a concurrent payment that<br />\n  races the balance toward zero (or a wrong amount on the reader) can't inflate it<br />\n  into a phantom fee — any excess capture surfaces honestly as an overpaid txn.</li>\n<li>A failed surcharge line-item write now <strong>fails the record loudly</strong> instead of<br />\n  recording a payment that would overpay the transaction.</li>\n<li>Server-side <strong>policy enforcement</strong>: a direct request can no longer record a method<br />\n  the admin disabled (the UI already hid it); idempotent retries are unaffected.</li>\n<li><strong>Surcharge values are capped</strong> (percent ≤ 100, flat ≤ $999.99) so a typo can't<br />\n  become a runaway fee.</li>\n<li>Saving settings while the Collect add-on is <strong>deactivated no longer wipes</strong> the<br />\n  saved card surcharge/label config.</li>\n<li>The cash-refund forward-migration is gated on its own completion flag, so a<br />\n  transient write failure <strong>retries</strong> instead of silently losing the setting.</li>\n<li>A loud <strong>admin notice</strong> fires if the Collect add-on is active but older than 0.2.0<br />\n  (which would otherwise let a card charge succeed at the reader and fail to record).</li>\n</ul>\n<h3>Migration / compatibility</h3>\n<ul>\n<li><strong>Behaviour-preserving by default.</strong> An untouched install offers exactly the same<br />\n  methods as before (card included when Collect was enabled), check numbers stay<br />\n  required unless an admin opts out, and the cash-refund setting is carried forward.<br />\n  Surcharge is off until configured. The money path is unchanged except for the<br />\n  surcharge line, which only applies when a card surcharge is configured. Sequence<br />\n  the install: <strong>eea-foh-collect 0.2.0 first, then eea-foh 0.21.0</strong>, so card never<br />\n  has a gap.</li>\n</ul>\n<h2>[0.20.1] — 2026-06-22</h2>\n<h3>Fixed</h3>\n<ul>\n<li><strong>Recurring \"payment method was automatically deactivated\" admin notice for the<br />\n  Return and Settlement payment methods.</strong> The methods were being reactivated<br />\n  correctly (returns/settlements kept working), but the stale persistent notice<br />\n  kept reappearing. Root cause: EE's <code>PersistentAdminNoticeManager</code> loads every<br />\n  notice into an in-memory collection and <strong>re-saves that whole collection to the<br />\n<code>ee_pers_admin_notices</code> option on <code>shutdown</code></strong> — so the prior fix's raw<br />\n<code>update_option()</code> delete was overwritten every request. The notice is now<br />\n  dismissed <strong>through EE's own collection</strong> (<code>dismissNotice(..., purge: true)</code>),<br />\n  which the shutdown save then removes for good, and the dismissal runs whenever<br />\n  the PMD row is already healthy (not only during a reactivation). New<br />\n<code>Support/EePersistentNotice</code> helper centralises the purge (incl. the<br />\n<code>sanitize_key()</code> collection-id vs raw option-key gotcha). Cosmetic-only,<br />\n  best-effort, never touches the money path.</li>\n</ul>\n<h2>[0.20.0] — 2026-06-22</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Device hardware capabilities (payment-methods config Phase 2 — the capability<br />\n  layer).</strong> Each device can now declare which payment hardware it has via a<br />\n<strong>Hardware</strong> block in the device editor: <strong>card reader</strong> and <strong>cash drawer</strong><br />\n  (extensible). A payment method whose required hardware is unchecked on a device<br />\n  is hidden on that station's point of sale — card needs a card reader, cash needs<br />\n  a cash drawer, check needs neither. This completes <code>capability ∩ policy ∩\n  authority</code>: <code>PaymentsService::availableMethods</code> / <code>methodDescriptors</code> now also<br />\n  intersect the acting device's capabilities.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>New <code>DEV_capabilities</code> column on the devices table (<code>EEA_FOH_SCHEMA_VERSION</code><br />\n  17→18; the column is added by the existing idempotent <code>dbDelta</code> upgrade on next<br />\n  load — additive, non-destructive).</li>\n</ul>\n<h3>Migration / compatibility</h3>\n<ul>\n<li><strong>Behaviour-preserving by default.</strong> Capability is <strong>visibility only</strong> — it never<br />\n  weakens role authority (the <code>can_take_payment</code> / <code>can_cash_refund</code> caps stay the<br />\n  gate) and never touches the money path (<code>recordPayment</code> and EE payment-method<br />\n  resolution are unchanged). A device with no stored capabilities reads as<br />\n<strong>all-true</strong>, so every existing device offers exactly the methods it did at<br />\n  0.19.0 until an admin unchecks a box. Admin (Mode A) and actorless resolution<br />\n  skip the capability layer entirely.</li>\n</ul>\n<h2>[0.19.0] — 2026-06-22</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Payment-methods configuration (Phase 1).</strong> A new data-driven method <strong>registry</strong><br />\n  replaces the hardcoded <code>['cash','check','card']</code> list: cash and check are built-in,<br />\n  and additional methods can be contributed through the new<br />\n<code>eea_foh_register_payment_methods</code> filter (card is the first such method, bridged in<br />\n  during this phase pending foh-collect's own registration in a later phase).</li>\n<li><strong>Settings ▸ Payment Methods.</strong> One row per registered method — enable/disable cash<br />\n  and check, set the cash-refund policy (the former \"Allow Cash Refunds\" toggle now<br />\n  lives here as the Cash method's option), and a read-only Card row showing its status<br />\n  (card-present is still provided by the Collect for Stripe add-on's own toggle).</li>\n<li><strong>Per-profile payment-method override.</strong> An access profile can now inherit the global<br />\n  enabled set (default) or override it to a restricted subset — it can switch a method<br />\n  off for that station but never enable one disabled globally<br />\n  (<code>PRO_scope.payment_methods = { inherit, methods[] }</code>).</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li><code>PaymentsService::availableMethods</code> / <code>methodDescriptors</code> / <code>resolvePaymentMethod</code> now<br />\n  iterate the registry and apply the policy (global ⊕ profile) ∩ role-authority<br />\n  intersection instead of a hardcoded list. The money path is unchanged:<br />\n<code>recordPayment(slug)</code> and EE payment-method resolution semantics are preserved, and<br />\n  role capabilities (<code>can_take_payment</code> / <code>can_cash_refund</code>) remain the authority.</li>\n</ul>\n<h3>Migration / compatibility</h3>\n<ul>\n<li><strong>Behaviour-preserving by default.</strong> An untouched install behaves exactly as 0.18.x —<br />\n  all methods enabled, card offered when Collect is enabled, cash refunds keyed off the<br />\n  migrated <code>payment_methods.cash.options.allow_refunds</code>.</li>\n<li><strong>Non-destructive read-through migration.</strong> The legacy <code>cash_refunds_enabled</code> and<br />\n  foh-collect's <code>eea_foh_collect_enabled</code> options stay readable; the new<br />\n<code>payment_methods</code> structure is derived on read from them (plus registry defaults)<br />\n  until an admin saves the new section, at which point cash/check become authoritative.<br />\n  Card stays read-through to its own plugin's gate. No schema change.</li>\n</ul>\n<h2>[0.18.2] — 2026-06-22</h2>\n<h3>Security</h3>\n<ul>\n<li><strong>Server-side ticket-category scope on the sell write path.</strong> <code>SellService::createSale</code><br />\n  now re-resolves the acting profile's allowed ticket ids (the same <code>eea_foh_scope_tickets</code><br />\n  path the catalog read uses) and refuses any line whose ticket is out of scope. Previously<br />\n  only the READ path (catalog) was scoped, so a clerk restricted to one category could POST a<br />\n  ticket id from another category on the same event. Fail-closed; admin (no profile) and<br />\n  unrestricted profiles are unaffected.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li><strong>Check-number dedup scoped to the transaction.</strong> A check number is only deduped within its<br />\n  own transaction now, so the same check number legitimately reused across different<br />\n  transactions (different payers/visits) is no longer rejected as a duplicate. Card charge-id<br />\n  dedup stays global (replay protection).</li>\n<li><strong>Return payment fails loud on an unresolved Return PMD</strong> instead of silently writing an<br />\n  unclassifiable <code>PMD_ID=0</code> onto a live money row (mirrors the settlement guard).</li>\n<li><strong>Customer-credit reversal is now confirmed + loud.</strong> <code>reverseCarrierPayment</code> returns a hard<br />\n  failure when the payment delete does not take, and both <code>CustomerCreditFlow</code> call sites<br />\n  (settle-store-credit, pay-with-credit) capture that result and leave a loud reconciliation<br />\n  trail — no more silent \"nothing changed\" on a transaction left wrongly paid.</li>\n<li><strong>Incident report is honest.</strong> <code>POST /station/incident</code> returns <code>recorded:false</code> when the<br />\n  audit insert fails (was always <code>true</code>).</li>\n<li><strong>Badge scan feedback.</strong> A no-match scan plays the denied tone + a \"Badge not found\" toast; a<br />\n  multi-match scan plays the already tone + a \"Multiple matches — select one\" toast (single-match<br />\n  auto-action unchanged).</li>\n<li><strong>PWA won't reload mid-incident.</strong> <code>isAppBusy</code> now also blocks on an open<br />\n<code>.incident-modal-overlay</code> so a pending \"Card charged — NOT recorded\" dialog can't be dismissed<br />\n  by an idle/update reload.</li>\n<li><strong>Toast timer</strong> cancels a prior dismissal so a rapid second toast isn't clipped early.</li>\n<li><strong>Service worker no longer evicts the companion PWA's cache.</strong> The activate-cleanup is<br />\n  scoped to FOH's own <code>eea-foh-</code> cache prefix — <code>caches.keys()</code> is per-origin, so the prior<br />\n  blanket deletion was wiping the attendee-facing companion app's <code>eea-ec-*</code> offline caches on<br />\n  every FOH update (both apps share the subsite origin).</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>Device User-Agent is sanitized at enroll; the <code>tendered</code> arg on apply-payment and the incident<br />\n  args are declared in the REST schema (incident <code>kind</code> validated at the schema layer); the<br />\n  Enroll Links admin page routes its action off the query string only (matches the other admin<br />\n  pages).</li>\n</ul>\n<h2>[0.18.1] — 2026-06-22</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Check payments at the desk.</strong> FOH now activates Event Espresso's built-in Check payment method in<br />\n  the admin scope (non-disruptively — it adds admin scope without removing any existing public-cart<br />\n  scope), so a clerk can take a check tender and the payment carries a real, classifiable PMD_ID. The<br />\n<code>check</code> descriptor + the client check-number prompt were already wired; this makes Check available.</li>\n</ul>\n<h3>Security</h3>\n<ul>\n<li><strong>Server-side event-scope enforcement</strong> on every transaction/registration-bound endpoint<br />\n  (PaymentController: paymentInfo, applyPayment, completeTransaction, settle, payCredit, emailReceipt;<br />\n  ReturnController: reverse-sale, exchange, returnable-items). A station clerk can now only act on a<br />\n  transaction/registration whose event is within their profile scope — closes an IDOR where a<br />\n  sequential id from another event drove a money/inventory/PII action. Fail-closed; Mode A admin<br />\n  unscoped.</li>\n<li><strong>Clerk/manager code hardening.</strong> Codes widened from 6-digit (10^6) to 10-digit numeric (10^10) and<br />\n  a per-device brute-force throttle (escalating lockout, loud audit) now guards clerk login, assist<br />\n  login, and the force-checkin override. A remote \"Clear lockout\" action on the Devices admin page<br />\n  lets a manager unstick a locked tablet. The attempted override code is no longer written to the<br />\n  audit log in plaintext.</li>\n</ul>\n<h2>[0.18.0] — 2026-06-22</h2>\n<h3>Security</h3>\n<ul>\n<li><strong>Card-present payments are now verified against the gateway before approval.</strong> A <code>card</code> payment<br />\n  previously recorded as PAID from a forgeable redirect-return URL with no gateway check. <code>applyPayment</code><br />\n  now fails closed: it requires a verifier (the eea-foh-collect companion) to confirm the charge<br />\n  succeeded at Stripe and reports the gateway's true captured amount — which is recorded in place of the<br />\n  client-supplied figure. A transient gateway-unreachable result is distinguished from a real decline so<br />\n  the clerk reconciles rather than re-charging.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li><strong>Idempotency on every first-touch money write.</strong> create-sale, exchange, cash apply-payment,<br />\n  pay-with-credit, and both settle paths now thread a stable idempotency key so a lost-response retry on<br />\n  flaky Wi-Fi can no longer double-apply (double sale/charge/credit-debit). create-sale anchors on a txn<br />\n  extra-meta key; exchange threads it through the replacement sale.</li>\n<li><strong>Audit failures are loud.</strong> <code>AuditRepo::log()</code> now returns a status and <code>error_log</code>s the action + DB<br />\n  error + row on a failed insert, so a lost audit row in the loud-failure channel can never vanish.</li>\n</ul>\n<h2>[0.17.2] — 2026-06-21</h2>\n<h3>Fixed</h3>\n<ul>\n<li><strong>Return / Settlement payment methods now reactivate themselves.</strong> Event Espresso auto-deactivates<br />\n  a payment method on any request where its add-on isn't loaded — which includes the brief<br />\n  deactivate→reactivate swap of a plugin update (and multisite/network contexts where FOH isn't<br />\n  active). Previously the rows stayed off until someone re-activated them by hand, breaking returns<br />\n  and settlements silently. FOH now detects the orphaned-inactive row on the next admin/cron load and<br />\n  at money-path use-time, reactivates it (admin-scope only), and clears EE's stale \"automatically<br />\n  deactivated\" notice. No manual step after an update, ever.</li>\n</ul>\n<h2>[0.17.1] — 2026-06-21</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Walk-up Return.</strong> A card-less entry to the Return / Exchange screen (a \"↩ Return\" button on the<br />\n  check-in screen) for a buyer who purchased earlier and came back with no registration to look up.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li><strong>The sales process now verifies the transaction's TRUE status before completing.</strong> Exiting the<br />\n  payment screen no longer falsely shows \"Sale Complete\" or auto-checks-in an unpaid sale — checkout<br />\n  re-queries Event Espresso for the real paid/balance state and shows an honest \"Payment Not<br />\n  Completed\" result when nothing was collected. Returns/exchanges apply the same check and flag an<br />\n  unresolved refund or balance rather than passing silently. Fail-safe: an unverifiable status is<br />\n  treated as unpaid, never as done.</li>\n</ul>\n<h2>[0.17.0] — 2026-06-21</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Admin-configurable branding.</strong> Four operator-set images on the Front of House Settings<br />\n  page (WP media picker): receipt logo, app icon, splash, and station background. The app icon<br />\n  overrides the PWA icon/manifest; the receipt logo is rasterized onto thermal receipts<br />\n  (resilient — a missing/failed logo never blocks the money path).</li>\n<li><strong>Bluetooth badge printing in wp-admin.</strong> A \"Print badges over Bluetooth\" page on the Clerks<br />\n  screen prints each active clerk's badge (CODE128 + QR) over a thermal printer ONE at a time,<br />\n  with a Next button so the operator can tear between prints. Built as a self-contained admin<br />\n  bundle so the station bundle is unchanged.</li>\n</ul>\n<h2>[0.16.2] — 2026-06-20</h2>\n<h3>Fixed</h3>\n<ul>\n<li>Service worker REST namespace <code>eea-checkin/v1</code> → <code>eea-foh/v1</code> so the never-cache-API guard<br />\n  matches the live namespace (the old pattern was a dead branch). No behavior change on prod.</li>\n</ul>\n<h2>[0.16.1] — 2026-06-20</h2>\n<h3>Changed</h3>\n<ul>\n<li>Self-hosted update channel + repository URLs moved to the <code>ee-extensions</code> org.<br />\n  The in-plugin updater now reads the org-owned public manifest; no change on the<br />\n  running site beyond the source of updates.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li>Removed a hard-coded external domain from the receipt printer's barcode/QR test<br />\n  page; it now prints a neutral placeholder URL.</li>\n</ul>\n<h2>[0.16.0] — 2026-06-19</h2>\n<h3>Changed</h3>\n<ul>\n<li><strong>Taking payment is now a lookup filter, not a separate mode.</strong> \"Take Payment\" was<br />\n  never its own surface — it was the registration search rendered as payment cards. It's<br />\n  now a segmented filter on the lookup screen: <strong>All · Needs check-in · Needs payment</strong><br />\n  (shown when the profile enables payment and the clerk can take payment). The \"Needs<br />\n  payment\" lens narrows to registrations with an outstanding transaction and renders<br />\n  payment cards (+ POS scan-to-pay); \"All\" is the prior check-in behavior; \"Needs<br />\n  check-in\" is a new lens. Removes the empty-page confusion of the old mode. Sell remains<br />\n  its own mode. No schema or server change.</li>\n</ul>\n<h2>[0.15.0] — 2026-06-19</h2>\n<p>Clerk badge printing + scan-to-login (camera or wedge). No schema change (v16).</p>\n<h3>Added</h3>\n<ul>\n<li><strong>Printable clerk badges.</strong> The Receipt Printer modal gains \"Print clerk badges\" —<br />\n  prints a badge per active clerk carrying their login code as both a CODE128 barcode<br />\n  (hardware wedge scanners) and a QR (camera). Backed by a manager-gated<br />\n<code>GET /station/clerk-badges</code>. (Clerk codes are attribution codes, not secrets — treat a<br />\n  printed badge as a physical login key.)</li>\n<li><strong>Camera scan-to-login.</strong> The clerk-login screen and the assist-login modal can scan a<br />\n  badge with the device camera (reusing the check-in scanner) — a decode fills the code<br />\n  and submits. Wedge-scanner login already worked; this adds the camera path for stations<br />\n  with no wedge scanner.</li>\n<li><strong>Per-device camera configuration.</strong> Device Settings → Cameras: detect cameras and pick<br />\n  which camera each scanner uses (login vs check-in may differ), plus an \"auto-start<br />\n  scanner on login\" toggle. Cameras are enumerated when the device is approved.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>The camera scanner is centrally torn down on every view transition and handed off<br />\n  between scanners (check-in ⇄ assist login) so only one holds the camera at a time;<br />\n  html5-qrcode stays lazy-loaded (no initial-bundle weight).</li>\n</ul>\n<h2>[0.14.2] — 2026-06-18</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Receipt Printer configuration modal.</strong> Device Settings → Receipt printer →<br />\n<strong>Configure ›</strong> opens a dedicated modal (driver selector, Bluetooth pair/connect +<br />\n  status, tear-feed) with on-device <strong>test buttons</strong> — \"Test receipt\" (a sample receipt<br />\n  through the real encoder) and \"Test barcode\" (CODE128 + QR) — so a clerk can prove the<br />\n  printer without ringing up a sale. Replaces the inline drawer controls; being a real<br />\n<code>.modal-overlay</code> it's also immune to the focus-reclaim issue by construction.</li>\n</ul>\n<h2>[0.14.1] — 2026-06-18</h2>\n<h3>Fixed</h3>\n<ul>\n<li><strong>Settings drawer form controls were unusable on touch devices.</strong> The station's<br />\n  focus-reclaim (which keeps the barcode-wedge scanner alive) only recognized<br />\n<code>.modal-overlay</code> as a blocking overlay, not the Settings drawer (<code>.drawer-backdrop</code>)<br />\n  or the payment-incident modal. Tapping the receipt-printer <code>&lt;select&gt;</code> (or the tear-feed<br />\n  input) got focus yanked back to the search box ~10ms later — the dropdown wouldn't<br />\n  open and the soft keyboard popped over the drawer. <code>isModalOpen()</code> now recognizes the<br />\n  drawer and the incident modal, so their controls work.</li>\n</ul>\n<h2>[0.14.0] — 2026-06-18</h2>\n<p>Bluetooth thermal receipt printing, templated header/footer, itemized receipts, and<br />\nloud failure handling for the card-present path. No schema change (v16).</p>\n<h3>Added</h3>\n<ul>\n<li><strong>Bluetooth thermal receipt printing.</strong> A Web Bluetooth (BLE GATT → ESC/POS) driver<br />\n  prints receipts directly from the station PWA to a 58mm thermal printer (Munbyn IMP001<br />\n  class) — pair once, reconnect-on-demand at print time, never a false success on a dead<br />\n  link. Select \"Bluetooth (thermal)\" in Device Settings → Connect. Requires HTTPS.</li>\n<li><strong>Templated receipt header/footer.</strong> Plain-text header/footer with <code>{token}</code><br />\n  substitution, authored globally in Check-In Settings with a per-access-profile override.<br />\n  The server ships the raw template + token values; the client substitutes in one pass.<br />\n  The transaction body (totals) is always generated, never templated.</li>\n<li><strong>Itemized receipts.</strong> Receipt body carries line items (tickets + taxes) with 32-col<br />\n  money alignment, derived from the EE line-item tree.</li>\n<li><strong>Configurable tear feed.</strong> Per-device trailing line-feed count for the manual tear.</li>\n<li><strong>Loud charged-but-unrecorded handling.</strong> When a Collect card charges but the record<br />\n  against the transaction fails, the station no longer swallows it: it retries, then<br />\n  escalates across every channel — structured log, persisted incident, a printed manager<br />\n  error slip (both our IDs and Stripe's chargeId/amount/status), a <code>POST /station/incident</code><br />\n  audit beacon, and a blocking manager modal (retry / reprint / acknowledge).</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>The ESC/POS layer (<code>escpos.ts</code>) is now generic primitives + a <code>composeDocument</code> frame;<br />\n  each print context (transaction receipt, error slip) composes its own body.</li>\n</ul>\n<h2>[0.13.0] — 2026-06-18</h2>\n<p>FOH returns, refunds &amp; customer credit — the v1.0.0-blocking foundation. Full design<br />\nin <code>docs/RETURNS-CREDIT-IMPLAN.md</code>.</p>\n<h3>Added</h3>\n<ul>\n<li><strong>Real transaction for returns &amp; exchanges.</strong> The return/exchange path now creates one<br />\n  real <code>EE_Transaction</code> (<code>POST /station/exchange</code>): replacement items are real ticket<br />\n  purchases (variation pricing + stock decrement, sold-out rolls the whole thing back),<br />\n  returned goods are recorded as a typed <strong>+Return payment</strong> (a first-class <code>EE_PMT_Return</code><br />\n  encoding <code>{ticket_id, option_id, qty}</code>), and the server computes the net. Restock fires on<br />\n  approval via the existing inventory hook. Replaces the old transaction-less store-credit<br />\n  IOU that returned no transaction id.</li>\n<li><strong>Settle credit owed (cash refund / forfeit).</strong> When a return/exchange overpays a<br />\n  transaction, the Transaction screen offers a settlement: <strong>Cash refund</strong> (money out of the<br />\n  drawer — gated on a new <code>can_cash_refund</code> role cap <strong>and</strong> an event-wide \"cash refunds<br />\n  enabled\" toggle) or <strong>Forfeit</strong> (the books-balancing \"keep it\"). Each records a typed<br />\n<strong>−payment</strong> clamped to what's owed, idempotency-keyed, stamped with the settle type +<br />\n  acting clerk.</li>\n<li><strong>Store credit + pay-with-credit</strong> (requires the new <strong>eea-customer-credit</strong> plugin).</li>\n<li><em>Store credit settle:</em> the \"Store credit\" button on credit-owed opens a dedicated<br />\n    customer lookup/create screen (email-keyed; echoes name + current balance), then parks<br />\n    the owed credit on the customer's account.</li>\n<li><em>Pay with store credit:</em> a \"Store credit\" payment-method button on a balance-due<br />\n    transaction looks up the customer, shows their available balance, and pays toward the<br />\n    balance — <strong>refused (never clamped)</strong> when the account can't cover it.</li>\n<li>Both go through the plugin's secure seam (the balance move is derived server-side from<br />\n    the real payment, one move per payment); checkin keeps a one-way dependency and hides<br />\n    the affordances entirely when the plugin is absent.</li>\n<li><strong>Suppress tax/fees on exchanges</strong> (per access profile, <strong>default on</strong>). A like-for-like<br />\n  exchange nets a return against the replacement, so EE re-charging full tax on the<br />\n  replacement double-taxes the swap. Exchanges now mark the replacement non-taxable (taxed on<br />\n  the net of $0); pure sales and pure returns are unaffected. Toggle on the profile to<br />\n  restore per-item tax.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li><strong>\"No route was found matching the URL\" on settling a return.</strong> The return path was<br />\n  transaction-less, so the client settled on an undefined transaction id and 404'd. Returns<br />\n  now create a real transaction with a real id.</li>\n<li><strong>Exchanges double-taxed a like-for-like swap</strong> (the patron was left owing the tax/fee on a<br />\n  net-zero exchange). See the exchange tax suppression above.</li>\n</ul>\n<h3>Notes</h3>\n<ul>\n<li>Schema v15 (Return payment method) → v16 (<code>can_cash_refund</code> cap + Settlement method).</li>\n<li>The FOH app stops calculating money client-side: \"owed\" and balances are read consistently<br />\n  from the server-reported remaining balance.</li>\n</ul>\n<h2>[0.12.3] — 2026-06-16</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Automatic PWA updates (safe for a live station).</strong> The app now polls for new<br />\n  service-worker versions (every 5 min + on returning to the foreground) and <strong>auto-applies</strong><br />\n  an update — but only when the station isn't busy (no open modal/transaction/checkout, no<br />\n  assist mode, no pending card redirect, empty search, empty cart), via foreground-return or<br />\n  ~60s-idle triggers, plus a tappable <strong>\"Update ready ↻\"</strong> pill for on-demand refresh. Reload-<br />\n  loop guarded. After this build is on a device, future deploys self-apply — no manual reopen.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li><strong>A–Z search bar no longer leaks into Sell mode.</strong> It's now part of the registration-search<br />\n  surface, so it only shows in Check-In / Take-Payment (where the search exists), not in the<br />\n  Sell/POS grid.</li>\n</ul>\n<h2>[0.12.2] — 2026-06-15</h2>\n<h3>Changed</h3>\n<ul>\n<li><strong>Card is strictly full-remaining.</strong> The card/redirect method ignores any edited partial<br />\n  amount and always charges the entire balance (so a card payment always settles the txn).<br />\n  Partial/split payments are taken cash/check-first (in-screen, leaving the balance open),<br />\n  then card clears the rest. True arbitrary splits across methods can come later.</li>\n</ul>\n<h2>[0.12.1] — 2026-06-15</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Cash tender + change.</strong> Taking a cash payment now prompts for the <strong>amount given</strong> with a<br />\n  live <strong>change-due</strong> readout and Square-style quick-tender buttons (Exact + common bills).<br />\n<code>PAY_amount</code> stays the applied charge; the <strong>tendered</strong> and <strong>change</strong> are recorded on the<br />\n<code>EE_Payment</code> (<code>PAY_extra_accntng</code> human-readable + <code>PAY_details</code> structured cents) and shown<br />\n  on the detail-modal payments list. The success screen calls out the change to hand back.</li>\n</ul>\n<h2>[0.12.0] — 2026-06-15</h2>\n<h3>Added — card-present payments (pluggable; Collect verified on-device)</h3>\n<ul>\n<li><strong>Card-present via a pluggable provider model.</strong> Core stays provider-agnostic: a<br />\n<code>redirect</code>-kind payment-method <strong>descriptor carries its own <code>launch</code> template + <code>return</code><br />\n  param-mapping + <code>record_method</code></strong>, so the PWA drives the deep-link launch and the<br />\n<code>/checkin/payment-result</code> return entirely from the descriptor — zero provider code in<br />\n  core/PWA. Cash/check remain the only core built-ins.</li>\n<li><strong><code>eea-checkin-collect</code> addon plugin</strong> (separate, scoped): registers the <code>card</code> method via<br />\n<code>eea_checkin_payment_methods</code> with the device-confirmed Collect-for-Stripe contract<br />\n  (<code>status</code>/<code>chargeId</code>/<code>finalAmount</code>, GET) and owns its own enable toggle via the new<br />\n<code>eea_checkin_settings_form_extra</code> / <code>eea_checkin_settings_saved</code> hooks. A future Square<br />\n  provider is an addon of the same shape — no core/PWA changes.</li>\n<li><strong>On-demand receipt</strong>: <code>POST /station/email-receipt</code> fires EE's own <code>EED_Messages::payment()</code><br />\n  (the same branded receipt EE's processor sends); the success screen offers an <strong>\"Email<br />\n  receipt\"</strong> action that captures an email on the spot when the walk-up didn't give one.</li>\n<li>Validated end-to-end on the real device: M2 reader, Collect's https callback contract, and<br />\n  the WebAPK in-scope return landing <strong>in the installed PWA</strong> (no <code>assetlinks.json</code> needed).</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>The <code>eea_checkin_payment_methods</code> hardener now preserves <code>record_method</code>/<code>launch</code>/<code>return</code><br />\n  for addon-provided descriptors and drops any malformed redirect descriptor.</li>\n</ul>\n<h2>[0.11.0] — 2026-06-15</h2>\n<h3>Added — Sell / POS mode (front-desk sales)</h3>\n<ul>\n<li><strong>Sell mode</strong> (third station mode; per-profile <code>modes</code> includes <code>sell</code> ∩ clerk<br />\n<code>can_take_payment</code>): a Square-style <strong>item grid</strong> (category tabs derived from ticket<br />\n  class — Admission / Meals / Apparel / …) + a <strong>persistent cart</strong> (quantities, running<br />\n  total) → <strong>native registrant-questions</strong> step → <strong><code>POST /station/create-sale</code></strong> →<br />\n  the shared <strong>Transaction screen</strong> (cash/check) → <strong>check-in</strong> of the new registrations →<br />\n<strong>Finish</strong>. Tablet-first, enterprise styling, all four themes.</li>\n<li><strong>Server</strong>: <code>SellService</code> + <code>SellController</code> with <code>GET /station/ticket-catalog</code>,<br />\n<code>GET /station/ticket-questions</code>, <code>POST /station/create-sale</code>. <code>create-sale</code> builds the<br />\n  transaction + line-item tree + per-item attendee/registration/answers through <strong>EE's own<br />\n  models</strong> (admin/service context — so selling after sale-end / sold-out is a non-issue),<br />\n  with <strong>automatic tax</strong>, <strong>RAP</strong> status, and <strong>full transactional rollback</strong> on any<br />\n  failure (no orphaned rows). Attendee fields mapped via EE's<br />\n<code>system_question_to_attendee_field_mapping()</code>. Sold-out / sale-ended tickets are shown<br />\n  but flagged (still sellable in admin context).</li>\n<li><strong>Native questions render</strong> with the new <strong><code>eea_checkin_registration_questions</code></strong> filter,<br />\n  so ticket-class question-hiding (<code>-cond</code>) applies in Sell exactly as in SPCO. Non-input<br />\n  EE pricing types (<code>monetary</code>/<code>pricing</code>) are excluded.</li>\n</ul>\n<h3>Changed — core is ticket-native; ticket classes moved to the companion</h3>\n<ul>\n<li>Core <code>eea-checkin</code> no longer knows about ticket <strong>classes</strong> (a concept added by the<br />\n  third-party <code>eea-ticket-classes</code> plugin). The profile \"Ticket Class Filter\" (PCRE) is<br />\n  replaced by an EE-native <strong>ticket multi-select</strong> (<code>PRO_scope.tickets</code>; none = all), and<br />\n  search/check-in/catalog scope by ticket IDs. Four hooks let the companion layer classes<br />\n  back on: <code>eea_checkin_scope_tickets</code>, <code>eea_checkin_ticket_catalog</code>,<br />\n<code>eea_checkin_profile_form_extra</code>, <code>eea_checkin_profile_saved</code>. <code>eea-ticket-classes</code> now<br />\n  owns the class-filter config (its own option), class-based scoping, and catalog<br />\n  categories — alongside the question-hiding it already did. No Round-Up specifics in core.</li>\n</ul>\n<h3>Note</h3>\n<ul>\n<li>Card-present (Collect) payment in Sell is intentionally <strong>deferred</strong> — it reuses the<br />\n  existing pluggable registry, and the card method stays disabled until the Collect device<br />\n  round-trip is confirmed. Cash/check work now.</li>\n</ul>\n<h2>[0.10.0] — 2026-06-15</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Assistive Login</strong> (schema v14): a role capability <strong><code>ROL_can_assist</code></strong> lets a permitted<br />\n  clerk <strong>temporarily log in <em>over</em> an active clerk</strong> on a device — a <em>stacked</em> session, not a<br />\n  replacement. The base clerk session is preserved untouched and resumes on exit (its idle<br />\n  timer revived). Endpoints <code>POST /station/assist-login</code> + <code>/station/assist-exit</code><br />\n  (<code>SES_kind</code>/<code>SES_parent_id</code>); assist-mode chrome = a contrasting viewport border, an<br />\n  \"Assisting as {name}\" indicator, and a bottom-left <strong>Exit Assist</strong> button; UI state is<br />\n  preserved across entry/exit. Gated on <code>can_assist</code> <strong>and</strong> the assisting clerk's station<br />\n  scope (audited). Plugin option <code>assist_invalidates_other_devices</code> (default off) ends the<br />\n  assisting clerk's other-device sessions on assist-login.</li>\n<li><strong>Per-device themes + manager Device Settings</strong>: four CSS-only themes via <code>data-theme</code><br />\n  (<strong>Corporate (System)</strong> — default, follows OS light/dark — plus Dark Pro, Light, System),<br />\n  persisted per-device, chosen from a <strong>manager-only Device Settings</strong> panel in the menu.</li>\n<li><strong>Q&amp;A + payments in the details modal</strong>: the role-gated detail now renders the registrant's<br />\n  questions &amp; answers (EE answers via the <code>eea_checkin_registration_answers</code> hook) and a<br />\n  transaction payments breakdown.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li><strong>Enterprise visual refinement</strong>: calmer, more spacious commercial look — glows replaced by<br />\n  crisp borders + subtle elevation, tabular-num money, tighter type/spacing scale, blurred<br />\n  modal backdrops, <code>:focus-visible</code> rings, full <code>prefers-reduced-motion</code> support.</li>\n<li><strong>A–Z search bar</strong>: letters-only (A–Z, no digits) for fast last-name lookup, ⌫ retained, and<br />\n  a bottom <strong>⌨</strong> key that drops into a <em>transient</em> keyboard (for email/address) which<br />\n  auto-reverts to the A–Z bar on input blur.</li>\n</ul>\n<h2>[0.9.0] — 2026-06-15</h2>\n<h3>Added — station modes, in-person payments, corrected check-in model (SPEC §16)</h3>\n<ul>\n<li><strong>Station modes</strong> (§16.5): the station is now modal — <strong>Check-In</strong> and <strong>Take Payment</strong> —<br />\n  with a top-bar mode switcher (per-mode accent so the active mode is unmistakable). Mode<br />\n  availability = a per-profile <strong><code>modes</code></strong> allow-list ∩ clerk capability (schema v12;<br />\n  Take-Payment requires <code>can_take_payment</code>).</li>\n<li><strong>Pluggable payment methods</strong> (§16.4): server registry exposed via the<br />\n<strong><code>eea_checkin_payment_methods</code></strong> filter producing hardened descriptors<br />\n<code>{slug,label,kind,available,ordering,capture?}</code>; the client dispatches by <code>kind</code><br />\n  (<code>instant</code> cash/check, <code>redirect</code> card). New method = new descriptor, no core change.</li>\n<li><strong>Shared Transaction screen</strong> + <strong><code>POST /station/apply-payment</code></strong> /<br />\n<code>GET …/payment-info</code>: take cash/check now; <strong>Collect card is scaffolded</strong> (descriptor +<br />\n<code>/checkin/payment-result</code> shell route) but <code>available:false</code> behind the<br />\n<code>eea_checkin_collect_enabled</code> option until the M2 device round-trip is confirmed.</li>\n<li><strong><code>POST /station/complete-transaction</code></strong>: finalizes an incomplete, zero-balance<br />\n  transaction via EE's own <code>update_status_based_on_total_paid()</code> +<br />\n<code>update_transaction_and_registrations_after_checkout_or_payment()</code> (guarded, idempotent).</li>\n<li><strong>Q&amp;A + payments in the details modal</strong> (§15.11): the role-gated detail endpoint now<br />\n  carries the registrant's questions &amp; answers (via <code>EE_Registration::answers()</code> →<br />\n<code>EE_Answer::pretty_value()</code>) behind the <strong><code>eea_checkin_registration_answers</code></strong> filter<br />\n  (so ticket-class question-hiding wires in at the enrichment layer, not core), plus a<br />\n  transaction <strong>payments</strong> breakdown and <code>balance</code>/<code>complete</code>.</li>\n</ul>\n<h3>Changed — the visibility ↔ action model (corrected; general EE, not Round-Up-specific)</h3>\n<ul>\n<li><strong>Visibility = registration status</strong>, configured per profile via <strong><code>PRO_checkin_statuses</code></strong><br />\n  (schema v13; default <strong><code>RAP</code></strong> = approved-only). Drives the search status-filter; the<br />\n  existing Force-Check-In covers any <em>visible</em> non-approved card. Approved-only visibility<br />\n  structurally hides abandoned/pending carts with zero event-specific code.</li>\n<li><strong>Action = the owning transaction's completeness, not its balance.</strong> Cards carry<br />\n<code>txn_status</code> + <code>txn_complete</code>; the check-in payment gate and the Take-Payment affordance<br />\n  fire on <code>txn_id &gt; 0 &amp;&amp; !txn_complete</code> (an incomplete transaction needs handling even at a<br />\n  $0 balance). The gate is role-governed client-side workflow (managers override; payment-<br />\n  capable clerks resolve inline; others get a manager-override prompt).</li>\n</ul>\n<h3>Polished</h3>\n<ul>\n<li>Full visual pass to a refined POS look: pill mode switcher with per-mode glow, richer<br />\n  result cards + chips, the Transaction screen centerpiece (tabular-nums balance, method-<br />\n  button states, a rewarding \"Paid in full ✓\" success), blurred modal backdrops,<br />\n<code>:focus-visible</code> rings, and comprehensive <code>prefers-reduced-motion</code> support.</li>\n<li><strong>A–Z search bar refined</strong>: letters-only (A–Z, no digits) for fast last-name lookup,<br />\n  ⌫ retained, and a bottom <strong>⌨</strong> key that drops into a <em>transient</em> keyboard mode (for<br />\n  email/address) which auto-reverts to the A–Z bar on input blur.</li>\n</ul>\n<h2>[0.8.0] — 2026-06-15</h2>\n<h3>Added — in-person payment foundation (SPEC §16.1; POS epic begins)</h3>\n<ul>\n<li><strong>Record-payment service</strong> (<code>PaymentsService::recordPayment</code>): records an <code>EE_Payment</code><br />\n  and finalizes via EE's own <code>EE_Payment_Processor::update_txn_based_on_payment</code> — applies<br />\n  to registrations, sets paid, recomputes transaction status, toggles RPP→RAP, fires the<br />\n  standard hooks/emails. Idempotent on the Stripe charge id / check number. The same path<br />\n  EE's admin \"Apply Payment\" uses; the landing pad for cash/check <strong>and</strong> Collect card.</li>\n<li><strong><code>POST /station/apply-payment</code></strong> + <code>GET /station/transaction/{id}/payment-info</code><br />\n  (balance + which methods resolve to an active EE payment method). Gated by a new role<br />\n  capability <strong><code>can_take_payment</code></strong> (schema v11; manager seeded on, clerk off).</li>\n<li>Verified on the dev EE install: an unpaid transaction → check payment → <code>TCM</code>/paid,<br />\n  idempotent re-run no-ops. <code>cash</code>/<code>check</code> resolve to EE's offline methods; <code>card</code>→<br />\n<code>Stripe_Onsite</code> (live only).</li>\n<li><em>Not yet wired:</em> the PWA take-payment UI and the Collect deep-link/return — next.</li>\n</ul>\n<h2>[0.7.2] — 2026-06-14</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Role-gated registration details modal</strong> (new role flag <code>ROL_can_view_details</code>,<br />\n  schema v10): a clerk whose role permits it can tap a result row to open a modal with<br />\n  the fuller record (contact, ticket, status, group/transaction, check-in history, linked<br />\n  context). Server-enforced on the detail endpoint (a real PII gate, <code>403</code> otherwise),<br />\n  not just hidden UI. New-install <code>manager</code> gets it; <code>clerk</code> does not.</li>\n<li><strong>A–Z search bar</strong>: an opt-in toggle (small/portrait screens) swaps the soft keyboard<br />\n  for a vertical right-edge A–Z scrubber (tap to append, one ⌫ to delete); choice is<br />\n  remembered. Wedge/camera scan unaffected.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li><strong>PIN pad no longer raises the Android soft keyboard</strong> (<code>inputmode=\"none\"</code>) — the<br />\n  on-screen keypad, wedge scanner, and paste still work.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li>Manager action-bypass (v0.7.1) and the details flag now take effect on the <em>first</em><br />\n  clerk login, not only after a reload (<code>handleClerkLoggedIn</code> wasn't carrying the role<br />\n  capability flags into the station view).</li>\n</ul>\n<h2>[0.7.1] — 2026-06-14</h2>\n<h3>Changed</h3>\n<ul>\n<li><strong>Managers override the per-station action gate.</strong> A clerk whose role has<br />\n<code>can_override</code> may lookup / check-in / check-out / undo on <em>any</em> station, regardless<br />\n  of the profile's allowed actions (<code>Actor::allows()</code> bypass). The clerk-login payload<br />\n  now carries <code>can_override</code>, and the station shows the full action set (incl. Check Out /<br />\n  Undo) when a manager is signed in. The existing force-checkin override is unchanged.</li>\n</ul>\n<h2>[0.7.0] — 2026-06-14</h2>\n<h3>Added — linked registrations (SPEC §15.10)</h3>\n<ul>\n<li><strong>Reverse-lookup of related registrations</strong> (schema v9, <code>eea_checkin_reg_links</code>):<br />\n  finding one person can surface their related regs as extra, independently-checkable<br />\n  rows — so a meals clerk searching the eater's name finds the meal that's stamped with<br />\n  the <em>buyer's</em> name, and a group purchase surfaces its transaction-mates.</li>\n<li><strong>Deterministic where it can be, imported where it can't:</strong> transaction siblings are<br />\n  live (<code>WHERE TXN_ID = ?</code>, per-profile \"Surface transaction siblings\" toggle); the<br />\n  subtle cross-name/cross-order meal→eater resolution is <strong>imported</strong> from the offline<br />\n  reconciliation resolver (no PHP re-derivation, no divergence) via<br />\n<code>wp eea-checkin import-reg-links &lt;csv&gt; [--relation] [--replace]</code> (refs accept REG_ID or<br />\n  REG_code).</li>\n<li><code>eea_checkin_search_results</code> filter (the \"results mutator\") + <code>ResultCard.linked</code><br />\n  ({via_reg_id, relation, flag?, note?}); the station renders linked rows subordinate,<br />\n  with a caution treatment for <code>flag:'caution'</code> (e.g. a meal with no admission).</li>\n</ul>\n<h2>[0.6.3] — 2026-06-14</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Pluggable row enrichment:</strong> an <code>eea_checkin_search_row</code> filter<br />\n  (<code>$card, EE_Registration $reg, ['dtt_id'=&gt;int]</code>) lets a site/add-on push<br />\n  display-ready items onto <code>$card['extra']</code> (<code>[{label, value, tone?}]</code>, hardened<br />\n  by <code>normalizeExtra()</code>); the station renders them as compact chips under each<br />\n  result. Core stays event-agnostic.</li>\n<li><strong>Example enricher</strong> (<code>examples/roundup-2026-enrichment.php</code>, a standalone<br />\n  mu-plugin — NOT part of core): surfaces this event's merch sizes (QST 22-25) and<br />\n  meal plan (QST 12, <code>d:N</code> decode) on the row.</li>\n</ul>\n<h2>[0.6.2] — 2026-06-14</h2>\n<h3>Changed</h3>\n<ul>\n<li><strong>Clerk timeout combine rule (SPEC §15.3):</strong> the effective timeout is now the<br />\n<strong>lowest-absolute</strong> magnitude across role / station / per-clerk, and <strong>zero-tolerance<br />\n  if any contributor is negative</strong>. <code>0</code>/blank = never (a no-op, excluded). A negative's<br />\n  magnitude is an idle cap layered on zero-tolerance: <code>-30</code> = logout on focus-loss AND a<br />\n  30-min idle cap; <code>-0</code> = focus-loss logout only. Resolved shape is <code>{ztol, minutes}</code>, so<br />\n  a station can run BOTH the idle countdown and instant focus-loss logout.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li><strong>Profile edit corrupted the validity window</strong> → device lockout. Saving a profile with<br />\n  no start/expiry wrote <code>0000-00-00 00:00:00</code> (the edit path coerced null through a <code>%s</code><br />\n  placeholder), which read back as \"expired in year 0\" → <code>ec_profile_expired</code> locked the<br />\n  bound device. <code>update()</code> now writes real <code>NULL</code>; reads normalize any zero-date to null<br />\n  (so an already-corrupted profile un-expires itself on read — re-save to clean the row).</li>\n<li><strong>\"Copy\" on enrollment-link launch URLs</strong> failed on plain HTTP (the Clipboard API is<br />\n  undefined outside secure contexts) — now uses <code>execCommand('copy')</code> with a Clipboard-API<br />\n  fallback and a Ctrl+C prompt.</li>\n</ul>\n<h2>[0.6.1] — 2026-06-14</h2>\n<h3>Added — clerk → station scoping (SPEC §15.9)</h3>\n<ul>\n<li><strong>Clerk → access-profile scoping (schema v8):</strong> a role carries an allowed-stations set<br />\n  (none = all); a clerk may narrow it (intersection). A clerk can't sign into — or keep<br />\n  operating — a station they're not cleared for (<code>ec_clerk_not_authorized</code>, audited<br />\n<code>clerk_denied</code>); enforced at clerk-login and re-checked per request. Role + clerk<br />\n  multiselects in admin; opt-in (ships unrestricted).</li>\n</ul>\n<h3>Added — custom roles &amp; stateful clerk sessions (SPEC §15)</h3>\n<ul>\n<li><strong>Configurable roles</strong> (<code>eea_checkin_roles</code>, schema v7): per-role timeout, multi-login<br />\n  policy, and \"can authorize a forced check-in\" capability. New <em>Clerk Roles</em> admin page;<br />\n  seeds <code>clerk</code> (60 min) and <code>manager</code> (2 min, can-override). Clerks remain custom<br />\n  PIN/badge identities — deliberately NOT WP users (keeps volunteers off the MU user<br />\n  table and out of wp-admin).</li>\n<li><strong>Stateful clerk sessions</strong> (<code>eea_checkin_clerk_sessions</code>): clerk tokens are now v2,<br />\n  session-bound; every clerk request validates a live session row (one indexed read).<br />\n  Enables force-logout.</li>\n<li><strong>Bump / force-logout</strong>: log a clerk out of one device, or everywhere; a live<br />\n  \"Logged-in clerks\" roster on the Devices page; revoking a clerk ends all their sessions.<br />\n  A new login on a shared tablet ends the previous operator's session there.</li>\n<li><strong>Effective timeout = strictest wins</strong> across role, station, and optional per-clerk<br />\n  override. Sentinel encoding: <code>N</code> minutes, <code>0</code> = never, negative (incl. <code>-0</code>) =<br />\n  zero-tolerance (logout on any focus loss). 12-hour hard cap removed.</li>\n<li><strong>Station idle/focus lifecycle</strong>: a confirmation modal (clerk nickname + live countdown,<br />\n  Continue / Log out) on idle-past-¼-timeout, blur, or refocus; <code>never</code> mode skips it;<br />\n  zero-tolerance logs out instantly on blur/sleep/refocus. <code>clerk-heartbeat</code> endpoint<br />\n  slides the session; tokens refresh on heartbeat and check-in.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li><strong>A clerk login is now always required</strong> to operate a station (search + check-in). The<br />\n  per-profile \"Clerk Required\" toggle is removed; <code>PRO_clerk_timeout</code> is relabeled<br />\n  \"Clerk timeout (station cap)\" and feeds the effective-timeout calculation.</li>\n<li>Manager-override / badge \"MANAGER\" tag is driven by the role's can-override capability<br />\n  instead of a hardcoded <code>manager</code> role.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li>New error states <code>ec_clerk_bumped</code> (session force-ended) and <code>ec_clerk_expired</code> (idle<br />\n  timeout) drop the station to clerk-login with distinct copy.</li>\n</ul>\n<h2>[0.5.1] — 2026-06-14</h2>\n<h3>Security</h3>\n<ul>\n<li><strong>Closes a bootstrap privilege-escalation hole.</strong> Through v0.4 the profile token<br />\n  was shared (one per profile), posted as a QR, and doubled as the operating<br />\n  credential; combined with on-screen (public) device UUIDs and a device-secret that<br />\n  auto-issued to whoever polled <code>/session</code> first, a leaked low-value token + a glimpsed<br />\n  UUID could claim a higher-value bound device's secret during the pre-secret window.<br />\n  v0.5 removes the coupling at the root (see below). Verified end-to-end on the dev<br />\n  stack: a foreign enroll token + a victim UUID with no/wrong nonce now gets<br />\n<code>ec_bad_register_nonce</code> and no secret.</li>\n<li><strong>Secret is now the sole operating credential.</strong> Neither a profile token nor an<br />\n  enroll token can drive a station request. Operating endpoints require a valid<br />\n<code>X-EEA-Checkin-Secret</code>; an enroll token can no longer even <em>read</em> a live station's<br />\n  config once a key has been claimed (<code>ec_secret_required</code>).</li>\n<li>Removed the <code>setSecretHash()</code> repo method — resetting a key now goes through<br />\n<code>requireReenroll()</code>, which clears the nonce and returns the device to Pending so a<br />\n  key can never be silently re-auto-issued into a reopened window.</li>\n</ul>\n<h3>Added</h3>\n<ul>\n<li><strong>First-class enrollment links (Schema v6).</strong> A new \"Enrollment Links\" admin page<br />\n  manages enrollment credentials decoupled from profiles: label, optional suggested<br />\n  profile (pre-fills the approval binding), optional expiry, revoke/delete, and a<br />\n  re-displayable QR (<code>/checkin/#e=&lt;token&gt;</code>). Deleting a link never breaks already-<br />\n  approved devices — they operate by their secret.</li>\n<li><strong>Nonce-sealed secret claim.</strong> <code>register-device</code> returns a one-time <code>register_nonce</code><br />\n  only on first registration (a repeat register for a known UUID is a touch — no new<br />\n  nonce, the anti-hijack property). The device-secret is then issued only to the holder<br />\n  of that nonce, atomically (<code>UPDATE … WHERE DEV_secret_hash IS NULL</code>), so a racing<br />\n  second caller is shut out.</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>Profiles page drops every launch-link affordance (the profile token is now dormant);<br />\n  there is nothing to copy, show, or regenerate there.</li>\n<li>\"Reset device key\" → \"Reset &amp; require re-enrollment\": clears the secret <em>and</em> nonce,<br />\n  returns the device to Pending, preserves the binding.</li>\n<li>Station PWA boots from <code>#e=</code> enrollment links, captures the nonce and secret, and<br />\n  authenticates secret-only thereafter; new screens for <code>ec_bad_enroll</code>,<br />\n  re-enroll-needed, <code>ec_secret_required</code>, unbound-device, and the dead <code>#t=</code> link.</li>\n<li>Device approval is now a modal that <strong>requires a nickname and a station/location</strong><br />\n  before approving — no more approve-then-it-does-nothing unbound devices.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li>Station no longer shows \"Offline — waiting for connection\" for server-side search<br />\n  responses (<code>ec_validation</code> query-too-short, <code>ec_out_of_scope</code>, <code>ec_not_found</code>). The<br />\n  offline banner is now reserved for genuine network failures; HTTP error responses<br />\n  render inline (empty results / a toast). Pressing Enter also respects the profile's<br />\n  minimum search length instead of firing a too-short query.</li>\n</ul>\n<h2>[0.4.0] — 2026-06-11</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Approve-to-anywhere (bootstrap-only)</strong>: approving a device bound to any profile<br />\n  brings it up as that station on its next poll — the binding is authoritative during<br />\n  the enrollment window (no secret issued yet) and after an admin key reset. Once a<br />\n  device holds its secret, token-only requests can no longer inherit the binding<br />\n  (UUIDs are visible on-screen; the secret is the device credential).</li>\n<li><strong>Persistent launch links</strong>: profile tokens stored encrypted at rest<br />\n  (libsodium, key derived from wp-config salts) — \"Show Launch Link\" re-displays<br />\n  the URL + QR anytime; each view audited. Pre-existing profiles offer<br />\n  regenerate-to-view. Schema v5.</li>\n<li>Approval dropdown warns when binding a device to a different profile than it<br />\n  enrolled under; Devices list gains Last Clerk column and Last Active rename.</li>\n</ul>\n<h3>Fixed</h3>\n<ul>\n<li>Waiting-room poll no longer swallows auth errors (stuck \"Pending Approval\").</li>\n<li>Service worker cache is version-stamped at serve time — plugin updates now<br />\n  actually reach installed devices (<code>ignoreSearch</code> removed, <code>no-cache</code> on sw.js).</li>\n<li>Token rotation confirmation states exactly which devices are and aren't affected.</li>\n</ul>\n<h2>[0.3.0] — 2026-06-11</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Device-secret auth</strong>: the launch-URL token is now an enrollment ticket. On first<br />\n  approved session each device receives a one-time-delivered secret and authenticates<br />\n  with it thereafter; profile comes from the device's server-side binding. Installed<br />\n  homescreen PWAs survive token rotation; reassigning a bound device is zero-touch.<br />\n  \"Reset device key\" admin action; <code>navigator.storage.persist()</code> at boot.</li>\n<li><strong>Distinct failure states</strong>: <code>ec_token_rotated</code> (detected via previous-hash<br />\n  tracking), <code>ec_profile_revoked</code>, <code>ec_profile_expired</code>, <code>ec_profile_not_started</code> —<br />\n  each with its own station screen saying what to do, replacing flattened<br />\n<code>ec_bad_token</code>.</li>\n<li>Schema v4 (<code>DEV_secret_hash</code>, <code>PRO_token_hash_prev</code>), reactivation-free.</li>\n</ul>\n<h2>[0.2.0] — 2026-06-11</h2>\n<h3>Added</h3>\n<ul>\n<li><strong>Ticket-class scoping</strong>: profiles limit stations to tickets whose <code>TKT_class</code><br />\n  tokens match PCRE patterns (gate=<code>attendee</code>, merch=<code>apparel-</code>, meals=<code>meal-</code>);<br />\n  enforced in both search and check-in; save-time regex validation.</li>\n<li><strong>Datetime pinning</strong>: <code>current</code> mode resolves the active datetime by clock —<br />\n  stations show no picker.</li>\n<li><strong>Cascade check-in</strong>: optional per-profile; checking in consumes all datetimes<br />\n  the ticket grants (full-event regs check in once). Checkout never cascades.</li>\n<li><strong>Device→profile binding</strong>: devices may be pinned to one profile at approval or<br />\n  via Devices→Edit; mismatched tokens are rejected and the station explains itself.</li>\n<li><strong>Clerk roles + manager override</strong>: force check-in of non-approved registrations<br />\n  requires a manager — directly, or via a manager-badge override scan recorded in<br />\n  the audit row alongside the acting clerk. MANAGER tag on badges.</li>\n<li><strong>Bulk clerk creation</strong> (all-or-nothing batches, <code>Name, CODE, manager</code> lines) and<br />\n<strong>bulk badge printing</strong> (card grid with cut lines).</li>\n</ul>\n<h3>Changed</h3>\n<ul>\n<li>Admin mutations moved to <code>load-{page}</code> hooks (proper redirect-after-POST).</li>\n<li>Clerk sign-in: visible text input with paste + wedge-scan + keypad.</li>\n<li>Schema v3 (<code>DEV_profile</code>, <code>CLK_role</code>) via reactivation-free migrations.</li>\n</ul>\n<h3>Removed</h3>\n<ul>\n<li>Station checked-in counter (and its per-poll SQL count).</li>\n</ul>\n<h2>[0.1.0] — 2026-06-11</h2>\n<h3>Added</h3>\n<ul>\n<li>Design spec (docs/SPEC.md): data model, REST API, auth flows, station PWA, admin UI.</li>\n<li>Foundation: profiles/devices/clerks/audit tables, opaque-token auth, device<br />\n  approval handshake, HMAC clerk sessions.</li>\n<li>REST layer (<code>eea-checkin/v1</code>), ranked 5-field search, explicit-direction check-in<br />\n  over EE models, append-only audit log.</li>\n<li>Station PWA (fullscreen manifest, service worker, audio cues, camera scanning),<br />\n  wp-admin management pages, Mode A drop-in admin station.</li>\n<li>Docker dev stack with EE core + seeder.</li>\n</ul>"
  }
}
