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
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.zipA 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
- Copy the plugin folder to
wp-content/plugins/ys-fluentcart-price-calculator. - 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.00only 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_priceandsale_priceare derived. They are recomputed from the four inputs on every write and never trusted from the client.discountis clamped toregular_price, sosale_priceis never negative andregular >= salealways holds. FluentCart zeroes a compare-at price that sits below the item price, so producing such a pair would silently lose the strike-through.syncisyeswhile the stored calculation still describes the live prices,noonce they diverge. Acompare_priceof0counts 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_calckey placed inother_infosurvives a product save from the editor. For simple products it survives because the SPA does not sendother_infoat all — measured save payload above — andProductResourceleaves the column alone when the key is absent. -
other_infois still not a safe sole home. Two paths rewrite it wholesale:ProductVariationResource::update()(POST products/variants/{id}) reducesother_infoto an allowlist wheneverpayment_type === 'onetime', dropping every unknown key — and it fires novariants_updatedaction, so nothing would notice.- On a variation product, the editor posts the
other_infoobject it loaded at page load, so a payload written after that load is overwritten by the stale copy.
Hence the
fct_product_metarow. Verified by strippingys_price_calcout ofother_infodirectly 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 withobject_type = 'product_variant_info'andmeta_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_titleandafter_excerptare 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-pricingor thevariants.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.