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 2026 — GitHub 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 2026 — GitHub 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 2026 — GitHub 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 2026 — GitHub 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 2026 — GitHub 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 2026 — GitHub 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 2026 — GitHub 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: falseand an explanation when there is nowhere to store the choice, and no longer firesfchub_mc/context_switchedfor 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
code—preference_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 2026 — GitHub 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_donewith an event array (['order' => $order, 'customer' => $customer, ...]), not a raw Order object. Two multicurrency handlers at priority 10 —OrderSnapshotHooks::saveSnapshot()andFluentCrmSync::onOrderPaid()— were treating the array as an Order and calling->getMeta()on it. That throws aTypeError, which is\Throwablebut not\Exception. FluentCart's integration runner catches\Exceptiononly, so theTypeErrorkilled 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 sharedFluentCartEvent::extractOrder()helper. - FluentCart settings page killed by
wp_send_json()inside a filter —MultiCurrencySettings::getGlobalFields()was registered as a WordPress filter callback but calledwp_send_json(), which callsdie(). 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 incorrectly —
saveGlobalSettings()wrapped the response in{data: {message, status}}but FluentCart expects{message, status}directly. The extradatawrapper meant the success toast might not show in all FluentCart versions. Removed the wrapper.
Security
- Rate limiter and event log used proxy IP behind Cloudflare —
REMOTE_ADDRbehind 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. NewIpResolverchecksCF-Connecting-IPfirst (Cloudflare), thenX-Forwarded-For(generic proxy), thenREMOTE_ADDR(direct). Multi-IPX-Forwarded-Forheaders are parsed correctly — first IP wins.
Fixed
- Exchange rate insert failures silently ignored —
ExchangeRateRepository::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 returnsbooland logs the DB error on failure. - Resolver chain cached forever within a request —
ContextModule::$cachedChainwas 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 UTC —
EventLogRepositoryusedcurrent_time('mysql')(local timezone), while rate history usedgmdate()(UTC). Cross-table queries and admin dashboards showed mismatched timestamps. GDPR exports also returned local-time timestamps. Now usesgmdate()consistently. - ECB provider used raw
error_log()instead of Logger — one strayerror_log()call bypassed the plugin's log level controls and would write to the PHP error log even with debug mode off. Now usesLogger::error()like everything else. - Guest currency preference saved without validation on login —
mergeGuestPreference()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, andcharged_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, andfchub_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 2026 — GitHub 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: 999pxflowed 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 editor —
backdrop-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 2026 — GitHub 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 vendoredSortable.min.jslived atadmin/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 asUnexpected token '<'. Relocated toadmin/lib/Sortable.min.jswhere no security rule will touch it.
v1.2.3
Released 7 March 2026 — GitHub 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 vendoredSortable.min.jsfrom 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 2026 — GitHub 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
sortablejsscript handle could collide with other plugins registering the same handle. WordPress silently drops duplicatewp_register_scriptcalls, so mySortable.min.jsnever loaded while the CSScursor: grabkept pretending everything was fine. Renamed tofchub-mc-sortablejsso 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 2026 — GitHub 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 switch —
findPriceTarget()didn't recognise FluentCart's nested price structure (.fct_line_item_totalinside.fct_line_item_price). SettingtextContenton the parent destroyed the styled inner<span>and itstext-sm font-mediumclasses. Prices jumped from 14px/500 to the body's 21px/300. Now drills into.fct_line_item_total,.fct_summary_value, and.fct_coupon_pricechild 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 2026 — GitHub 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 payment —
saveSnapshot()ran onorder_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 duringcheckout/prepare_other_datawhile the customer's HTTP request is still warm. Theorder_paid_donehook 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-Forheader, which is user-controllable. Your visitors were writing their own hall passes. Now usesREMOTE_ADDRexclusively. - Enabled switch actually enforced — setting Multi-Currency to "disabled" didn't actually disable the public
/contextendpoint, 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 payments —
SaveOrderSnapshotActionresolved 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-pricewasn'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_priceand.fct-coupon-priceselectors 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.
stripRegexalternating results —RegExp.test()with thegflag advanceslastIndexbetween 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 detection —
fetchedAtstored in local time, compared against UTC. Off by your timezone offset. All timestamps now usegmdate()consistently, andstrtotime()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, soisStale()always returned false. Now treats future dates as stale. RoundingMode::Noneshowing 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.4217000000001is not a price.- Corrupted enum values crashed the site —
CurrencyPosition::from()andRoundingMode::from()throwValueErroron unrecognised values. A corrupted option row would take the entire site down. Now usestryFrom()with safe defaults. - Non-numeric API rates crashed cron — if an exchange rate provider returned
"Infinity"or"NaN",bccomp()would throw aValueError. Now validates withis_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
Infinitypassed the guard (!Infinityisfalse), rendering every price as"$Infinity". Now usesNumber.isFinite(). - Negative decimals crashed price formatting —
toFixed(-1)throws aRangeErrorthat killed all price projection. Now clamped to 0–20. - Missing JS asset triggered PHP warning —
filemtime()on a deleted file returnedfalsewith a warning. Now suppressed with fallback to the plugin version string. - Refresh lock race condition — between
get_option()andupdate_option(), another process could acquire the lock. Replaced with atomic compare-and-swap. TOCTOU: third time's the charm. - Checkout data changes ignored —
fluentCartCheckoutDataChangedevent (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 mbstring —
mb_chr()was called without checking if the extension exists. Hosts withoutmbstringgot 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 disabled —
mergeGuestPreference()read the cookie regardless of thecookie_enabledsetting. If you disabled cookies, the cookie you already had would still haunt you on next login. - Scientific notation rates crashed BCMath — a rate of
1e3from a provider passesis_numeric()but makesbccomp()throw aValueError. Because PHP thinks1e3is a perfectly reasonable number, and BCMath disagrees. Now rejected at the gate. RoundingMode::Nonerounded instead of truncating — "None" was callinground(), 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 properly —
fchub_mc_rate_refresh_lockoption andfchub_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_precisionsetting removed — was saved, validated, and even sent to the browser, but never consumed by anything. Per-currencydecimalscontrols 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 2026 — GitHub 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. ThebasePriceRegexcaptures surrounding whitespace via\s*, andtext.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 > spanand.fct-product-variant-compare-price > del > span. These classes aren't in the main price selector list, andprojectVariantButtons()only converted the invisibledata-item-price/data-compare-priceattributes. 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-typediv 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 staleis-hiddenclasses 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. NewreplaceAllInlinePrices()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 numbers —
projectCurrencySigns()swapped the currency symbol but left the<input>values untouched. A$100filter became€100instead of€93. NewprojectPriceFilterInputs()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 2026 — GitHub 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 onwindow. 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 towindow, where FluentCart has been shouting into the void this whole time.
High
- Variant switching left prices in base currency — missing
fluentCartSingleProductVariationChangedlistener. 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
fluentCartSingleProductModalOpenedlistener. Same problem, same fix, same facepalm. - Checkout subscription renewal line not converted —
.fct_item_payment_infowasn'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 bytextContentreplacement. 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 —$optionStorewas 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
/ratesresponse — 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
labelattribute —[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
alignattribute —[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 cached —
buildResolverChain()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 fixed —
delete_option+add_optionreplaced with atomicupdate_option. TOCTOU bugs: the gift that keeps on giving. current_time('mysql')replaced — deprecated WordPress function replaced withwp_date()in 2 more files. WordPress has been asking nicely since 5.3.
v1.1.4
Released 6 March 2026 — GitHub Release
Twelve bugs walked into the codebase. None of them survived the code review.
- EUR prices now actually look like EUR prices —
formatNumber()was borrowing the base currency's decimal and thousand separators when formatting display currency prices. EUR showing as1,234.56€instead of1.234,56€. Every right-positioned currency was wrong. Every single one. Fixed. half_downrounding no longer lies — theMath.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 atomicadd_optionfirst, 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 tosanitize_text_fieldbecause€is not a<script>tag. current_time('timestamp')replaced withtime()— 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 aligned —
FrontendModulesaid0,Constantssaid2. They now agree on2, 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
requestAnimationFrameloop 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 2026 — GitHub 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€→parseFloatwould 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 2026 — GitHub 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 2026 — GitHub 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.



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 API —
fchub_mc_format_price()function for theme/plugin developers - JavaScript API —
window.fchubMcSwitchCurrency(),window.fchubMcInitSwitchers(),window.fchubMcProjectPrices()globals - Custom hooks —
fchub_mc/context_switched,fchub_mc/rates_refreshed,fchub_mc/contextfilter,fchub_mc/modulesfilter - GDPR compliant — personal data exporter and eraser registered with WordPress privacy tools
- Feature flags —
js_projectionandgeo_resolverfor gradual rollout control