Skip to main content

This update brings a few improvements behind the scenes, including better permission checks for the β€œSave as Views” filter, more flexibility for managing specific emails, smoother PDF downloads on the receipt page, and improved Turnstile CAPTCHA handling.

We also fixed a handful of issues across checkout, licensing, subscriptions, payment gateway settings, and order parsing, plus a couple of annoying UI bugs that should just stay gone.

Full Changelog:

  • Adds Permission checks for the β€œSave as Views” filter
  • Adds Filter hooks to manage specific emails
  • Fixes the loading animation issue across all pages
  • Fixes License expiration handling issue
  • Fixes Mollie subscription issues
  • Fixes Paddle email notification compliance issue
  • Fixes Deprecated timezone alias handling in OrderParser
  • Fixes the issue where an empty SKU string is used instead of null
  • Fixes the Modal checkout visibility toggle issue
  • Fixes Typos and other issues in payment gateway settings
  • Improves PDF download functionality on the receipt page
  • Improves Turnstile CAPTCHA handling
  • Internal Security Audit and Improvements

A surprise feature release will happen in the next few days. I am personally excited for that. Watch out!

A client is attempting to checkout and they keep getting this error message: "Security Check Failed. Please refresh the page and try again" Is this coming from FluentCart? I'm having trouble finding the cause of this as I've had multiple other people checkout successfully. Its a brief message that momentarily appears and then vanishes.

I don't see a way to close the add to cart drawer on mobile. You are forced to go to checkout. Am I missing something or is this option not there?

TL;DR

On MySQL instances where sql_mode does NOT include STRICT_TRANS_TABLES and NO_ZERO_DATE (which is the default on Ubuntu 22.04 / MySQL 8 packaged by Canonical, and on many shared hosts), every newly-created Lifetime license is stored in the database with expiration_date = '0000-00-00 00:00:00' (zero-date) instead of NULL. The hourly license scheduler then reads the zero-date as "expired ages ago" and flips the license to status = 'expired' within ~60 minutes of purchase.

The symptom visible to customers: a Lifetime license that shows "Lifetime" as the expiration and "Expired" as the status at the same time β€” an impossible state.

I just sold a lifetime license on my own shop and was contacted immediately by the customer, wondering why their freshly purchased license was expired. Not a good first impression!

Environment where reproduced

  • FluentCart: 1.3.17 (free + Pro)
  • MySQL: 8.0.45 (Ubuntu 22.04 package)
  • sql_mode: NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION (Ubuntu default β€” note the absence of STRICT_TRANS_TABLES and NO_ZERO_DATE)

Not reproduced on a MySQL Community 8.0.35 installation where sql_mode is the upstream default (ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION). In that environment, the same write path errors out instead of silently storing zero-date.

Steps to reproduce

  1. Install FluentCart + FluentCart Pro 1.3.17 on a MySQL instance where sql_mode does NOT include STRICT_TRANS_TABLES and NO_ZERO_DATE.
  2. Create a product variation configured as Lifetime (validity unit = lifetime).
  3. Purchase the product (any valid payment path).
  4. Observe: the new license is created in fct_licenses with expiration_date = '0000-00-00 00:00:00'.
  5. Wait for the next hourly cron tick (or manually run do_action('fluent_cart/scheduler/hourly_tasks')).
  6. Observe: the license's status is now expired.
  7. Visit the license detail screen in the admin UI. The expiration displays as "Lifetime" but the status badge reads "Expired."

Reproducing the same bug via the admin UI (UPDATE path, not just INSERT)

  1. Take any existing Lifetime license (currently status=active, expiration_date=NULL).
  2. In the admin, click Edit on the expiration date, set it to a real future date (e.g., 2029-04-01), save.
  3. Observe: fct_licenses.expiration_date is now 2029-04-01 00:00:00. Correct.
  4. Click Edit again, type literally lifetime, save.
  5. Observe: fct_licenses.expiration_date is now '0000-00-00 00:00:00', NOT NULL. The extendValidity() method code explicitly does $this->expiration_date = null; $this->save(); at this point β€” so the null-to-empty-string coercion happens somewhere in the save pipeline, not in user input.

Expected vs. Actual

Expected: expiration_date for Lifetime licenses is stored as SQL NULL. The scheduler's whereNotNull('expiration_date') correctly excludes them.

Actual: expiration_date is stored as '0000-00-00 00:00:00' on non-strict MySQL. The scheduler's whereNotNull does NOT exclude zero-date values (they are not NULL β€” they are a real datetime value that MySQL accepts when strict mode is off). The subsequent WHERE expiration_date <= :cutoff check passes because year 0000 is before any cutoff. The license gets flipped to expired.

Root cause analysis

Three code locations that together produce the bug:

1. Read-side accessor masks the zero-date (License model)

File: fluent-cart-pro/app/Modules/Licensing/Models/License.php (~line 60)

public function getExpirationDateAttribute($value)
{
    if (empty($value) || $value === '0000-00-00 00:00:00') {
        return null;
    }
    return $value;
}

When PHP code reads $license->expiration_date, this accessor converts zero-date β†’ null. The UI (which reads through the model) therefore displays "Lifetime," correctly per the accessor. The admin UI and the developer never see that the raw DB value is actually zero-date.

Problem: there is no matching write-side mutator (setExpirationDateAttribute). When code writes $license->expiration_date = null, the null goes straight through to the attribute array. Something downstream in the save pipeline (WPFluent ORM and/or the database driver) converts it to an empty string before the UPDATE statement. On non-strict MySQL, '' in a DATETIME column is silently coerced to '0000-00-00 00:00:00'.

2. Scheduler query bypasses the accessor (runs raw against the DB)

File: fluent-cart-pro/app/Modules/Licensing/Hooks/Handlers/LicenseSchedulerHandler.php (~line 31)

$licenses = License::query()
    ->whereIn('status', ['active', 'inactive'])
    ->whereNotNull('expiration_date')
    ->where('expiration_date', '<=', $dateTime)
    ->limit(100)
    ->get();

whereNotNull translates to WHERE expiration_date IS NOT NULL in SQL. Zero-date '0000-00-00 00:00:00' is NOT NULL β€” it's a real datetime value. So the filter does not exclude it. The <= :cutoff check then matches because year 0000 is before any conceivable cutoff. Licenses with zero-date expiration are marked expired.

3. UI controller honors lifetime sentinel, but the save path still fails

File: fluent-cart-pro/app/Modules/Licensing/Http/Controllers/LicenseController.php (~line 92)

The extendValidity() controller method correctly handles the string lifetime as a sentinel and calls the model's extendValidity() method, which does:

if ($newDate == 'lifetime' || $newDate === null) {
    $this->expiration_date = null;
}
$this->save();

This code is correct in intent β€” assign null, save. But on non-strict MySQL the value lands as zero-date regardless. This is the smoking gun: the bug is in the save pipeline, not in the controller or the model method. Something in WPFluent's save path converts null β†’ '' before the UPDATE SQL fires.

Evidence

sql_mode comparison

Environment sql_mode Non-strict (bug reproduces) NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION Strict (bug does not reproduce) ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

DB state before and after the UI lifetime save (UPDATE path repro)

-- Before: known-good NULL state
id=5, status=active, expiration_date=NULL

-- Step 1: user sets expiration to 2029-04-01 via UI Edit, saves
id=5, status=active, expiration_date='2029-04-01 00:00:00'   (correct)

-- Step 2: user types 'lifetime' in UI Edit, saves
id=5, status=active, expiration_date='0000-00-00 00:00:00'   (BUG β€” should be NULL)

-- Verified: raw SQL "UPDATE ... SET expiration_date = NULL WHERE id = 5" does land NULL correctly.
-- So MySQL accepts NULL for this column. The null-to-empty-string coercion is happening in PHP land.

Confirmation that the hourly scheduler runs on the zero-date row

Within ~60 minutes of step 2 above, the scheduler ticked and status changed from active to expired without any user or code action. This is the observable customer-facing symptom.

Suggested fixes

Either fix independently resolves the bug. Both together provide defense in depth.

Fix A (preferred, root cause): add a write-side mutator on the License model

File: fluent-cart-pro/app/Modules/Licensing/Models/License.php

Add alongside the existing getExpirationDateAttribute:

public function setExpirationDateAttribute($value)
{
    if ($value === null || $value === '' || $value === '0000-00-00 00:00:00') {
        $this->attributes['expiration_date'] = null;
        return;
    }
    $this->attributes['expiration_date'] = $value;
}

This normalizes writes symmetrically with the existing read accessor. Any code path that sets $license->expiration_date = null (or empty string, or accidentally a zero-date) will land as true SQL NULL. The fix is environment-independent and would work on both strict and non-strict MySQL.

Fix B (defense in depth, cheap): harden the scheduler query

File: fluent-cart-pro/app/Modules/Licensing/Hooks/Handlers/LicenseSchedulerHandler.php

$licenses = License::query()
    ->whereIn('status', ['active', 'inactive'])
    ->whereNotNull('expiration_date')
    ->where('expiration_date', '!=', '0000-00-00 00:00:00')   // <-- add this line
    ->where('expiration_date', '<=', $dateTime)
    ->limit(100)
    ->get();

This ensures that even if any zero-date values slip through past writes (existing data on upgraded shops), they're treated as "not expirable via cron." No existing customer gets falsely flipped to expired.

Fix C (data cleanup migration, recommended alongside A+B)

Once the code fixes are in, run a one-time cleanup on upgrade to normalize existing data:

UPDATE fct_licenses
SET expiration_date = NULL, updated_at = NOW()
WHERE expiration_date = '0000-00-00 00:00:00';

This rescues any customer whose license is currently in the broken state from previous releases.

Why this matters beyond this one shop

  • Ubuntu 22.04's default MySQL 8 sql_mode is missing STRICT_TRANS_TABLES and NO_ZERO_DATE (Canonical's packaging choice, not MySQL's upstream default).
  • CloudPanel, a popular free control panel, runs on Ubuntu and inherits this sql_mode for its managed MySQL instances.
  • Most budget/shared hosts use similarly permissive sql_mode settings for compatibility with older applications.

That means the typical FluentCart deployment is likely affected. Shops that sell Lifetime licenses will see every new Lifetime buyer hit the bug within an hour of purchase. Most shop owners will think this is an isolated incident rather than a systemic issue, because the UI shows "Lifetime / Expired" (visually contradictory but not obviously a bug class).

A fix in the plugin itself (Fix A and/or B above) eliminates the bug for every shop regardless of MySQL configuration, and is far more reliable than asking every shop owner to tighten their sql_mode (which can surface unrelated latent bugs).

Lifetime licenses expire within the hour on MySQL with default (non-strict) sql_mode Lifetime licenses expire within the hour on MySQL with default (non-strict) sql_mode

See title, I can't seem to find a way to link a coupon to a fluent affiliate user so they always get the referral when their coupon is used. Seems pretty basic, so I figured I must be missing the option or something?

I was told that currently, there is no option or filter hook available to remove the display of prices when a product is out of stock.

Does anyone happen to have a solution for this? I'd like to keep the out-of-stock items for SEO and for collecting interested customers.

Hi,

I'm trying to use the Instant Modal Checkout feature on my site, which runs both FluentCart and FluentCommunity.

Setup:

  • I added the PHP filterΒ add_filter('fluent_cart/enable_modal_checkout', '__return_true')Β via a snippet plugin (frontend only)
  • I'm using theΒ [fluent_cart_checkout_button variation_id="X" instant_checkout="yes"]Β shortcode or the direct checkout URL
  • Payment gateway (Stripe) is configured and active

Problem: Instead of opening a popup, clicking the button opens a new page with the checkout β€” the modal content loads as a full page rather than an overlay.

I tested in normal browsing mode on two different browsers (not private mode), same behavior.

FluentCommunity seems to sanitize content inside Spaces and Courses β€” is it the issue ?

I also tested placing the button in the sidebar link area (outside of Spaces), same result.

Question: Is there a known way to make Instant Modal Checkout work inside a FluentCommunity environment? Or is there a workaround β€” for example, a way to trigger the modal via a plain URL parameter so it doesn't rely on JS being loaded in a specific context?

Thanks

Does anyone have experience implementing a restock notifier in FluentCart? I’m looking to add an email capture field directly on product page when an item is out of stock (ideally positioned next to the disabled "Not Available" button) so people do not have to scroll down to find the field.

My goal is to have a simple way to collect a waiting list and tag customers with something like [productX_waiting] upon signup so I can trigger custom notification workflows manually.

I sell bulk beef. Customer pays deposit to old their spot on next processing run. Once we know the actual weight we can calculate the final balance. Every order is different.

The installment system doesn't work as it charges a fixed amount automatically and we don't know what the balance is until after processing.

It is becoming a deal breaker for me and I might need to head back to woo until I can find a solution in fluent.

Has anyone built this kind of flow?