Files
foh-updates/eea-foh.json
T
ee-extensions release bot 94baf897c4 release eea-foh 0.22.1
2026-06-23 18:38:01 +00:00

30 lines
66 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>AZ 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>AZ search bar</strong>: letters-only (AZ, 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 AZ 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>AZ search bar refined</strong>: letters-only (AZ, 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 AZ 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>AZ search bar</strong>: an opt-in toggle (small/portrait screens) swaps the soft keyboard<br />\n for a vertical right-edge AZ 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>"
}
}