Settings and configuration
Every plugin option and where it can be overridden, why the margin ships without a default, the two environment variables, the admin page, and the two admin API routes.
There are three places a setting can come from, and they resolve in a fixed order. Once you have that order, everything else on this page follows from it.
FX_PRICING_DISABLED (environment) -> can only force `enabled` off
|
Settings > FX pricing (persisted) -> what an operator saved
|
medusa-config.ts (plugin options) -> the fallback for an unset overrideEvery runtime path - the price-change subscriber, the daily job, the Recompute
now button, the admin config route - resolves all of this through one method,
getResolvedRuntimeOptions(), at the top of each run. Nothing is captured at boot.
A change saved in the admin is live on the very next run, with no restart.
Plugin options
All three are optional. An install that passes none is configured entirely from the admin.
| Option | Type | Default | What it does |
|---|---|---|---|
enabled | boolean | false | Seeds the persisted toggle, once, when the settings row is first created |
marginMultiplier | number | none | Fallback margin when no override is saved |
stalenessToleranceHours | number | 120 | Fallback staleness tolerance, in hours |
sourcePriceIncludesVat | boolean | true | Whether the PLN default price is gross and must be reduced to net before conversion |
vatRate | number | 0.23 | The VAT rate to strip when sourcePriceIncludesVat is true; ignored otherwise |
plugins: [
{
resolve: "@zanreal/medusa-fx-pricing",
options: {
enabled: false,
marginMultiplier: 1.25,
stalenessToleranceHours: 120,
sourcePriceIncludesVat: true,
vatRate: 0.23,
},
},
],Unlike marginMultiplier and stalenessToleranceHours, the last two are not overridable from
the admin - see "VAT: gross PLN, net EUR/USD" below for why.
The margin has no default, deliberately
marginMultiplier is the one number in this plugin that decides what a customer
is charged. A shipped default would be some other store's commercial preference,
applied to your prices, without you having chosen it.
So there is none. Until a margin is set - in medusa-config.ts, or in the admin -
a recompute run refuses the whole run before fetching a single rate, records
the reason in the run summary, and writes nothing. The settings page shows a
warning saying the same thing in the same words.
Set 1 if you genuinely want the raw NBP mid rate with no markup. That is a
choice, and the point is that it is recorded as one.
The staleness tolerance does have a default
stalenessToleranceHours keeps its 120, and the asymmetry is the point: it is a
tolerance for a public rate table's publication schedule, not a commercial
preference. The unsafe direction is also already closed off, because setting it too
low only skips a run - it can never price something off a rate you did not intend.
See Rates, and days without one.
VAT: gross PLN, net EUR/USD
This plugin's origin store configures its default prices with PLN gross (brutto,
23% VAT) and EUR/USD net (netto) - price_preference.is_tax_inclusive is
true for pln and false for both eur and usd. Converting the PLN amount
straight into a field the store itself declares net is wrong regardless of the
margin: it puts a gross amount somewhere net is expected, so 23% VAT rides along
uncorrected and inflates the effective markup - a configured 1.1 landed as an
effective ~1.353 in production before this was caught (see AI-655).
sourcePriceIncludesVat (default true) and vatRate (default 0.23) are what
control this:
net_pln_amount = sourcePriceIncludesVat ? pln_amount / (1 + vatRate) : pln_amount
foreign_amount = net_pln_amount / nbp_rate * margin_multiplierIf your store's PLN default price is net instead of gross, set
sourcePriceIncludesVat: false in medusa-config.ts - vatRate is then ignored
entirely and the raw PLN amount is converted exactly as it was before this option
existed. This is a one-line, fully reversible flip that takes effect on the next
backend restart, and needs no migration or database change. See
computeForeignAmount and toNetPlnAmount in
src/modules/fx-pricing/lib/compute.ts, and their tests, for the exact math and
edge cases.
Persisted settings
fx_pricing_settings is a one-row singleton with a fixed primary key. It is
created on first read, and a concurrent first read that loses the insert re-reads
the winner's row rather than duplicating it.
The three settings on it are not stored the same way, and the difference is intentional.
enabled is a real boolean, not a nullable override. It is seeded once from
options.enabled at the moment the row is first created, and after that the admin
toggle is the only thing that changes it. There is no meaningful "fall back to the
config value on every read" for a kill switch: an operator flips it, and that is
the answer until they flip it again. Changing options.enabled in
medusa-config.ts later has no effect on an installation that already has a
settings row.
margin_multiplier and staleness_tolerance_hours are nullable overrides.
null means "not overridden here", and the plugin falls back to the corresponding
plugin option on every read. Clearing an override in the admin writes null, which
returns that setting to whatever medusa-config.ts says. When
margin_multiplier is null and no marginMultiplier option was configured,
there is no margin at all, and a run refuses rather than inventing one.
The row also carries last_run_at and last_run_summary, which are not
configuration but the last run's own report - written after every run, successful
or not, and read back by the settings page.
Environment variables
FX_PRICING_DISABLED
Forces the plugin off at runtime regardless of what is persisted. Any non-empty
value counts, except 0 and false (compared case-insensitively after trimming).
It can only ever force off, never on. An operator can still flip the persisted toggle while it is set, and that persisted state takes effect the moment the variable is cleared. The admin page shows a badge while it is in force, so nobody has to wonder why a toggle that reads "on" is not doing anything.
Use it for an environment - staging, a deploy being investigated - where the job and the manual action must not run whatever the database says.
FX_PRICING_CRON
Overrides the job's schedule. Defaults to 0 3 * * *: once a day at 03:00, which
is long after table A's 11:15 CET publication window has closed for the previous
day and well before most stores' business hours, so a price change is never
visible mid-session.
The job is the backstop, not the mechanism - a PLN price change is picked up by the subscriber within seconds, and the schedule only governs the full nightly pass that catches a moved rate, a price written outside the workflows, or an event that was dropped. Moving it is a decision about when that sweep happens, not about how quickly a new product gets its prices.
This one is an environment variable rather than a plugin option for a structural
reason, not a stylistic one. Medusa evaluates a scheduled job's config.schedule
at plugin-load time, before the DI container - and therefore before this plugin's
resolved options - exists. There is nothing to read a plugin option from at the
moment the schedule is needed.
The admin page
Settings > FX pricing is the plugin's only admin surface. There is deliberately no per-product widget: this plugin has nothing per-product to show that the variant's own price editor does not already show.
- Enabled - the persisted toggle, saved immediately on flip. No separate Save button, because this is a kill switch and not a form field. Shows the forced-off badge when the environment variable is set.
- Configuration - margin multiplier and staleness tolerance, with a Save button and a Clear saved values action that appears once either is overridden. While no margin is set anywhere, a warning explains that a recompute will write nothing and why that is on purpose.
- Current NBP rates - fetched live on every page load, so you can check what the next run would compute before triggering it.
- Last run - the timestamp and per-currency summary of the most recent run, plus Recompute now, which runs the same logic as the scheduled job and renders the result inline.
Admin API
Both routes sit under /admin/fx-pricing and use Medusa's standard admin
authentication.
GET /admin/fx-pricing/config
Returns the resolved configuration, the live rates, and the last run's summary.
{
"effectiveEnabled": true,
"forceDisabled": false,
"persistedEnabled": true,
"marginMultiplier": 1.25,
"marginMultiplierOverridden": false,
"stalenessToleranceHours": 120,
"stalenessToleranceHoursOverridden": false,
"lastRunAt": "2026-08-13T03:00:00.000Z",
"lastRunSummary": {
"ranAt": "2026-08-13T03:00:00.000Z",
"ran": true,
"currencies": {
"usd": {
"reached": true,
"currencyDisabled": false,
"rateUnavailable": false,
"rateStale": false,
"failed": false,
"plannedCreates": 3,
"plannedUpdates": 12,
"created": 3,
"updated": 12,
"unchanged": 140,
"skippedManualOverride": 5,
"skippedNoPlnPrice": 0,
"skippedQuantityTiered": 0,
"stampFailed": 0,
"rate": 3.9123,
"rateEffectiveDate": "2026-08-12"
}
},
"pricesWritten": 15
},
"liveRates": {
"usd": { "mid": 3.9123, "effectiveDate": "2026-08-13", "tableNo": "154/A/NBP/2026" },
"eur": { "error": "NBP request for eur failed with status 503" }
}
}Three fields are worth pointing at.
persistedEnabled and effectiveEnabled differ exactly when forceDisabled is
true, which is what lets the page show a toggle in the "on" position alongside a
badge explaining that the environment is overriding it.
A liveRates entry is either a rate or an { "error": ... } object, per currency
and independently. One currency failing never fails the request.
lastRunSummary is null until the first run completes. Within it, every
target currency is present, always. A currency the run never got to carries
reached: false, and one whose own pass threw carries failed: true and an
error; neither is left out. An earlier version omitted a currency it never
reached, which read exactly like a currency that had been fine.
POST /admin/fx-pricing/config
Writes an override. Body: { enabled?, margin_multiplier?, staleness_tolerance_hours? }.
Only the keys present are written. Returns the same shape as the GET, reflecting
the state just saved.
enabledmust be a boolean. It does not acceptnull- it is a real toggle, not an override of a fallback.margin_multipliermust be a finite number greater than0and at most10, ornullto clear the override. The ceiling is a guard against a fat-fingered entry, not a business limit.staleness_tolerance_hoursmust be an integer greater than0and at most720, which is 30 days, ornullto clear the override.
An unknown key, a wrongly typed value, or a body with no writable key at all is
rejected with a 400 naming the problem.
POST /admin/fx-pricing/recompute
Runs the same recompute the job runs, immediately, and returns
{ "summary": { ... } } with the RunSummary shape shown above.
It does not check the toggle itself. The shared recompute function owns that check,
so this route and the job can never disagree about whether they are allowed to
run - when the plugin is off, both get { "ran": false } and nothing is written.
Reading a run summary
| Field | Meaning |
|---|---|
ranAt | ISO timestamp of the run |
ran | false when the run did nothing because the toggle was off |
trigger | What set the run going: scheduled, manual, event or workflow |
scopedVariantCount | How many variants the run was narrowed to, or null for a full catalogue pass |
error / errorName / errorStack | Present when the run failed. The message is the real one, never "[object Object]" |
pricesWritten | Prices written and stamped across every currency. 0 on a completed run is called out in the admin |
currencies[code].reached | false when the run ended before this currency's turn |
currencies[code].failed / error | This currency's own pass threw. The next currency was still attempted |
currencies[code].currencyDisabled | Not in the store's supported currencies |
currencies[code].rateUnavailable | The NBP rate could not be fetched or parsed |
currencies[code].rateStale | The newest published rate is older than the tolerance |
currencies[code].plannedCreates / plannedUpdates | What the run decided to do, before it wrote anything |
currencies[code].created / updated | What actually landed and was stamped |
currencies[code].unchanged | Already at the target and still plugin-managed |
currencies[code].skippedManualOverride | Left alone, see Manual overrides |
currencies[code].skippedNoPlnPrice | No usable default PLN price to convert from |
currencies[code].skippedQuantityTiered | Priced as a quantity ladder, see Manual overrides |
currencies[code].stampFailed | Written but not recorded as plugin-owned. Never routine, see below |
currencies[code].rate / rateEffectiveDate | The rate the run used, present whenever one was fetched |
Read created against plannedCreates, not on its own. They are separate
fields because they used to be the same one: a run reported created: 61 while
nothing at all had been written, because the counter was read off the plan and
the writes that followed it failed. plannedCreates is the intent,
created is the result, and a gap between them means a write or a stamp did not
land.
stampFailed is never routine. A price this plugin writes but cannot stamp
is, by its own ownership rule, somebody else's price from that moment on - the
next run sees a price it has no record of writing and skips it permanently. The
remedy is to delete those prices so the next run can create and stamp them
cleanly.
Using the workflows directly
The plugin exports its workflows so a host can trigger a recompute from its own script, admin action or schedule:
import { recomputeFxPricesWorkflow, runFxPricingRecompute } from "@zanreal/medusa-fx-pricing/workflows";runFxPricingRecompute(container) is the plain async function the job, the admin
route and the subscriber all call. recomputeFxPricesWorkflow wraps it as a
workflow for composition into a larger one.
A second argument narrows and labels the run:
await runFxPricingRecompute(container, {
trigger: "event", // "scheduled" | "manual" | "event" | "workflow"
variantIds: ["variant_01ABC"], // omit for a full catalogue pass
});variantIds changes only which variants are read - every rule (the toggle, the
margin refusal, the per-currency skips, the manual-override decision, the
stamping) is the same code either way. An empty array means no variants, not
all of them: a caller that resolved its input down to nothing must never fall
through into repricing the whole store. Only a full pass persists its summary as
last_run_summary; a narrowed one reports itself in the log.
It is deliberately not compensated. Every write is a reconciliation of PLN-derived prices toward a target computed fresh from the current PLN price and the current rate, so the repair for a partial run is simply another run. A compensation that undid a partial recompute would leave prices further from the target than before the run started, not closer.
Dry run
previewFxPricingRecompute is the read-only twin of runFxPricingRecompute: it
runs the same catalogue read, the same live NBP rate fetch, and the same
planCurrencyRecompute a real run would, but it never writes a price and never
records a run summary or a managed-price stamp. It answers "what would change" -
the current PLN price, the net base it would actually convert from (see "VAT:
gross PLN, net EUR/USD" above), and the resulting EUR/USD amount - before anything
is armed or run for real.
A Medusa plugin cannot itself carry a medusa exec script - such a script belongs
to the host project's src/scripts/. Add one there:
import type { MedusaContainer } from "@medusajs/framework/types";
import { formatFxPricingPreview, previewFxPricingRecompute } from "@zanreal/medusa-fx-pricing/workflows";
export default async function fxPricingPreview({ container }: { container: MedusaContainer }) {
console.log(formatFxPricingPreview(await previewFxPricingRecompute(container)));
}npx medusa exec ./src/scripts/fx-pricing-preview.jsThis writes nothing and flips no toggle. The report lists, per currency, the live
rate and whether it is stale, one line per variant this run would create or
update (current PLN, the net base used, and the proposed amount), and the
unchanged/manual-override/no-PLN-price/quantity-tiered counts for everything else.
A host that wants a different output shape can call previewFxPricingRecompute
directly and render the plain FxPricingPreviewResult itself instead of using
formatFxPricingPreview.