Overview

A Medusa v2 plugin that derives USD and EUR variant prices from a store's PLN price using the NBP table A mid rate, the moment that PLN price changes, without ever overwriting a price a human set.

@zanreal/medusa-fx-pricing answers one question, for every variant in your catalogue: given what this costs in PLN, what should it say in USD and in EUR?

It takes the variant's own default PLN price, reduces it to a net base if that price is stored gross (the default - see Settings and configuration), divides by the NBP (Narodowy Bank Polski, the Polish central bank) table A mid rate, multiplies by a margin you choose, and writes the result as that variant's default price in the target currency. It does that the moment the PLN price changes, and again every night, because the rate moves even when your prices do not.

The gap it fills

Medusa's Pricing module is perfectly happy to hold a USD price next to a PLN one. It has no opinion at all about where the USD number came from, and no machinery for keeping it in step with anything.

So a store selling in PLN that also wants USD and EUR listed has two options today. It can price them by hand, which is fine on the day it is done and progressively less fine every day after, as the rate moves and nobody notices. Or it can write a script, which every such store then writes again, slightly differently, and maintains alone.

This plugin is that script, with the two hard parts already solved: knowing which rate to use on a day the central bank did not publish one, and knowing which prices it is allowed to touch.

The formula

net_pln_amount = sourcePriceIncludesVat ? pln_amount / (1 + vatRate) : pln_amount
foreign_amount = net_pln_amount / nbp_rate * margin_multiplier

nbp_rate is PLN per one unit of the foreign currency, which is NBP's own convention, so dividing converts into that currency at the raw market mid rate. margin_multiplier then grosses it up: 1.25 puts 25% on top, 1 means you want the bare mid rate and nothing else. The VAT step runs first, and only when sourcePriceIncludesVat is true - the default, matching this plugin's origin store, where the PLN default price is gross and EUR/USD default prices are net. See Settings and configuration.

The result is rounded half-up to two decimal places. If any input cannot produce a real price - a PLN amount of zero, a non-positive rate, a non-positive margin - the function returns nothing at all rather than a guess, and the variant is skipped for that run. That refusal-instead-of-guess reflex runs through the whole plugin, and it is sharpest in the one place it matters most: there is no default margin. See Settings and configuration.

What one run does

  toggle on?  ->  no  ->  log "skipped (disabled)", write nothing
       |
      yes
       v
  margin configured?  ->  no  ->  refuse the whole run, record the reason
       |
      yes
       v
  read the store's supported currencies + every variant with its prices
       |
       v
  for usd, then eur:
       currency enabled in the store?     ->  no  ->  skip this currency
       fetch the latest NBP table A rate  ->  fails  ->  skip this currency
       rate older than the tolerance?     ->  yes  ->  skip this currency
       |
       v
       for each variant with a default PLN price:
           compute the target amount
           decide: create / update / leave alone  ->  see Manual overrides
       |
       v
       write the creates and updates, re-read them, stamp what we wrote
       |
       v
  persist the run summary  ->  Settings > FX pricing shows it

Two things about that shape are worth saying out loud.

A currency is skipped whole, never per variant. If EUR is not enabled in the store, or the EUR rate cannot be fetched, or the newest published EUR rate is older than your tolerance, the run does not attempt a single EUR write. It logs why, records it in the summary, and moves on to the next currency. A half-priced catalogue is worse than an unpriced one.

Only the default price is ever read or written. A price scoped by a rule - a region override, a customer-group price, a quantity break, anything living in a price list - is a decision somebody made deliberately, and this plugin has no business near it. It reads and writes exactly the price the product edit page's basic price grid shows.

When a run happens

Three things start one, and they run the same code with the same rules.

A PLN price changed. A subscriber listens for the product, variant and price events Medusa emits when a price is written, works out which variants are affected, and recomputes only those - within seconds. Create a product at 09:00 and its USD and EUR prices exist at 09:00, not at 03:00 tomorrow.

It does not act on its own writes. A price event is resolved back to a price row and kept only if that row is in PLN; this plugin only ever writes USD and EUR, so its own output is dropped before anything is planned. And a burst - a nine-variant save, a CSV import - is collected and recomputed once rather than once per event, so an import does not turn into a thousand requests to NBP.

The daily job, at 03:00. A full pass over the catalogue, and the backstop for everything an event cannot tell anyone: the published rate moved while your prices did not, a price was written by raw SQL, an event was dropped over a restart. This is also the only run whose summary is stored as "the last run" on the settings page - a two-variant run reacting to one product save reports itself in the log instead of overwriting the catalogue-wide picture.

You pressed Recompute now. A full pass, immediately, from Settings > FX pricing.

Install

@zanreal/medusa-fx-pricing is on npm as 0.1.0:

npm install @zanreal/medusa-fx-pricing

That resolves to a prebuilt tarball - the published package already contains the .medusa/server output its exports point at, so nothing needs to compile on install.

main keeps moving after a release ships, so if you need a fix or feature that has landed on main but not yet been released, install it as a git dependency instead, pinned to a commit:

package.json
{
  "dependencies": {
    "@zanreal/medusa-fx-pricing": "github:zanreal-labs/medusa-fx-pricing#5f00ff7801972c1fb757d58e3da98733f5bd3b7d"
  }
}

Pin to the commit you tested against. #main would move under you on the next push; a pinned commit means the same thing tomorrow that it means today.

Installed this way, the package compiles itself on install - prepare runs medusa plugin:build, which turns the checked-out source into the .medusa/server output its exports point at. pnpm 10 and newer refuse to run that script for a dependency they do not already trust, so a fresh install needs it allowed once:

pnpm-workspace.yaml
allowBuilds:
  "@zanreal/medusa-fx-pricing@https://codeload.github.com/zanreal-labs/medusa-fx-pricing/tar.gz/5f00ff7801972c1fb757d58e3da98733f5bd3b7d": true

The key is the exact tarball URL pnpm resolves the pinned commit to, so it carries the same SHA as the dependency line. Move both together. Then register the plugin:

medusa-config.ts
module.exports = defineConfig({
  plugins: [
    {
      resolve: "@zanreal/medusa-fx-pricing",
      options: {
        marginMultiplier: 1.25,
      },
    },
  ],
});

Every option is optional, including that one - an install that sets nothing is configured entirely from the admin instead. Then run the migrations it ships:

npx medusa db:migrate

After installing, nothing happens

That is deliberate, and it is the first thing to know. A fresh install is inert: enabled seeds to false, and while it is off the subscriber, the daily job and the manual recompute button all do nothing and say so. A plugin that repriced a live catalogue the moment it was installed would be a plugin nobody could safely try.

Open Settings > FX pricing, set a margin, flip the toggle, and press Recompute now to watch a full run happen while you are looking at it. The summary it writes tells you, per currency, how many prices it planned to write and how many actually landed, how many were already correct, and how many were left alone because a human owns them. A run that writes nothing says so in the same place, rather than looking like a clean run.

Requirements

Node.js 22.13 or newer, and Medusa 2.18.0 - @medusajs/admin-sdk, @medusajs/framework, @medusajs/icons, @medusajs/js-sdk, @medusajs/medusa, @medusajs/ui and react-i18next are peer dependencies, and a Medusa project already has all of them. The admin page is translated into English and Polish under the fxPricing.* key namespace.

Where to go next

  • Rates, and days without one - which NBP endpoint is called and why the weekend needs no special case, what the staleness tolerance actually measures, and what happens when a currency is not turned on in your store.
  • Manual overrides - the heart of the plugin. Why the ownership marker cannot live on the price row, what the stamp records, and the four branches the decision takes.
  • Settings and configuration - every option, where each one can be overridden, the two environment variables, the admin page, the two admin API routes, and the VAT/net-vs-gross handling for the PLN source price. Also covers the read-only dry run that reports "current PLN -> proposed EUR/USD" without writing anything.

On this page