Manual overrides
Why the ownership marker cannot live on the price row, what the FxManagedPrice stamp records, the four branches the decision takes, and the one way to hand a price back to the plugin.
A plugin that rewrites prices every night has to answer one question before it is safe to install: how does it know which prices are its own?
Get that wrong in the permissive direction and it silently overwrites the price somebody set by hand for a good reason. Get it wrong in the restrictive direction and it stops managing prices it should be managing, quietly, with no error anywhere. This page is the answer, and it is the part of the plugin worth reading even if you read nothing else.
Why the marker cannot live on the price
The obvious design is to mark the prices you wrote, and skip the ones you did not. Medusa v2 gives you nowhere to put that mark.
Price has no metadata column. Unlike PriceList, the money-amount row
itself carries no free-form JSON. There is simply no field to stamp.
price_rules are not a place to hide a flag. They exist to scope a price to a
pricing context: a region, a customer group, a quantity break. Attaching
something like { fx_pricing_managed: "true" } would not mark the price, it would
change what the price is: a price that only matches a checkout context which
happens to supply that same attribute. Since no real checkout supplies it, the
price would become invisible at checkout. That is not a marker with a side effect,
that is a broken price.
So the ownership record has to live somewhere else, and it does.
The stamp
FxManagedPrice is this plugin's own table. One row per (variant, currency) the
plugin has ever priced, recording, among audit fields, the two that decide
everything:
| Column | What it means |
|---|---|
price_id | The exact Price row this plugin last wrote |
amount | The exact amount it wrote into that row |
source_pln_amount, nbp_rate, margin_multiplier | The inputs that produced it, kept for audit and debugging |
computed_at | When it was last written |
Read that as an optimistic-concurrency stamp, not a flag. The plugin is not
recording "I own this". It is recording "when I last looked, this price was row
X holding amount Y". On the next run it checks whether that is still true, and
a price is only still the plugin's to touch if it is still exactly what the
plugin left it as.
That framing is what makes the rule robust. It does not depend on catching an edit as it happens, on a hook firing, or on anyone remembering to tell the plugin anything. It only asks a question the database can always answer.
The four branches
decidePriceAction is a pure function, and it takes exactly four paths. In order:
1. No price exists in that currency yet
existingDefaultPrice === null -> { action: "create" }Nothing to protect, so create one and record the stamp.
2. A price exists, and the plugin has never recorded writing one
managedRecord === null -> { action: "skip", reason: "manual-override" }Somebody else put it there. An initial catalogue import, a manual edit, an edit made before this plugin was ever installed - it does not matter which. Left alone, permanently.
3. A price exists, the plugin has a stamp, but the ids differ
managedRecord.priceId !== existingDefaultPrice.id
-> { action: "skip", reason: "manual-override" }The row the plugin wrote is gone and a different row is now the default price in that currency. Something deleted and replaced it. Whatever is there now is not what the plugin wrote, so it is hands off.
4. Same id, but the amount no longer matches
managedRecord.amount !== existingDefaultPrice.amount
-> { action: "skip", reason: "manual-override" }This is the important one, and the reason the stamp records the amount and not just the id. An in-place price edit in the admin changes the amount and keeps the row id. If the stamp only tracked ids, that edit would be invisible and the next run would silently overwrite it.
Otherwise: the price is still exactly what the plugin left
existingDefaultPrice.amount === targetAmount -> { action: "noop", priceId }
otherwise -> { action: "update", priceId }Safe to manage. It is only actually written if the target has moved; if the rate
and margin still produce the amount already there, the run counts it as
unchanged and writes nothing.
Skipping is permanent, and that is the point
Branches 2, 3 and 4 are not "skip this run". The condition that triggers them does not heal on its own: an amount that no longer matches the stamp will still not match tomorrow, and the run after that, forever.
A stale stamp is deliberately not deleted when this happens. It no longer matches the live price either way, so it decides nothing, and removing it would only turn a clear "somebody else owns this" into branch 2's "nobody ever stamped this" - the same answer, reached less informatively.
Handing a price back
There is exactly one way to make the plugin manage a variant and currency again: delete the price.
Deleting it puts the next run back at branch 1, which creates a fresh price and stamps it. That is the reclaim path, and it works whether the price was set by hand or was a plugin price the plugin later lost track of.
The service also exposes clearManagedPrice(variantId, currencyCode), which
removes the stamp without touching the price. It is not called from the recompute
path at all - it exists for a maintenance script that wants to explicitly un-adopt
a variant and currency. On its own it does not hand the price back: with the stamp
gone and the price still present, the next run lands on branch 2 and still skips.
Reading the result
The run summary counts every branch, per currency, and Settings > FX pricing renders it:
plannedCreates/plannedUpdates- what the run decided to docreated- branch 1, and only counting prices that landed and were stampedupdated- a live price moved to a new target, again only once stampedunchanged- already at the target and still plugin-managedskippedManualOverride- branches 2, 3 and 4 combinedskippedQuantityTiered- the variant is priced as a quantity ladder, see belowstampFailed- written, but the stamp did not land. See below
A healthy steady state is a large unchanged, a small updated as the rate
drifts, and a skippedManualOverride that matches the number of prices your team
knows it set by hand. If that last number grows when nobody edited anything, that
is worth investigating: something else in your stack is writing default prices.
What it never touches at all
Only the default price in a currency is ever read or written, in either direction: no price list, no price rules, and no quantity bound. A region override, a customer-group price, a quantity break or anything inside a price list is invisible to this plugin.
The quantity half of that test is easy to get wrong, so it is worth spelling out.
Medusa stores a quantity ladder as min_quantity/max_quantity COLUMNS on the
price row, not as price rules - so rules_count is 0 on every step of a ladder,
and a !rules_count check on its own would happily pick the first tier and treat
it as the base price. The check is !rules_count && min_quantity == null && max_quantity == null. A variant priced as a ladder has no unbounded price to
derive from or write to, so it is skipped and counted under
skippedQuantityTiered rather than being rolled into skippedManualOverride -
the reason and the remedy are different, and an operator reading "manual override"
would go looking for a manual edit that never happened.
A write that is not stamped
The stamp is written after the price, from a re-read of what the database actually
holds - and created/updated only count a price once both halves are done. If a
price is written but the stamp cannot be recorded, that is counted as
stampFailed, logged as a warning, and shown in the admin, because it is the one
outcome that quietly costs you a variant: the next run sees a price it has no
record of writing, and branch 2 above skips it permanently. Delete such a price to
put the next run back on branch 1.