NBP FX Pricing
PLN-based USD and EUR pricing
@zanreal/medusa-fx-pricing
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 Copy to clipboarddocs/.
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 Copy to clipboardforeign_amount = net_pln_amount / nbp_rate * margin_multiplier 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. Copy to clipboardnet_pln_amount 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.
What it does
- Fetches the latest published NBP table A mid rate for USD and EUR (Copy to clipboard
GET /api/exchangerates/rates/a/usd/and Copy to clipboard.../eur/- no date suffix, so it always answers with the most recently published table, which is how weekends and Polish public holidays - days NBP does not publish a new table - are handled without any special-casing). - Computes Copy to clipboard
foreign_amount = net_pln_amount / nbp_rate * margin_multiplierfor every product variant that has a default (no price-list, no price-rule) PLN price, and writes it as that variant's default USD/EUR price. Copy to clipboardnet_pln_amountstrips VAT from the PLN amount first when the PLN price is stored gross - see "VAT: gross PLN, net EUR/USD" below; this is the default. - Recomputes the affected variants as soon as their PLN price changes, via a subscriber on the product, variant and price events - so a new product, or a corrected price, has its USD/EUR prices within seconds rather than at 03:00 tomorrow. See "Reacting to a price change" below.
- Runs a full pass once a day as the backstop for everything an event cannot say (the rate moved, a price was written outside the workflows, an event was dropped), and on demand via a "Recompute now" admin action.
- Never touches a price a human has set. The moment a USD/EUR price is created or edited by anything other than this plugin, it is permanently left alone - see "How manual overrides stay sacred" below for the exact mechanism.
- Leaves a quantity ladder alone. A variant whose prices carry Copy to clipboard
min_quantity/Copy to clipboardmax_quantitybounds has no single default price to derive from or write to, so it is skipped and reported under its own counter - see "Quantity ladders" below. - Skips a currency gracefully (logs why, does not crash) when it is not yet enabled in the store's Copy to clipboard
supported_currencies, when the NBP rate cannot be fetched, or when the latest published rate is older than a configurable staleness tolerance.
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.
How manual overrides stay sacred
Medusa v2's Copy to clipboardPrice (money amount) row has no Copy to clipboardmetadata column - unlike Copy to clipboardPriceList, Copy to clipboardPrice itself carries no free-form JSON a plugin could stamp an ownership marker into. Its Copy to clipboardprice_rules exist to scope a price to a pricing context (a region, a customer group, a quantity break); attaching a marker rule such as Copy to clipboard{ fx_pricing_managed: "true" } 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 (Copy to clipboardFxManagedPrice, one row per variant+currency this plugin has ever priced): the exact Copy to clipboardprice_id and Copy to clipboardamount 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:
- No price exists yet in that currency -> create one, and record the stamp. This is also the reclaim path: deleting a price (manual or plugin-written) makes the plugin free to create a fresh one next run.
- A price exists, but this plugin never recorded writing one for it -> a human (or something else) set it, at any point, including before this plugin was installed. Skipped, permanently, until it is deleted.
- A price exists, the plugin's stamp points at that exact price id, and the amount still matches what was stamped -> still exactly what this plugin left it as. Safe to update again if the target has moved (a no-op if it has not).
- A price exists, the plugin has a stamp for it, but the price id differs, or the id matches and the amount does not -> someone edited it since the stamp was written (an in-place admin price edit changes the amount, not the id). Skipped, permanently, from that point on.
This decision is a pure function - see Copy to clipboarddecidePriceAction in Copy to clipboardsrc/modules/fx-pricing/lib/decision.ts - and is exhaustively unit tested in Copy to clipboardsrc/modules/fx-pricing/lib/__tests__/decision.test.ts.
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 Copy to clipboardcreated/Copy to clipboardupdated only count a price once both halves have landed. A price written whose stamp could not be recorded is counted as Copy to clipboardstampFailed, 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.
Quantity ladders
Medusa stores a quantity break as Copy to clipboardmin_quantity/Copy to clipboardmax_quantity columns on the price row, not as Copy to clipboardprice_rules - so Copy to clipboardrules_count is Copy to clipboard0 on every step of a ladder, and a Copy to clipboard!rules_count test on its own would pick the first tier and treat it as the base price. The default-price test is therefore Copy to clipboard!rules_count && min_quantity == null && max_quantity == null. A variant priced as a ladder has no unbounded price to convert from or write to, so it is skipped and counted under Copy to clipboardskippedQuantityTiered rather than being folded into Copy to clipboardskippedManualOverride: the reason and the remedy are different, and "manual override" would send an operator looking for an edit nobody made.
How a price is actually written
Two pricing-module primitives, and deliberately not Copy to clipboardupsertVariantPricesWorkflow:
- Copy to clipboard
addPrices({ priceSetId, prices })appends one price to the variant's existing price set. - Copy to clipboard
updatePrices([{ id, amount }])moves one existing price row's amount by id.
Both are generated onto the pricing module service from its Copy to clipboardPrice model, so they exist on the instance without being declared on Copy to clipboardIPricingModuleService; 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 Copy to clipboardupsertVariantPricesWorkflow looks like the obvious call and is the wrong one in both of its branches. It splits its input on Copy to clipboardpreviousVariantIds: a variant not in that list gets a brand-new Copy to clipboardPriceSet created and linked to it - but neither side of the Copy to clipboardProductVariantPriceSet link is declared Copy to clipboardhasMany, so Copy to clipboardRemoteLink.create rejects the second link for a variant that already has a price set with Copy to clipboardCannot create multiple links between 'productService' and 'pricingService'. A variant in the list goes to Copy to clipboardupdatePriceSets, 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 Copy to clipboardsrp-store-price script in Copy to clipboardzanreal-labs/medusa reached the same two primitives for the same reason; see Copy to clipboardsrc/workflows/lib/price-writes.ts for the full write-up.
Reacting to a price change
Copy to clipboardsrc/subscribers/fx-pricing-price-change-recompute.ts recomputes the affected variants - and only those - as soon as their PLN price moves. The daily job is the backstop, not the mechanism.
The events, and why each one
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 Copy to clipboardproduct.created Copy to clipboardemitEventStep in Copy to clipboardcreateProductsWorkflow 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. Copy to clipboardproduct.updated Copy to clipboardemitEventStep in Copy to clipboardupdateProductsWorkflow That workflow runs Copy to clipboardupsertVariantPricesWorkflow as a step - it writes variant prices - while emitting only the product event. A Copy to clipboardPUT /admin/products/:id carrying new prices is invisible without this line. Copy to clipboardproduct-variant.created Copy to clipboardcreateProductVariantsWorkflow A variant added to an existing product. Copy to clipboardproduct-variant.updated Copy to clipboardupdateProductVariantsWorkflow The admin's variant editor and the Copy to clipboardPOST /admin/products/:id/variants/batch bulk price edit both end here (Copy to clipboardbatchProductVariantsWorkflow runs those workflows as steps). Copy to clipboardpricing.price.created / Copy to clipboardpricing.price.updated Not a constant anywhere: Copy to clipboardMedusaService's Copy to clipboardinterceptEntityMutationEvents builds the name at runtime from the ORM's Copy to clipboardafterCreate/Copy to clipboardafterUpdate on the Copy to clipboardPrice model plus the pricing module's service name The path no product event covers: Copy to clipboardpricing.addPrices / Copy to clipboardupdatePrices / Copy to clipboardupdatePriceSets called directly by a script, a backfill or another plugin.
Copy to clipboardpricing.price.deleted 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. Copy to clipboardproduct.deleted and Copy to clipboardproduct-variant.deleted are absent for the plain reason that there is nothing left to reprice.
Why this does not loop
A recompute writes USD and EUR prices through Copy to clipboardaddPrices/Copy to clipboardupdatePrices, and both of those are Copy to clipboard@EmitEvents()-decorated - so every write this plugin makes emits a Copy to clipboardpricing.price.* 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:
- A price event is resolved through its currency. Copy to clipboard
listVariantIdsByPriceIdsreads each price id back and keeps only the rows whose Copy to clipboardcurrency_codeis Copy to clipboardpln. This plugin only ever writes USD and EUR, so its own output resolves to zero variants and the handler returns before anything is queued. The Copy to clipboardproduct.*and Copy to clipboardproduct-variant.*events need no such guard: those are emitted by core's product workflows, and this plugin never calls one - it writes through the pricing module directly, for the reasons in "How a price is actually written". - A second pass would have nothing to write anyway. Even if a loop did start, the second lap over the same variant finds the price already at the target amount and still carrying this plugin's stamp, so Copy to clipboard
decidePriceActionanswers Copy to clipboardnoop, nothing is written, and no further event is emitted. The loop is convergent, not merely guarded.
Bursts
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 Copy to clipboardsrc/subscribers/lib/recompute-queue.ts.
What it does not do
- It does not persist a run summary. Copy to clipboard
last_run_summaryis a single column that Settings > FX pricing renders as "the last run"; letting a two-variant event-driven run overwrite it would replace the catalog-wide picture with counters that are true of two variants and nothing else, dozens of times a day. Only the full pass persists. An event-driven run reports itself in the log instead: Copy to clipboardrecomputed N variant(s) after a PLN price change: M price(s) written. - It does not run while the plugin is off. The toggle is checked before any query, so a store that has never armed the plugin pays one indexed single-row read per product save and stops.
- It does not change any rule. Narrowing a run changes only which variants are read. The margin refusal, the per-currency skips, the manual-override decision and the stamping are the same code, because there is exactly one implementation of them - Copy to clipboard
runFxPricingRecompute, called with Copy to clipboard{ variantIds }.
Install
Copy to clipboard@zanreal/medusa-fx-pricing is on npm:
1npm install @zanreal/medusa-fx-pricing
That resolves to a prebuilt tarball - the published package already contains the Copy to clipboard.medusa/server output its Copy to clipboardexports point at, so nothing needs to compile on install.
Copy to clipboardmain keeps moving after a release ships (see Releasing), so if you need a fix or feature that has landed on Copy to clipboardmain but not yet been released, install it as a git dependency instead, pinned to a commit:
123456// package.json{"dependencies": {"@zanreal/medusa-fx-pricing": "github:zanreal-labs/medusa-fx-pricing#5f00ff7801972c1fb757d58e3da98733f5bd3b7d"}}
Pin to the commit you tested against. Copy to clipboard#main 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 - Copy to clipboardprepare runs Copy to clipboardmedusa plugin:build, which turns the checked-out source into the Copy to clipboard.medusa/server output its Copy to clipboardexports 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 Copy to clipboardpnpm-workspace.yaml:
123# pnpm-workspace.yamlallowBuilds:"@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, 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 Copy to clipboardmedusa-config.ts:
1234567891011121314151617181920import { defineConfig } from "@medusajs/framework/utils";export default defineConfig({// ...plugins: [{resolve: "@zanreal/medusa-fx-pricing",options: {enabled: false,// No margin is shipped as a default. Set yours here, or leave it out// and set it in Settings > FX pricing instead. 1 means no markup.marginMultiplier: 1,stalenessToleranceHours: 120,// Defaults shown explicitly - see "VAT: gross PLN, net EUR/USD" below.// Flip sourcePriceIncludesVat to false if your PLN default price is// ever net instead of gross; vatRate is then ignored.sourcePriceIncludesVat: true,vatRate: 0.23,},},
Then sync the module's migrations into your app's database:
1npx medusa db:migrate
Options
Option Type Default Description Copy to clipboardenabled Copy to clipboardboolean Copy to clipboardfalse Seeds the persisted toggle on first install. See "Persisted settings" below. Copy to clipboardmarginMultiplier Copy to clipboardnumber none Fallback margin multiplier when no override is saved. Copy to clipboard1 = no markup, Copy to clipboard1.25 = 25% over the raw NBP mid rate. No default is shipped - see "No default margin" below. Copy to clipboardstalenessToleranceHours Copy to clipboardnumber Copy to clipboard120 Fallback staleness tolerance (in hours) when no override is saved. Copy to clipboardsourcePriceIncludesVat Copy to clipboardboolean Copy to clipboardtrue 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. Copy to clipboardvatRate Copy to clipboardnumber Copy to clipboard0.23 The VAT rate to strip when Copy to clipboardsourcePriceIncludesVat is Copy to clipboardtrue. Ignored otherwise.
Copy to clipboardmarginMultiplier and Copy to clipboardstalenessToleranceHours 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. Copy to clipboardsourcePriceIncludesVat and Copy to clipboardvatRate 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 Copy to clipboardmedusa-config.ts and take effect on the next restart, the same as Copy to clipboardenabled's install-time seed.
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) - Copy to clipboardprice_preference.is_tax_inclusive is Copy to clipboardtrue for Copy to clipboardpln and Copy to clipboardfalse for both Copy to clipboardeur and Copy to clipboardusd. 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 Copy to clipboard1.1 landed as an effective ~1.353 in production before this was caught - see AI-655).
Copy to clipboardsourcePriceIncludesVat (default Copy to clipboardtrue) and Copy to clipboardvatRate (default Copy to clipboard0.23) control this:
12net_pln_amount = sourcePriceIncludesVat ? pln_amount / (1 + vatRate) : pln_amountforeign_amount = net_pln_amount / nbp_rate * margin_multiplier
If your store's PLN default price is net instead of gross, set Copy to clipboardsourcePriceIncludesVat: false in Copy to clipboardmedusa-config.ts - Copy to clipboardvatRate 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 Copy to clipboardmedusa exec invocation of a script that resolves it), and does not require a migration or a database change. See Copy to clipboardtoNetPlnAmount and Copy to clipboardcomputeForeignAmount in Copy to clipboardsrc/modules/fx-pricing/lib/compute.ts, and their tests, for the exact math and edge cases.
No default margin
Copy to clipboardmarginMultiplier 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 Copy to clipboard1 if you genuinely want the raw NBP mid rate with no markup; that is a choice, and it is recorded as one. Copy to clipboardenabled defaults to Copy to clipboardfalse 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.
Hard-disabling from the environment
Copy to clipboardFX_PRICING_DISABLED (any non-empty value other than Copy to clipboard0/Copy to clipboardfalse) 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.
The margin math
12net_pln_amount = sourcePriceIncludesVat ? pln_amount / (1 + vatRate) : pln_amountforeign_amount = net_pln_amount / nbp_rate * margin_multiplier
Copy to clipboardnbp_rate 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 Copy to clipboardmargin_multiplier grosses that up. The VAT step runs first, only when Copy to clipboardsourcePriceIncludesVat is Copy to clipboardtrue (the default) - see "VAT: gross PLN, net EUR/USD" above. Rounded half-up to 2 decimal places. See Copy to clipboardcomputeForeignAmount and Copy to clipboardtoNetPlnAmount in Copy to clipboardsrc/modules/fx-pricing/lib/compute.ts and their tests for the exact edge cases (a non-positive PLN amount, rate, or margin, or a Copy to clipboardvatRate that cannot produce a real net amount, all resolve to Copy to clipboardundefined rather than a guessed price - the "no silent defaults" rule the rest of this plugin follows too).
Persisted settings
Copy to clipboardFxPricingSettings (Copy to clipboardsrc/modules/fx-pricing/models/fx-pricing-settings.ts) is a one-row singleton, read and written through Copy to clipboardGET/Copy to clipboardPOST /admin/fx-pricing/config (see "Admin API" below).
- Copy to clipboard
enabledis a real persisted boolean, not a nullable override. It is seeded once, from Copy to clipboardmoduleOptions.enabled(itself defaulting to Copy to clipboardfalse), the moment the settings row is first created - after that, Settings > FX pricing is the only way to change it. This mirrors the sibling Copy to clipboardmedusa-allegroplugin's runtime-toggle pattern rather than Copy to clipboardmedusa-product-costs's nullable-override pattern: a kill switch has no meaningful "fall back to the config default on every read" - an operator flips it, and that is the answer until they flip it again. - Copy to clipboard
margin_multiplierand Copy to clipboardstaleness_tolerance_hoursfollow the Copy to clipboardmedusa-product-costspattern instead: nullable, Copy to clipboardnullmeaning "not overridden here", resolved against Copy to clipboardmoduleOptions.marginMultiplier/ Copy to clipboardmoduleOptions.stalenessToleranceHourson every read. When Copy to clipboardmargin_multiplieris null AND no Copy to clipboardmarginMultiplieroption was configured, there is no margin at all: a run refuses rather than falling back to a guessed one.
Every runtime path - the scheduled job, the manual "Recompute now" action, the admin config route - resolves all three through Copy to clipboardFxPricingModuleService.getResolvedRuntimeOptions(), never from a value captured at boot. A change saved from Settings > FX pricing takes effect on the very next run, no backend restart.
Admin UI
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).
- Enabled - the persisted toggle, saved immediately on flip (no separate Save button - this is a kill switch, not a form field). Shows a badge when Copy to clipboard
FX_PRICING_DISABLEDis forcing it off. - Configuration - the margin multiplier and staleness tolerance, with a Save button and a "Reset to plugin default" action that appears once either is overridden.
- Current NBP rates - fetched live on every page load, so an operator can sanity-check what the next run would compute before running it.
- Last run - the most recent run's timestamp and per-currency summary (created/updated/ unchanged/skipped counts, or why a currency was skipped entirely), plus a Recompute now button that runs the same logic as the scheduled job and shows its result inline.
What the run summary promises
- Every target currency is always present. A currency the run never got to carries Copy to clipboard
reached: false; a currency whose own pass threw carries Copy to clipboardfailed: trueand its Copy to clipboarderror, and the next currency is still attempted. A currency is never simply missing from the report. - Copy to clipboard
created/Copy to clipboardupdatedcount prices that landed AND were stamped. What the run intended is kept separately as Copy to clipboardplannedCreates/Copy to clipboardplannedUpdates, so the two can be compared instead of confused. - An error is preserved, not stringified. A Medusa workflow throws the orchestrator's serialized error - a plain object, not an Copy to clipboard
Errorinstance - so Copy to clipboardString(err)renders it as Copy to clipboard"[object Object]". Copy to clipboarddescribeErrorreads the message, name and stack off whatever was actually thrown, including nested Copy to clipboard{ action, error }wrappers, and falls back to JSON rather than to nothing. - A run that writes nothing says so. Copy to clipboard
pricesWrittenis the total across every currency, and a completed full run that leaves it at Copy to clipboard0logs a warning with the counts that explain why and shows a line in the admin. A plugin that decides to touch nothing and reports nothing is indistinguishable from one that works. (A narrowed, event-driven run that writes nothing is the ordinary outcome of saving a product whose PLN price did not move, so that one logs at Copy to clipboarddebug- warning on each of those would train an operator to ignore the warning that matters.) - A run says what set it going, and how wide it was. Copy to clipboard
triggeris one of Copy to clipboardscheduled, Copy to clipboardmanual, Copy to clipboardeventor Copy to clipboardworkflow, and Copy to clipboardscopedVariantCountis the number of variants the run was narrowed to or Copy to clipboardnullfor a full pass. Without those two, Copy to clipboardskippedNoPlnPrice: 0reads as a statement about the catalogue when it may be a statement about two variants.
Admin API
All routes are under Copy to clipboard/admin/fx-pricing and use Medusa's standard admin authentication.
Copy to clipboardGET /admin/fx-pricing/config
The resolved runtime configuration, the live NBP rates, and the last run's summary.
1234567891011121314151617181920{"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,"pricesWritten": 15,"currencies": {"usd": {"reached": true,"currencyDisabled": false,"rateUnavailable": false,"rateStale": false,"failed": false,
Copy to clipboardPOST /admin/fx-pricing/config
Persists an override: Copy to clipboard{ enabled?, margin_multiplier?, staleness_tolerance_hours? }. Only the keys present are written. Copy to clipboardmargin_multiplier/Copy to clipboardstaleness_tolerance_hours accept Copy to clipboardnull to clear the override back to the Copy to clipboardmedusa-config.ts default; Copy to clipboardenabled does not accept Copy to clipboardnull (see "Persisted settings" above). Returns the same shape as the Copy to clipboardGET above, reflecting the just-saved state.
Copy to clipboardmargin_multiplier must be a positive number up to Copy to clipboard10. Copy to clipboardstaleness_tolerance_hours must be a positive integer up to Copy to clipboard720 (30 days). An unknown key, a wrongly-typed value, or a body with no writable key at all is rejected with Copy to clipboard400.
Copy to clipboardPOST /admin/fx-pricing/recompute
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 Copy to clipboard{ "summary": { "ran": false, ... } } without writing anything, rather than duplicating (and risking disagreeing with) the job's own gate.
1{ "summary": { "ranAt": "...", "ran": true, "trigger": "manual", "scopedVariantCount": null, "currencies": { "usd": { "...": "..." }, "eur": { "...": "..." } } } }
Dry run
Copy to clipboardpreviewFxPricingRecompute (Copy to clipboardsrc/workflows/preview-fx-prices.ts, exported from Copy to clipboard@zanreal/medusa-fx-pricing/workflows) is the read-only twin of Copy to clipboardrunFxPricingRecompute: it fetches the same catalog and the same live NBP rates and runs the exact same Copy to clipboardplanCurrencyRecompute 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 Copy to clipboardRunSummary 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 Copy to clipboardmedusa exec script - Copy to clipboardmedusa exec runs a script from the host project's Copy to clipboardsrc/scripts/, the same place the sibling Copy to clipboardfx-pricing-run.ts 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:
123456789101112// src/scripts/fx-pricing-preview.ts, in the HOST project (not this plugin)import type { MedusaContainer } from "@medusajs/framework/types";import { formatFxPricingPreview, previewFxPricingRecompute } from "@zanreal/medusa-fx-pricing/workflows";export default async function fxPricingPreview({container,}: {container: MedusaContainer;}): Promise<void> {const preview = await previewFxPricingRecompute(container);console.log(formatFxPricingPreview(preview));}
1npx medusa exec ./src/scripts/fx-pricing-preview.js
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.
1create 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. Copy to clipboardformatFxPricingPreview is a pure function over Copy to clipboardpreviewFxPricingRecompute's plain-data result - see its own unit tests in Copy to clipboardsrc/workflows/__tests__/preview-fx-prices.test.ts for the exact report shape - so a host project that wants a different format (JSON, a CSV export) can call Copy to clipboardpreviewFxPricingRecompute directly and render Copy to clipboardFxPricingPreviewResult itself instead.
The scheduled job (the backstop)
Copy to clipboardfx-pricing-daily-recompute (Copy to clipboardsrc/jobs/fx-pricing-daily-recompute.ts) 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 Copy to clipboardFX_PRICING_CRON (a standard cron expression) - Medusa evaluates a scheduled job's Copy to clipboardconfig.schedule 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:
- The rate moved, not the price. NBP publishes a new table A every business day and no store event accompanies it. Nothing but a schedule notices that yesterday's USD price is now a day of currency drift out of date - which is the entire point of this plugin.
- A price written outside a workflow. Raw SQL or a migration changes what customers are quoted and emits nothing at all.
- An event that was dropped. A restart mid-burst, an event bus that lost a message, a handler that threw. Every event-driven system needs a pass that assumes it missed something.
- A price handed back. Deleting a manually-overridden USD price makes the variant eligible again, but the deletion itself is not a trigger this plugin acts on - see "Reacting to a price change".
It is also the only caller that scans the whole catalog and the only one whose summary is persisted as Copy to clipboardlast_run_summary.
When the plugin is disabled (the common case for a fresh install - Copy to clipboardenabled defaults to Copy to clipboardfalse), the job logs Copy to clipboardskipped (disabled...) and returns immediately, writing nothing - and so does the subscriber.
Handling a currency that is not enabled yet
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 Copy to clipboardsupported_currencies is skipped for the entire run (not per-variant) - logged once, reported in the run summary as Copy to clipboardcurrencyDisabled: true - 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.
Development
Requires Node.js >= 22.13 (pnpm 11, pinned via Copy to clipboardpackageManager in Copy to clipboardpackage.json, needs it).
123456pnpm installpnpm test # vitest - rate parsing, margin math, and the manual-override decision, all unit testedpnpm exec medusa lint srcpnpm exec tsc --noEmit -p tsconfig.json # backendpnpm exec tsc --noEmit -p src/admin/tsconfig.json # admin UIpnpm build # medusa plugin:build
Generating a fresh migration after changing a model requires a scratch Postgres database:
1DATABASE_URL=postgres://user:pass@localhost:5432/scratch_db npx medusa plugin:db:generate
What is unit tested, and what is not
The pure business logic has exhaustive unit tests and no framework dependency:
- Copy to clipboard
src/modules/fx-pricing/lib/nbp.ts- parsing an NBP table A response (Copy to clipboardparseNbpRatesResponse), the fetch wrapper with an injectable Copy to clipboardfetch(Copy to clipboardfetchNbpRate), and the staleness check (Copy to clipboardisRateStale). - Copy to clipboard
src/modules/fx-pricing/lib/compute.ts- the margin math (Copy to clipboardcomputeForeignAmount), including the VAT strip (Copy to clipboardtoNetPlnAmount) with both Copy to clipboardsourcePriceIncludesVatsettings - see AI-655. - Copy to clipboard
src/modules/fx-pricing/lib/decision.ts- the manual-override decision (Copy to clipboarddecidePriceAction). - Copy to clipboard
src/modules/fx-pricing/lib/errors.ts- reducing any thrown value to a real message (Copy to clipboarddescribeError), including Medusa's serialized non-Copy to clipboardErrorworkflow throws. - Copy to clipboard
src/workflows/lib/plan.ts- Copy to clipboardplanCurrencyRecompute, which runs Copy to clipboarddecidePriceActionacross a whole batch of variants and tallies the result, still with no I/O, including that it passes an optional VAT adjustment straight through to Copy to clipboardcomputeForeignAmount. - Copy to clipboard
src/workflows/preview-fx-prices.ts- Copy to clipboardformatFxPricingPreview, the plain-text dry-run report (see "Dry run" above), from fixture data. - Copy to clipboard
src/workflows/lib/variant-prices.ts- reading a variant's default price out of its raw price list (Copy to clipboardfindDefaultPrice, Copy to clipboardhasQuantityTieredPrice). - Copy to clipboard
src/subscribers/lib/events.ts- which events are subscribed to and how the ids are read out of one (Copy to clipboardparseFxPricingEvent), including that the match is exact rather than by prefix and that the three payload shapes Medusa can hand a subscriber are all accepted. - Copy to clipboard
src/subscribers/lib/recompute-queue.ts- the burst coalescing (Copy to clipboardcreateRecomputeQueue): one flush per burst with the union of its ids, the quiet period, the deadline that stops a long import holding the first price hostage, and that two recomputes never overlap. The clock is injected, so the timing is asserted rather than waited for.
Copy to clipboardsrc/workflows/recompute-fx-prices.ts (the orchestration: fetching rates, querying the catalog, writing prices, re-reading and stamping the result), Copy to clipboardsrc/workflows/preview-fx-prices.ts's Copy to clipboardpreviewFxPricingRecompute (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 Copy to clipboardmedusa-product-costs and Copy to clipboardmedusa-allegro 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").
Roadmap
Other target currencies. Only USD and EUR are supported (Copy to clipboardFxSourceCurrency in Copy to clipboardsrc/modules/fx-pricing/lib/nbp.ts) - NBP table A carries dozens of currencies, so adding a third is a matter of extending that type and the Copy to clipboardTARGET_CURRENCIES 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 Copy to clipboardFxManagedPrice 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 Copy to clipboardmedusa-product-costs's own widget) is the natural place for this.
Releasing
Publishing happens only from Copy to clipboard.github/workflows/release.yml, 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 Copy to clipboardnpm publish from a laptop would put a version on npm carrying no provenance, and a published version cannot be replaced afterwards, only deprecated. Copy to clipboardpublishConfig.provenance in Copy to clipboardpackage.json makes that local publish fail rather than quietly succeed without it.
Copy to clipboard@zanreal/medusa-fx-pricing@0.1.0 is on the registry; see Install for how to consume it. Copy to clipboardpackage.json stays at the last released version until someone bumps it, so Copy to clipboardmain 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:
- Move the Copy to clipboard
## [Unreleased]entries in CHANGELOG.md under a heading for the new version, dated. - Bump Copy to clipboard
versionin Copy to clipboardpackage.jsonon Copy to clipboardmain. - Publish a GitHub Release whose tag is Copy to clipboard
v<version>, exactly.
The workflow refuses to publish when the tag disagrees with Copy to clipboardpackage.json, or when that version is already on the registry. A release marked as a prerelease on GitHub publishes under the Copy to clipboardnext dist-tag, so Copy to clipboardnpm install @zanreal/medusa-fx-pricing never resolves to a release candidate.
Authentication is an Copy to clipboardNPM_TOKEN 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 Copy to clipboardzanreal-labs, repository Copy to clipboardmedusa-fx-pricing, workflow Copy to clipboardrelease.yml, environment Copy to clipboardnpm - and then delete the Copy to clipboardNPM_TOKEN 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.
License
MIT

