FCHubFCHub.co

Changelog

Every release, every fix, every rate recalculation.

What changed, when, and whether it actually affects you.


August 2026

v1.4.8

Released 18 August 2026GitHub Release

A deep audit of the whole plugin, verified against FluentCart 1.6.1's actual source rather than against our own assumptions about it. Two of those assumptions turned out to be quietly wrong for a long time.

The shop price filter works again after a currency switch

FluentCart's price filter speaks base currency end to end — its slider writes base values into the inputs, and the server compares base cents. Converting those inputs corrupted every filtered query, and the thousands separator alone was enough to make the server drop the filter entirely. The filter block now stays honestly in the base currency, sign included, and simply works: correct bounds in, correct products out, prices displayed in the visitor's currency.

Two FluentCart integrations were dead on arrival

The checkout disclosure fragment turned FluentCart's fragments list into a JSON object, which crashed its forEach and stopped checkout updates for anyone browsing a converted currency. The fragment is gone; nothing ever read it, and the disclosure ships client-side. Separately, orders have no user_id column — the WordPress user lives on the customer relation — so the FluentCRM order sync and the manual-order snapshot fallback had never fired. Both now do, and the test doubles are shaped like FluentCart actually ships, so this class of bug cannot pass green again.

Honest surfaces, hardened edges

The selector-buttons block no longer bakes one visitor's currency into cached HTML. The switcher gained real keyboard navigation. The freshness badge is rendered by the browser from a shipped timestamp, so a cached page stops insisting the rates were updated "2 hours ago" forever. The REST endpoint counts every attempt, keeps an unforgeable per-socket ceiling and validates forwarded IP headers; a rogue fchub_mc/context filter now degrades with a log line instead of a white screen; guests can use the no-JS form however stale the cached nonce. A daily maintenance schedule prunes the event log and rate history on every store — the old pruning rode a cron that manual-rate stores never had.

The settings page grew up

The admin monolith became per-tab components with a stylesheet instead of a JavaScript string. The currency grid renders its columns, the tab underline keeps up with the tabs, diagnostics states every value — including a one-click action that creates the missing FluentCRM custom fields — and the default display currency now follows the store base unless you deliberately pick otherwise, which is what it should have done from the start.

Thanks to @ManniGH for the live 1.4.7 measurements on #72 that set this release's direction.

v1.4.7

Released 17 August 2026GitHub Release

The stale currency no longer gets a four-second farewell tour.

Reported and production-tested by @ManniGH in the follow-up on #72, after 1.4.6 fixed the cache boundary but left one intermediate paint alive. Thank you for testing the awkward second half rather than declaring victory when the first half looked tidy.

One lock now owns the whole correction

After a guest confirms a currency, the destination page immediately reflects that choice in both switcher variants and keeps them disabled. Cached prices stay hidden rather than displaying the reload's old currency, even politely dimmed. The lock is released only after the cache-busted context response has been validated, every currency surface has converged and the first price projection has completed.

A failed or malformed recovery restores the currency actually served by the page before unlocking. A preference changed during the request wins the race, controls that were disabled beforehand remain disabled, and stores with browser projection switched off finish at the context boundary instead of waiting for a script they quite deliberately declined to run.

Earlier where it matters, smaller where it does not

The shared recovery runtime now starts in the document head. That puts the lock in place before the body can paint and starts the correction request before the stale markup has finished parsing. The runtime is roughly 4 KB compressed; duplicating its resolver in a fashionable micro-bootstrap would have saved very little and created two opinions about which currency belongs to the visitor. One opinion is already plenty.

Projection shielding moved into its own stylesheet, under 1 KB compressed, so a page without a visible switcher no longer downloads the full switcher stylesheet merely to hide stale prices. The complete projection selector set is checked against that stylesheet, which saves the next FluentCart price surface from being added to one list and forgotten in the other.

A real browser lane held a deliberately stale USD document through a 4.5-second context delay. The recovery request started before first contentful paint; that paint contained no readable USD amount, and the page eventually revealed only the corrected EUR price with both selectors unlocked and no console errors. Runtime tests also challenge failed responses, storage and cookie races, pre-disabled controls, projection exceptions and projection-disabled stores.

v1.4.6

Released 17 August 2026GitHub Release

The edge cache was not impressed by Cache-Control. Fair enough; it had already ignored the cookie.

Reported and production-tested by @ManniGH in #72, with the working path contributed in PR #149. Thank you for spending a week finding the actual hosting boundary, then another two days proving the repair while 1.4.5 was still admiring its green local tests.

A guest switch now survives the first cached reload

Rocket.net serves the same cached HTML whether a guest sends no currency cookie, EUR or USD. The first 1.4.5 repair still reloaded that shared URL and expected a browser-readable cookie to rescue it afterwards. That was narrower than the real failure and, unsurprisingly, the real failure remained.

After the server confirms a guest choice, the storefront now mirrors that same three-letter code in local storage and reloads through the configured currency URL parameter. The URL resolver runs before the cookie, so the returned HTML already has the selected currency even when the edge never forwards the cookie. Signed-in visitors keep their account-backed path, and a failed save still writes nothing and reloads nothing.

The browser revalidates that first currency URL through the cache-busted context endpoint before removing the one-shot parameter. If an edge ignores the query and serves the wrong document, the currency is repaired first. If recovery fails, the parameter stays put. Other query values and fragments survive the cleanup, while a real currency link without the matching confirmed browser choice remains a real currency link. No vanishing evidence tricks, tempting though they may be.

The mirror is a fallback, not a second opinion

The cookie and local-storage value carry the same confirmed choice. Existing cookie preferences and Manni's plain local-storage value migrate automatically. The mirror expires on the site's configured cookie lifetime, disappears when cookie persistence is disabled, and is discarded when malformed, expired or no longer allowed.

Explicit currency links and signed-in account preferences remain higher priority. A response already in flight cannot overwrite a newer cookie or browser mirror, and blocked local storage falls back to the existing cookie path rather than breaking the switcher.

Every recovery request now uses a unique context URL as well as no-store. That covers both the normal browser-mirror path and the rarer inverse case where cached HTML claims another guest's cookie but this browser has no preference at all. One shared endpoint no longer gets to recycle yesterday's answer with a straight face.

Prices remain visible while that recovery request is pending. Once the currency is known, the short projection shield still keeps the final rewrite together; it no longer turns a slow edge request into several seconds of decorative blank space.

Checkout records the currency the customer actually saw

FluentCart 1.6.1 puts fluent_cart/before_payment_methods inside both regular and modal checkout forms, then exposes the submitted fields through fluent_cart/checkout/prepare_other_data. Multi-Currency now uses that path to send the final browser display code with checkout. The server validates the code and resolves its own rate; the browser never supplies a price or payment currency.

This matters after cached-page recovery: the displayed currency can be correct while the request cookie is still the stale value Rocket.net delivered. Order metadata now records what the customer saw, while FluentCart continues charging exclusively in its own base currency.

Display currencies keep their own number format

FluentCart's formatter quite reasonably assumes it is formatting the store currency. Asked to format another code, it still applies the base currency's zero-decimal rule. That made a JPY-base store turn a converted EUR 6.25 into 6 in PHP helpers and FluentCRM output.

Converted server-side prices now use the configured display currency's decimals, separators, symbol and position. Zero-decimal, two-decimal and three-or-four-decimal display currencies share the same rounding contract as browser projection. Removing the current default display currency also returns the default to FluentCart's base, rather than leaving an impressive but impossible setting behind.

A non-base choice is now accepted only when the server can resolve a usable rate under the configured stale-rate policy. Missing or rejected stale rates leave the existing preference untouched and return a clear error, rather than saving a choice that the next page would immediately ignore.

The runtime suite now challenges the Rocket-style cookie-less path, immediate guest reload, an edge ignoring that reload query, safe URL cleanup, account and explicit-link precedence, disabled settings, expiry, malformed and conflicting storage, failed persistence, unavailable browser storage, cookie and storage races, checkout hand-off, stale submitted fields, tampered codes, slow recovery, unavailable rates, last-known fallback, zero-decimal bases and three-decimal display currencies.

v1.4.5

Released 17 August 2026GitHub Release

The cookie was fine. The cached HTML was confidently remembering somebody else.

Reported by @ManniGH in #72, then chased through Rocket.net's edge cache and contributed back in PR #149. That production investigation established the boundary the plugin had to own. Thank you for doing the awkward bit on a real shop, where bugs develop rather better manners than they do in a local demo.

Cached pages now recover the visitor's actual currency

An edge cache can serve HTML generated before it ever sees the visitor's currency cookie. The page therefore arrived in the store default even though the browser still held the visitor's choice. Asking every host to exempt every sales page was technically possible in the same way that maintaining a spreadsheet of fires is a fire-safety system.

The storefront now checks the existing fchub_mc_currency cookie and, when cached HTML disagrees, resolves a fresh context through the cache-safe GET /context endpoint before projecting prices. The cookie remains the only browser preference store: there is no localStorage shadow state to drift, expire differently or win a future argument with the server.

Recovery updates the whole surface, not merely the first price it finds:

  • product prices, ranges, variants, subscriptions, filters, totals and receipt amounts;
  • dropdown labels, flags, active ticks, button switchers and rate footers;
  • Current Currency, Exchange Rate and Currency Context blocks;
  • base-currency choices, expired URL preferences, missing cookies and malformed cookies.

An explicit valid URL preference and a signed-in account preference still outrank cookie recovery. A recovery response is rejected unless its currency and positive rate belong to the configured set, and an older response cannot overwrite a choice the visitor made while the request was in flight.

A successful request now has to mean a successful save

Guest requests no longer send a REST nonce baked into cached HTML. Signed-in requests still use normal WordPress cookie authentication and the current nonce. The switcher reloads only after the server confirms persisted: true; a failed cookie header, failed user-meta write, disabled persistence channel or decorative 2xx response leaves the page in place and reports the real outcome.

The configured URL preference is removed only after the replacement choice is stored. Context responses are explicitly no-store, so neither a browser nor an intermediary gets to recycle one visitor's answer for the next.

Exchange rates are snapshots, not a collection of optimistic rows

Manual mode now has an actual rate editor and a dedicated save action. Manual and remote updates both require one positive, plain-decimal rate for every configured quote currency, then persist the set with one timestamp and one multi-row write.

If a provider omits a currency, returns zero, scientific notation or malformed data, or if the database rejects the write, the previous database rows and cache remain untouched. The success hook fires only after persistence. Generic refresh is unavailable in Manual mode, so yesterday's values can no longer acquire today's timestamp by pressing a button with confidence.

Removed currencies are rejected by context resolution and omitted from the public rates endpoint even when historical rows still exist. Stale rates are used only when Last known rate is explicitly selected; the safer Base currency policy also rejects future-dated rates rather than treating time travel as freshness.

FluentCart remains the money authority

Multi-Currency now takes the base currency and store separators from FluentCart's canonical currency API everywhere. Saving the add-on panel cannot restore an old second base currency, automatic display separators follow FluentCart's number format, and PHP helpers keep FluentCart's cent-based amount contract.

Price projection also handles negative half-up and half-down ties symmetrically, keeps the minus sign before the currency, honours zero-decimal currencies and an explicitly disabled thousands separator, and leaves FluentCart's variant source attributes unchanged. Repeated variation events therefore reproject from the base price instead of converting yesterday's conversion again. Compound interest is lovely; compound price conversion less so.

Orders remember checkout, not whatever happened later

The display currency and exchange rate are captured while the customer's checkout request is active. A later paid event, webhook or admin request cannot overwrite that snapshot with another context. Guest orders receive the same protection, and the order keeps no conversion snapshot when stale-rate policy correctly falls back to the store base currency.

The tests now exercise what ships

The CI lane runs the actual browser JavaScript for cached-context recovery, switcher failures, Manual rate editing and FluentCart price projection. The PHP suite challenges incomplete snapshots, failed persistence, removed currencies, stale boundaries, checkout timing and cent-based formatting. Tests that copied implementation branches were removed; green theatre already has enough funding.

v1.4.4

Released 14 August 2026GitHub Release

One stale setting, two orders of magnitude.

Converted prices were 100x too high in comma-decimal stores

Reported by @zellfusion in #142, traced to the exact line, with a patch attached — the kind of report that does most of the work for you. Thank you. A Polish store showing 20,00 zł was converting it to 465,19 € instead of 4,65 €.

FluentCart's Number Format is one radio, stored as decimal_separator. The plugin was reading currency_separator — a leftover key that CurrencySettings::get() still hands out and which FluentCart itself no longer reads anywhere. A store set to comma decimals was therefore told its decimal separator was a dot, and the frontend parser did the only reasonable thing with that lie: treated the comma in 20,00 as a thousands separator, stripped it, and carried on with 2000.

The pairing now comes from decimal_separator alone, with the thousand separator as its opposite — the same two lines FluentCart uses internally, so the display layer and the store can no longer disagree about what a comma means. Stores on the dot pairing were never affected and are unchanged.

v1.4.3

Released 9 August 2026GitHub Release

A follow-up to 1.4.2, correcting something that should never have shipped.

Updates work again

The plugin was prepared for a WordPress.org listing that has not happened yet, and the GitHub updater was removed ahead of it. That left a gap nobody wants to be in: not on WordPress.org, so no update channel there — and no self-updater either, so no update channel at all. Anyone who installed it stayed on the version they installed.

The updater is back, and it stays until the directory actually lists the plugin. The include is guarded, so a package built without it means no automatic updates rather than a fatal on activation, and the whole decision now hangs on one flag in wporg/plugins.json that the builder and the release gates both read.

v1.4.2

Released 9 August 2026GitHub Release

Two bugs, both reported from production, both of the same family: code that was confidently wrong and never said so.

FluentCRM's automation email editor no longer refuses to open

Reported by @bergy10 in #74, diagnosed down to the line. Opening the email block of any FluentCRM automation threw TypeError: (window.fcrm_funnel_context_codes || []) is not iterable and the editor never loaded. Site-wide, not just automations that touch currency.

registerFunnelSmartCodes() added its group under a string key. Every other contributor appends to the list. One string key among the integers turns the array associative, an associative array encodes to a JSON object, and the editor spreads that object — which throws. The group is now appended with a proper key, so the payload stays a JSON array.

The trigger guard was guarding nothing

Found while fixing the above. The filter was registered with accepted_args = 2 while FluentCRM dispatches three, so the callback's $funnel parameter was receiving the trigger name instead of the funnel object. Every isset($funnel->trigger_name) check failed, and the group leaked into every automation on the site — FluentForms triggers included.

Worse, the trigger list it was supposed to match had never contained a real trigger name. FluentCRM registers FluentCart triggers as hook names — fluent_cart/order_paid_done — and the constant held dash-style inventions like fluent-cart-order-paid, which match nothing. The registration now takes three arguments and the list holds all eleven genuine FluentCart triggers, each of which carries a real order id for the smart codes to resolve against.

The group used to appear in the smart-code picker for every automation, purely by accident. It is now offered only for FluentCart triggers, which is what it always claimed to do.

The currency switcher stopped lying to logged-out visitors

Reported by @ManniGH in #72: guests picked a currency, the page reloaded, and the default came straight back — while logged-in users were fine.

The cause was Cookie Persistence set to Disabled. With no cookie to write and no account to write to, the preference went nowhere — and the endpoint answered 200 "Currency preference saved." anyway. The switcher, which never looked at the response, reloaded into a reverted page. Two silent failures stacked into one baffling symptom.

  • The endpoint now returns 409 with persisted: false and an explanation when there is nowhere to store the choice, and no longer fires fchub_mc/context_switched for a switch that did not happen.
  • The switcher checks the response before reloading. On failure it keeps the page, restores the previous selection and shows the server's message.
  • The no-JS <noscript> path stopped faking a cookie it never set.
  • Every response from the context endpoint now carries a stable codepreference_saved, persistence_unavailable, module_disabled, rate_limited, invalid_currency — so integrations branch on the code rather than on message text, which is translated.
  • The Cookie Persistence setting now admits, in the admin, that turning it off leaves logged-out visitors unable to keep a currency.

v1.4.1

Prepared 6 August 2026, superseded by 1.4.2 before release

Never tagged, so nobody received it on its own; its changes ship inside 1.4.2.

  • Updates move to WordPress.org.
  • Manual becomes the no-network default, and remote rate providers require explicit configuration.
  • Remote rate requests are bounded, and malformed or oversized responses are rejected without replacing valid rates.
  • Minimum WordPress raised to 7.0, alongside the existing PHP 8.3 requirement.

March 2026

v1.4.0

Released 10 March 2026GitHub Release

The "post-payment automations silently do nothing" release. If you had multicurrency active and wondered why FluentCRM contacts weren't being tagged, FluentCommunity spaces weren't being populated, and membership grants weren't being created — this is why, and this is the fix.

Critical

  • Post-payment integrations killed by multicurrency plugin — FluentCart fires order_paid_done with an event array (['order' => $order, 'customer' => $customer, ...]), not a raw Order object. Two multicurrency handlers at priority 10 — OrderSnapshotHooks::saveSnapshot() and FluentCrmSync::onOrderPaid() — were treating the array as an Order and calling ->getMeta() on it. That throws a TypeError, which is \Throwable but not \Exception. FluentCart's integration runner catches \Exception only, so the TypeError killed the entire hook chain at priority 10 — before FluentCart's integration feeds at priority 11 could execute. Every FluentCRM tag, every FluentCommunity enrollment, every membership grant, every Fakturownia invoice — silently dead. Payment succeeded, everything else didn't. Both handlers now extract the Order from either payload shape via a shared FluentCartEvent::extractOrder() helper.
  • FluentCart settings page killed by wp_send_json() inside a filterMultiCurrencySettings::getGlobalFields() was registered as a WordPress filter callback but called wp_send_json(), which calls die(). Filters are supposed to return a value and let the caller decide what to do with it. Instead, the entire PHP process terminated mid-filter. FluentCart's own response wrapper and field defaults never ran. The settings page worked by accident — the response format was close enough that the Vue component didn't complain. Now returns the field array properly and lets FluentCart handle the response.
  • Save settings response wrapped incorrectlysaveGlobalSettings() wrapped the response in {data: {message, status}} but FluentCart expects {message, status} directly. The extra data wrapper meant the success toast might not show in all FluentCart versions. Removed the wrapper.

Security

  • Rate limiter and event log used proxy IP behind CloudflareREMOTE_ADDR behind a Cloudflare tunnel is the edge server IP, not the client's. Every visitor shared the same rate limit bucket (30 requests/minute total), and every event log entry had the same IP hash. One legitimate user could burn the limit for everyone. New IpResolver checks CF-Connecting-IP first (Cloudflare), then X-Forwarded-For (generic proxy), then REMOTE_ADDR (direct). Multi-IP X-Forwarded-For headers are parsed correctly — first IP wins.

Fixed

  • Exchange rate insert failures silently ignoredExchangeRateRepository::insert() didn't check the return value of $wpdb->insert(). If the insert failed (table missing, disk full, connection error), the caller cached a rate that was never persisted. On the next cache miss, the rate vanished. Now returns bool and logs the DB error on failure.
  • Resolver chain cached forever within a requestContextModule::$cachedChain was set once and never reset. If admin settings changed mid-request (e.g. saving settings and then resolving context), the stale chain with old configuration was used. CurrencyContextService::reset() now also clears the cached resolver chain.
  • Event log timestamps in local time, rate history in UTCEventLogRepository used current_time('mysql') (local timezone), while rate history used gmdate() (UTC). Cross-table queries and admin dashboards showed mismatched timestamps. GDPR exports also returned local-time timestamps. Now uses gmdate() consistently.
  • ECB provider used raw error_log() instead of Logger — one stray error_log() call bypassed the plugin's log level controls and would write to the PHP error log even with debug mode off. Now uses Logger::error() like everything else.
  • Guest currency preference saved without validation on loginmergeGuestPreference() saved the cookie value to user meta without checking if the currency was still enabled. A stale cookie with a disabled currency code would persist indefinitely, retried on every page load. Now validates against the enabled currency list before saving.
  • Admin panel swallowed REST errors in silence — exchange rate loading, diagnostics refresh, and several other admin AJAX calls had no .catch() handlers. A failed request would leave the loading spinner spinning indefinitely with no indication that anything went wrong. All REST API calls now surface proper error toasts via FluentCart's notification system, because a spinner that never stops is not an error message.
  • FluentCRM contact-not-found logged at wrong level — when a customer had no matching FluentCRM contact, the sync logged it at the same level as actual failures. Noisy logs full of expected non-events made real problems harder to spot. Now logs at debug level, where it belongs.
  • FluentCRM custom field existence unchecked — the diagnostics tab checked for FluentCRM's presence but never verified that the required custom fields (preferred_currency, last_order_currency) actually existed. Missing fields would cause silent sync failures with no admin-visible explanation. Diagnostics now includes a custom field health check that tells you exactly which fields are missing and need creating.

Features

  • FluentCRM Smart Codes — new mc_order.* smart code group for FluentCRM automation emails. Six codes: display_currency, base_currency, display_total, display_subtotal, exchange_rate, and charged_notice. Your post-purchase automations can now show amounts in the customer's display currency instead of blindly defaulting to the store's base currency. Because sending "You paid $93.42" to someone who checked out in euros is how you lose trust in one email.
  • Public API — two new helper functions for theme and plugin developers: fchub_mc_get_order_display_currency() returns the display currency from a past order's snapshot, and fchub_mc_format_order_price() formats a price using that order's stored currency and exchange rate. For anyone building custom order templates, receipts, or invoices who was tired of reverse-engineering the snapshot meta keys.

v1.3.0

Released 7 March 2026GitHub Release

The Gutenberg glow-up. The shortcode still works, but now it has a proper block-shaped colleague with six outfits and a whole family of friends.

Features

  • Currency Switcher Gutenberg block — a proper block with six style presets (Default, Pill, Minimal, Subtle, Glass, Contrast), 20+ inspector controls, live editor preview, and shortcode transform. Drop it in headers, footers, template parts, synced patterns — anywhere blocks go. The shortcode era isn't over, but it has company now.
  • Current Currency block — inline display of the active currency. Flag + code, code only, or full name. For sticky status bars and utility rows.
  • Exchange Rate block — "1 PLN = 0.2350 EUR" with configurable precision. For the transparency enthusiasts.
  • Currency Context Notice block — "Viewing prices in USD. Checkout charged in PLN." Trust-building disclosure without a dropdown.
  • Currency Selector Buttons block — one button per currency, no dropdown. For stores with 3-4 currencies that don't need the ceremony.
  • Currency Showcase pattern — all five blocks on the page in one click. Because manually assembling test pages is nobody's idea of fun.
  • Admin live preview — the Switcher settings tab now has a sticky preview widget at the top. Same CSS classes, same SVG flags, instant updates. Change a preset, toggle a flag, see it move. No more save-refresh-squint-repeat.
  • No-JS fallback that actually works — the old one was a trigger button with delusions of interactivity. Replaced with a real <form> that POSTs with a nonce, validates the currency server-side, persists, and redirects back. Affects 0.3% of visitors and 100% of your accessibility audit.

Fixed

  • Pill preset dropdown was oval--fchub-mc-radius: 999px flowed uncapped into the dropdown container and option rows, producing beautifully rounded ovals instead of a rectangular list. Trigger stays pill-shaped, dropdown and options now capped at sensible radii.
  • Glass preset looked identical to Default in admin and editorbackdrop-filter: blur(14px) was technically applied, but the preview backgrounds were solid white. Nothing behind it to blur = no glass effect. Both previews now swap to a colourful gradient background when glass is active, giving the blur something to work with.
  • Viewport-aware dropdown positioning — the dropdown now measures available space in every direction and picks the least-bad position instead of blindly trusting the preferred alignment. No more dropdowns vanishing off-screen or clipping behind the block toolbar.

v1.2.4

Released 7 March 2026GitHub Release

Turns out many managed WordPress hosts (Rocket.net, Cloudways, GridPane, etc.) block HTTP requests to any path containing /vendor/ — a blanket security rule to prevent direct access to Composer dependencies. Reasonable policy, collateral damage to my third-party library. Moved SortableJS out of admin/vendor/ to admin/lib/ so it stops getting caught in the crossfire.

Fixed

  • SortableJS blocked by server-level /vendor/ path rules — managed hosting platforms commonly deny requests to any URL containing /vendor/ to prevent exposure of Composer packages. My vendored Sortable.min.js lived at admin/vendor/Sortable.min.js — a perfectly innocent path that looked guilty by association. The server returned a 403 HTML page instead of JavaScript, which the browser parsed as Unexpected token '<'. Relocated to admin/lib/Sortable.min.js where no security rule will touch it.

v1.2.3

Released 7 March 2026GitHub Release

Improved SortableJS asset delivery for environments where v1.2.2's handle fix wasn't sufficient.

Fixed

  • SortableJS not loading on some hosting configurations — certain server environments with aggressive static asset caching or non-standard plugin_dir_url() resolution could prevent the vendored Sortable.min.js from being served, even after the handle collision fix in v1.2.2. Hardened the asset pipeline to ensure the library reaches the browser regardless of hosting quirks. If drag-and-drop wasn't working for you after updating to v1.2.2, this one's for you.

v1.2.2

Released 7 March 2026GitHub Release

Turns out the drag-and-drop currency reordering was silently broken for some users and I couldn't reproduce it. The SortableJS script handle was registered as sortablejs — a name generic enough that any other plugin registering the same handle first would silently take over the handle I had registered. WordPress doesn't throw, doesn't warn, just uses whoever registered first and moves on. The grab cursor worked (pure CSS), but the actual drag library never loaded. Classic WordPress script handle collision — the kind of bug that works perfectly on every dev machine and breaks in production.

Fixed

  • Drag-and-drop currency reordering broken on some installs — the sortablejs script handle could collide with other plugins registering the same handle. WordPress silently drops duplicate wp_register_script calls, so my Sortable.min.js never loaded while the CSS cursor: grab kept pretending everything was fine. Renamed to fchub-mc-sortablejs so it can't be stolen. Also added a console warning when SortableJS fails to load, because silent failure is not a debugging strategy.

v1.2.1

Released 7 March 2026GitHub Release

One bug, one symptom: every price in the checkout order summary rendered 50% larger and lighter after switching currency. Turns out textContent is a wrecking ball.

Fixed

  • Checkout prices rendered at wrong font size after currency switchfindPriceTarget() didn't recognise FluentCart's nested price structure (.fct_line_item_total inside .fct_line_item_price). Setting textContent on the parent destroyed the styled inner <span> and its text-sm font-medium classes. Prices jumped from 14px/500 to the body's 21px/300. Now drills into .fct_line_item_total, .fct_summary_value, and .fct_coupon_price child elements before falling back to the parent. Also marks the inner target as projected to prevent the selector loop from re-processing it. Your checkout no longer looks like it's shouting the prices at you.

v1.2.0

Released 7 March 2026GitHub Release

A deep audit found things hiding in the codebase that no single pair of eyes would have caught. This is the "I actually read every line" release. A second pass found the P1 I missed — order snapshots were reading the ambient request context instead of the customer's. Which means admin-completed orders, webhook payments, and guest checkouts could all get the wrong currency. Lovely.

Critical

  • Order snapshots now captured at checkout, not paymentsaveSnapshot() ran on order_paid_done, which fires via Action Scheduler — no cookies, no logged-in user, no shopper context. The earlier fallback to user meta was insufficient because the ambient context could be the admin's preference, guest orders had no user_id at all, and disabled currencies weren't validated. Now captures the snapshot during checkout/prepare_other_data while the customer's HTTP request is still warm. The order_paid_done hook stays as a fallback for manual/API orders, but validates against enabled currencies first. No context? No snapshot. Better than wrong.

Security

  • Rate limiter IP spoofing fixed — the rate limiter was trusting the X-Forwarded-For header, which is user-controllable. Your visitors were writing their own hall passes. Now uses REMOTE_ADDR exclusively.
  • Enabled switch actually enforced — setting Multi-Currency to "disabled" didn't actually disable the public /context endpoint, checkout disclosure injection, or order snapshot recording. The off switch was decorative. Now returns 403, skips disclosure, and skips snapshots when disabled.
  • Rate limiter no longer punishes invalid requests — the counter incremented before JSON parsing and currency validation. An attacker could burn your rate limit bucket with garbage requests, locking out real users. Counter now increments only after validation passes.

Fixed

  • Order snapshots lost on webhook/IPN paymentsSaveOrderSnapshotAction resolved currency from the ambient request (cookies, logged-in user). In webhook, IPN, or admin-paid flows there's no shopper context, so snapshots silently disappeared — and CRM sync with them. Now falls back to the order customer's stored currency preference when ambient context is empty.
  • Modal checkout prices not converted.fct-modal-cs-line-price wasn't in the selector list. Modal users saw base currency prices while the rest of the page was converted.
  • Coupon discount amounts stuck in base currency.fct_coupon_price and .fct-coupon-price selectors were missing. Your "– $25.00" coupon discount stayed in dollars while everything else was in euros.
  • Thank you page prices not converted — total, line items, and payment info selectors were all missing. Customers completed an order in EUR and got a receipt in USD. Confidence-inspiring.
  • Pricing table payment type text stuck in base currency — "300.00zł per year for 12 cycles + 100.00zł setup fee" lived in a sibling element, not a child. The engine never knew it existed.
  • stripRegex alternating resultsRegExp.test() with the g flag advances lastIndex between calls, so the bare-number guard returned different results on consecutive calls with the same input. JavaScript: where stateful regex is a feature, not a bug.
  • Timezone drift in staleness detectionfetchedAt stored in local time, compared against UTC. Off by your timezone offset. All timestamps now use gmdate() consistently, and strtotime() explicitly appends ' UTC' so rogue plugins changing PHP's default timezone can't break staleness checks.
  • Future timestamps made rates permanently fresh — a future fetched_at (clock skew, data corruption) produced a negative age, so isStale() always returned false. Now treats future dates as stale.
  • RoundingMode::None showing 15 decimal places — "None" meant "no rounding at all", which meant raw floating point output. Now rounds to the currency's declared decimal count, because €93.4217000000001 is not a price.
  • Corrupted enum values crashed the siteCurrencyPosition::from() and RoundingMode::from() throw ValueError on unrecognised values. A corrupted option row would take the entire site down. Now uses tryFrom() with safe defaults.
  • Non-numeric API rates crashed cron — if an exchange rate provider returned "Infinity" or "NaN", bccomp() would throw a ValueError. Now validates with is_numeric() before comparison.
  • ECB provider returned wrong rates for non-EUR base — if the store's base currency wasn't in ECB's data, rates were returned relative to EUR without rebasing. Every price on the site would be wrong. Now returns empty and logs a warning.
  • Infinite rate crashed the projection engine — a rate of Infinity passed the guard (!Infinity is false), rendering every price as "$Infinity". Now uses Number.isFinite().
  • Negative decimals crashed price formattingtoFixed(-1) throws a RangeError that killed all price projection. Now clamped to 0–20.
  • Missing JS asset triggered PHP warningfilemtime() on a deleted file returned false with a warning. Now suppressed with fallback to the plugin version string.
  • Refresh lock race condition — between get_option() and update_option(), another process could acquire the lock. Replaced with atomic compare-and-swap. TOCTOU: third time's the charm.
  • Checkout data changes ignoredfluentCartCheckoutDataChanged event (dispatched from 6 places in FluentCart) had no listener. Coupon application, shipping changes, and quantity updates at checkout didn't trigger re-projection.
  • Price flicker on rapid events — each event handler had its own setTimeout, so rapid events caused multiple clear+reproject cycles. Now uses a shared debounce timer — last event wins.
  • Flag emoji crashed without mbstringmb_chr() was called without checking if the extension exists. Hosts without mbstring got a fatal error loading the currency catalogue. Now falls back gracefully to empty string, and malformed 1-character country codes no longer cause undefined string offsets.
  • Cookie persistence ignored the cookie_enabled setting — you could disable cookies in settings and the plugin would cheerfully set one anyway. The setting was decorative, the cookie was eternal.
  • Guest currency preference merged on login even with cookies disabledmergeGuestPreference() read the cookie regardless of the cookie_enabled setting. If you disabled cookies, the cookie you already had would still haunt you on next login.
  • Scientific notation rates crashed BCMath — a rate of 1e3 from a provider passes is_numeric() but makes bccomp() throw a ValueError. Because PHP thinks 1e3 is a perfectly reasonable number, and BCMath disagrees. Now rejected at the gate.
  • RoundingMode::None rounded instead of truncating — "None" was calling round(), which is... rounding. 33.337 became 33.34 instead of 33.33. Now truncates towards zero, which is what "no rounding" actually means.

Cleanup

  • Uninstall now cleans up properlyfchub_mc_rate_refresh_lock option and fchub_mc_rl_* rate limiter transients were orphaned on uninstall. Your database was collecting souvenirs.
  • Uninstall now multisite-aware — single-site installs would clean up fine, but multisite would only hit the main blog. The diagnostics transient and object cache group were also orphaned. Your database was collecting memories.
  • rounding_precision setting removed — was saved, validated, and even sent to the browser, but never consumed by anything. Per-currency decimals controls display precision. Dead weight is dead weight.
  • Geolocation shown as "Coming Soon" in admin — the resolver exists behind a feature flag, the settings exist in the backend. Now the General tab shows a faded geolocation section so it's visible but clearly not ready yet.

v1.1.6

Released 6 March 2026GitHub Release

Three bugs walked into the price projection engine. One was my fault, two were always there.

Regression

  • Variant price line hidden by Buy Now button — the v1.1.5 refactor of replaceInlinePrice() removed explicit whitespace padding around converted prices. The basePriceRegex captures surrounding whitespace via \s*, and text.replace(match[0], converted) consumed it without restoring it. The layout collapsed and the Buy Now button swallowed the price line. Now preserves leading and trailing whitespace from the original regex match.

Bugs

  • Variant option button prices stuck in base currency — variant buttons render prices in .fct-product-variant-item-price > span and .fct-product-variant-compare-price > del > span. These classes aren't in the main price selector list, and projectVariantButtons() only converted the invisible data-item-price / data-compare-price attributes. The visible <span> text stayed in base currency. Now converts both the data attributes and the visible span text.
  • Subscription variant prices stuck in base currency — subscription products wrap the price text inside a nested .fct-product-payment-type div instead of using direct text nodes. projectVariantPrice() only checked direct child text nodes and never found the price. Now descends into the payment type child when no direct text nodes contain a price. Additionally, switching between billing interval tabs (Monthly/Half Yearly/Yearly) caused the converted price to flash then vanish — clearProjectionMarkers() was restoring innerHTML from initial render, which re-applied stale is-hidden classes and hid the active tab's price. Now syncs child visibility with the parent's current state after restoration.
  • Variant compare price blocked actual price conversion — when a variant has both a compare/reference price and an actual price, converting the <del> compare price incremented the internal counter, which then prevented the code from descending into the payment type child to convert the actual price text node. All one-time tier prices and installment prices stayed in base currency while the strikethrough compare price was correctly converted. Removed the counter guard so both are always converted.
  • Installment setup fee stuck in base currency — text like "300.00zł per year for 12 cycles + 100.00zł one-time setup fee" contains two prices in one text node. replaceInlinePrice() only replaced the first match. New replaceAllInlinePrices() uses a global regex callback to convert all prices in a single pass, while skipping bare numbers like "12" in "12 cycles".
  • Min/max price filter sidebar shows unconverted numbersprojectCurrencySigns() swapped the currency symbol but left the <input> values untouched. A $100 filter became €100 instead of €93. New projectPriceFilterInputs() converts the displayed value while preserving the original base-currency value in a data attribute for re-conversion on slider changes.

v1.1.5

Released 6 March 2026GitHub Release

The price projection engine was architecturally deaf. Every dynamic price update — cart changes, variant switches, modal opens — was broken. Hat tip to @ManniGH for the bug reports that exposed the full extent of the carnage.

Critical

  • Price projection engine was deaf to FluentCart — all event listeners were attached to document, but FluentCart dispatches every event on window. Custom events don't bubble across event targets. The projection engine never received cart updates, variant changes, or modal opens. Every dynamic price update was broken in production. Fixed by moving all listeners to window, where FluentCart has been shouting into the void this whole time.

High

  • Variant switching left prices in base currency — missing fluentCartSingleProductVariationChanged listener. The engine simply didn't know variants existed. Now listens and re-projects with a 200ms delay for FluentCart to finish rendering.
  • "View Options" modal prices stuck in base currency — missing fluentCartSingleProductModalOpened listener. Same problem, same fix, same facepalm.
  • Checkout subscription renewal line not converted.fct_item_payment_info wasn't in the price selector list. It is now. Subscriptions showing "$9.99/month" next to "€8.72 total" was not the vibe.
  • Subscription text destroyed during projection — "per month, until cancel" was getting obliterated because the projection replaced entire text nodes. Now uses regex to surgically replace only the price portion, leaving the suffix text intact.
  • Pricing table elements destroyed<sup> currency signs and <span class="repeat-interval"> were being vaporised by textContent replacement. Now detects mixed content and modifies only the text node containing the price, because destroying DOM elements is not a currency conversion strategy.
  • fchub_mc_format_price() fatal error$optionStore was undefined when the currency context was already cached. Every call after the first page load would crash. The function that formats prices couldn't format prices. Brilliant.

Security

  • Rate limiting added to public POST /context — unauthenticated endpoint could trigger unlimited FluentCRM API calls and DB writes. Now limited to 30 requests/minute per IP. Your store is no longer a free DDoS relay.
  • Provider names removed from public /rates response — was leaking which exchange rate API the store uses. Nobody needs to know that.
  • Checkout disclosure text now sanitised on output — admin-configurable template now runs through wp_kses() because trusting raw HTML from a settings field is how XSS happens.

Features

  • SVG flag icons — bundled 50 SVG country flags from flag-icons (MIT licensed). Windows users no longer see mysterious two-letter codes where flags should be.
  • Shortcode label attribute[fchub_currency_switcher label="Select currency:"] renders a text label next to the dropdown. For those who believe in accessibility, or just labelling things.
  • Shortcode align attribute[fchub_currency_switcher align="right"] supports right and centre alignment.
  • Per-currency decimal/thousand separators — display currencies can now have explicit separator config instead of the position-based heuristic that thought all right-positioned currencies use commas.

Code Quality

  • Resolver chain cachedbuildResolverChain() no longer creates fresh objects on every call. Object creation is not a hobby.
  • Duplicated isAllowedCurrency() extracted to trait — was copy-pasted across 4 resolver classes. DRY exists for a reason.
  • Race condition in stale lock handling fixeddelete_option + add_option replaced with atomic update_option. TOCTOU bugs: the gift that keeps on giving.
  • current_time('mysql') replaced — deprecated WordPress function replaced with wp_date() in 2 more files. WordPress has been asking nicely since 5.3.

v1.1.4

Released 6 March 2026GitHub Release

Twelve bugs walked into the codebase. None of them survived the code review.

  • EUR prices now actually look like EUR pricesformatNumber() was borrowing the base currency's decimal and thousand separators when formatting display currency prices. EUR showing as 1,234.56€ instead of 1.234,56€. Every right-positioned currency was wrong. Every single one. Fixed.
  • half_down rounding no longer lies — the Math.round(x * (1-EPSILON)) trick doesn't hold for all values, as it turns out mathematics hasn't changed. Replaced with a proper floor-based comparison that behaves correctly instead of mostly correctly.
  • fchub_mc_format_price() stops rebuilding the universe on every call — the function was spinning up the full resolver chain plus DB queries each time it was invoked. It now reuses the cached context like a reasonable piece of software.
  • Stale rate warning stops interrogating the database on every admin page load — now cached via transient with a 5-minute TTL. Your DB server sends its regards.
  • ExchangeRate::from() no longer throws a fatal on unknown providers — if a provider string in the DB didn't match the enum, you got a crash. It now falls back to Manual and carries on with its life.
  • Currency switcher fetch() now handles network failures — previously had no .catch(), so a network error would silently kill the loading spinner forever. Errors are now caught and surfaced like adults.
  • Rate refresh lock fixed — TOCTOU race condition on get_option/add_option. Now uses atomic add_option first, so two concurrent refreshes can't both think they won the lock.
  • Currency symbol no longer sanitized with wp_kses_post — allows HTML. A currency symbol field. Tightened to sanitize_text_field because is not a <script> tag.
  • current_time('timestamp') replaced with time() — deprecated across 3 files. WordPress has been politely suggesting this for years.
  • FluentCrmSync and FluentCommunitySync now go through OptionStore — they were calling settings directly, bypassing the default settings merge. Fixed.
  • Rounding precision fallback alignedFrontendModule said 0, Constants said 2. They now agree on 2, which is the number of decimal places a price should have.
  • Sortable drag-and-drop DOM restoration fixed — the logic for restoring DOM order during downward drags was inverted. Moving a currency down would put it up. Philosophy aside, this is not ideal.
  • Admin sidebar requestAnimationFrame loop bounded — the injection would keep looping forever if FluentCart's markup changed. Now it gives up gracefully instead of melting the browser tab.

v1.1.3

Released 6 March 2026GitHub Release

Three bugs walked into a price projector. None of them walked out.

  • Strikethrough prices restored — the projection engine was cheerfully nuking <del> tags when converting compare prices, turning "$100.00 $80.00" into just "€74.40" with no visual hint that it used to cost more. findPriceTarget() now recognises <del> elements, so your sale prices actually look like sale prices again.
  • "From" prices on shop cards now convert — "From $80.00" on product cards was silently ignored because the regex couldn't handle a currency symbol wedged between the prefix text and the digits. Affected every left-positioned currency (USD, GBP, JPY…) — which is, you know, most of them.
  • Double-conversion edge case fixed — compare prices inside variant containers could get converted twice when the display currency used a right-positioned symbol (e.g. 93.00€parseFloat would happily parse it again). Child compare-price elements are now marked as projected after the parent variant processes them.

v1.1.2

Released 6 March 2026GitHub Release

Fixed exchange rate cron going missing after deactivation/reactivation cycles. The refresh event was only scheduled inside register_activation_hook — if WordPress cleared it for any reason, rates would go stale with no way to recover. The init hook now re-schedules the cron automatically when it detects it's missing.

v1.1.1

Released 6 March 2026GitHub Release

Fixed a fatal error when multiple FCHub plugins are active at the same time. The shared GitHubUpdater class used a class_exists guard that PHP's OPcache cheerfully ignored during early class binding — so the second plugin to load would redeclare the class and take the site down. Wrapped the class inside the conditional so OPcache actually respects it.

v1.1.0

Released March 2026 — Currency reorder

  • Drag-and-drop currency ordering — reorder currencies in the Currencies tab with drag handles. The order you set is the order visitors see in the currency switcher dropdown. Powered by SortableJS.

v1.0.0

Released March 2026 — Initial release

The first release of FCHub Multi-Currency. Display-layer multi-currency for FluentCart — because not everyone thinks in dollars.

Multi-Currency settings panelCheckout disclosure notice in display currencyCurrency switcher dropdown on the shop page

Core Features

  • Currency switcher widget — custom dropdown with flag emojis, ARIA listbox roles, keyboard navigation, and a rate freshness badge. Place it anywhere with [fchub_currency_switcher]
  • Price projection engine — JavaScript-based real-time price conversion across all FluentCart elements. Product cards, cart drawer, checkout totals, pricing tables, variant buttons — everything gets projected
  • Exchange rate management — four providers (Exchange Rate API, Open Exchange Rates, European Central Bank, Manual), automatic cron refresh, rate history with 90-day retention, staleness detection
  • Checkout disclosure — configurable notice at checkout with template tokens ({base_currency}, {display_currency}, {rate}). Injected into checkout summary, cart drawer, and cart page
  • Order snapshots — display currency, base currency, and exchange rate saved to order metadata on payment
  • Context resolution chain — URL parameter → user meta → cookie → geolocation (feature-flagged) → fallback default. First match wins, all validated against display currency whitelist

Integrations

  • FluentCRM sync — automatic contact tagging with currency tags, custom field updates on context switch and order payment
  • FluentCommunity sync — preferred currency written to community user meta
  • FluentCart addon — appears in FluentCart's integration modules, extends store settings API, injects checkout data fragments

Admin

  • Six-tab settings SPA — General, Currencies, Exchange Rates, Checkout, CRM, and Diagnostics. Built as a Vue 3 component inside FluentCart's admin
  • Real-time diagnostics — plugin version, PHP/bcmath status, FluentCart/FluentCRM presence, rate count, stale currency detection, feature flags
  • Stale rate admin notice — dashboard warning when any exchange rate exceeds the stale threshold

Developer

  • REST API — public endpoints for context get/set and rate listing; admin endpoints for settings, rate refresh, currency catalogue, and diagnostics
  • PHP APIfchub_mc_format_price() function for theme/plugin developers
  • JavaScript APIwindow.fchubMcSwitchCurrency(), window.fchubMcInitSwitchers(), window.fchubMcProjectPrices() globals
  • Custom hooksfchub_mc/context_switched, fchub_mc/rates_refreshed, fchub_mc/context filter, fchub_mc/modules filter
  • GDPR compliant — personal data exporter and eraser registered with WordPress privacy tools
  • Feature flagsjs_projection and geo_resolver for gradual rollout control

On this page