Плагин Medusa v2, который рассчитывает цены вариантов в USD и EUR из базовой цены в PLN по среднему курсу таблицы A НБП (центрального банка Польши). Пересчёт идёт сразу при изменении цены и затем ежедневно, с настраиваемой наценкой и защитой ручных правок.
A Medusa v2 plugin that derives USD and EUR variant prices from a store's native PLN selling price, using the NBP (Narodowy Bank Polski, the Polish central bank) table A mid rate plus a configurable margin. Reprices within seconds of a PLN price changing, with a daily job as the backstop; a manual price edit is never overwritten.
Full documentation, in English and Polish, is published at https://zanreal.com/docs/oss/medusa-fx-pricing and authored in .
There is no FX-pricing plugin in the Medusa ecosystem today. A store that sells in PLN and wants USD/EUR listed too either prices them by hand (and lets them drift out of date as the rate moves) or wires up a bespoke script. This plugin is that script, packaged: a small, standalone module that computes for every variant with a PLN price - as soon as that PLN price changes, and again every night as the rate moves - and gets out of the way of anything a human has already priced by hand. is the PLN price with VAT stripped when it is stored gross (see "VAT: gross PLN, net EUR/USD" below) - by default it is, matching this plugin's origin store.
It ships an admin Settings > FX pricing page: the enabled toggle, the editable margin multiplier and staleness tolerance, the live NBP rates, the last run's summary, and the manual recompute action. See "Admin UI" below.
Medusa v2's (money amount) row has no column - unlike , itself carries no free-form JSON a plugin could stamp an ownership marker into. Its exist to scope a price to a pricing context (a region, a customer group, a quantity break); attaching a marker rule such as would make that price only match a checkout context that happens to supply the same attribute, which would make the price invisible at checkout instead of marking it. Neither mechanism can safely carry an ownership flag.
So this plugin tracks ownership itself, in its own table (, one row per variant+currency this plugin has ever priced): the exact and it last wrote. That is an optimistic-concurrency stamp, not a flag stored on the price. On every run, for a variant+currency this plugin might touch:
This decision is a pure function - see in - and is exhaustively unit tested in .
Only the variant's default price in a currency (no price-list, no price-rule scoping it - the same one the admin product edit page's basic price grid shows) is ever read or written. A region-specific, customer-group, or price-list price is a different, deliberately-configured price this plugin has no business touching.
The stamp is written after the price, from a re-read of what the database actually holds, and the run summary's / only count a price once both halves have landed. A price written whose stamp could not be recorded is counted as , logged as a warning, and surfaced in the admin - it is the one outcome that silently costs you a variant, because the next run sees a price it has no record of writing and skips it forever. Deleting such a price hands it back.
Medusa stores a quantity break as / columns on the price row, not as - so is on every step of a ladder, and a test on its own would pick the first tier and treat it as the base price. The default-price test is therefore . A variant priced as a ladder has no unbounded price to convert from or write to, so it is skipped and counted under rather than being folded into : the reason and the remedy are different, and "manual override" would send an operator looking for an edit nobody made.
Two pricing-module primitives, and deliberately not :
Both are generated onto the pricing module service from its model, so they exist on the instance without being declared on ; the plugin asserts both are present at the start of a run and refuses the run naming the problem if they are not, rather than discovering it halfway through a currency.
Core's looks like the obvious call and is the wrong one in both of its branches. It splits its input on : a variant not in that list gets a brand-new created and linked to it - but neither side of the link is declared , so rejects the second link for a variant that already has a price set with . A variant in the list goes to , which replaces the price set's price list: it deletes every existing default price whose id is not in the incoming array, so handing it one USD price would delete the variant's PLN price. The sibling script in reached the same two primitives for the same reason; see for the full write-up.
recomputes the affected variants - and only those - as soon as their PLN price moves. The daily job is the backstop, not the mechanism.
Measured against the Medusa 2.18.0 packages this plugin pins, because the answer is not visible by grepping for a string:
| Event | Where it comes from | Why it is needed |
|---|---|---|
| in | A product is created with its variants and prices in one call, and no variant event is emitted at all. Without this line, a new product has no USD/EUR price until the next daily run - the exact gap this subscriber exists to close. | |
| in | That workflow runs as a step - it writes variant prices - while emitting only the product event. A carrying new prices is invisible without this line. | |
| A variant added to an existing product. | ||
| The admin's variant editor and the bulk price edit both end here ( runs those workflows as steps). | ||
| / | Not a constant anywhere: 's builds the name at runtime from the ORM's / on the model plus the pricing module's service name | The path no product event covers: / / called directly by a script, a backfill or another plugin. |
is deliberately not subscribed to. It carries the id of a row that no longer exists, so its currency cannot be read - and the currency is the whole recursion guard (see below). What that leaves uncovered is the reclaim path: deleting a manually-overridden USD price to hand it back to this plugin is picked up by the daily job rather than immediately. and are absent for the plain reason that there is nothing left to reprice.
A recompute writes USD and EUR prices through /, and both of those are -decorated - so every write this plugin makes emits a event that this same subscriber is listening for. Two independent things stop that becoming an event loop, and the first is the one relied on:
Saving a nine-variant product emits nine variant events plus a product event plus a price event per row, and a CSV import emits thousands - each of which, handled alone, would fetch the NBP rate for USD and again for EUR. So ids are collected into an in-process queue and the recompute runs once per burst: 2s after the last event, or 30s after the first, whichever comes sooner. Firing after the last write also means the recompute reads the finished state of a multi-step save rather than a half-written one.
The queue is in-process rather than lock-and-cache coordinated across workers, because the work is already partitioned by variant id: two workers each holding half a burst produce two runs over disjoint variant sets, which is the correct answer reached in two passes. The cost is that ids held in the queue are lost if the process exits before the flush - which is one more thing the daily job is the backstop for. See .
is on npm:
That resolves to a prebuilt tarball - the published package already contains the output its point at, so nothing needs to compile on install.
keeps moving after a release ships (see Releasing), so if you need a fix or feature that has landed on but not yet been released, install it as a git dependency instead, pinned to a commit:
Pin to the commit you tested against. would move under you on the next push to the repository; a pinned commit is the one spec that means the same thing tomorrow that it means today.
Installed this way, the package compiles itself on install - runs , which turns the checked-out source into the output its 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, in your project's :
The key is the exact tarball URL pnpm resolves the pinned commit to, which is why it carries the same SHA as the dependency line above - update both together when you move the pin.
Register it as a plugin in your Medusa app's :
Then sync the module's migrations into your app's database:
npx medusa db:migrate| Option | Type | Default | Description |
|---|---|---|---|
| Seeds the persisted toggle on first install. See "Persisted settings" below. | |||
| none | Fallback margin multiplier when no override is saved. = no markup, = 25% over the raw NBP mid rate. No default is shipped - see "No default margin" below. | ||
| Fallback staleness tolerance (in hours) when no override is saved. | |||
| Whether the PLN default price is stored gross (brutto) and must be reduced to net before conversion. See "VAT: gross PLN, net EUR/USD" below. | |||
| The VAT rate to strip when is . Ignored otherwise. |
and are starting points, not the final word. An operator can override either from Settings > FX pricing in the admin, without editing any file or restarting the backend - see "Persisted settings" below. and are not exposed there: they describe a fact about how the store's PLN price is configured, not a per-run commercial choice, so they are set once in and take effect on the next restart, the same as 's install-time seed.
This plugin's origin store configures its default prices with PLN gross (brutto, 23% VAT) and EUR/USD net (netto) - is for and for both and . 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 landed as an effective ~1.353 in production before this was caught - see AI-655).
(default ) and (default ) control this:
If your store's PLN default price is net instead of gross, set in - 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; it takes effect on the next backend restart (or the next invocation of a script that resolves it), and does not require a migration or a database change. See and in , and their tests, for the exact math and edge cases.
deliberately has no default. A margin decides what a customer is charged, so a shipped one would be some other store's commercial preference applied to your prices without you choosing it. Until a margin is set - here, or in Settings > FX pricing - a recompute run refuses and writes nothing, and the Settings page says so. Set if you genuinely want the raw NBP mid rate with no markup; that is a choice, and it is recorded as one. defaults to regardless of what a store sets here at the moment of a fresh install seed - the option only changes what the very first persisted row starts as; after that, Settings > FX pricing is where it is changed.
(any non-empty value other than /) forces the plugin off at runtime, regardless of the persisted toggle. It can only ever force the plugin off, never on - an operator can still flip the persisted toggle while the env var is set, and it takes effect the moment the env var is cleared. Use this for an environment (staging, a broken deploy) where the job/manual action must not run no matter what is saved in the database.
is PLN per 1 unit of the foreign currency (NBP's own convention), so dividing converts to the foreign currency at the raw market mid rate, and grosses that up. The VAT step runs first, only when is (the default) - see "VAT: gross PLN, net EUR/USD" above. Rounded half-up to 2 decimal places. See and in and their tests for the exact edge cases (a non-positive PLN amount, rate, or margin, or a that cannot produce a real net amount, all resolve to rather than a guessed price - the "no silent defaults" rule the rest of this plugin follows too).
() is a one-row singleton, read and written through / (see "Admin API" below).
Every runtime path - the scheduled job, the manual "Recompute now" action, the admin config route - resolves all three through , never from a value captured at boot. A change saved from Settings > FX pricing takes effect on the very next run, no backend restart.
Settings > FX pricing is the plugin's only admin surface - there is no per-product widget, because this plugin has nothing per-product to show that is not already the variant's own price (visible on the product's own price editor).
All routes are under and use Medusa's standard admin authentication.
The resolved runtime configuration, the live NBP rates, and the last run's summary.
Persists an override: . Only the keys present are written. / accept to clear the override back to the default; does not accept (see "Persisted settings" above). Returns the same shape as the above, reflecting the just-saved state.
must be a positive number up to . must be a positive integer up to (30 days). An unknown key, a wrongly-typed value, or a body with no writable key at all is rejected with .
Runs the same recompute the scheduled job runs, immediately. Gated by the same toggle check the job uses - when the plugin is disabled, this returns without writing anything, rather than duplicating (and risking disagreeing with) the job's own gate.
(, exported from ) is the read-only twin of : it fetches the same catalog and the same live NBP rates and runs the exact same a real run would, but it never resolves a price writer, never writes a price, and never records a run summary or a managed-price stamp. Safe to run against production at any time, whether or not the plugin is armed.
It answers the question a can only answer after the fact: what would change - the current PLN price, the net base it would actually be converted from (see "VAT: gross PLN, net EUR/USD" above), and the resulting EUR/USD amount, side by side, before anything is armed or run for real.
A Medusa plugin cannot itself carry a script - runs a script from the host project's , the same place the sibling script already lives for a supervised real run (see that script's own doc comment for why it exists alongside the admin's "Recompute now" button). Add a small script there to run this dry run:
This writes nothing and changes no toggle. The printed report includes, per currency: the live NBP rate and whether it is stale, and one line per variant this run would create or update, e.g.
create variant=variant_01ABC PLN 503.07 (net base 409.00) -> EUR 112.48 (current: none)plus the unchanged/manual-override/no-PLN-price/quantity-tiered counts for everything it would not touch. is a pure function over 's plain-data result - see its own unit tests in for the exact report shape - so a host project that wants a different format (JSON, a CSV export) can call directly and render itself instead.
() runs a full catalog pass once a day at 03:00 server time by default - after the NBP table A publication window has closed for the previous day and before most stores' business hours, so a price change is never visible mid-shopping-session. Override the schedule with (a standard cron expression) - Medusa evaluates a scheduled job's at plugin-load time, before the DI container (and this plugin's resolved options) exists, so the schedule has to be read from the environment rather than from a plugin option or the persisted settings.
Since the subscriber handles a PLN price changing, this job exists for everything an event cannot say, and the list is real:
It is also the only caller that scans the whole catalog and the only one whose summary is persisted as .
When the plugin is disabled (the common case for a fresh install - defaults to ), the job logs and returns immediately, writing nothing - and so does the subscriber.
Not every store has USD and EUR turned on in Settings > Store > Currencies the moment this plugin is installed. A target currency that is not in the store's is skipped for the entire run (not per-variant) - logged once, reported in the run summary as - rather than attempting writes Medusa would reject, or crashing the job. Turning the currency on in the store's settings makes it eligible again on the very next run.
Requires Node.js >= 22.13 (pnpm 11, pinned via in , needs it).
Generating a fresh migration after changing a model requires a scratch Postgres database:
The pure business logic has exhaustive unit tests and no framework dependency:
(the orchestration: fetching rates, querying the catalog, writing prices, re-reading and stamping the result), 's (the same read-and-plan orchestration, minus the writing), the subscriber handler itself, the scheduled job, and the admin API routes are deliberately thin glue around the tested functions above and are not unit tested - the same split and use, since exercising them for real needs a live Medusa container and a live Postgres, which CI does not have (see the reference plugin's own README for the same reasoning, under "Known gap").
Other target currencies. Only USD and EUR are supported ( in ) - NBP table A carries dozens of currencies, so adding a third is a matter of extending that type and the list, not a redesign.
Reclaim without deleting. Today the only way to let this plugin manage a variant+currency again after a manual edit is to delete the price entirely. An explicit "reclaim" admin action (per variant, or per SKU) that clears the stale stamp without requiring a delete-then- recreate round trip is a natural follow-up once there is a per-product surface to put it on.
Per-product visibility. There is currently no per-product widget showing whether a given variant's USD/EUR price is plugin-managed or a manual override - an operator has to infer it from the price editor plus the last run's summary. A widget on the product detail page (mirroring 's own widget) is the natural place for this.
Publishing happens only from , and there is no second path. npm provenance is a signed statement about where a tarball was built and from which commit, and only a cloud CI run holding an OIDC identity can produce one. An from a laptop would put a version on npm carrying no provenance, and a published version cannot be replaced afterwards, only deprecated. in makes that local publish fail rather than quietly succeed without it.
is on the registry; see Install for how to consume it. stays at the last released version until someone bumps it, so can sit ahead of what npm resolves to - the pinned git dependency in Install is the only way to consume whatever has landed since. Closing that gap, and choosing the version it bumps to, is the maintainer's call.
To cut a release:
The workflow refuses to publish when the tag disagrees with , or when that version is already on the registry. A release marked as a prerelease on GitHub publishes under the dist-tag, so never resolves to a release candidate.
Authentication is an repository secret: a granular access token with write permission on this package. npm's trusted publishing (OIDC, with nothing stored in GitHub) cannot cover the first publish, because npmjs.com only offers the trusted publisher form on a package that already exists. Once the first version is up, add one under the package's settings on npmjs.com - GitHub Actions, owner , repository , workflow , environment - and then delete the secret. The workflow needs no edit for that: npm attempts the OIDC exchange first and falls back to the token only when the exchange fails.
MIT
npm install @zanreal/medusa-fx-pricing1// package.json2{3 "dependencies": {4 "@zanreal/medusa-fx-pricing": "github:zanreal-labs/medusa-fx-pricing#5f00ff7801972c1fb757d58e3da98733f5bd3b7d"5 }6}1# pnpm-workspace.yaml2allowBuilds:3 "@zanreal/medusa-fx-pricing@https://codeload.github.com/zanreal-labs/medusa-fx-pricing/tar.gz/5f00ff7801972c1fb757d58e3da98733f5bd3b7d": true1import { defineConfig } from "@medusajs/framework/utils";2
3export default defineConfig({4 // ...5 plugins: [6 {7 resolve: "@zanreal/medusa-fx-pricing",8 options: {9 enabled: false,10 // No margin is shipped as a default. Set yours here, or leave it out11 // and set it in Settings > FX pricing instead. 1 means no markup.12 marginMultiplier: 1,13 stalenessToleranceHours: 120,14 // Defaults shown explicitly - see "VAT: gross PLN, net EUR/USD" below.15 // Flip sourcePriceIncludesVat to false if your PLN default price is16 // ever net instead of gross; vatRate is then ignored.17 sourcePriceIncludesVat: true,18 vatRate: 0.23,19 },20 },21 ],22});1net_pln_amount = sourcePriceIncludesVat ? pln_amount / (1 + vatRate) : pln_amount2foreign_amount = net_pln_amount / nbp_rate * margin_multiplier1net_pln_amount = sourcePriceIncludesVat ? pln_amount / (1 + vatRate) : pln_amount2foreign_amount = net_pln_amount / nbp_rate * margin_multiplier1{2 "effectiveEnabled": true,3 "forceDisabled": false,4 "persistedEnabled": true,5 "marginMultiplier": 1.25,6 "marginMultiplierOverridden": false,7 "stalenessToleranceHours": 120,8 "stalenessToleranceHoursOverridden": false,9 "lastRunAt": "2026-08-13T03:00:00.000Z",10 "lastRunSummary": {11 "ranAt": "2026-08-13T03:00:00.000Z",12 "ran": true,13 "pricesWritten": 15,14 "currencies": {15 "usd": {16 "reached": true,17 "currencyDisabled": false,18 "rateUnavailable": false,19 "rateStale": false,20 "failed": false,21 "plannedCreates": 3,22 "plannedUpdates": 12,23 "created": 3,24 "updated": 12,25 "unchanged": 140,26 "skippedManualOverride": 5,27 "skippedNoPlnPrice": 2,28 "skippedQuantityTiered": 0,29 "stampFailed": 0,30 "rate": 3.9123,31 "rateEffectiveDate": "2026-08-12"32 },33 "eur": { "...": "..." }34 }35 },36 "liveRates": {37 "usd": { "mid": 3.9123, "effectiveDate": "2026-08-13", "tableNo": "154/A/NBP/2026" },38 "eur": { "mid": 4.2567, "effectiveDate": "2026-08-13", "tableNo": "154/A/NBP/2026" }39 }40}{ "summary": { "ranAt": "...", "ran": true, "trigger": "manual", "scopedVariantCount": null, "currencies": { "usd": { "...": "..." }, "eur": { "...": "..." } } } }1// src/scripts/fx-pricing-preview.ts, in the HOST project (not this plugin)2import type { MedusaContainer } from "@medusajs/framework/types";3import { formatFxPricingPreview, previewFxPricingRecompute } from "@zanreal/medusa-fx-pricing/workflows";4
5export default async function fxPricingPreview({6 container,7}: {8 container: MedusaContainer;9}): Promise<void> {10 const preview = await previewFxPricingRecompute(container);11 console.log(formatFxPricingPreview(preview));12}npx medusa exec ./src/scripts/fx-pricing-preview.js1pnpm install2pnpm test # vitest - rate parsing, margin math, and the manual-override decision, all unit tested3pnpm exec medusa lint src4pnpm exec tsc --noEmit -p tsconfig.json # backend5pnpm exec tsc --noEmit -p src/admin/tsconfig.json # admin UI6pnpm build # medusa plugin:buildDATABASE_URL=postgres://user:pass@localhost:5432/scratch_db npx medusa plugin:db:generate