Full changelog for GSheet Membership.
The most recent entries also appear in readme.txt.

== Changelog ==
= 4.5.8 =
* Multi-address roster cells now split on any separator: a second email address added on a new line inside the cell, or separated only by a space, is now recognized. Previously only commas and semicolons split, so an address on its own line silently fused with its neighbor and disappeared from logins, rosters, and email lists.

= 4.5.7 =
* Escaped result-box output at the point it's printed, rather than relying on callers to have already done so, resolving a Plugin Check finding. No visible change.

= 4.5.6 =
* Action-result boxes can now be placed next to the exact form or buttons that produced them: a result may carry a placement tag, and the page renders tagged results at the matching spot (used by the Pro Group Email send/test buttons). Untagged results keep rendering under the page title.

= 4.5.5 =
* Action results and save confirmations now appear inline on the plugin's own admin pages — directly under the page title, next to the forms that triggered them — instead of at the very top of the admin screen.

= 4.5.4 =
* Admin feedback (save confirmations, action results, upload results, the Stripe connection status and the webhook-failure warning) is now shown in the plugin's own message boxes instead of standard WordPress notice banners. Admin-cleanup and white-label plugins commonly hide or relocate every standard notice banner, which silently swallowed this feedback on affected sites.

= 4.5.3 =
* Admin notices (save confirmations, error reports, action results shown after a redirect) now survive hosts whose object cache drops transients: each notice is also stored as a short-lived option and read from there first, so redirect round-trip feedback no longer disappears silently.

= 4.5.2 =
* Reworded comments and documentation describing what the Pro add-on supplies, consistently phrased as capability it adds on top of the free plugin, never as something the free plugin restricts or withholds. No functional changes.

= 4.5.1 =
* Free-checkout polish: when a free ($0) signup can't be recorded, the buyer now always sees the generic translated failure message — the specific reason stays in the admin purchase log, matching every other checkout failure. Internal documentation now lists the 'free' gateway.

= 4.5.0 =
* Free ($0) memberships are now supported: enter 0 in a Pricing Matrix cell to offer that combination at no charge. Buyers who pick it skip the payment step entirely — the membership is recorded and the confirmation email sent immediately, and the picker shows "Free" instead of a zero amount. A blank cell still means not-for-sale.

= 4.4.2 =
* The Membership Levels block on the Pricing Matrix screen is now its own form with a "Save Levels" button for choosing which levels are offered for purchase. The price grid below keeps its own Save button for prices only.

= 4.4.1 =
* The plugin no longer stores any Stripe API keys of its own — card payments on the free plugin always run through the hosted "Connect with Stripe" onboarding. Sites using their own Stripe keys via the Pro add-on are unaffected: the add-on now stores those keys itself. The PayPal enable toggle likewise moved to the Pro add-on's settings defaults — the free plugin no longer defines any PayPal setting.

= 4.3.6 =
* A fresh install's two default membership levels are now 1-Year and Lifetime (previously 1-Year and 3-Year). Existing sites are unaffected — the defaults only apply when no levels have been saved yet. Pairs with 4.3.5: a Lifetime member on a stock free install can change their delivery/postage at the correct price differential, no Pro add-on required.

= 4.3.5 =
* Existing members of a non-expiring level can now change their delivery/postage option without the Pro add-on: the plugin computes the price differential itself and charges exactly that. Same-option and lower-priced requests are declined with clear messages. The Pro add-on's role narrows to gift-upgrade validation.

= 4.3.4 =
* Readme: added a Mailchimp entry to the External services disclosure section (Pro's optional Group Email delivery method). No functional changes.

= 4.3.3 =
* Wording: the plugin header description now reflects the current Pro feature set (level editor, Mailchimp delivery option, authenticated mailbox sending, PDF watermarks, large-file links). No functional changes.

= 4.3.2 =
* Wording: the Pro add-on summaries (settings-page teaser card and readme) now mention the optional Mailchimp delivery for Group Email. No functional changes.

= 4.3.1 =
* Wording: the Pro teaser card on the Access settings page now describes the Pro add-on's level editor instead of the outdated "multi-year and Lifetime durations" line (3-Year is included in the free plugin as of 4.3.0).

= 4.3.0 =
* A fresh install now ships two ready-to-sell membership levels, 1-Year and 3-Year. Existing sites keep their current levels unchanged.
* Wording corrections across the Pricing Matrix screen and readme: the Pro add-on's boundary is a level editor (create, rename, remove, redefine levels, including Lifetime) — not a level count.

= 4.2.17 =
* Plugin Check compliance: annotated the intentional use of WordPress's DONOTCACHEPAGE caching convention on the login page so it is no longer flagged as an unprefixed plugin constant. No functional changes.

= 4.2.16 =
* Wording refinements on the Pricing Matrix screen.

= 4.2.15 =
* Wording: the readme no longer references any predecessor plugins — the description, installation and FAQ present the plugin on its own. Two admin hints were reworded to point at the Access settings page. No functional changes.

= 4.2.14 =
* Wording: the plugin description and the add-on feature list now say the Pro and Chapters add-ons ADD functionality (they do not "unlock" anything in this plugin).

= 4.2.13 =
* Card payments run through the hosted Stripe Connect onboarding — connect your account and you're done, no API keys to paste. The own-keys option (commission-free processing with your own Stripe API keys) is part of the GSheet Membership Pro add-on, which adds its key settings to the Payments screen. Any keys saved earlier are preserved.

= 4.2.12 =
* The Pricing Matrix screen now has a real "Offer this level for purchase" checkbox on every level, so availability is under the site admin's direct control on any site, free or Pro.
* Card payments now always use the site's own Stripe API key as soon as one is saved; the hosted Connect route is only a convenience default until then, for every site.
* Settings-save hooks now receive fully sanitized data instead of the posted form.
* Cleanup: removed a leftover no-op level-save stub and reworded add-on preview cards and internal comments.

= 4.2.10 =
* Caching-plugin compatibility on the login page: the page carrying the login form now also sets the standard WordPress "do not cache this page" flag (DONOTCACHEPAGE), which cache plugins that ignore no-cache headers still honor. Without it, a cached copy of the page could replay the same stale error message for every attempt. If messages still look frozen after updating, purge your caching plugin once — copies stored before this update are served until cleared.

= 4.2.9 =
* The login-code form now says why a code was refused: a wrong entry shows how many attempts remain before the code is invalidated, and once the limit is hit the visitor is sent back to the email step with a clear message to request a fresh code (instead of the generic "incorrect or expired" text repeating until lockout).

= 4.2.8 =
* Login security: a one-time login code now allows at most 5 wrong guesses. After the fifth wrong entry the code is invalidated and the visitor must request a fresh one (which is itself limited to 5 sends per 15 minutes), closing the window for guessing a code by brute force before it expires.

= 4.2.7 =
* Electronic-only purchases now default to the strict US ZIP rule, with a new "My address is outside the United States" checkbox that switches the box to international postal codes. Ticking it opens the same international address fields (foreign state/province, country, zone — whichever sheet columns are configured in the settings) used by the international postage options, and hides the US State field. The checkbox appears only for electronic delivery — print options already declare the country via the postage choice — and it ticks itself for returning buyers whose on-file postal code is clearly international.

= 4.2.6 =
* Plugin Check cleanup: the login verification-code read is reordered so the sanitizer visibly wraps the input for static analysis (same runtime result), and the password-field reader's documented exemptions now cover both scanner warnings (nonce is verified by every calling handler). No behavior changes.

= 4.2.5 =
* Clearer admin wording on the Pricing and Levels screens for levels not currently offered for purchase. Text only — no behavior changes.

= 4.2.4 =
* Even stricter live screening on the purchase form: with a US delivery selected, the ZIP box only accepts digits and one hyphen (max 10 characters — pasting "abc12345def" leaves "12345"), switching back to international delivery restores letters for postal codes like "K1A 0B1", and the phone box stops accepting digits at the 15-digit international maximum.

= 4.2.3 =
* The phone and ZIP boxes on the purchase form now screen entries live, like most online stores: characters that can never be valid don't appear as you type, an inline message shows the moment you leave a box with an invalid entry (clearing instantly when fixed), and the ZIP rule switches automatically between the strict US format and international postal codes based on the chosen delivery. The server-side checks added in 4.2.2 remain the final authority.

= 4.2.2 =
* Security hardening: every admin form input is now unslashed and sanitized immediately where it is read, before being handed to hooks, filters, or save routines (WordPress.org plugin review guidance).
* Checkout and profile updates now validate phone numbers (7–15 digits, common formatting accepted) and postal codes (strict 12345 / 12345-6789 format for US-domestic shipping, flexible international format elsewhere) instead of accepting arbitrary text.

= 4.2.1 =
* Internal maintenance: the two admin page addresses that still used the old short prefixes were renamed — Settings is now at admin.php?page=gsma-settings and Sales at admin.php?page=gsms-sales (submenus follow). Old bookmarks to these admin pages need a one-time update; nothing else changed.

= 4.2.0 =
* Membership level management (creating, renaming and removing levels) moved to the Pro add-on's Level Manager.
* Whether a level is offered for purchase is now a per-level "available for purchase" data flag stored with the level itself; the free plugin simply honors it, and levels switched off are greyed out with their prices, codes and existing members untouched.
* Requires GSheet Membership Pro 1.8.0 or newer for level management on Pro sites.

= 4.1.13 =
* Translation readiness: every text on the Sales admin screens (Pricing Matrix, Code Matrix, Levels, Payments, Settings, Recent Purchases) is now wrapped for translation, so non-English sites can localize the full admin experience. No behavior changes.

= 4.1.12 =
* Internal maintenance: the free plugin no longer names any add-on directly — Pro and Chapters (and any future add-on) are detected through single compatibility seams, so add-on updates can never require a free-plugin change. No behavior changes.

= 4.1.11 =
* Upgrading to Pro no longer breaks card checkout for sites that set up Stripe through the hosted "Connect with Stripe" onboarding: payments keep routing through the connected account (the platform commission still applies) until the site's own Stripe API keys are saved, and the Payments screen warns about this in-between state.

= 4.1.10 =
* Readme restructure: how-to and reference sections moved under Installation and FAQ so the plugin directory no longer truncates the description (its 2,500 limit is measured in words, and unrecognized sections count toward it).

= 4.1.9 =
* Internationalization: translator comments added to every translatable message with a placeholder, and the readme description shortened so the plugin directory no longer truncates it (setup steps moved under Installation).

= 4.1.8 =
* All text in the new sheet code check and the Recent Purchases "Previous" column is now translatable.

= 4.1.7 =
* Lifetime (non-expiring) members upgrading their membership no longer see cheaper options at all — downgrades are hidden instead of greyed out. Equal-price options remain visible, so the picker shows the member's current combination tagged "current". If nothing pricier exists, the picker says so; if the current combination has no configured price, it explains that upgrades can't be offered.

= 4.1.6 =
* The Code Matrix screen now checks every configured code against the membership codes actually in the sheet and warns about codes that match no roster row — the usual sign of a typo that silently breaks renewals, lifetime detection, and gifts. If the sheet can't be read, an honest "couldn't check" notice is shown instead.
* Reverted the 4.1.5 bare-member-number fallback: bare numbers in the membership column are no longer treated as codes. The real fix for unrecognized members is correcting the code matrix — which the new check now surfaces.

= 4.1.5 =
* Lifetime members whose roster row stores only a bare member number (e.g. "L" or "L42") instead of a full membership code are now recognized: they get the "membership is active — no purchase needed" screen and their level pre-selected in the upgrade picker. The delivery tier of such legacy rows is unknown, so the lowest (electronic) tier is assumed as the upgrade baseline — the member is never overcharged.

= 4.1.4 =
* Recent Purchases now records and displays the member's PREVIOUS expiration alongside the new one ("none" for a brand-new registration), so any problem transaction can be undone straight from the log.

= 4.1.3 =
* Security/correctness: the Stripe webhook now also rejects events whose live/test mode doesn't match the configured Stripe mode — a test-mode "paid" session (no real money) can no longer update the membership sheet on a live site. The Connect relay service applies the same check.

= 4.1.2 =
* Security/correctness: the Stripe webhook now ignores checkout sessions whose payment_status is not "paid" — a session that completes without payment (asynchronous methods, forged or stale events) can no longer update the membership sheet.

= 4.1.1 =
* Admins now see a dashboard warning when the Pro add-on is older than 1.7.0 (which would silently hide the gift option from the purchase form after the 4.1.0 split).

= 4.1.0 =
* All gift-purchase logic, UI, and validation moved out of the free plugin into the Pro add-on. The free plugin keeps only generic extension seams (filters/actions on the purchase picker, checkout request, and webhook processing) — without Pro, gift metadata on a payment is ignored and processed as a normal self-purchase.

= 4.0.5 =
* Removed the one-time pre-4.0.0 settings transfer helper (no longer needed; all supported upgrade paths now start from the level registry).

= 4.0.4 =
* Fix: the 4.0.3 chapter annual-mode detection checked the wrong class name for the Chapters add-on, so the override was still not honoured on live sites. Now detected correctly.

= 4.0.3 =
* Fix: Sales now honours the Chapters add-on's calendar-year (annual mode) override. Previously a chapter site with the override ON showed annual mode as "off" on the Sales settings page and computed rolling (date-based) expirations at purchase time.
* The Sales settings page notes when the detected annual mode comes from the chapter override.

= 4.0.2 =
* Internationalization: all error messages (Stripe, webhook, Google Sheets, Connect service) are now translatable instead of hardcoded English.

= 4.0.1 =
* Membership Levels manager: new Order column — set where each level appears in the picker (e.g. slot a 2-Year level between 1-Year and 3-Year).
* Membership Levels manager: the Pro-only flag is no longer editable. Existing levels keep their flag; newly added levels are always Pro-only, so a site that later deactivates Pro never keeps selling them on the free tier.

= 4.0.0 =
* Membership levels are now fully data-driven. Levels (slug, label, duration in years or "never expires", member-number prefix) live in an editable registry instead of a fixed 1-year/3-year/lifetime list.
* The free plugin ships with a single 1-Year level out of the box. With the Pro add-on active, admins can add, rename, re-time and remove levels (e.g. a custom 2-Year level) on the Pricing Matrix screen — no code changes needed.
* Every level now has its own row in the Membership Code Matrix, so a stored code always identifies the exact level it was purchased under (1-year and 3-year no longer share codes).
* "Lifetime" is now simply a level with no expiration; upgrade rules for non-expiring levels are unchanged.
* Existing pricing, codes and labels are converted automatically on upgrade.
* Removed: pre-3.3.0 legacy aliases — old-prefix shortcodes, the /wp-json/gma-sales/v1/ webhook routes, ?gms_gift/?gms_extend links, and the one-time prefix-rename migration. Use the current gsma_/gsms_ names.

= 3.6.0 =
* Stripe Connect: the connection link now stays valid for 2 hours, so first-time Stripe onboarding (business profile, bank details) can finish without the link expiring.
* Stripe Connect: an expired connection link now shows a clear "click Connect again" message instead of a cryptic error code.
* Stripe Connect: the connection security token is now stored in a regular option instead of a transient, so hosts whose object caches drop transients can complete the connection.
* Fixed a stray backslash in the commission text on the Payments screen.

= 3.5.0 =
* NEW — Stripe Connect for the free tier: the Payments screen now offers **Connect with Stripe** instead of secret-key fields. Onboarding is hosted by Stripe (Standard connected account); no API keys to paste. **Pricing disclosure:** connected free sites pay a **2% platform commission** per card sale (shown on the Payments screen), on top of Stripe's own processing fees. The commission is computed server-side by the hosted platform service when each Checkout Session is created — never by the plugin.
* The **Pro add-on removes the commission entirely** (own API keys, 0% platform fee, permanently). Deactivating Pro reverts to Connect mode without losing settings; activating Pro restores your own-key configuration exactly.
* Legacy free installs with saved keys keep working unchanged until they connect.
* Refunds from Recent Purchases work on connected accounts — routed via the platform so the commission is reversed proportionally.
* Connect-mode webhooks arrive relayed by the platform service, signed per-site with the same HMAC scheme as direct Stripe deliveries.
* PayPal (Pro) is unchanged and commission-free.

= 3.4.1 =
* UI: the Authenticated Mailbox card (Pro add-on) — and its locked preview without Pro — now renders directly above the Email Verification (OTP) card it governs, instead of below the settings form. Technically: a new gsma_admin_before_otp_card action renders the card in place, while its controls belong to a detached form (HTML form="" attribute) emitted on gsma_admin_after_form, so the page never nests forms and the two forms' fields stay fully separate.
* UI: the Pro Add-on teaser card now lists the complete Pro feature set — including authenticated mailbox sending (all plugin email from your own mailbox, the spam fix), PayPal checkout, gift purchases, multi-year & lifetime durations, and the admin refund/retry tools.

= 3.4.0 =
* New: delivery seam for add-ons — verification-code (OTP) and purchase-confirmation emails now pass through the gsma_pre_send_mail / gsms_pre_send_mail filters before wp_mail(), so the Pro add-on can deliver them through its connected mailbox (aligned SPF/DKIM — the fix for these emails landing in spam). Without Pro, or with the Pro option off, delivery is exactly as before.
* New: gsma_admin_after_form action lets add-ons render standalone cards (with their own forms) below the main settings form; Pro uses it for its new Authenticated Mailbox card.
* UI: the settings page now shows a preview of the Pro Authenticated Mailbox feature when the Pro add-on is not active, and the OTP From-field hints explain that Pro mailbox routing bypasses them.

= 3.3.1 =
* Compliance: all frontend and admin CSS/JS is now loaded through wp_enqueue_style()/wp_enqueue_script() from files under assets/ instead of inline <style>/<script> blocks (WordPress.org review requirement). No visual or behavioural change: the "force original colors" overrides still load conditionally after the base styles, the login form font-scale setting is applied as a tiny inline addition to the enqueued stylesheet, and the verification-code countdown/paste-cleanup script now ships as its own file.
* Security: uploads to the protected folder now go through the core wp_handle_upload() API (with a scoped upload_dir filter) instead of a custom move.
* Security: the OTP debug log now records codes as short hashes, never plaintext, and the GSMA_DEBUG_OTP fallback default is false — logging always requires both the wp-config constant and the admin toggle.
* Cleanup: removed the inert Freemius scaffold (placeholder credentials, no SDK) and the last NOWDOC block.
* Deliverability: the verification-code email no longer forces a From header by default — SMTP/mail plugins keep control of the authenticated sender (SPF/DKIM stays aligned). Setting a sender name/address in OTP settings is now an explicit opt-in override.
* Removed: the 3.0.x legacy-plugin handover code that silently deactivated the former separate Access/Sales plugins (and pre-rename copies). All installs migrated long ago, and a plugin should not deactivate other plugins.

= 3.3.0 =
* Compliance: all identifier prefixes renamed to satisfy the WordPress.org 4-character prefix requirement (the underscore separator doesn't count): gma_ → gsma_, gms_ → gsms_ (classes, functions, constants, options, transients, hooks, AJAX actions, shortcodes, REST routes). The unused GM_VERSION constant was removed.
* Back-compat: existing installs migrate automatically — stored settings, the purchase log, pricing/code matrices and caches are renamed one-time on upgrade; the old shortcode names ([gma_membership_purchase], [gma_user_info], [gma_expiration_notice], [gma_login_form], [gma_protected_link], [gms_admin_picker]) keep working as aliases; the old Stripe/PayPal webhook URLs (/wp-json/gma-sales/v1/...) remain registered so no gateway reconfiguration is needed; old ?gms_gift/?gms_extend links still work.

= 3.2.1 =
* Cleanup: removed a call to openssl_free_key(), deprecated since PHP 8.0 (key objects are garbage-collected automatically).
* Fix: the purchase picker misclassified any logged-in member whose roster row has no expiration date (e.g. legacy print rows) as a lifetime member, showing "Your lifetime membership is active — no purchase needed" instead of the picker. Lifetime status is now decided from the membership code itself, matching the webhook's logic.


= 3.2.0 =
* **WordPress.org compliance: all Pro-exclusive code now ships only in the Pro add-on.** The admin refund tool, the failed-sheet-write retry, gift memberships (recipient check, both gift emails and their templates), the 3-year/lifetime upgrade evaluator and the entire PayPal integration (checkout, buyer return, webhooks, credential settings) were physically moved out of this plugin into GSheet Membership Pro. Without Pro these features are absent — not merely disabled; with Pro active, behaviour is unchanged. Stored settings (PayPal credentials, gift email templates) are preserved either way and come back when Pro is activated.
* Documentation: the plugin header description and the admin help text now also name Group Email and Email-in as Pro add-on features, matching the readme (all three self-descriptions agree on what is free vs Pro).

= 3.1.6 =
* Documentation: the readme now clearly marks PayPal checkout, multi-year/Lifetime membership durations, and Group Email / Email-in as Pro add-on features. No code changes.

= 3.1.4 =
* Plugin Check cleanup, round two: the last remaining warning on the custom login redirect was resolved, so the plugin now passes the official WordPress.org Plugin Check with zero errors and zero warnings.

= 3.1.3 =
* **Plugin Check cleanup for WordPress.org review.** Fixed all 26 errors and the actionable warnings reported by the official Plugin Check tool: ordered i18n placeholders and translators comments; escaped every flagged output; replaced move_uploaded_file()/rename()/unlink() with WordPress file APIs; disabled suppress_filters; added wp_unslash()+sanitization to all flagged inputs; "Tested up to" raised to 7.0; readme title now matches the plugin header; changelog and upgrade notices trimmed to WordPress.org limits (full history now ships in changelog.txt).

= 3.1.2 =
* **WordPress.org submission prep.** Plugin URI now points to the plugin's WordPress.org page (was a placeholder), the readme short description was shortened to fit the 150-character listing limit, and "Tested up to" was raised to 6.8. No functional changes.

= 3.1.1 =
* **Fix: fatal error when activating next to a still-installed pre-rename copy.** The old `gsheet-membership` plugin declares the same legacy-handover helper functions and loads first, so activating the renamed plugin crashed with a "cannot redeclare function" error before the self-heal guard could run. The helpers are now declared conditionally; the renamed plugin loads, deactivates the old copy automatically, and shows the usual handover notice.

= 3.1.0 =
* **Plugin slug renamed to `membership-google-sheets`** ahead of WordPress.org distribution (folder, main file and text domain). The display name is unchanged. All options, data and add-ons carry over; if an old `gsheet-membership` copy is still active after updating, it is deactivated automatically with a notice.
* **Readme: new "Built for chapter-based organizations" section**, updated tagline and tags highlighting Google Sheets and chapter support.

= 3.0.9 =
* **Docs: transaction-fee policy disclosed.** A new FAQ states that the free plugin currently charges no transaction fee, that a future version may introduce an optional Stripe Connect flow with a small, clearly-disclosed platform fee, and that the Pro add-on will always remain commission-free. No functional changes.

= 3.0.8 =
* **Naming cleanup: all descriptions, screen headings and error messages now say "GSheet Membership"** instead of referencing the former separate *GSheet Membership Access* and *GSheet Membership Sales* plugins (merged in 3.0.0). The Access settings heading, Google Sheets error messages, protected-uploads marker, and the Sales settings hints were updated; historical changelog entries are unchanged. No functional changes.

= 3.2.0 =
* **WordPress.org compliance: all Pro-exclusive code now ships only in the Pro add-on.** The admin refund tool, the failed-sheet-write retry, gift memberships (recipient check, both gift emails and their templates), the 3-year/lifetime upgrade evaluator and the entire PayPal integration (checkout, buyer return, webhooks, credential settings) were physically moved out of this plugin into GSheet Membership Pro. Without Pro these features are absent — not merely disabled; with Pro active, behaviour is unchanged. Stored settings (PayPal credentials, gift email templates) are preserved either way and come back when Pro is activated.
* Documentation: the plugin header description and the admin help text now also name Group Email and Email-in as Pro add-on features, matching the readme (all three self-descriptions agree on what is free vs Pro).

= 3.1.6 =
* Documentation: the readme now clearly marks PayPal checkout, multi-year/Lifetime membership durations, and Group Email / Email-in as Pro add-on features. No code changes.

= 3.1.4 =
* Plugin Check cleanup, round two: the last remaining warning on the custom login redirect was resolved, so the plugin now passes the official WordPress.org Plugin Check with zero errors and zero warnings.

= 3.1.3 =
* **Plugin Check cleanup for WordPress.org review.** Fixed all 26 errors and the actionable warnings reported by the official Plugin Check tool: ordered i18n placeholders and translators comments; escaped every flagged output; replaced move_uploaded_file()/rename()/unlink() with WordPress file APIs; disabled suppress_filters; added wp_unslash()+sanitization to all flagged inputs; "Tested up to" raised to 7.0; readme title now matches the plugin header; changelog and upgrade notices trimmed to WordPress.org limits (full history now ships in changelog.txt).

= 3.1.2 =
* **WordPress.org submission prep.** Plugin URI now points to the plugin's WordPress.org page (was a placeholder), the readme short description was shortened to fit the 150-character listing limit, and "Tested up to" was raised to 6.8. No functional changes.

= 3.1.1 =
* **Fix: fatal error when activating next to a still-installed pre-rename copy.** The old `gsheet-membership` plugin declares the same legacy-handover helper functions and loads first, so activating the renamed plugin crashed with a "cannot redeclare function" error before the self-heal guard could run. The helpers are now declared conditionally; the renamed plugin loads, deactivates the old copy automatically, and shows the usual handover notice.

= 3.1.0 =
* **Plugin slug renamed to `membership-google-sheets`** ahead of WordPress.org distribution (folder, main file and text domain). The display name is unchanged. All options, data and add-ons carry over; if an old `gsheet-membership` copy is still active after updating, it is deactivated automatically with a notice.
* **Readme: new "Built for chapter-based organizations" section**, updated tagline and tags highlighting Google Sheets and chapter support.

= 3.0.9 =
* **Docs: transaction-fee policy disclosed.** A new FAQ states that the free plugin currently charges no transaction fee, that a future version may introduce an optional Stripe Connect flow with a small, clearly-disclosed platform fee, and that the Pro add-on will always remain commission-free. No functional changes.

= 3.0.8 =
* **Naming cleanup: all descriptions, screen headings and error messages now say "GSheet Membership"** instead of referencing the former separate *GSheet Membership Access* and *GSheet Membership Sales* plugins (merged in 3.0.0). The Access settings heading, Google Sheets error messages, protected-uploads marker, and the Sales settings hints were updated; historical changelog entries are unchanged. No functional changes.

= 3.0.7 =
* **Fix: in chapter mode, all sales features now use the chapter's own roster sheet instead of the main sheet.** Previously purchases were always recorded on the main roster sheet — and the sales feature was disabled whenever the site lacked Editor access to the main sheet — even on chapter-mode sites, whose local roster IS the chapter sheet (chapter sites typically can only read the national main sheet). Now the checkout gates, the Sales admin warning, the purchase writes (new rows and renewals), member/index numbering, purchase-date stamping, contact-info prefill, the admin "No Change" profile update, and the refund "clear membership code" tool all target the site's primary roster: the chapter sheet in chapter mode, the main sheet otherwise.
* The Sales "Expiration column override" setting, when left blank, now inherits the chapter roster's expiration column on chapter-mode sites.

= 3.0.6 =
* **New: the Membership Sales admin pages now show a prominent warning when the roster sheet is not shared with Editor access.** Purchases are recorded by writing to the roster sheet, so without Editor rights the whole sales feature is disabled. Previously this state was only visible on the public purchase page; now every Sales admin sub-page (Pricing Matrix, Code Matrix, Payments, Settings, Recent Purchases) shows a red warning explaining that sales are disabled, exactly what visitors see instead of the picker, and how to fix the share. Sales re-enable automatically within about two minutes of granting Editor access.
* The public purchase-page notice now reads "Online membership sales are temporarily unavailable" for clarity.

= 3.0.5 =
* **Documentation: all setup and sharing guidance now says Editor access, not Viewer.** Since 3.0.4 (and Pro 1.1.3) the Sales and Group Email features require the service account to have Editor (write) rights on the roster sheet, but the setup steps, hints, and error messages still said "Viewer access is enough". They now recommend sharing as an Editor, and note that Viewer is enough only if the site uses page protection alone.

= 3.0.4 =
* **Security: membership sales are now disabled whenever the site cannot WRITE to the roster sheet.** Purchases are recorded by writing the buyer's row to the roster, so if the service account loses (or never had) Editor rights on the sheet, the purchase picker, the public checkout endpoint, and the admin "apply membership" tool all refuse up front — buyers can no longer pay for a membership the site could not record. Admins see the exact reason on the purchase page; a fixed share is picked up again within about two minutes.

= 3.0.3 =
* **Fix: activating the merged plugin while the old Access/Sales plugins were still active crashed with a fatal error** instead of deactivating them automatically. The old plugins declare the same start-up function names as this plugin, and PHP refuses to load two functions with the same name — the crash happened before the 3.0.1 safety guard could run, so the guard (and its "self-heal on next page load" promise) never actually engaged. The plugin's start-up functions are now declared defensively, so activation alongside the old plugins works as documented: the old plugins are deactivated automatically and an admin notice asks you to delete them. If activation previously failed for you, simply activate the plugin again — no other action needed.

= 3.0.2 =
* **Fix: Group Email (Pro) failed on Main-site installs with a leftover chapter setting.** If a site had ever been in Chapter mode (or stored a stale chapter setting while the Chapters add-on was deactivated), the Pro Group Email screen still tried to draw recipients from the chapter roster and errored, even though the settings page correctly showed Main site mode. The runtime mode now matches what the settings page shows: Chapter mode only applies when the Chapters add-on is actually active.

= 3.0.1 =
* **Fix: old plugins left active after upgrading to the merged plugin.** 3.0.0 only deactivated the old Access/Sales plugins when they were installed in exactly-named folders; copies installed from versioned zips (e.g. `gsheet-membership-access-2.2.0/`) were missed, stayed active, and crashed the merged plugin with duplicate-class errors. The plugin now finds the old plugins in any folder, deactivates them automatically (self-healing on the next admin page load, not just at activation), and shows an admin notice asking you to delete the old entries from the Plugins screen.
* Reminder: WordPress never deletes plugin files automatically &mdash; after upgrading, delete the deactivated *GSheet Membership Access* and *GSheet Membership Sales* entries from the Plugins screen. Your settings and data live in the database and are not affected.

= 3.0.0 =
* **The free Access and Sales plugins are now ONE plugin: GSheet Membership.** Install and activate a single plugin instead of two; activation automatically deactivates the old separate plugins. All settings, shortcodes (`[gsma_membership_purchase]`, `[gsms_admin_picker]`, `[gsma_user_info]`, …), REST webhook URLs, admin pages, hooks and stored data are unchanged — nothing to reconfigure. The Pro and Chapters add-ons continue to work as before.

= Earlier Access releases =

= 2.2.0 =
* **Settings page reorganised:** the "Membership Expiration — Main Roster" card now sits directly after the Main Roster card, making clear that its rules apply only to the main roster.
* **Per-roster expiration rules.** The Warning Window, Grace Period and annual mode are now resolved per roster: add-ons can supply different rules for their own rosters via the new `gsma_expiration_rules` filter. The Chapters add-on (1.2.0+) uses this for its new "Membership Expiration — Chapter Roster" card. Sites without the add-on are unaffected.
* **Filterable expiration status.** The final per-member expiration verdict now runs through a new `gsma_expiration_status` filter. The Chapters add-on (1.2.0+) uses it for its opt-in "chapter expiration takes precedence" mode; when that mode keeps a member with a lapsed main membership active (because their chapter membership is current), protected pages show a blue "please renew your main membership" notification banner instead of denying access.

= 2.1.0 =
* **New: Annual (calendar-year) membership mode.** Tick the new checkbox in the Membership Expiration settings when your expiration column holds a covered-to YEAR (e.g. `2026`) instead of a date. Membership then runs to December 31 of that year, the existing Grace Period setting controls how many days into the new year lapsed members keep access, and member-facing screens show the year itself rather than a synthetic date.
* The Sales plugin (2.1.0+) detects this mode automatically and writes covered-to years instead of dates after a purchase.

= 2.0.1 =
* **Reorganised settings page.** The Spreadsheet ID, Sheet (Tab) Name and column mapping now live together in one "Main Roster" card, with its own "Save & Test Main Roster" button.
* The **PDF Watermark** card now shows a shaded preview when the Pro add-on isn't active, so you can see what the add-on offers.
* The **Site Mode** card is now always visible — the Chapter option is greyed out until the Chapters add-on is active, so you can see what the add-on adds.
* Clarified which roster the **Extra User-Info Fields** and **Membership Expiration** settings apply to. On chapter sites, extra user-info fields are now also read from the local chapter roster (requires Chapters add-on 1.1.0).

= 2.0.0 =
* **The plugin is now split into a free core plus optional add-ons.** This free plugin keeps everything needed to run the login gate: the Google Sheet roster connection, page restriction, member login (including one-time email codes), profile lookups and protected file downloads.
* Moved to the **Pro add-on** (separate plugin): group email / newsletters, the Email-in mailbox, personalised PDF watermarking and members-only large-file links.
* Moved to the **Chapters add-on** (separate plugin): chapter-site local rosters and the local-vs-main roster comparison report.
* **Nothing breaks if you don't install the add-ons.** All your settings are kept — Pro and Chapters settings are simply ignored until the matching add-on is activated, at which point they work again exactly as before. Admin pages show a short note where an add-on feature used to be.
* New extension hooks throughout so the add-ons plug in cleanly.

= 1.19.8 =
* **New: leave the Sheet (Tab) Name blank to always read the first tab.** If your roster is produced by converting an XLSX file into the spreadsheet and the import creates a tab with a new name each time, clear the tab name field — the plugin now looks up the spreadsheet's first tab automatically (cached for 5 minutes) and uses it for all reads and writes. Works for both the main roster and chapter local rosters.

= 1.19.7 =
* **Security: attachment download links in group emails are now members-only.** Large attachments (over 5 MB) and extra attachments beyond the first used to be linked with their raw public uploads URL — anyone with the link could download them. These links now go through the plugin's protected download proxy: the reader must log in, and their membership is checked at click time. The links never expire (unlike the 2-hour page links), because access is enforced on every click rather than by the link itself.
* **Security: files mailed in via Email-in are now stored in the protected uploads folder** (`wp-content/uploads/gma-protected/`, blocked from direct web access) instead of the public media folder.
* Tip: for attachments you pick in the compose form, upload them via **Media → Protected Files** so the file itself also lives in the protected folder.

= 1.19.6 =
* **New: personalize group emails with the recipient's first name.** Write `{first_name}` anywhere in the message body — e.g. start with `{first_name},` — and each member receives the email with their own first name from the roster sheet. Works in both the compose form and Email-in messages. Members whose sheet row has no first name get "Member" instead. Names are looked up once when the send is queued, and "Send a test" uses your own roster name so you can preview it.

= 1.19.5 =
* **New: replies to emailed-in messages go to the original sender.** Group emails created via Email-in now carry a Reply-To header set to the authorized person who mailed the message in, so when a member hits Reply it goes straight to them. The From: address stays the site's own address so SPF/DKIM keep passing and deliverability is unaffected.

= 1.19.4 =
* **Fix: Email-in now respects the membership code filter.** Emailed-in messages without a `[codes: …]` subject tag used to go to the entire roster, ignoring the filter saved on the Group Email page. They now use that saved filter automatically; a subject tag still overrides it, and a blank filter still means everyone. The mailbox status line now also shows which codes were applied.

= 1.19.3 =
* **Fix: "Check mailbox now" said "Email-in is disabled."** The manual check button now works even while automatic mailbox polling is switched off (the enable checkbox only controls the scheduled checks), and it saves the on-screen settings first — same as "Save & Test connection".
* **Fix: the membership code filter really persists now.** The last-used filter is stored permanently, so it stays filled after Email-in actions on the same page (save / test / check now), page reloads, and even logging out and back in — not just after send/preview.

= 1.19.2 =
* **Fix: the site admin no longer receives a copy of every group email.** Each message was addressed "To:" the admin address with the member hidden in Bcc, so the admin inbox got one copy per recipient. Messages are now addressed directly to each member (still one individual message per member, so no addresses are ever exposed).
* **New: Unsubscribed members list.** The Group Email page now shows who has clicked the unsubscribe link and when, with a "Re-subscribe" button for each — so if a member opts out by mistake or changes their mind, you can add them back.

= 1.19.1 =
* **Fix: Email-in "not fully configured" despite being connected with Google.** When you sign in with Google, the IMAP host and username fields may now be left blank — the plugin automatically uses imap.gmail.com and the connected Google account. The error message also now names exactly which settings are still missing instead of listing everything.

= 1.19.0 =
* **Clearer admin layout.** On the Group Email page, the membership-code filter and the "Preview recipients" button now live in their own Audience section, separate from Compose — the same filter applies whether you send from the admin form or by forwarding to the Email-in mailbox with a "[codes: …]" subject tag. On the main settings page, the Site Mode selector moved to the top in its own small section, the local chapter roster settings moved to their own "Local Chapter Roster" section placed after the main-roster settings, and all chapter-related settings stay hidden until Chapter mode is selected.
* **Fixes:** the membership-code filter no longer goes blank after sending or testing (it now sticks across every action), and the Email-in "Test connection" button now saves the on-screen settings first before testing (it previously tested the last-saved settings, which could wrongly report a working connection as broken) — the button is now labelled "Save & Test connection".

= 1.18.0 =
* **Group-email code filter now works exactly like the page-viewing codes.** The membership-code filter in the email sender (and the "[codes: …]" subject tag for Email-in) uses the same matching engine as page protection: comma-separated, case-insensitive, respects the Access match mode setting (exact or letters), and "*" / "ANY" means every member. The filter is now also available on chapter sites — leave it blank to email the whole chapter, or enter codes to email only those levels (chapter members without a code count as your Default chapter code, as on pages).

= 1.17.0 =
* **"Sign in with Google" for the Email-in mailbox (Google Workspace).** Instead of an App Password, the mailbox can now be connected with Google's own sign-in: choose "Sign in with Google" in the Email-in settings, enter the OAuth Client ID/Secret from your Workspace's Google Cloud project (one-time admin setup, consent screen set to "Internal"), and click the button. The plugin stores the refresh token, mints short-lived access tokens automatically, and signs in to IMAP with XOAUTH2 through its built-in mailbox client — no password stored, revocable any time from the Google admin console. App Password sign-in remains available and is still the right choice for free @gmail.com mailboxes.

= 1.16.0 =
* **Email-in no longer requires the PHP IMAP extension.** The plugin now ships its own pure-PHP mailbox client (IMAP over SSL) and MIME parser, used automatically on servers where the `imap` extension is missing — the same host/port/App-Password settings work unchanged, including Gmail / Google Workspace. Servers that DO have the extension keep using it, and the Test connection result now shows which transport is in use. This also future-proofs Email-in: the IMAP extension has been removed from PHP core as of PHP 8.4 and is disappearing from hosting stacks.

= 1.15.0 =
* **Main-site privileges now require WRITE access to the main roster sheet.** A site can only switch from Chapter to Main mode if its service account has Editor rights on the main roster spreadsheet — verified with a harmless probe when the setting is saved (an invisible temporary marker is created and immediately deleted in one atomic API call; no cell, formula or format is ever touched). The same check runs again before EVERY group email to the main roster, so flipping the mode in the database doesn't bypass it, and revoking Editor rights takes effect on the very next send. This puts bulk-mailing authority in the hands of the sheet owner: a chapter admin can change their own site's settings, but only the sheet owner can grant Editor access.

= 1.14.0 =
* **Multiple email addresses per roster row.** An email cell may now hold several addresses separated by commas or semicolons (e.g. a couple sharing one membership row). Every address works for logging in and passing access checks, group email sends a separate message to each address, and the roster comparison report treats the whole group as ONE member — if any one of the addresses is found current on the main roster, the member counts as valid. Applies to both the main and local rosters. Note: group-email unsubscribe remains per email address — one person opting out does not opt out the other addresses on the same row.

= 1.13.0 =
* **Names in the roster comparison report.** Two new optional fields in the local roster settings — *First Name Column* and *Last Name Column* — let you point at the name columns of the local sheet. When set, the roster comparison report shows each flagged member's name (from the local roster) next to their email in both the "missing from the main roster" and "expired on the main roster" tables, so you can tell at a glance who each entry is.

= 1.12.0 =
* **Roster comparison report.** A new *Compare with Main Roster* button in the local roster settings checks every email on the local (chapter) roster against the main roster and produces a report listing: members **missing from the main roster**, and members present there whose **main-roster membership has expired** (based on the main sheet's expiration-date column). Both sheets are fetched fresh for the comparison, a summary notice shows the counts, and the full report (with email addresses and expiration dates) appears below the local roster settings for 10 minutes. Members whose main-roster row has no expiration date count as current.

= 1.11.1 =
* **Paste the whole Google Sheets URL — it just works.** Both Spreadsheet ID fields (main roster and local roster) now accept a full Google Sheets URL and automatically extract the spreadsheet ID on save. Pasting the entire URL instead of just the ID was the most common setup mistake and produced a confusing "Spreadsheet not found (HTTP 404)" error from Google.

= 1.11.0 =
* **Default membership code for local members.** A new optional *Default Membership Code for Local Members* field in the chapter roster settings (e.g. `LOCAL`). When set, every local-roster member whose row has no membership code of their own is treated as holding that code. This lets a chapter make pages **exclusive to local members**: enter the same code in a page's *Required membership code(s)* box — local members pass, while people who appear only on the main organization roster do not, even in *Local + main* mode. Rows that already have their own code keep it; the default never applies to the main roster. Leaving the field blank preserves the previous behavior exactly (codeless rows stay codeless and can only satisfy `*`/ANY pages).

= 1.10.0 =
* **Email-in: forward an email to the roster.** New Email-in card on the Group Email screen configures a dedicated intake mailbox (IMAP host/port/username/password, stored like the plugin's other secrets). The plugin polls the mailbox on WP-cron at a configurable interval (default 5 minutes, minimum 2); forwarding a message to that mailbox from an **authorized sender** queues it to the roster through the existing Group Email engine — inheriting the chapter/main audience rules, per-recipient BCC privacy, HMAC-tokenized unsubscribe links, throttled batch sending, and the per-recipient send log with retry/cancel.
* **Authorization & anti-spoofing:** a sender allowlist (one address per line) plus an optional **required subject keyword** — a shared secret that defeats From:-header spoofing. Unauthorized messages are marked read, logged, and never sent. The keyword and any audience tag are stripped from the subject before delivery.
* **Subject audience filter (main sites):** embed `[codes: GOLD,SILVER]` anywhere in the subject to email only those membership levels. Chapter sites ignore the tag — their audience is always the local chapter roster only.
* **Attachments honored:** attachments are sideloaded into the media library; the first is attached to the outgoing email (files over 5 MB auto-convert to a download link, matching compose-form behavior) and any additional attachments are appended as download links.
* **Never sent twice:** processed messages are marked read (\Seen) in the mailbox AND their Message-IDs are recorded in a persistent store, so even a mailbox whose read flags are reset cannot double-send. If a group email is already sending, the incoming message stays unread and is retried on the next poll.
* **Admin tooling:** Test connection button, live status (last check time/result + last processed message), and a "Check mailbox now" button for an immediate poll. A clear notice is shown if the server's PHP lacks the IMAP extension.
* The readme documents mailbox setup (Google Workspace IMAP + App Password) and the spoofing caveat that motivates the subject keyword.

= 1.9.1 =
* **Fix:** the Group Email menu item linked to a broken URL (`/wp-admin/gma-group-email` → 404). The submenu is now registered after the parent Membership Access menu, so the link correctly points to the Group Email admin page.

= 1.9.0 =
* **Group Email.** New **Group Email** screen (under the Membership Access menu) lets an admin send a bulk email to the membership roster. **Main sites** email the main roster — either all members or only those matching one or more membership codes (comma-separated filter). **Chapter sites** email *only* the local chapter roster: the main organization's list is hard-excluded from recipient assembly, even when the site consults the main sheet for page access — a chapter can never bulk-email the parent organization.
* **Privacy by construction:** each recipient gets an **individual message** with their address in **BCC** (the visible To: is the site's own address), so member addresses are never exposed to each other — and every message can carry that recipient's own one-click unsubscribe link. Recipients are de-duplicated automatically.
* **Throttled background sending:** the send runs on WP-cron in small batches (20 individual messages per batch, one batch per minute) so shared hosting isn't flagged for bulk mail. A per-recipient send log shows queued / sent / failed status, failed recipients can be re-queued with one click, and an in-progress send can be cancelled.
* **Unsubscribe support:** every email carries an automatic footer plus a `List-Unsubscribe` header (with one-click support). Opting out adds the address to a persistent opt-out list that is excluded from all future group emails. Unsubscribe links are HMAC-signed so nobody can opt out somebody else.
* **Attachment size guard:** attach a media-library file by ID; files over 5 MB are automatically converted to a download link in the message body instead of an attachment.
* **Test send** button emails a preview copy (subject prefixed `[TEST]`) to the logged-in admin before committing to the full audience.
* The Group Email screen includes **deliverability guidance** for larger lists: route `wp_mail` through the Google Workspace SMTP relay (`smtp-relay.gmail.com`, up to 10,000 recipients/day) or a transactional provider, with SPF/DKIM alignment.

= 1.8.0 =
* **Chapter mode: local roster support.** A new **Site Mode** card lets an install declare itself a *Main site* (single roster — the pre-1.8.0 behavior and still the default) or a *Chapter site* with its own local roster spreadsheet. A chapter site configures a second, independent sheet (Spreadsheet ID, tab, header row, email column, optional membership-code column, optional expiration column) that uses the **same Service Account** as the main sheet — just share the local spreadsheet with the same service-account email. Chapter admins choose who counts as a member: **Local roster only**, or **Local + main** (a person on *either* sheet is a member). All membership lookups — page access, OTP login, `[gsma_user_info]` — automatically consult every active roster; when someone appears on both sheets, the chapter roster's non-empty membership code takes precedence and the richer main-sheet profile is kept. Each roster caches independently, and in Local + main mode one unreachable sheet no longer locks out members found on the other (an error is returned only when *every* roster fails).
* **Codeless rosters.** The local roster's membership-code column is optional: leave it blank and simply being on the list makes someone a member (their code is empty). Such rosters pair with the new any-member page rule below.
* **"Any member" page protection.** Enter `*` (or `ANY`) in a page's *Required membership code(s)* box to grant access to anyone found on the active roster(s), regardless of membership code — including members of codeless rosters. Expiration rules still apply when an expiration column is configured.
* **Save & Test Local Roster** button verifies the chapter sheet independently of the main sheet, and *Clear Cache* now clears both rosters' caches.
* **Upgrade safety:** existing installs have no site mode saved and default to *Main site*; every lookup, error message, and cache behaves exactly as in 1.7.26 until chapter mode is explicitly enabled.

= 1.7.26 =
* **Auto-detect white-space watermark mode now picks the real margin gap on OCR-underlay scans (not the photo region).** 1.7.25 made auto-detect *return* a gap on scanned pages — but it picked the WRONG gap on the FG264 reproducer. The watermark landed in the upper-left of the back cover (over the photo of fruit), not in the blank bottom 1/4 strip below the address block where it visually belonged. Root cause: on a scanned PDF the OCR text bands cover only the regions where there's actual TEXT — a photo region has no OCR text underneath, so to this algorithm the photo looks like one giant unoccupied gap. On FG264's back cover the photo region was a 404pt gap (~51% of the 797pt page height) which beat the legitimate 153pt bottom-margin gap (the blank strip between the "NOTE: Look at your mailing label…" line and the "California Rare Fruit Growers" address block). From 1.7.26, when the page was flagged as scanned (a full-page background image was detected and skipped per 1.7.25), `find_best_gap` rejects any candidate gap larger than 40% of page height — those are almost always photo-shaped pseudo-gaps, not real whitespace. The next-largest legitimate gap wins. Verified on FG264 page 32: the auto-y centre moved from 589pt (~74%, over the photo) to 110pt (~14%, in the blank bottom-margin strip the user actually wanted). Pages on non-scanned PDFs are unaffected — the cap is only applied when a full-page background image was detected, so the original 1.7.21 behaviour for layout-rich documents (text + placed figure + margins) is preserved.

= 1.7.25 =
* **Auto-detect white-space watermark mode now works on OCR-underlay scanned PDFs (ABBYY FineReader, Adobe OCR, etc.).** 1.7.21 introduced the "auto-detect white space" vertical-position mode and 1.7.24 made the watermark visible on scanned PDFs — but the two features did not work together. On the original FG264 magazine reproducer (32-page ABBYY-FineReader scan) the auto-detect path always fell back to the fixed % setting because every page was reported as 100% occupied. Root cause: a scanned PDF places one page-sized image (the photographed/scanned page itself) via a single `cm + Do` pair whose y-translate=0 and y-scale=page-height. The image-band scan in `find_occupied_regions` correctly recorded that band as `[0, page_height]`, which after merging with the OCR text bands swallowed the entire page → no usable gap → fallback. From 1.7.25 the image-band scan ignores any single `cm + Do` band whose height is ≥90% of the page height (treating it as a full-page background scan rather than placed content). The OCR text-position bands (Td/Tm) remain as a proxy for where the visible text on the scanned page actually is, which is what auto-detect needs. Verified on FG264: the previous "no usable gap" diagnostic is now `auto gap=404.3 centre=589.3` — the watermark lands cleanly in the magazine's blank top margin instead of falling back to the fixed 8% strip at the bottom-left. Pages that legitimately contain a non-full-page placed image (logo, figure) are unaffected: their `cm + Do` band is well under 90% of page height and is still counted.

= 1.7.24 =
* **Fixes invisible watermarks on OCR-underlay scanned PDFs (ABBYY FineReader, Adobe Acrobat OCR, etc.).** 1.7.23 made the watermark structurally present in linearized magazine archives — `pdftotext` extracts the watermark text on every page — but the watermark still rendered invisibly in macOS Preview, Apple PDFKit, iOS, Adobe Reader, and Poppler-based renderers (`pdftoppm`, `pdftocairo`). Root cause: PDF spec ISO 32000-1 §7.8.2 says when a page's `/Contents` is an array of streams, the streams "shall be concatenated… as if [they] were a single stream", and §9.3.1 lists *text rendering mode* (the `Tr` operator) among the **text-state parameters that are NOT reset by `BT`**. ABBYY-FineReader-produced scans use `3 Tr` (text rendering mode 3 = invisible) for the OCR layer that sits behind the page image — so users can search/select text without seeing it on top of the scan. Once the original page stream sets `3 Tr` and never resets it, every subsequent BT/ET block in the page's concatenated content — including our appended watermark — inherits the invisible mode. `pdftotext` ignores `Tr` and extracts the text anyway, which masked the bug for two releases. From 1.7.24 the watermark content stream explicitly resets the full set of text-state parameters that survive across BT/ET (`Tr`, `Tc`, `Tw`, `Tz`, `Ts`, `TL`) immediately after `BT` — so the watermark renders visibly regardless of what the upstream content streams left in the text state. Verified by visual diff on the original FG264 reproducer: pre-1.7.24 render shows only the cover image; 1.7.24 render shows the gray watermark block at the bottom-right of every page.

= 1.7.23 =
* **Completes the linearized-PDF watermark fix that 1.7.22 partially shipped.** 1.7.22 patched the linearization parameter dictionary's `/L` (declared file length) on the theory that strict readers (Apple PDFKit / macOS Preview / iOS / Apple Mail) were stopping at /L and would resume reading past it once /L matched the new file size. Empirical testing on a follow-up reproducer (a 32-page ABBYY-FineReader-produced magazine archive that the original member re-uploaded after installing 1.7.22) showed that patching /L is INSUFFICIENT: these readers — plus Poppler — use a fundamentally different reading mode when they see `/Linearized 1`. They consume the linearized "first part" (the page tree and cached pages near the top of the file) and skip the trailer/xref chain at the end of the file entirely, which is where every incremental update we write lives. With the flag still set, even a perfectly-correct /L gives the reader no reason to look past the linearized first part, so the watermark stays invisible. From 1.7.23 the stamper instead unconditionally flips `/Linearized 1` to `/Linearized 0` after writing the incremental update — a single 1-byte-for-1-byte edit that invalidates the lin-dict and forces every reader (strict and lenient) to fall back to the standard end-of-file `/Prev` xref path, where our appended objects live. The cost is the loss of progressive web rendering for the download, which is acceptable: stamped PDFs are served as a one-time download, not streamed, so Fast Web View provides no user-visible benefit anyway. The /L-patching code path from 1.7.22 is removed.
* **Also matches the original PDF's cross-reference format in the incremental update, so traditional-xref-table PDFs no longer get a cross-reference STREAM appended.** A separate hybrid-reference issue was masking the linearization fix above: the stamper unconditionally wrote a PDF 1.5+ cross-reference STREAM for every incremental update, even when the original file used a traditional text-table xref (PDF 1.4 and most OCR-scanned linearized archives). Per the PDF specification (§7.5.8.4), mixing the two within a `/Prev` chain produces a hybrid-reference structure that requires a `/XRefStm` entry in the legacy trailer to bridge the formats — without it, strict readers refuse to follow the chain and fall back to the unstamped page objects. From 1.7.23 the stamper detects the format of the outermost cross-reference during initial parsing and writes a matching incremental update: traditional text table + `trailer << ... >>` for table-based files, cross-reference stream for stream-based files. The diagnostic log line for a successful stamp now distinguishes `xref_stream` vs `xref_table` so operators can confirm which format was emitted.

= 1.7.22 =
* **PDF watermarks now appear on linearized ("Fast Web View") PDFs in macOS Preview, Apple Mail, iOS, and any other reader that uses Apple PDFKit.** Members reported that the watermark stamp was missing from older scanned-and-OCRd documents (notably magazine archives produced by ABBYY FineReader and similar tools that always linearize their output) when those documents were opened in Preview on a Mac — yet the same files showed the watermark correctly in Adobe Reader, Chrome, and Firefox. The cause was specific to the linearization parameter dictionary that linearized PDFs carry near the start of the file: it has an `/L` entry that declares the EXACT byte length of the file as it was originally written, and Apple PDFKit (per the PDF specification's strict reading) treats `/L` as the authoritative end of the file and stops reading there. Every byte the plugin appended for the watermark — the new content stream, the updated page object, and the new cross-reference stream — lived past the original `/L` value, so PDFKit simply never saw the update and rendered the document unchanged. Lenient readers (Poppler, MuPDF, Adobe Reader) ignore the `/L` mismatch and read to actual end-of-file, which is why the bug was viewer-specific and easy to miss. From 1.7.22 the stamper detects the linearization parameter dictionary and rewrites `/L` in-place to match the new total file length after the incremental update, restoring spec-compliance and making the watermark visible in every reader. The `/L` slot is fixed-width — the new value is left-padded with zeros if it has fewer digits than the original (valid PDF integer syntax) — and on the rare occasion that the new file size needs more digits than the slot can hold, the stamper falls back to flipping the dictionary's `/Linearized 1` flag to `/Linearized 0`, which tells strict readers to ignore the (now-invalid) linearization fast path and use the normal cross-reference table instead. Either path produces a fully readable file in every viewer. The patch is logged as `GMA: linearization /L patched OLD -> NEW (file size N)` so an operator investigating a "Preview can't see my watermark" report can confirm the fix triggered. Non-linearized PDFs are unaffected — the patch is a no-op on documents that don't carry a linearization dictionary.

= 1.7.21 =
* **PDF watermarks can now auto-position vertically in the largest white-space gap on each page, so the name/email/date stamp no longer collides with body text or images on documents whose layout varies page-to-page.** Until now the watermark Y position was a single global "% from bottom" value applied to every stamped page — fine for templated documents (always-blank top margin, always-blank bottom band) but it could land on top of paragraphs, headers, footers, or embedded images on documents whose page layout varied (mixed text + figure pages, certificates with decorative borders, multi-section reports). The Watermark card in Settings → GSheet Access now has a "Vertical Position Mode" radio with two choices: **Fixed** (the existing behaviour: use the % from bottom value as-is — unchanged default, so existing installs see no behaviour change after upgrade) and **Auto-detect white space** (scan each stamped page's content stream for vertical regions occupied by text or images, find the tallest unoccupied gap, and centre the watermark in that gap). Auto-detect identifies text by Td / TD / Tm operators and images by cm + Do operator pairs, merges overlapping bands, and requires the chosen gap to fit the watermark block (font size × line count) plus a small breathing margin. When no usable gap is found — rotated pages, pages with inline content streams, or unreadable / encrypted streams — the placement transparently falls back to the % from bottom value, so a page that auto-detect can't analyse still gets a watermark. Per-page placement is recorded in the existing `GMA: inject ok` debug log line as `y=NNN (auto gap=GG centre=CC)` or `y=NNN (auto-fallback-fixed)` so operators can see where each page landed and why.

= 1.7.20 =
* **Pasting a verification code into the OTP-entry field now works reliably across email clients.** Members reported being able to type the 6-digit code by hand but unable to paste it from their email — the form would silently reject the paste and the failed attempt never appeared in the operator's debug log, making the failure both invisible and unreproducible. The cause was the OTP input's `maxlength="6"` attribute combined with the way many email clients (Outlook, Gmail mobile, several webmail providers) copy verification codes: with a leading or trailing space, a non-breaking space, a zero-width character, or wrapped as "Code: 123456". The browser truncated those pastes to the first six characters BEFORE any digit reached the field, so the user clicked Verify on a field that contained no digits at all — and the empty-submit short-circuit in the handler returned without ever calling the verifier (which is the only place log lines are written), so the debug log stayed empty. From 1.7.20 the field's `maxlength` is widened to 32, a tiny inline JS normalizer strips every non-digit and the common invisible characters (NBSP / zero-width / BOM), takes the first 6 digits, and re-populates the field. The same normalizer fires on both `paste` and `input` events, so manual typing of a non-digit is rejected the moment it happens, and password-manager autofill is cleaned the same way as a paste.
* **The OTP submit handler now defense-in-depth-trims invisible whitespace server-side too, and logs empty submissions explicitly.** The client-side normalizer is best-effort UX — a JS-disabled browser, a stale cached form, or a non-browser POST can all bypass it — so the server-side handler now strips non-breaking spaces, zero-width spaces, and BOM characters before its empty-vs-non-empty branch decides what to do. (`sanitize_text_field()` does not strip these.) Empty submissions now also write a `verify` log line with `result=empty`, alongside the existing `send` / `resend` / `verify` / `mark_verified` / `destroy` events, so when a member reports "I clicked Verify and nothing happened" the operator can confirm whether the click reached the server at all and whether the field was empty when it did.

= 1.7.19 =
* **OTP debug logging now has an admin toggle as a runtime ON/OFF switch on top of the wp-config constant introduced in 1.7.18.** 1.7.18 deliberately had no admin-UI control because a settings export, an accidental admin click, or a direct database edit could have turned plaintext-OTP-in-log on in production. From 1.7.19 the architecture is two layers instead of one: the wp-config `GSMA_DEBUG_OTP` constant is still the master switch (file-system access is still required to turn the feature on at all — the original boundary is preserved), AND a new "Write OTP debug log lines to the WordPress error log (troubleshooting)" checkbox in the Email Verification (OTP) card is the runtime switch (admin can flip this without editing files when troubleshooting, then flip it back off when done). Both must be ON for any line to be written. The setting defaults to OFF, so an install with the constant set but the toggle untouched logs nothing. The settings card also shows a live indicator of whether the wp-config constant is currently active, so admins can confirm their wp-config edit took effect without trial-and-error.
* **Temporary opt-in for installs that cannot edit wp-config.php: the plugin now defaults `GSMA_DEBUG_OTP` to true if it is not already defined.** Without this opt-in default, a site whose operator cannot edit wp-config (some hosted WordPress environments) could not use the new admin toggle at all — the constant gate would always block. The opt-in default is gated on `! defined( 'GSMA_DEBUG_OTP' )`, so a wp-config-defined value (true OR false) ALWAYS wins. To restore the strict 1.7.18-style file-system-only boundary, either explicitly add `define( 'GSMA_DEBUG_OTP', false );` to wp-config.php (the wp-config value wins, plugin auto-define becomes a no-op) or delete the auto-define block from the top of `gsheet-membership-access.php`. Note: the runtime admin toggle still defaults to OFF, so even with the constant auto-defined, no log line is written until an admin actively ticks the checkbox in the Email Verification (OTP) card.

= 1.7.18 =
* **The OTP-entry screen now shows a live countdown of how much time is left on the verification code.** Members who took longer than the configured expiry window (default 10 minutes) to enter their code previously saw the same generic "Incorrect or expired code" message a wrong digit would produce — they had no way to tell their code had simply timed out, especially when corporate spam filters or sandboxing add 5–10 minutes of latency before the email arrives. From 1.7.18 the form displays a "Code expires in M:SS" indicator that ticks down once per second; when it reaches zero, the indicator switches to "This code has expired — request a new one", the Verify button is disabled (Resend stays enabled), and the visitor can request a fresh code in one click. The countdown is purely a UX hint — server-side expiry enforcement is unchanged and remains the source of truth, so disabling the button client-side does not relax security in any way.
* **Optional debug logging of the full OTP round-trip for troubleshooting failed verifications.** When a member reports they entered the right code and the form still rejected it, until now there was no server-side trail to tell which of the three internal failure branches fired (no session, expired, code mismatch) or what the user actually typed vs what was emailed. From 1.7.18, adding a single line to `wp-config.php` — `define( 'GSMA_DEBUG_OTP', true );` — turns on a structured one-line-per-event log via WordPress's standard error log (visible in plugins like Debug Log Manager). Each send, resend, verify, mark-verified, and destroy event records the session id, the email, the code in plaintext, the submitted code, the precise failure branch, the age of the session in seconds, and the configured expiry window. **The gate is exclusively the wp-config constant** — no admin UI toggle exists, so a settings export, an admin click, or a database edit cannot accidentally turn this on in production. With the constant undefined or false (the default), the logger is a complete no-op: zero `error_log` calls fire, regardless of `WP_DEBUG`. Intended for test sites only — codes appear in plaintext in the log so any operator with debug-log access could read them.

= 1.7.17 =
* **Closes an access-control hole where a trailing comma in a page's allowed-membership-codes setting silently let everyone in.** When the page setting was set to letters-mode and the value contained an empty entry (for example `GOLD,` with a trailing comma, or `GOLD,,SILVER` with a stray middle one), the access check was treating the empty entry as "no letters required" and granting access to every logged-in user regardless of their membership code. Empty entries are now skipped.
* **Sheet-cell values can no longer carry inline `style="…"` attributes.** Cell content comes from an external source (Google Sheets), and a malicious or accidental cell like `<span style="position:fixed;top:0;left:0;…">` could overlay the page. The `style` attribute has been dropped from the allowlist; class attributes are still permitted so the plugin's own badge markup is unaffected.
* **OTP verification-code requests are now rate-limited.** A given email address can request at most 5 verification codes per 15 minutes. This prevents inbox flooding and email-address-membership enumeration. Visitors who hit the limit see a friendly message telling them how many seconds to wait.
* **Sheet data and OAuth tokens are now cached in `wp_options` instead of WordPress transients.** On hosts where the object-cache backend (Memcached / Redis) silently drops `set_transient()` writes, the cache never reached the database — every request hit the Google Sheets API anew, exhausting the per-minute token quota. Storing these in `wp_options` (with `autoload=no` and lazy expiry on read) makes caching reliable on every host. Old transients are cleared automatically on upgrade.
* **The login gate and the page-access gate now share a single cached row-fetch.** Until 1.7.16 they pulled the sheet through two independent code paths, so a request could find the email in one cache and not the other (a race during cache warm-up). They now read from the same underlying snapshot, eliminating the inconsistency window.
* **Membership expiration date now displays in the site's timezone instead of UTC.** Previously, sites west of UTC could show "expired yesterday" on the actual day of expiry because the date was rendered against UTC midnight. The displayed date now matches the calendar day the admin entered.
* **Download links now expire on a 2-hour rolling window.** Each generated download URL carries a time-window stamp (`gsma_tw`) that's part of the signed token; tokens are valid for the window in which they were issued plus the immediately preceding window (clock-skew tolerance). **One-time disruption:** PDF/media download links generated before this update will be rejected — members who try an old link will need to revisit the page on your site to get a fresh one.
* **Protected-folder repair button now actually works.** The "Repair" admin action that regenerates the `.htaccess` and `index.php` guard files in the protected uploads folder used to be wired up in the UI but never handled on the server. It now regenerates both files unconditionally and reports success or the reason it could not write them.
* **Production sites no longer flooded with PDF-watermark diagnostic log entries.** The per-PDF diagnostic messages (page counts, injection success/failure per page, cross-reference offsets) only fire when `WP_DEBUG` is on. Genuine failures (parse errors, exceptions) still log unconditionally so site admins can see actionable problems.
* Internal: when `WP_DEBUG` is on, the plugin now logs a one-line warning if the same email address appears on more than one row of the sheet, so the data issue can be found and fixed instead of being silently masked by last-row-wins behaviour.

= 1.7.16 =
* **The Custom Login Page and Access-Denied Redirect settings are now page pickers instead of URL boxes — so they survive a staging→production migration.** Until 1.7.15 both settings stored the full page URL. That was convenient but tied the configuration to the current site domain: cloning a site from `staging.example.com` to `example.com` (or moving to a new domain entirely) silently broke both — the login redirect would still bounce visitors to the staging URL and the access-denied redirect would too. From 1.7.16 the admin screen shows a WordPress page dropdown for both settings, storing the page ID (a portable, domain-agnostic reference) instead of a URL. At read time the plugin resolves the ID into a fresh permalink for whatever the current site URL is, so the same setting works correctly on staging, production, and any future domain. **Existing installs are not disrupted** — the legacy URL values are preserved as a silent fallback, so a pre-1.7.16 install that upgrades but never opens the settings screen keeps working byte-for-byte. When an admin DOES open the settings screen, a clearly-marked yellow notice flags any legacy URL value and recommends picking a page from the new dropdown to gain the portability fix. The resolver also verifies the chosen page exists and is published at read time, so a deleted/unpublished page falls through to the legacy URL or to auto-discovery instead of pinning callers to a 404.

= 1.7.15 =
* **The "Check your email" screen now reminds visitors to look in their spam or junk folder.** Across every email service, transactional one-time-code emails occasionally land in spam — especially on the very first email a domain ever sends to a recipient — and visitors who don't see the code in their inbox often assume the system is broken instead of checking spam. The OTP-entry screen now shows a short italic hint immediately under the "We've sent a 6-digit code to …" line: *"Don't see it? Check your spam or junk folder — verification emails sometimes land there."* Hint is styled to match the other supporting text (smaller, muted, italic) and inherits the wrapper's font-scale baseline (so it scales with the 1.7.13 **Login form text size** setting). Also styled in the `Force the login form … original colors` override block so admins who use that toggle still see the hint clearly.

= 1.7.14 =
* **Login form text size now also scales the alert/notice banners that appear above the form on the initial sign-in screen.** 1.7.13 added an admin **Login form text size** knob (and an `em`-based scaling baseline) that worked correctly for every text rule INSIDE the form wrapper — heading, sub-text, labels, inputs, button, hint, OTP digits. But the four code paths that emit standalone alert banners (the "You have been logged out" notice after a logout, the "Your verification session has expired" error after a stale link, the after-submit flash messages, and the URL-coded error fallback for hosts whose flash storage drops writes) all printed bare `<div class="gma-alert ...">` markup with no `.gma-form-wrap` parent. The alert's `font-size: .9em` rule therefore resolved against the page body's font, not the scaled wrapper, so admins who scaled the login text up still saw small alert text on the very first sign-in screen visitors usually see (because they got there from a redirect that triggered exactly one of those alerts). From 1.7.14 every standalone alert emission is wrapped in `<div class="gma-form-wrap">…</div>` so the admin's font-scale setting applies to alerts on the initial sign-in screen too. No new setting; no behaviour change for installs that left the scale at 1.0.

= 1.7.13 =
* **Login form text now scales with theme/accessibility font settings AND with a new admin knob — instead of being locked to a fixed pixel size.** Until 1.7.12 every text rule inside the `[gsma_login_form]` shortcode was declared in `rem`, which is sized against the `<html>` element. That meant accessibility plugins that scale font-size on `body` or on a content wrapper (the more common pattern) had no effect on the login form, and admins on themes with small body fonts had no way to enlarge the login text without forking CSS. From 1.7.13 the form wrapper sets a single `font-size: <scale>em` baseline and every text rule inside the form (heading, sub-text, field labels, input text, hint text, OTP digits, button label, alerts, secondary action) is declared in `em` — so the entire form scales as a unit with whatever font the parent context provides AND with a new **Login form text size** setting (Settings → GSheet Membership Access → Front-end Appearance). The setting is a multiplier (default 1.0 = inherit verbatim, allowed range 0.5–3.0) that's filterable through the standard `gsma_settings` flow and clamped at both write time and render time as defense in depth. Spatial declarations (padding, gap, margin, border-radius, max-width) deliberately stay in `rem` so increasing the text scale doesn't crowd or break the form layout. The optional `Force the login form … original colors` override block emits the same `em` text rules so admins who use that toggle still get the new scaling behaviour.

= 1.7.12 =
* **Login URL leak fix: the plugin no longer exposes your "hide login" plugin's secret admin slug in front-end notification emails, the user-info widget login link, the logout link, or the post-logout destination.** When a hardening plugin like WPS Hide Login is active, `wp-login.php` is moved to a custom secret slug and `wp_login_url()` / `wp_logout_url()` resolve to that slug. Until 1.7.11, every front-end caller of "the login URL" — the `[gsma_user_info]` widget's "Log in" link, the logout button URL it renders for signed-in members, the access-denied redirect, the "you must log in to view this file" notice, and the email login link the companion Sales plugin (1.7.9+) drops into purchase-confirmation emails — silently fell back to that secret slug whenever the plugin's `Login Page URL` setting was empty. The slug ended up visible to every member receiving a purchase email and to any signed-in visitor who hovered the logout button — exactly the URL admins had paid a hardening plugin to keep secret. From 1.7.12 there are TWO independent fixes: (1) the `login_page_url()` helper now AUTO-DISCOVERS the front-end login page by scanning published pages and posts for the `[gsma_login_form]` shortcode (cached for 12h via the `gsma_autodiscovered_login_page` transient, invalidated on `gsma_settings` save and on any `save_post`/`delete_post`/`wp_trash_post`), so the fallback is the public member-facing login page in 99% of installs without admins having to remember to populate the setting; and (2) a new `wp_logout_url()` filter routes logout links through a front-end `?gsma_action=logout&_wpnonce=...` endpoint on the public login page (handler at `init` priority 1, nonced against `gsma_logout`) which calls `wp_logout()` server-side, fires the existing `wp_logout` hook, and lands the visitor on the public login page with the standard "you have been logged out" notice — so the secret slug never appears in a hovered-link tooltip, an HTML view-source, or a copy-link-address. Admins who explicitly set the `Login Page URL` setting see no change in behavior; admins who left it blank get the leak fix automatically on upgrade.

= 1.7.11 =
* **Login fix, completion of the 1.7.10 storage swap: the returning-user password-entry step now also survives a broken object cache.** 1.7.10 moved the verification-code session out of WordPress transients and into `wp_options` so it could not be lost by a host whose object-cache backend silently drops writes. That fixed the "Incorrect or expired code" loop. But the auth flow has TWO places that create a session: the verification-code path (which 1.7.10 fixed) AND a separate "returning user already has a password — skip the code, go straight to password entry" path. The second path was building its session by calling `set_transient` directly, inline in `class-gma-login.php`, completely bypassing the `GSMA_OTP` class — so 1.7.10's swap missed it. On hosts with a broken object cache, that meant the returning-user session was written to the cache, lost immediately, and the next request displayed "Your verification session has expired. Please start again." From 1.7.11, the returning-user path delegates to a new `GSMA_OTP::create_email_only_session()` method that routes through the same `update_option(..., autoload=no)` storage as every other session, so it lands in the database and survives. No setting changes; no behavior change for working hosts.
* **Audit guarantee for the future.** A new test case (1.7.11 block in `tests/test_access.php`) asserts that `class-gma-login.php` contains zero direct `set_transient`/`get_transient`/`delete_transient` calls — anywhere — for session-like state. That regression guard means any future code that re-introduces an inline transient session will fail the test before it can ship.

= 1.7.10 =
* **Login fix, root cause: the verification-code session itself is now stored in a place your host cannot lose.** 1.7.8 and 1.7.9 made the error MESSAGE survive on hosts where the temporary-data layer drops writes (1.7.8 by switching the message slot from an IP key to a session-ID key, 1.7.9 by encoding the message into the redirect URL itself). Both helped the user *see* what was going wrong, but neither fixed the deeper problem: the OTP session — which holds the hashed code, the expiry timestamp, and the email address — was ALSO stored in a WordPress transient, and on hosts where the underlying object-cache backend drops writes (aggressive Memcached eviction, misconfigured Redis, multi-worker caches without coherence) `set_transient` writes ONLY to the cache when one is present and never reaches the database. The next request finds nothing, `verify()` returns false, and the user is told "Incorrect or expired code" forever, no matter how perfectly they type the code. From 1.7.10, the session is written to the `wp_options` table directly via `update_option( ..., $autoload = false )`. The WordPress options layer ALWAYS writes to the database; the object cache is only a read-through layer in front of the DB, so options round-trip correctly even when the cache backend is silently dropping writes. The session shape is unchanged otherwise (same 40-char hex session ID, same 30-minute total lifetime, same configurable code expiry) and the public API of `GSMA_OTP` is byte-for-byte the same. No setting changes needed.
* **Lifecycle: a daily WP-Cron event sweeps expired session rows.** New cron event `gsma_cleanup_otp_sessions` runs daily and deletes any `gsma_sess_*` row whose `created_at` is older than the 30-minute session lifetime. Lazy expiry on read still happens too (so expired sessions never look valid even before the sweep), but the cron prevents abandoned-session accumulation in `wp_options` on busy sites. The cron is scheduled both on plugin activation and self-heals on every page load via `GSMA_OTP::init()` from `gsma_init()`, so existing installs upgrading via the Update Plugin button (which does NOT fire `register_activation_hook`) still get the cron registered. On deactivation the cron is unscheduled and a final sweep runs so the options table is left clean.

= 1.7.9 =
* **Login fix follow-up: the verification-code form now ALWAYS shows you why it rejected your code, even when your host's cache layer is dropping temporary data.** 1.7.8 fixed the "no error visible" loop for sites behind a load-balanced proxy by switching the temporary error slot from an IP-keyed key to a session-ID-keyed key. That fixed the symptom on most sites, but on environments where the underlying transient/object-cache layer itself drops writes (aggressive Memcached eviction, misconfigured Redis, full-page caches that strip query strings or return stale snapshots — common on managed-WP hosts and on Cloudflare APO setups), even the SID-keyed slot vanished and the user was left staring at the same code prompt with no explanation. From 1.7.9, every error redirect ALSO encodes the failure reason directly into the URL as `?gsma_e=<code>` (e.g. `gsma_e=otp_invalid`, `gsma_e=otp_empty`, `gsma_e=session_expired`, `gsma_e=pass_incorrect`, `gsma_e=pass_short`, `gsma_e=pass_mismatch`, `gsma_e=membership_gone`, `gsma_e=account_failed`, `gsma_e=invalid_email`, `gsma_e=not_member`, `gsma_e=mail_failed`). The render layer reads `gsma_e` from the URL and renders the matching, translated message inline — independent of any storage layer. URL parameters cannot be lost by any cache layer because the parameter IS part of the cache key, so the visitor always sees what went wrong. The flash mechanism stays in place as a richer secondary channel for messages that don't have a dedicated code (e.g. the success notice after a code resend). No setting changes needed.
* **Cache-layer mitigation: pages that host the plugin's shortcodes now send `Cache-Control: no-store, no-cache, must-revalidate, max-age=0` plus `Pragma: no-cache` on the HTTP response.** This stops aggressive page-cache layers (Cloudflare APO, managed-WP edge caches like WP Engine / Pantheon / Kinsta, WP Super Cache, W3 Total Cache, LiteSpeed Cache) from serving a stale "still on the OTP step" HTML snapshot AFTER the handler has already redirected the visitor on, which to the user looked identical to the silent-loop bug. The detection is content-based: any front-end page whose post content contains `[gsma_login_form`, `[gsma_user_info`, or `[gsma_expiration_notice` as a shortcode opening token gets the no-cache headers via `template_redirect` priority 1 (before `the_content` runs). Other pages on the site are unaffected and remain freely cacheable. No setting changes needed.

= 1.7.8 =
* **Login fix: the verification-code form no longer silently bounces you back to itself when the code is correct.** The custom login form's flash messages (errors, success notes) used to be stored in a temporary slot keyed by the visitor's IP address. On any site sitting behind a load-balanced reverse proxy or a CDN — Cloudflare, Pantheon, WP Engine, Kinsta, etc. — the visitor's REMOTE_ADDR can change between the POST that wrote the message and the GET that tries to read it, so the message was silently lost. The OTP-step's "Incorrect or expired code, please try again or request a new one" error went into one slot and the very next render looked in a different one, leaving the user staring at the same code prompt with no explanation of what went wrong. From 1.7.8, every flash message that has a session ID in scope (every step after email entry) is keyed to the SID instead of the IP. The SID round-trips in the redirect URL, so the next render is guaranteed to find the right slot regardless of which proxy IP the visitor's request lands on. The IP-keyed slot is kept as a fallback only for the email-entry step, which has no SID yet. No setting changes needed.
* **WPS Hide Login interop.** When the WPS Hide Login hardening plugin (or any plugin that sets `WPS_HIDE_LOGIN_VERSION` / defines `WPS_Hide_Login` / declares `wps_hide_login()`) is active, this plugin's `wp-login.php` redirect (added in 1.3.0 as `maybe_redirect_wp_login` on the `login_init` action) now stands down. Those hardening plugins replace the `wp-login.php` endpoint with a custom slug and serve a 404 on direct visits — our redirect was either pre-empted by their 404 wall or fought their hook, and either way the user's intent is already satisfied (admins reach login via the custom slug, non-admins via our `[gsma_login_form]` page). `wp_login_url()` calls still get rewritten to the custom login page via the `login_url` filter, exactly as before, so links elsewhere in WP that point at "the login page" still go to the right place. No setting changes needed.

= 1.7.7 =
* **Force original login colors: text now keeps its intended size, and the input boxes stay visible, even on themes that use `!important` to recolor or shrink form controls.** 1.7.6 added the toggle but its second style block used specificity alone, with no `!important`. On themes that normalize form controls or page sections with their own `!important` (very common — e.g. `body { font-size: 12px !important }`, `input { border: none !important; font-family: <theme-font> !important }`), specificity could never beat `!important`, and the login form's input boxes lost their borders / font and the user-info table cells, the expiration notice, and the in-form text rendered in the theme's smaller font even with the toggle on. The override now re-asserts every text rule's `font-size` (and the form chrome — input/button border, padding, font-family) with `!important`, so the plugin's intended typography and form chrome win. The plain *color* surface (alert tints, link colors) deliberately still uses no `!important` so a theme that genuinely wants to re-tint those surfaces can still do so by being explicit. Default-off behaviour is unchanged. Mirrors the Sales 1.7.7 cascade-contract refinement so the two plugins behave consistently.

= 1.7.6 =
* New: opt-in **Force original login colors** toggle on the Membership Access settings page (under **Front-end Appearance**). When switched on, the `[gsma_login_form]` login screen, the `[gsma_user_info]` status panel, and the `[gsma_expiration_notice]` banner emit a second inline `<style>` block that re-asserts every color rule using doubled-class wrapper selectors (`.gma-form-wrap.gma-form-wrap`, `.gma-user-info.gma-user-info`, `.gma-expiration-notice.gma-expiration-notice`) plus `color-scheme: light` on each wrapper. This restores the intended palette — action blue, error red, warning amber, success green — on themes that recolor generic form/button elements or run a dark-mode palette inversion. Mirrors the **Force original picker colors** toggle that shipped in the companion **GSheet Membership Sales** plugin v1.7.5, so the two plugins behave consistently. Default off, so existing installs keep their current behaviour byte-for-byte.

= 1.7.5 =
* **Menu promoted to top level.** The plugin's admin page used to live under **Settings → Membership Access**, which buried it next to a dozen other plugins' settings sub-items. From 1.7.5 it sits as its own top-level **Membership Access** menu in the WordPress admin sidebar, immediately above the companion **Membership Sales** menu, so the two related plugins are visually grouped and reachable in one click. Existing bookmarks pointing at `options-general.php?page=gma-settings` will need to be updated to `admin.php?page=gma-settings`. No other behaviour changed; the settings, the form, and the underlying data are exactly the same.

= 1.7.4 =
* New: public helper `GSMA_Sheets::get_column_values( $col_setting )` returns every non-empty value in a single column (excluding the header row). Used by the companion Sales plugin v1.6.0+ to compute the next member-number / index-number when appending a new row. **Required by Sales 1.6.0** — older Access installs will keep Sales deactivated until upgraded.

= 1.7.3 =
* New: `GSMA_Sheets::detect_date_format()` recognises the date shape used in your Google Sheet's expiration column (e.g. US 2-digit-year `01/01/27`, UK `30/07/2026`, ISO `2026-07-30`, etc.). Used by the Sales plugin's renewal flow so the expiration date is written back in the **same shape it already had** — no more silent rewrites of `01/01/27` into `2027-01-01` after every purchase.
* Fix: explicit `m/d/y` and `d/m/y` (2-digit-year) entries added to `GSMA_Sheets::parse_date()` so admins entering dates as `01/01/27` get reliable parsing and round-tripping even if PHP's natural-language parser ever changes its mind about which side of the slash is the day.

= 1.7.2 =
* Fix: `GSMA_Sheets::resolve_column()` now resolves column-setting fields as **header name first**, **column letter second**, and returns explicit "not found" instead of silently falling back to column A. Previously, settings whose value happened to be 1–3 A-Z characters but did not match any header (e.g. `ZIP`, `ID`, `URL`) were misinterpreted as the literal letter range — for `ZIP` this encoded ~column 17,000, which Google's newer Sheets builds reject with `Range exceeds grid limits`, breaking writes for the entire row. The Sales plugin's webhook surfaces any skipped column settings on its **Recent Purchases** admin page; sites running with `WP_DEBUG` also get an `error_log` entry.
* **Upgrade note for wide-sheet / letter-only setups:** the column-letter fallback now applies a bounds check to **3-letter** inputs (`AAA`–`ZZZ`, columns 703–18,278): they only resolve when the index lies inside the configured sheet's grid (i.e. `< count(header_row)`). 1–2 letter inputs (`A`–`ZZ`, columns 1–702) are unaffected and behave exactly as before. If you genuinely use 3-letter columns on a very wide sheet, ensure the header row extends through that column or switch the setting to the header label — otherwise the field will be reported as unresolved on Recent Purchases.
* Polish: column-setting placeholders in **Settings** now read `e.g. A or Email Address`, `e.g. B or Membership Type`, etc. — making it explicit that either format works.

= 1.3.0 =
* Added multi-step custom login shortcode [gsma_login_form].
* First-time users receive a 6-digit email verification code (OTP) before setting a password.
* Returning users log in with their email and password.
* Password reset button re-sends an OTP to verify identity before allowing a new password.
* OTP expiry, sender name, and sender email are configurable in the Membership Access menu (top-level in the WordPress admin sidebar).
* Custom login page URL setting redirects all wp_login_url() calls to your chosen page.
* Sessions managed via WordPress transients (no cookies beyond WP's own auth cookie).

= 1.2.0 =
* Switched to Google Service Account authentication — sheet is now fully private, no public sharing required.
* JWT signing using PHP OpenSSL (RS256).
* Access tokens cached as transients and refreshed automatically.
* Admin page shows service account email when a key is already saved.

= 1.1.0 =
* CSV link approach (required public sharing — superseded by 1.2.0).

= 1.0.0 =
* Initial release (API key based).

= Earlier Sales releases =

= 2.2.0 =
* **3-year and Lifetime tiers are now exclusive to the Pro add-on.** On the free plugin, buyers see and can purchase only the 1-year option; the checkout endpoint also rejects forged requests for the Pro tiers. Existing 3-year and lifetime members on your sheet are unaffected — their expirations, lifetime status, and admin tools keep working. Prices entered for the Pro tiers are preserved and take effect the moment the Pro add-on is activated (the Pricing Matrix page marks these tiers accordingly).

= 2.1.0 =
* **New: Annual (calendar-year) membership support.** When the Access plugin's new *Annual (calendar-year) memberships* mode is on, purchases write a covered-to YEAR (e.g. `2027`) to the expiration cell instead of a date. Existing members — active or lapsed — always renew forward from the current year (a lapsed member paying in November still just gets the current year; an active member covered through 2026 who buys a year lands on 2027). Multi-year purchases extend the same base; the cell shows only the last year covered.
* New **"New-member bonus cutoff"** setting (General Settings, default July 1): a brand-new member (email not yet on the sheet) who signs up on or after this date also gets the following year included. Removing someone from the sheet lets them qualify for the bonus again.

= 2.0.1 =
* The gift membership email templates on the Emails tab now show a shaded preview when the Pro add-on isn't active, so you can see what the add-on offers. Stored gift templates are always preserved.
* Declared the required GSheet Membership Access plugin via the WordPress 6.5+ "Requires Plugins" header, so WordPress enforces the dependency automatically (older WordPress versions simply ignore the header).

= 2.0.0 =
* **The plugin is now split into a free core plus an optional Pro add-on.** This free plugin keeps the full Stripe Checkout flow — pricing picker, Stripe payment, writing the buyer into the Google Sheet, purchase log and buyer emails.
* Moved to the **Pro add-on** (separate plugin): PayPal checkout, gift purchases, and the admin refund / sheet-write retry tools.
* **Nothing breaks if you don't install Pro.** Stored PayPal settings and past gift/refund history are kept untouched; the related controls simply disappear until Pro is activated, at which point everything works again exactly as before. If Pro is absent, a gift-flagged checkout is processed as a normal purchase and PayPal webhooks are answered with "gone" so PayPal stops retrying.
* New extension hooks throughout so the Pro add-on plugs in cleanly.

= 1.7.13 =
* **Critical fix: PayPal cancel + webhook race no longer silently fails to grant a membership.** If a buyer clicked Cancel in the PayPal window while the CHECKOUT.ORDER.APPROVED webhook was simultaneously delivering, the cancel handler could flip the pending log row to `cancelled` before `finalize_order()` ran. `finalize_order()` searches for `pending` rows only, so it saw nothing and returned `duplicate` — meaning the buyer's payment was captured by PayPal but the membership was never written to the sheet and no error was raised. From 1.7.13 `finalize_order()` also searches for `cancelled` rows for the same order, reopens the row to `pending`, and runs the full capture-and-upsert pipeline normally. If capture fails (genuine cancellation before PayPal charged the card), the row goes back to `cancelled` safely.
* **Fix: PHP fatal on activation when the Access plugin is absent now shows a graceful admin notice instead.** All `require_once` calls for the Sales plugin's own class files were happening unconditionally at plugin load time, before the dependency check in `gsms_init()` could fire. If any future class file referenced `GSMA_Sheets` or `GSMA_Settings` at class-definition time (not inside a method body), this would produce a fatal error instead of the intended "please install the Access plugin" notice. The `require_once` chain is now guarded by the same `class_exists('GSMA_Sheets')` check as `gsms_check_dependency()`, so a missing dependency is always a notice, never a crash.
* **Fix: concurrent webhook deliveries for different buyers could silently drop a purchase log entry.** The purchase log is a single `wp_options` row. Two simultaneous webhook deliveries (say, two buyers checking out at the same moment) both read the same log snapshot, both prepend their entry, and one write silently won — the other purchase disappeared from the audit trail entirely (though the sheet row itself was unaffected, since it has its own per-email lock). All four log mutation operations — `append()`, `update_at()`, `delete_at()`, `delete_pending_paypal()` — now hold a brief advisory mutex (the same `add_option()` atomic-insert lock pattern used for per-event and per-email serialization) for the duration of their read-modify-write cycle.
* **Fix: PayPal OAuth token now cached in `wp_options` instead of transients.** On hosts where the object-cache backend (Memcached / Redis) silently drops transient writes, every PayPal API call — `create_order`, `capture_order`, `verify_webhook_signature`, `refund_capture` — was fetching a fresh OAuth token from PayPal's servers, burning API quota and adding latency to every PayPal operation. Same fix applied to the Access plugin's sheet-data cache in 1.7.17.
* **Fix: processed-event list no longer silently evicts old IDs, preventing duplicate sheet writes and emails.** Until 1.7.12 all processed event IDs were stored in one shared `wp_options` array capped at 500 entries. When the cap was reached, older IDs were dropped silently. A Stripe event whose ID aged off the bottom (possible on high-volume sites within hours) would be reprocessed on the next redelivery attempt, writing a duplicate row and sending a duplicate confirmation email to the buyer. From 1.7.13 each event gets its own `wp_options` row (key `gsms_evt_{md5}`) with a 72-hour TTL — well beyond Stripe's 3-day retry window and PayPal's retry window. There is no cap-based eviction. A daily WP-Cron job sweeps rows older than 72 hours to keep the options table tidy. The legacy shared-array option is deleted on the first cleanup run.
* **Fix: email body templates no longer have their newlines mangled on save.** Email bodies were being sanitized with `wp_kses_post()`, which is designed for HTML and normalises whitespace — silently collapsing the carefully placed blank lines that separate paragraphs in a plain-text `wp_mail()` body. They are now sanitized with `sanitize_textarea_field()`, which strips tags but preserves newlines, matching what `wp_mail()` actually sends.
* **Fix: multibyte street addresses (Japanese, Arabic, etc.) no longer cause Stripe or PayPal to reject the checkout request.** Stripe metadata values and PayPal description/invoice fields were truncated with `substr()` at a fixed byte limit. On multibyte UTF-8 strings this could split a character at its byte boundary, producing an invalid encoding sequence that both gateways reject with a 400 error. All truncations now use `mb_substr(..., 'UTF-8')` with a matching `mb_strlen()` guard.
* **Fix: purchase date stamped in site timezone, not UTC.** The purchase-date column was stamped with `gmdate()` (UTC), which appeared as "tomorrow" for site administrators west of UTC making a purchase after their local 8 PM. The date is now formatted using the WordPress site timezone setting (`timezone_string` option, with a `gmt_offset` fallback), so the stamped date matches the calendar day the admin sees locally.
* **Fix: refund handler no longer risks marking the wrong log row as refunded.** The outbound gateway refund call has up to a 20-second timeout. Between the initial log read (to find the row to refund) and the post-refund write (to flip its status), another buyer's checkout could append a new log entry and shift row indices enough that the write landed on the wrong row. The handler now re-searches by session-id + timestamp + gateway immediately before the write-back, and `update_at()` holds the log lock for the duration of its own read-modify-write, making the final update atomic.
* **Fix: missing `return` after the "No Change" success branch in `ajax_admin_apply()`.** `wp_send_json_success()` calls `wp_die()` internally, so in production the code never fell through. However, relying on `wp_die()` for flow control breaks in unit-test environments where `wp_die()` is mocked not to halt. An explicit `return` is now present.
* **Rate limiting on unauthenticated membership-lookup endpoints.** The `gsms_compare_buyer` and `gsms_check_recipient` AJAX actions (both registered with `wp_ajax_nopriv_`) answer "is this email in the sheet?" to anonymous visitors. Without throttling, an automated script could enumerate the full membership list by iterating addresses. Non-logged-in callers are now limited to 30 lookups per 5-minute window per IP (stored in `wp_options`, same pattern as the Access plugin's OTP rate limiting). Callers who exceed the limit receive a `exists: false` response — they learn nothing useful and the real data is not exposed.
* **Best-effort CSRF protection on PayPal return/cancel URLs.** A per-order HMAC nonce (derived from the order id and `wp_salt('nonce')`) is now checked when present in the return/cancel URL query string. Pre-1.7.13 bookmarked return URLs (without the nonce) are still accepted gracefully, so no buyer is broken by the upgrade.

= 1.7.12 =
* **Renewals no longer lose a day when the new expiration date crosses Feb 29 of a leap year.** Until 1.7.11 the renewal flow added a flat 365 days for a 1-year renewal and 1095 days for a 3-year renewal. Whenever the new expiration window straddled Feb 29, that flat-day arithmetic landed one calendar day earlier than "the same date next year" — so for example a 1-year renewal of a member whose old expiration was 15 March 2027 would land on 14 March 2028 instead of 15 March 2028, and a 3-year renewal lost a day in nearly every window because almost every three-year span contains a leap day. From 1.7.12 the renewal date is computed with real calendar arithmetic ("+1 year" / "+3 years" in UTC), so the new expiration always lands on the same calendar day as the base date (with the lone exception that a base date of Feb 29 itself rolls forward to Mar 1 in non-leap target years — the customer-friendly outcome). The stored date format (US m/d/Y, etc.) is unchanged. No setup change is required; the next renewal a member buys will simply land on the correct day.

= 1.7.11 =
* **`[gsms_admin_picker]`: new "No Change" duration option.** When the admin types an existing member's email into the admin direct-apply picker, the duration row now shows an extra **No Change** button alongside the regular 1 year / 3 year / lifetime choices. Clicking it tells the server to update only the profile fields (first name, last name, phone, address, city, state, ZIP, foreign state, country, zone — whichever columns you have configured) and leave the member's existing membership code AND expiration date completely untouched. Useful for fixing a typo in an address, updating a phone number, or correcting any other contact-info cell without accidentally extending the membership or changing its tier. The button only appears when the typed email actually matches a row on file (a profile-only update against a non-existent row would make no sense, so the server also re-validates this). No confirmation email is sent and no purchase log entry is recorded — this is a maintenance write, not a sale.

= 1.7.10 =
* **Code-delivery emails now remind the reader to check spam if the message goes missing later.** The buyer self-confirmation and the gift-recipient confirmation both carry the access code the reader needs to log in — and members frequently report that the email landed in their spam/junk folder weeks later when they tried to find it again. From 1.7.10 a short, polite "P.S. — if you can't find this email later, please check your spam or junk folder; consider adding our sender address to your contacts" line is appended to those two emails. The line is filterable via the `gsms_spam_notice` WordPress filter (returning an empty string disables it), so admins can adjust the wording without forking the plugin.
* **Gift acknowledgement now nudges the gifter to follow up with their recipient.** Email delivery is best-effort, and gift recipients are the ones most likely to silently never see the notification (a brand-new sender to their inbox, an unfamiliar subject line). From 1.7.10 the gifter's own acknowledgement appends a P.S. like "Membership notification emails occasionally land in spam folders. If <recipient> hasn't seen their email within an hour or two, please reach out to them directly so the gift isn't missed." The recipient's name is filled in from the form when known, otherwise the message gracefully says "your recipient." The line is filterable via the `gsms_gifter_followup_notice` WordPress filter.

= 1.7.9 =
* **Notification-email login link no longer leaks the admin's secret login slug on sites running a "hide login" hardening plugin.** Until 1.7.8, the email login link was built by `GSMS_Email::login_url()` calling `GSMA_Settings::get('login_page_url', '')` directly and falling back to `wp_login_url()` when that setting was empty — and on sites running WPS Hide Login (or any other plugin that relocates `wp-login.php` to a custom secret slug), `wp_login_url()` resolves to that secret slug. The result was the slug being broadcast in every purchase-confirmation email, exactly the URL the admin had paid a hardening plugin to keep private. From 1.7.9 the email login URL is sourced from the companion Access plugin's `GSMA_Login::login_page_url()` helper, which (from Access 1.7.12) auto-discovers the page hosting `[gsma_login_form]` whenever the explicit setting is empty — so the email defaults to the public member-facing login page. **Requires GSheet Membership Access 1.7.12 or later for the auto-discovery fallback to engage.** On older Access installs (or if the helper isn't available for any reason), the code falls back to the original `GSMA_Settings::get('login_page_url')` lookup and then `wp_login_url()`, so behaviour is unchanged for admins who have already populated the setting.

= 1.7.8 =
* **Thank-you page fix: the membership status summary is no longer rendered twice after a purchase.** The recommended setup (which the readme has always told admins to follow) is to put `[gsma_user_info]` on the thank-you page so visitors see their status when they arrive there for any reason. After a purchase, the plugin also runs a content filter on that same page to add a "Thank you for your purchase!" banner above the status. Until 1.7.7 that filter ALSO rendered `[gsma_user_info]` itself before re-attaching the page's original content — so on a correctly configured site the status block appeared twice (once from the filter, once from WordPress's normal shortcode pass on the page content). From 1.7.8 the filter checks whether the page already contains `[gsma_user_info]` and skips rendering it a second time. Sites that did NOT put the shortcode on the thank-you page still get a status summary injected automatically, exactly as before, so this is a strict cleanup with no setup change.

= 1.7.7 =
* **Buyer info fields stay visible when the theme also uses `!important` on form inputs.** 1.7.6 re-declared the full input chrome (border, padding, font, etc.) on the buyer-info section but used specificity alone, with no `!important`. On themes that normalize form controls with `input { border: none !important; font-family: <theme-font> !important; ... }`, specificity could never beat `!important` and the boxes still rendered as invisible unbordered text fields in the theme's font (see the second "invisible boxes" report). Because the whole purpose of "Force original picker colors" is "make the plugin's styling win over my theme," the buyer-info `label` and `input` / `textarea` rules in the override block now use `!important`. The picker option, button, gateway-box, and diff-panel rules deliberately still avoid `!important` so a theme that genuinely wants to restyle those surfaces can still do so explicitly. Default-off behaviour is unchanged.

= 1.7.6 =
* **Buyer info fields stay visible when "Force original picker colors" is on.** The 1.7.5 override re-asserted the *color* of the buyer-info input borders but not the border itself, so on themes that strip the input chrome with `border: none` (rather than just recoloring it) the email / name / phone / address boxes rendered with no visible outline at all even with the toggle on (see attached "invisible boxes" report). The override now re-declares the full input chrome &mdash; `border`, `border-radius`, `padding`, `font: inherit`, and `box-shadow: none` &mdash; plus an explicit label font-family / size / weight, so the buyer-fields section looks the same as the picker options above it regardless of which property the theme is overriding. Default-off behaviour is unchanged: with the toggle off, the picker still inherits the theme's input styling exactly as it did in 1.7.5.

= 1.7.5 =
* **New "Force original picker colors" toggle.** A new opt-in checkbox under *GSheet Membership Sales &rarr; Settings &rarr; Picker appearance* makes the buy picker render in its intended palette regardless of theme overrides. Until now the picker inherited generic colors from the active theme &mdash; which is what you usually want, but on themes that recolor form controls (or run a dark-mode palette inversion swapping red for green, dark for light, etc.) the picker's semantic cues could come out wrong: the "selected" blue could turn green, the "you'll save" green could turn red, the price-up / error red could lose its warning meaning, and so on. With the toggle on, the picker emits a defensive second style block that re-asserts every color rule with extra-specific selectors (`.gms-picker.gms-picker .X`) and declares `color-scheme: light` on the wrapper, so the original blue/green/red/gold semantics win against the common theme patterns. The block deliberately avoids `!important` so a theme that genuinely wants to restyle the picker can still do so explicitly. **Default off** &mdash; existing installs whose themes style the picker the way they want see no change unless they explicitly enable it.

= 1.7.4 =
* **Setup-instructions box now visible on sites running other admin plugins.** The blue "Setup &mdash; shortcodes to add to your pages" box at the top of every Sales admin sub-page used WordPress's built-in `notice notice-info` class. A number of third-party admin-cleanup, security, and "hide admin notices" plugins aggressively hide or strip every `.notice` element in wp-admin to declutter the dashboard, which silently swallowed our help box on sites with such plugins active (it rendered fine on stock WP). The setup-help block and the inline "missing codes" warning on the Code Matrix page now use our own private CSS classes with inline styles that visually match the original look, so no third-party `.notice` rule can touch them. Real WP admin notices the plugin emits via the proper `admin_notices` hook (flash messages and the sheet-failure banner) keep the standard classes &mdash; if you intentionally suppress those with a hide-notices plugin, that behaviour is unchanged.

= 1.7.3 =
* **Sheet now stores dates in US format only.** Up to 1.7.2 the renewal flow tried to be polite — if the existing expiration cell looked like UK `30/07/2026` or German `30.07.2026` we'd write the renewed date back in the same shape. From 1.7.3 the spreadsheet is treated as the system of record and stores dates in US `m/d/Y` (or `m/d/y` if the admin already chose 2-digit years) only. Any non-US shape detected in the cell — UK day-first, European dot, ISO `Y-m-d` — is rewritten to canonical US `m/d/Y` on the next renewal so the column converges to one locale. First-time purchases also default to US `m/d/Y` (the old ISO `Y-m-d` default was retired). The same allowlist applies to the optional purchase-date column.
* **Localised presentation is unaffected.** UK / non-US visitors who see their expiration on the front-end still get it formatted via WordPress's locale-aware `date_i18n()` (driven by the WordPress *Date format* setting), so what *they* see on the page is unchanged. Only the underlying spreadsheet cell is normalised.

= 1.7.2 =
* **Abandoned PayPal checkouts are visible in Recent Purchases.** When a buyer hits PayPal's *Cancel and return* link (or closes the approval tab and we later detect it), the matching log row is flipped to a new `cancelled` sheet status and rendered in the admin table as a muted grey "cancelled" pill with a "buyer abandoned at PayPal" caption. Distinct from `failed` (red) so admins can see at a glance which rows need attention vs which were just abandonments — no payment was taken and nothing was written to the sheet for these.
* **Status legend + counts summary above the Recent Purchases table.** A one-line legend explains each sheet status; immediately below, a counts-by-status summary ("Last N entries: X inserted, Y updated, Z pending, W failed, V cancelled") gives a at-a-glance health check so spikes (e.g. a wave of PayPal abandonments) jump out without scrolling. The summary tallies the exact same window the table is rendering.
* **Daily auto-cleanup of stale checkout rows.** Two new background jobs run on a single daily WP-Cron tick to keep the bounded purchase log (capped at 200 entries) representative of real activity:
  * **Cancelled rows older than 30 days** are purged. Successful, failed, and pending rows are never touched. The retention window is conservative — long enough to spot patterns of buyer abandonment, short enough that a steady drip of cancels can't push real purchases out of the Recent Purchases window.
  * **Orphaned PayPal `pending` rows older than 24 hours** are purged. These are rows we wrote at PayPal create-order time for buyers who then never approved AND never cancelled (e.g. they closed the tab). PayPal's own orders expire after roughly three hours, so 24 hours is a safe orphan window. Stripe pending rows and PayPal cancelled rows are explicitly left alone.
  Both jobs share a single cron hook so the deactivation teardown stays a single `wp_clear_scheduled_hook` call — no leftover schedules to clean up if the plugin is later removed.
* **Refund target now persisted on PayPal rows too.** A small follow-up to 1.7.1's refund button: the PayPal capture id is now stitched into the synthesised checkout session so it lands in `gateway_refund_target` the same way Stripe's payment intent does. The Refund control on the Recent Purchases table now lights up correctly for completed PayPal rows from this version onwards.

= 1.7.1 =
* **Per-row Refund button.** The Recent Purchases admin table grows a Refund control on every Stripe or PayPal row that still has its gateway capture id on file. Expanding the control shows a confirmation checkbox, an optional "also clear the membership code from the sheet" checkbox, and an Issue refund button. The refund is dispatched directly to the gateway (Stripe `POST /v1/refunds` keyed on the payment intent, PayPal `POST /v2/payments/captures/{id}/refund` keyed on the capture id) over an AJAX call, so failures surface inline next to the button without a page reload. On success the row's gateway status flips to `refunded`, the button hides, and (if asked) the membership cell on the buyer's row is cleared — the rest of the sheet row is intentionally left alone so admins can still mail out the gift if they choose to.
* **Idempotent refunds.** Both gateway calls send a deterministic `Idempotency-Key` (Stripe) / `PayPal-Request-Id` (PayPal) derived from the capture id, so a double-click can't issue two refunds. PayPal's `CAPTURE_FULLY_REFUNDED` 422 response is treated as a soft success — the local row is still flipped to `refunded` so the UI stays consistent if a refund was issued out-of-band in the gateway dashboard.
* **Refund target persisted on the log row.** New `gateway_refund_target` field on each Recent Purchases entry holds the Stripe `payment_intent` (or PayPal capture id). The Refund button is hidden on rows where this field is empty (older log entries from before 1.7.1, or admin direct-apply rows that never had a gateway charge), so an admin can never click Refund on a row that has no refundable target — those refunds still need to be issued by hand in the gateway dashboard.
* **Stripe column renamed to Gateway** in the Recent Purchases table, since the column has been gateway-agnostic since 1.7.0.

= 1.7.0 =
* **PayPal Checkout gateway.** Independently enable Stripe and/or PayPal in *Membership Sales → Payments*. When only one is enabled, that gateway is used silently for every purchase. When both are enabled, the membership picker shows a *Pay with…* radio so the buyer chooses Card (Stripe) or PayPal at checkout. Saving the settings page with both gateways disabled is rejected.
* **PayPal credentials & webhook.** Per-mode (Sandbox / Live) Client ID, Client Secret and Webhook ID; webhook URL exposed inline for paste-into-PayPal. Webhook signatures are verified server-side against PayPal's `verify-webhook-signature` API; events are deduped via the same processed-events option as Stripe and namespaced by gateway so a Stripe event id can never collide with a PayPal one. The PayPal dedupe key is the per-attempt **transmission id** (per the Webhooks v1 spec), not the event id, so PayPal's normal redelivery of the same event under a fresh transmission still re-runs the handler instead of being silently dropped.
* **Dual-gateway choice is required.** When both Stripe and PayPal are switched on, the buyer must explicitly pick one in the picker; the server rejects checkouts that arrive without a gateway value (or with an unknown one) with a clear "choose a payment method" 400 — no silent default — so a stale or tampered front-end can't charge through the wrong rail.
* **Buyer return-URL handler.** When the buyer is redirected back from PayPal, the plugin captures the order synchronously before rendering the thank-you page; the webhook still fires (and is the source of truth) but the buyer's confirmation lands instantly. The two paths are guarded by a per-order finalisation lock; if the webhook arrives mid-capture (or vice versa) the loser returns a 200 'locked' response so PayPal's normal retry kicks in cleanly without raising an admin failure notice.
* **Pending row preserved on transient failures.** The 'pending' purchase log row is only deleted after the sheet write succeeds, so if Google Sheets is briefly unavailable the next webhook retry (or buyer-return retry) finds the pending metadata and replays — the purchase is never silently dropped because the row was removed early.
* **Purchase log columns renamed.** `stripe_session_id` / `stripe_status` are now `gateway_session_id` / `gateway_status`, and a new `gateway_name` column distinguishes Stripe / PayPal / admin-direct-apply rows. The Recent Purchases table flushes once on upgrade to 1.7.0 to drop the old schema cleanly.
* **Admin retry form passes gateway name** so a failed-row retry resolves the right log entry even when a Stripe and PayPal id collide on the rare hash-prefix overlap.

= 1.6.8 =
* **Admin picker auto-fills existing members.** When the site administrator types an email into `[gsms_admin_picker]` that already has a row in the spreadsheet, the picker now fetches the on-file values and pre-fills first name, last name, phone, address, city, state, ZIP, and (if used) foreign state, country, and zone. The picker's three dimensions (delivery, postage, duration) are also pre-selected to match the member's existing membership code so it's instantly clear what tier they currently hold. Unlike the gift-mode 1.6.4 lock, **everything stays editable** — these are pure defaults the admin can freely override before clicking Apply.
* The lookup endpoint is admin-only by every measure: registered with `wp_ajax_` (no `nopriv` counterpart), capability re-checked in the handler, and gated by a dedicated nonce that's only minted on the admin picker render — the public picker never sees the action name or nonce. Unknown emails return a clean `exists:false` with no PII. Brand-new emails are unaffected and behave exactly as before.

= 1.6.7 =
* **Admin-only direct-apply picker.** A new `[gsms_admin_picker]` shortcode renders the regular three-dimensional picker for site administrators only (`manage_options`) and applies the chosen membership directly to the spreadsheet **with no Stripe payment**. Useful for granting comp memberships, fulfilling cheque/cash-in-hand orders, or back-filling an old purchase. Non-admins who somehow land on the page see a polite "you must be a site administrator" notice and no picker.
* The admin picker reuses every protection of the public picker via the same webhook code path: gift-to-existing-member still preserves the recipient's existing code, lifetime-upgrade re-validation still fires, expirations still extend correctly, the purchase log still records the entry (with `stripe_session_id` prefixed `admin-`, `stripe_status=admin_no_payment`, and `amount_cents=0` for clear auditability), and the buyer (and gifter, if applicable) still receive the standard confirmation email.
* The picker UI in admin mode skips the "you're already a lifetime member / you have an active membership" early-out screens, blanks all prefill (so the admin doesn't accidentally apply the membership to themselves), and swaps the Buy button for an "Apply to spreadsheet" button that posts to a separate, capability-checked AJAX endpoint and renders an inline green success notice on completion (no Stripe redirect).
* **Note:** unlike the Stripe-webhook flow (which deduplicates by Stripe event ID), the admin direct-apply endpoint has no event-level dedupe — each successful click is one apply event. The per-email atomic lock still prevents two concurrent admin clicks for the same email from racing each other. If you click "Apply to spreadsheet" twice in a row, the second click will extend the membership again. This is intentional for an admin tool but worth knowing.

= 1.6.6 =
* **Foreign-member fields.** Three new optional profile columns — **Foreign State / Province**, **Country**, and **Zone** — that the buyer is asked to fill in **only** when the chosen postage is *Canada/Mexico* or *Other international*. Domestic and electronic purchases never see them, and any value typed before switching postage is discarded so a buyer can't accidentally overwrite their on-file foreign details with US-domestic ones.
* The fields appear automatically on the picker when the admin sets a column letter for them under **GSheet Membership Sales → Settings**. Like the existing US fields they are matched against the on-file row in the diff panel for returning anonymous buyers, included in the gift-recipient identity-lock, and persisted by the webhook with no further configuration required.
* Server-side: the create-checkout endpoint enforces required-when-visible validation; the picker mirrors the same rule for live UX. The on-file compare endpoint surfaces foreign-field mismatches alongside US-field mismatches with no leakage of stored values.
* **Membership-extension email notice.** Confirmation emails for any non-lifetime renewal or gifted extension now prepend a clear "Membership extended" notice that highlights the **new expiration date** (and, when known, the previous one). Wording is tailored per perspective — the buyer's own confirmation, the gift recipient's notice, and the gifter's acknowledgement each get their own intro. Lifetime upgrades keep their own decoration and take precedence; first-time purchases are unaffected.

= 1.6.5 =
* **Lifetime upgrade flow.** Lifetime members can now upgrade to a more expensive lifetime tier — and gifters can pay to expand an existing lifetime member's coverage — by paying only the **price difference** between the current and new tier. Previously a lifetime member's code (or a gift to a lifetime recipient) could never change; this release adds a tightly-scoped exception with two strict rules:
  * **Self-upgrade** (a lifetime member buying for themselves at `?extend=1`): any pricier lifetime tier is allowed. The buyer pays the differential.
  * **Gift-upgrade** (a gifter buying for an existing lifetime member): the new delivery must be a **strict superset** of what the recipient already has — i.e. you can ADD features (electronic→both, print→both, postage upgrades) but never REMOVE one (electronic→print is blocked because it would drop electronic). The gift must also be strictly more expensive. The gifter pays the differential.
* The picker UI enters an "upgrade mode" automatically when these conditions are detected: duration is locked to lifetime, every option is shown as a delta vs the current price, invalid combinations (same/cheaper, or for gifts non-superset) are greyed out, and the Buy button is disabled until a valid upgrade is selected. The total flips from "Total" to "Upgrade payment" and shows just the differential.
* The recipient lookup now also returns the recipient's lifetime price (for lifetime recipients only) so the picker can compute deltas locally. Name, address, phone, expiration, etc. are still **never** returned.
* The Stripe metadata gains `upgrade=1` + `upgrade_kind` + `recipient_old_code` for upgrade purchases. The webhook (and manual retry) honours `upgrade=1` by writing the new code through, overriding the 1.6.3 protect-recipient-code rule. Defense in depth: the webhook re-validates the strict-superset + strictly-pricier rules against the recipient's current row before writing.
* Lifetime members landing on the picker now see an extra "Upgrade my lifetime membership →" link alongside the existing "Buy a gift" link on the no-purchase-needed screen.

= 1.6.4 =
* Picker now locks the **delivery + postage** options to the recipient's existing tier when gifting to someone who is already a member. As soon as the gifter types a recipient email that's on file, the picker decodes that recipient's current membership and only the matching delivery + postage option remains selectable; the others are greyed out. **Duration stays freely selectable** so the gifter can choose how long an extension to buy. This pairs naturally with 1.6.3's rule that the membership code itself is preserved on a gift to an existing member — the gifter can no longer pay for one tier and have nothing applied (or the wrong tier applied).
* The recipient lookup endpoint now returns the recipient's `(delivery, postage)` alongside the existence flag. Name, address, phone, expiration, and other private fields are still **never** returned.
* If the recipient's stored membership code can't be decoded (blank cell, bespoke admin code that isn't in the matrix, etc.) the picker falls back to the 1.6.3 behaviour — the gifter can pick any tier, but the recipient's code stays untouched on write either way.

= 1.6.3 =
* Companion to 1.6.2: a gift purchase can no longer **change the recipient's membership code (tier)**, even when the gifter buys a different/cheaper tier than the recipient currently holds. For example, if the recipient is on a "premium" tier and someone buys them a "basic" gift, the recipient stays on "premium" — only the expiration extends by the gifted duration. The membership-code cell is now left untouched whenever the recipient is already a member.
* The webhook + manual-retry paths both honour this rule. The original purchased gift code is still written to the admin log so you can see what was paid for, but it is **not** pushed into the recipient's row.

= 1.6.2 =
* Security / data integrity: when a member buys a gift for someone whose email is **already in the sheet**, the gifter can no longer overwrite that recipient's account record. Previously, anything the gifter typed in the recipient name + address fields would replace the existing member's stored values.
  * **Picker UI**: the moment the gifter types a recipient email that belongs to an existing member, the name / phone / address / city / state / ZIP inputs are disabled and greyed out, and a notice appears: *"This person is already a member. Their account details (name, address, etc.) will stay exactly as they are — your gift will simply extend their membership."* The recipient's actual stored details are **never** revealed (privacy).
  * **Server-side guard**: the checkout endpoint and the Stripe webhook independently re-check `email_exists()` for gift purchases and strip the recipient identity fields from the upsert call — so even a tampered or out-of-date picker cannot overwrite an existing member's record.
  * Membership code, expiration date, and (if configured) purchase-date stamp still get written for the gift — that *is* the gift. Only the identity / contact cells are protected.
* New AJAX endpoint `gsms_check_recipient` (returns `{ exists: bool }` only) backs the picker check. It exposes the same yes/no signal the existing `gsms_compare_buyer` endpoint has exposed since 1.5.0; no new privacy surface.

= 1.6.1 =
* Fix: when a logged-in member bought a gift, the **"Your email (for the payment receipt)"** field at the bottom of the picker was blank — easy to leave empty or typo, in which case the gifter's confirmation email and Stripe receipt would never reach them. The field is now pre-filled with the logged-in member's account email so a valid address is always present.
* Fix: international (Canada / Mexico / Other International) and electronic-delivery purchases were silently dropping the buyer's typed postal code before sending it to Stripe. The picker has shown the ZIP / Postal code field for these purchases since 1.5.8 (so members can volunteer one for the record), but a stale server-side guard was wiping it again — meaning, e.g., a Canadian gift recipient's M5V 2H1 never made it to the sheet. Postal codes typed for non-US-domestic purchases are now persisted as-is. ZIP remains *required* only for US-domestic shipping.

= 1.6.0 =
* New: three optional bookkeeping columns can be configured under **Settings → Per-purchase bookkeeping**. Each is independent — leave any of them blank to skip.
  * **Purchase date** — stamped on every successful sheet write (new memberships *and* renewals). The date format is auto-detected from values already present in the column; an empty column falls back to `MM/DD/YY`.
  * **Member number** — assigned only when a new row is appended (renewals keep their existing number). Lifetime memberships get an `L`-prefixed integer (e.g. `L100`); all other tiers get a plain integer (e.g. `1207`). The two pools are independent — bumping one never disturbs the other, and each is computed from the current maximum found in the column.
  * **Index number** — assigned only on new rows; one greater than the current maximum integer in the column.
* Note on race safety: two simultaneous *new* memberships for different emails could in theory read the same maximum and produce duplicate numbers. Stripe delivers webhook events sequentially per endpoint so this is rare; if it ever happens, the duplicates are easy to fix by hand on the Recent Purchases screen.

= 1.5.12 =
* Fix: when an admin saved either of the new gift email subject/body fields containing a straight apostrophe (e.g. the default "You've received a gift membership!"), WordPress's slash-escaping accumulated on every save, producing visible backslashes like `You\\\'ve received...` in the delivered email. The save handler now unslashes the input first, matching the existing buyer-email body field. **If you've already seen extra backslashes in your sent emails, open Settings → save once with this version installed to clear them.**

= 1.5.11 =
* New: gift purchases now send **two** distinct confirmation emails. The recipient gets a "you've received a gift membership" message that names the gifter (pulled from the Stripe cardholder name) and the gifter's email so they know who to thank. The gifter gets their own acknowledgement that confirms what they bought, who it's for, and explains that the recipient has been notified separately. The standard, non-gift confirmation is unchanged.
* New: four template fields on the settings screen (subject + body for each of the two new emails). Both gift templates accept the existing placeholders plus `{gifter_name}`, `{gifter_email}`, `{recipient_name}`, and `{recipient_email}`. Sensible defaults ship out of the box so existing installs immediately get the improved gift wording without any settings change.
* Behind the scenes: the gifter's first/last name is now persisted in the failed-purchase log, so a manual retry of a failed gift sheet-write resends both emails with full context (instead of falling back to the gift-unaware template).

= 1.5.10 =
* Tweak: in extend mode, the **Duration** group is now freely selectable — only **Delivery** and **Postage** stay locked to the buyer's current subscription. Renewing members can therefore upgrade (or downgrade) the term length of their renewal — e.g. from 1 month to 1 year — without admin help.
* New: ticking the **gift checkbox** while extending now temporarily releases the duration / delivery / postage lock and clears the buyer's prefilled contact details, since a gift purchase describes the recipient (whose subscription has nothing to do with the buyer's). Unticking the box restores the original lock and re-fills the buyer's details exactly as they were on first paint, so toggling back and forth doesn't lose the server-rendered defaults.

= 1.5.9 =
* Fix: when a renewing **print** member hit the picker via `?extend=1`, the duration / delivery / postage boxes still defaulted to **electronic** (the cheapest combination) instead of pre-selecting their current subscription. The picker blob was being built before the renewal-prefill block ran, so it always carried `currentSelection: null`. The blob is now built *after* the prefill block populates the buyer's decoded subscription from the sheet — print members see "print + regular" pre-selected, international members see their original international postage, etc.
* New: in extend mode, all options other than the buyer's current subscription are now visually faded and disabled, so the renewal flow defaults to "extend exactly what I have today". The notice above the picker has been updated to explain that changes to duration / delivery / postage as part of a renewal must go through an administrator. (Buyers who genuinely want to switch tiers can still drop the `?extend=1` flag and use the regular purchase flow.)

= 1.5.8 =
* Fix: the **ZIP / Postal code** field is now visible on the checkout form whenever the column is configured, including for **electronic-delivery-only** purchases. Previously the row stayed hidden unless the buyer picked a US-domestic shipping postage, which left electronic-only stores with no way to capture a postal code from buyers. The field is still only marked *required* for US-domestic postages; the server-side validator continues to clear any non-domestic ZIP before the row is written, so no stale value can leak into the membership record.

= 1.5.7 =
* Fix: logged-in buyers visiting the **gift purchase** page (`?gift=1`) no longer see a `Warning: Undefined variable $current_selection` notice at the top of the page. The picker variable was being read before its initialisation in the renewal-prefill block; it's now initialised up-front so the gift code path (which skips renewal prefill) renders cleanly.

= 1.5.6 =
* Fix: renewal expirations are now written back to the sheet in the **same date format** the cell already used (e.g. US 2-digit-year `01/01/27` stays `01/01/28`, UK `30/07/2026` stays `30/07/2027`). Previously the webhook always wrote ISO `Y-m-d`, silently rewriting admins' preferred format on every purchase. First-time purchases (no existing date in the row) keep the historical `Y-m-d` default. Requires the access plugin **1.7.3+**.

= 1.5.5 =
* Fix: the webhook used to silently fall back to **column A** (the email column) whenever a column-setting field contained a label that wasn't a valid column letter — so a buyer-contact column configured as `ZIP` (instead of `J`) would either overwrite the email column or, on newer Google Sheets builds, throw an out-of-range API error and fail the entire row write. Settings now resolve as **header name first**, **column letter second**, and unresolvable settings are skipped (with a warning surfaced on the **Recent Purchases** page) instead of clobbering column A.
* New: the **Recent Purchases** table now shows a per-row warning under the Sheet column when one or more configured columns couldn't be resolved, so admins can spot and fix bad column settings without digging through `debug.log`.
* Polish: column-setting placeholders in **General Settings** now read `e.g. F or Phone`, `e.g. J or ZIP`, etc. — making it explicit that either format works. Requires the access plugin **1.7.2+**.

= 1.5.4 =
* Change: **City and State** are now always shown and required (including for the electronic delivery option). Only **ZIP** stays US-domestic-only — international, Canada, Mexico and electronic purchases hide the ZIP field.
* New: **Lifetime members** who land on the purchase page (including via the new "Extend my existing membership" link) now always see the "no purchase needed" screen — and that screen offers only the **Buy a membership as a gift** option, since extending a lifetime membership is a no-op.

= 1.5.3 =
* Fix: name fields were blank when an existing member opened the picker to renew. The picker now falls back to the **first/last name stored in the sheet** when the WordPress user record has none (common for accounts provisioned via Sign-in-with-Google or magic link).
* New: for renewing members, the **Duration / Delivery / Postage** boxes are now pre-selected to match the buyer's current subscription (decoded from their existing membership code) instead of the cheapest combination. A short notice appears above the picker explaining that switching delivery or postage may need administrator help — the boxes can simply be left as-is to extend the existing plan.

= 1.5.2 =
* New: when an already-active member lands on the purchase page, the "no purchase needed" notice now offers two follow-up actions — **Extend my existing membership** (new) and **Buy a membership as a gift**. The extend link uses `?extend=1` (or the namespaced `?gsms_extend=1`) to bypass the duplicate-purchase block; the webhook continues to roll the new term forward from the existing expiration date so an early renewal never shortens coverage.

= 1.5.1 =
* Fix: the **gifter-email** input was visible on first paint even when the gift checkbox was unchecked, and the City / State / ZIP block stayed visible after switching to International or Canada/Mexico — both because author display rules silently overrode the `[hidden]` HTML attribute. Hidden rows now stay hidden in every state.
* Fix: gift mode now syncs on init (not just on toggle), so `?gift=1` deep-links and the unchecked default both render the right fields immediately.

= 1.5.0 =
* Buyer contact: **City** is now collected as a 5th contact column (default Sheet column H), shifting State to I and ZIP to J. City is required for US domestic shipping (regular / first class) and hidden for international.
* Street address is now **always required** (including electronic-only purchases) so every member has a complete mailing record on file.
* First and last name are now required at checkout (client + server validation), and the picker no longer relies on Stripe-side name collection as a fallback.
* New **Buy as a gift** flow: any visitor can tick "This is a gift" to clear all prefilled fields and enter the recipient's details; the gifter's email is collected separately and receives the Stripe receipt while the membership is granted to the recipient.
* Active members can hit the picker with `?gsms_gift=1` (or use the new "Buy as a gift" link) to purchase a gift membership without the duplicate-purchase block.
* Webhook honours `metadata.recipient_email` on gift purchases — the membership row is written under the recipient, not the gifter — and the failure-retry path carries gift fields through.

= 1.4.2 =
* Fix: option buttons (Duration / Delivery / Postage) failed to render on
  some live sites with a JavaScript "'#' not followed by identifier" error.
  Cause: the picker's inline `<script>` block, returned from the shortcode,
  was being mangled by `wpautop`, theme content filters, or page-builder /
  optimisation plugins. Fix: move the controller into a real enqueued
  asset (`assets/picker.js`) and pass per-instance config via
  `wp_add_inline_script(..., 'before')` so the JS lives outside
  `the_content` and cannot be touched by content filters. No behaviour
  changes — same picker, same flow, same Stripe metadata.

= 1.4.0 =
* Picker now collects buyer contact info and writes it to the spreadsheet:
  * **Phone** is required for every purchase.
  * **Address** is required for any print or print+electronic purchase.
  * **State** and **ZIP** are additionally required for US domestic postages
    (Regular / First Class). International, Canada, and Mexico postages
    collect just a single address line.
* New **Sheet columns for buyer contact info** block on the General Settings
  page: pick the column letter (or header name) the picker should write each
  field into. Leave a field blank to skip collecting it entirely.
* Logged-in members get phone, address, state, and ZIP pre-filled from their
  existing row — edit and re-submit to update.
* Unauthenticated buyers whose email is already on file get an inline alert
  when their typed contact details differ from the row in the sheet, with a
  clear choice between "Update with what I just entered" and "Use the
  details we have on file". The alert lists which fields differ but never
  reveals the on-file values themselves, so a curious anonymous visitor
  cannot fish for another member's address or phone number by guessing
  emails.
* Webhook (and the failed-write retry path) writes phone, address, state,
  and ZIP to the configured columns. Blank values are skipped so a renewing
  buyer who doesn't re-type their address won't have their existing row
  cleared.
* New helpers `GSMS_Pricing::is_domestic_postage()`,
  `GSMS_Pricing::requires_address()`, and `GSMS_Pricing::requires_state_and_zip()`
  encode the region rules in one place, mirrored on both client and server.
* New `GSMS_Profile` class wraps the sheet read and provides the
  privacy-preserving `compare()` used by the new `gsms_compare_buyer` AJAX
  action.
* Requires the access plugin **1.7.2+** (adds the public
  `GSMA_Sheets::get_cells_for_email()` cell reader the new picker uses).
* Tests: +43 cases covering region helpers, the privacy-preserving compare,
  and contact-column writes (114 total, all green).

= 1.3.0 =
* Admin: every Membership Sales sub-page now shows a "Setup" notice listing the required shortcodes (`[gsma_membership_purchase]`, `[gsma_user_info]`) and the configured thank-you page.
* General Settings: thank-you-page field now mentions the `[gsma_user_info]` shortcode inline.

= 1.2.0 =
* Removed the `nonprofit` postage option from the Pricing and Code matrices.
* Documented and enforced "blank cell = unavailable" across the picker, server-side validation, and the cheapest-combination logic. The fallback for an unknown postage now uses the first configured postage instead of `nonprofit`.

= 1.1.0 =
* Added `canada_mexico` and `other_international` postage categories.
* Refactored the admin code-matrix table to render postage columns dynamically.

= 1.0.0 =
* Initial release: 3D picker shortcode, Stripe Checkout integration, sheet upsert, configurable buyer email, Recent Purchases log, per-event + per-email locks, admin failure notice, and a manual retry button for failed sheet writes.
