WP Manifestindependent plugin directory
manifest / ecommerce / ys-fluentcart-price-calculator

YS FluentCart Price Calculator

Price calculator for FluentCart — product cost + service fee + international shipping − discount, typed straight into the Price and Compare-at fields, with a storefront price breakdown (shortcode or hook position). English + Traditional Chinese.

by YANGSHEEP DESIGN · github.com/ya19880104/ys-fluentcart-price-calculator · website

★ 0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/ya19880104/ys-fluentcart-price-calculator/archive/refs/heads/main.zip

A FluentCart add-on for stores that price by hand from a formula. It puts a calculator inside the Pricing card of the product editor: you type what the item costs you and what you are adding to it, and FluentCart's own Price and Compare-at price fields fill in as you type. Optionally, the storefront shows the buyer the same breakdown.

Regular price (compare-at) = product price + service fee + international shipping fee
Sale price (price)         = regular price − discount

Built for a US personal-shopper (proxy-buying) store trading in USD, where every listing is priced that way.

It touches nothing inside FluentCart. Everything runs through FluentCart's published PHP hooks, its own form inputs, and this plugin's REST routes.


Requirements

WordPress 6.0+
PHP 7.4+
FluentCart 1.6.0+ (developed and verified against 1.6.3)

Install

  1. Copy the plugin folder to wp-content/plugins/ys-fluentcart-price-calculator.
  2. Activate it (wp plugin activate ys-fluentcart-price-calculator).

The editor panel needs no configuration. The storefront breakdown prints nothing until you choose a position or place the shortcode.

If FluentCart is missing or older than 1.6.0 the plugin registers nothing but an admin notice saying so — no fatal, no half-wired hooks.


The calculator panel

Simple products only. Open a simple product in the FluentCart editor: the panel appears inside the Pricing card, directly under FluentCart's Price field. Products with Simple Variations or Advanced Variations get no panel at all (see Known limitations).

Six fields, two columns:

Field
Product price what the item costs
Service fee with a % button that fills it from the configured default percentage, and a live readout of what it currently is as a share of the product price
International shipping fee
Discount
Regular price (compare-at) read-only — a result, not an input
Sale price read-only

There is no Apply button. Every keystroke recomputes the two results and, 150 ms later, types them into FluentCart's own Price and Compare-at fields — opening the collapsed "Additional display prices" panel the first time, so you can see the number that lands there. The four inputs are stored through this plugin's REST route 600 ms after you stop typing.

Loading the panel never writes a price. It reads the stored calculation (or, for a product that has none, derives a starting point from the live prices: the compare-at — or the price, when there is no compare-at — is the regular price, and the gap between them is the discount) and waits.

The product is still saved with FluentCart's own Save button, so its validation, its dirty-state tracking and its own hooks all still run. Nothing is written behind FluentCart's back.

When someone edits a price by hand

The plugin never silently overwrites a price. If the live prices stop matching the last calculation — because someone typed a different number into FluentCart's Price field and saved — the stored calculation is flagged sync: "no" and the panel opens with a warning:

These prices were changed by hand after the last calculation. [ Overwrite prices with the calculator ]

Pressing the button (or editing any calculator field) re-syncs. Leaving the panel alone leaves the hand-set price exactly where it is.


The storefront breakdown

The same calculation, printed for the buyer:

Product price                          $100.00
Service fee                       $10.00 (10%)
International shipping                  $20.00
Discount                                −$2.00
───────────────────────────────────────────────
Regular price                          $130.00
Sale price                             $128.00
Exchange rate (USD→TWD)                   32.1
Approx. in TWD (for reference only)   NT$4,109
Sale price = product price + service fee + international shipping − discount

Every row can be switched off and relabelled in the settings. Amounts use FluentCart's own Helper::toDecimal(), so they follow the store currency; the TWD row is fixed to NT$ with thousands separators.

Rows that hide themselves regardless of the settings:

  • Discount when the discount is zero — −$0.00 only makes a reader wonder what went wrong.
  • Exchange rate and Approx. in TWD unless an exchange rate is configured. The store trades in USD; the TWD pair is a courtesy, and a stale rate on a storefront is worse than no rate.

Nothing is printed at all when the product has no stored calculation, or when that calculation is out of sync with the live price. A breakdown whose rows do not add up to the price beside it is worse than no breakdown.

Two ways to print it

1. A position, chosen in the settings. One of:

Setting Hooks it binds
Do not print automatically — (shortcode only; the default)
After the product title fluent_cart/product/{single,group}/after_title_block
After the short description …/after_excerpt_block
Before the price …/before_price_block
After the price …/after_price_block
Before the buy buttons …/before_actions_block
After the buy buttons …/after_actions_block

Each position binds both the single/ and the group/ action, because which of FluentCart's two renderers draws a product page is the theme's choice, not ours (see Compatibility notes). The render is gated on is_singular('fluent-products') and the product being the one the page is about, plus a once-per-request latch, so a shop grid stays clean and exactly one breakdown is printed per product page.

Four positions need FluentCart 1.6.3. The after_title, after_excerpt, before_actions and after_actions hook pairs were introduced in FluentCart 1.6.3; on 1.6.0–1.6.2 they do not exist, so those positions print nothing. The settings screen labels them and warns when one is selected on an older release. before_price, after_price and the shortcode work back to 1.5.x.

Not every position fires in every theme. Measured on WordPress 7.1 with Twenty Twenty-Five and FluentCart's block product template: price comes through the group/ pair, actions and quantity through the single/ pair, and title / excerpt through neither — that template prints the core post-title and post-excerpt blocks, so FluentCart's own title and excerpt renderers never run and there is no hook to fire. On such a theme, use after_price, one of the actions positions, or the shortcode. See the table in Compatibility notes.

2. The [ys_price_breakdown] shortcode, for a block template, a page builder, or anywhere a position does not reach. It emits plain HTML, so do_shortcode() from GreenShift / Blocksy content works too.

Attribute Default
product_id the product page you are on Which product to describe. Off a product page with no id, nothing is printed — a breakdown for the wrong product is a pricing error.
rows the settings Comma-separated row keys: product_price, service_fee, intl_fee, discount, regular_price, sale_price, exchange_rate, twd_estimate, formula. Unknown keys are dropped.
show_formula the settings yes/no, for the note row only.
class — Extra class on the wrapper.
[ys_price_breakdown]
[ys_price_breakdown product_id="13"]
[ys_price_breakdown rows="service_fee,sale_price" show_formula="no" class="compact"]

Both ways can be used at once; the shortcode always prints, independently of the automatic position's once-per-page latch.

Settings

FluentCart → Price Calculator (admin.php?page=ys-fct-pcalc-settings), stored in the ys_fct_pcalc_settings option:

Setting Default
Automatic position none Where the breakdown prints by itself.
Exchange rate (USD → TWD) empty Entered by hand; never fetched. Blank hides the exchange-rate and TWD rows.
Rows and labels all on, default labels A checkbox and a label field per row. A blank label uses the translated default.
Formula note on, default text The explanatory line under the rows.
Default service fee (%) empty Powers the panel's % button. With no value the button is hidden.

Upgrading from 0.1.0 migrates the old show_breakdown flag: yes becomes the after_price position, no becomes none. The migration happens on read as well as once on disk, so an option row that never gets rewritten still reads correctly.


Data format

The calculator payload for one variation. Every amount is an integer number of cents; there are no floats anywhere in the pipeline.

{
  "product_price": 10000,
  "service_fee": 1000,
  "intl_fee": 2000,
  "discount": 200,
  "regular_price": 13000,
  "sale_price": 12800,
  "sync": "yes",
  "updated_at": "2026-09-11T10:00:00Z"
}
  • regular_price and sale_price are derived. They are recomputed from the four inputs on every write and never trusted from the client.
  • discount is clamped to regular_price, so sale_price is never negative and regular >= sale always holds. FluentCart zeroes a compare-at price that sits below the item price, so producing such a pair would silently lose the strike-through.
  • sync is yes while the stored calculation still describes the live prices, no once they diverge. A compare_price of 0 counts as agreement when the calculation produced no discount — that is FluentCart's "no strike-through" sentinel.
  • A missing key counts as 0.

Where it is stored

Two places, on purpose:

Store Role
wp_fct_product_variations.other_info.ys_price_calc The portable copy. Travels with the variation row, comes back in FluentCart's own product API response, survives a table export.
wp_fct_product_meta (object_type = 'variation', object_id = <variation id>, meta_key = 'ys_price_calc') The durable copy.

Reads prefer the meta row and fall back to other_info, so a payload written by hand into other_info (or migrated from another site) is still picked up. Writes always update both, and the other_info copy is re-applied after every product save.

The second store exists because other_info alone is not safe. See Compatibility notes.

REST routes

Namespace ys-fct-pcalc/v1. All three require FluentCart's products/edit permission (falling back to manage_options if FluentCart's permission service is unavailable) and a wp_rest nonce; anonymous callers get 401.

Route Purpose
GET /variations/{id} The stored payload plus the variation's live item_price / compare_price.
POST /variations/{id} Store the four inputs for a variation. Writes only the calculator payload — never a price.
GET /products/{id} A product's variation_type and every variation with its prices and payload. This is how the panel learns which variation it is editing, and whether it belongs on the product at all.

Compatibility notes

Everything below was measured against FluentCart 1.6.3 on WordPress 7.1. These are the specific things a FluentCart upgrade could break.

The DOM anchors the panel depends on

The panel drives FluentCart's own form, so it depends on markup rather than an API. All three were measured in the running editor:

Anchor Used for
.simple-product-pricing The Pricing card of a simple product. The panel mounts only when the Price input is inside this element.
#variants.0.item_price FluentCart's Price input. The panel is inserted after its .fct-price-with-inclusion (or .el-form-item) wrapper, and the sale price is typed into it.
#variants.0.compare_price FluentCart's Compare-at input, inside the collapsed .el-collapse-item the panel opens once per mount.

Markup is not a sufficient gate on its own. On a Simple Variations product the variations table also renders an input with id variants.0.item_price — measured — so an id-only check would mount the panel and drive the first variation row. Two things prevent that: the Price input must be inside .simple-product-pricing (which that table is not), and the panel waits for GET /ys-fct-pcalc/v1/products/{id} to confirm variation_type === 'simple' before mounting at all. The editor briefly renders the simple pricing form on a variation product before the product data arrives, and the REST check is what survives that flash.

Values are written with the same native input + blur pair a person produces, so they land in the Vue model and in the editor's change tracker with no private API touched. A MutationObserver throttled to one requestAnimationFrame re-glues the panel when Vue re-renders the card, and removes it when the card is no longer there.

Price units in the Vue model and the save payload: CENTS. item_price and compare_price are integers in cents (128.00 is 12800); FluentCart's PriceInput component converts to and from decimals for display only. Measured from the save request body: {"variants":[{"billing_summary":"","id":4,"item_price":12000}]}.

Dark mode follows the fluent_theme_dark class FluentCart puts on <body> (AdminTheme::DARK_CLASS), stamped by an inline script before first paint.

wp-admin out-specifies a lone class. The panel's inputs are styled through .ys-fct-pcalc-panel input.ys-fct-pcalc-control__input, because wp-admin styles fields with input[type="text"] and a bare class selector loses to it — the fields come out white-on-dark otherwise.

Where we enqueue

Name Kind What we do with it
fluent_cart/admin_js_loaded PHP action Where we enqueue. It fires at the end of MenuHandler::enqueueAssets(), which only runs on admin.php?page=fluent-cart — that is the gate that keeps our script off every other admin screen.
fluent-cart_global_admin_hooks Script handle Declared as our script's dependency. The fluent-cart half is FluentCart's app.slug, read from the app instance at enqueue time rather than hard-coded.

PHP hooks

Hook Kind What we do with it
fluent_cart/product/variant_save_data filter ($variant, $postId) Sanitise and recompute an incoming payload; decide sync from the payload's prices.
fluent_cart/product_updated action Reconcile sync against the rows that actually committed, and re-apply the other_info mirror.
fluent_cart/product/variants_updated action Same reconciliation, for the paths that fire this instead.
fluent_cart/product/{single,group}/{before,after}_{title,excerpt,price,actions}_block actions The storefront breakdown, at the configured position.

variant_save_data does not fire for simple products, and the editor sends no other_info for them either. That is why the authoritative sync decision is made afterwards, from the committed rows, by SyncReconciler — which covers every save path including the ones that fire no filter at all.

Which product-page hooks actually fire

Measured by binding all twenty single/* and group/* block actions on this build (WordPress 7.1, Twenty Twenty-Five, FluentCart's block product template):

Region Single product page Shop grid (/shop/)
title neither group/ (per card)
excerpt neither — (no excerpts set)
price group/ (scope = product_card) group/ (per card)
quantity single/ —
actions single/ group/ (per card)

So on a block-template theme the group/ price actions fire for the single product page too — the same actions every card in a shop grid fires, with scope = product_card. Hence the is_singular + queried-object guard. A classic-template theme goes through ProductRenderer and fires the single/ pair for price as well; both are bound, so either works.

before_actions / after_actions fire inside FluentCart's .fct-product-buttons-wrap, which is a two-column grid. The breakdown carries grid-column: 1 / -1 (and a flex equivalent) so it takes its own row instead of becoming a third button-sized cell.

other_info round-trip — verified

  • A ys_price_calc key placed in other_info survives a product save from the editor. For simple products it survives because the SPA does not send other_info at all — measured save payload above — and ProductResource leaves the column alone when the key is absent.

  • other_info is still not a safe sole home. Two paths rewrite it wholesale:

    1. ProductVariationResource::update() (POST products/variants/{id}) reduces other_info to an allowlist whenever payment_type === 'onetime', dropping every unknown key — and it fires no variants_updated action, so nothing would notice.
    2. On a variation product, the editor posts the other_info object it loaded at page load, so a payload written after that load is overwritten by the stale copy.

    Hence the fct_product_meta row. Verified by stripping ys_price_calc out of other_info directly in SQL: reads kept working from the meta row, and the next reconciliation put the mirror back.

    (Note: FluentCart's ProductMetaResource::delete() — called on the variant update path — only removes rows with object_type = 'product_variant_info' and meta_key = 'product_thumbnail', so it cannot touch ours.)


Tests

php tests/run.php

Zero dependencies — no composer install, no WordPress. 159 assertions across:

  • Calculator — cent coercion, the formula, discount clamping, payload sanitising, and the sync decision.
  • Settings — the 0.1 → 0.2 migration, normalisation of a hand-edited option row, and the Settings API sanitiser.
  • Breakdown — the service-fee percentage, the TWD estimate, which rows get built and which hide themselves, and the escaping in the rendered markup.
  • Shortcode / AutoDisplay — attribute parsing and the position → hook map.
  • VariantSaveHandler::apply() — the pure half of the save filter: payloads it ignores, recomputation of derived values, and the no-silent-overwrite rule.
  • Bootstrap — loads the real plugin file with no FluentCart present and asserts it registers nothing but an admin notice.

Exit code is 0 on pass, 1 on failure.


Known limitations

  • Simple products only. Simple Variations and Advanced Variations get no panel. Everything below the UI — storage, sanitising, sync reconciliation, the storefront breakdown — is variation-type agnostic and still works for those rows if a payload is written through the REST route.
  • The breakdown describes a product's first variation. For a simple product that is its only one. FluentCart's two renderers hand the hook different prices for the same page (the block renderer passes the range minimum, the classic one the default variant), so picking by price would print a different breakdown depending on the theme; the first row is the same answer either way.
  • after_title and after_excerpt are dead on a block-template theme, because FluentCart's title and excerpt renderers never run there. The setting is offered because a classic-template theme does fire them.
  • The panel depends on markup, not an API. If FluentCart renames .simple-product-pricing or the variants.0.* input ids, the panel stops appearing (or stops filling the fields) — it cannot corrupt anything, but it needs a re-check after a FluentCart upgrade.
  • Exchange rates are entered by hand. Nothing is fetched; a rate that goes stale stays stale until someone edits it. The TWD row says "for reference only" for that reason.
  • Zero-decimal currencies are not special-cased in the editor panel, which always shows two decimal places. The storefront follows Helper::toDecimal() and therefore the store's own setting.
  • No bulk import. Calculations are entered per product in the editor, or written through the REST routes.