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 override

Every 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.

OptionTypeDefaultWhat it does
enabledbooleanfalseSeeds the persisted toggle, once, when the settings row is first created
marginMultipliernumbernoneFallback margin when no override is saved
stalenessToleranceHoursnumber120Fallback staleness tolerance, in hours
sourcePriceIncludesVatbooleantrueWhether the PLN default price is gross and must be reduced to net before conversion
vatRatenumber0.23The VAT rate to strip when sourcePriceIncludesVat is true; ignored otherwise
medusa-config.ts
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_multiplier

If 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.

  • enabled must be a boolean. It does not accept null - it is a real toggle, not an override of a fallback.
  • margin_multiplier must be a finite number greater than 0 and at most 10, or null to clear the override. The ceiling is a guard against a fat-fingered entry, not a business limit.
  • staleness_tolerance_hours must be an integer greater than 0 and at most 720, which is 30 days, or null to 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

FieldMeaning
ranAtISO timestamp of the run
ranfalse when the run did nothing because the toggle was off
triggerWhat set the run going: scheduled, manual, event or workflow
scopedVariantCountHow many variants the run was narrowed to, or null for a full catalogue pass
error / errorName / errorStackPresent when the run failed. The message is the real one, never "[object Object]"
pricesWrittenPrices written and stamped across every currency. 0 on a completed run is called out in the admin
currencies[code].reachedfalse when the run ended before this currency's turn
currencies[code].failed / errorThis currency's own pass threw. The next currency was still attempted
currencies[code].currencyDisabledNot in the store's supported currencies
currencies[code].rateUnavailableThe NBP rate could not be fetched or parsed
currencies[code].rateStaleThe newest published rate is older than the tolerance
currencies[code].plannedCreates / plannedUpdatesWhat the run decided to do, before it wrote anything
currencies[code].created / updatedWhat actually landed and was stamped
currencies[code].unchangedAlready at the target and still plugin-managed
currencies[code].skippedManualOverrideLeft alone, see Manual overrides
currencies[code].skippedNoPlnPriceNo usable default PLN price to convert from
currencies[code].skippedQuantityTieredPriced as a quantity ladder, see Manual overrides
currencies[code].stampFailedWritten but not recorded as plugin-owned. Never routine, see below
currencies[code].rate / rateEffectiveDateThe 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:

src/scripts/fx-pricing-preview.ts (host project)
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.js

This 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.

On this page