@hartl-services/medusa-free-shipping
v0.2.2
Published
Configurable free-shipping threshold plugin for Medusa.
Readme
Medusa Free Shipping
Medusa v2.19 plugin for a shop-wide free-shipping threshold with a separate
threshold for affiliates. It has no Admin page and no HTTP route of its own:
its switch and its settings are declared to @hartl-services/medusa-base and
edited on the base plugin's generic page Settings → Hartl Services Plugins →
Versandkostenfrei → Öffnen (/settings/hartl-services/plugin/free_shipping).
What it does
Keeps two automatic Medusa promotions (
AUTO-FREE-SHIPPING-STANDARD,AUTO-FREE-SHIPPING-AFFILIATE, 100 % off shipping) in sync with the settings and the plugin switch:- right after the settings are saved in the Admin
(
plugin-settings.updated.v1subscriber), - right after the switch is changed in the Admin
(
plugin-features.updated.v1subscriber), - every 15 minutes through the
free-shipping-promotion-syncscheduled job (worker instances only), which also repairs drift and covers a lost event.
Every run holds one Locking-module lock (
free-shipping-promotion-sync, 60 s timeout) around the whole sync - reading the switch and the settings and reconciling both promotions - so the job and the two subscribers never interleave their rule changes, and the last run always writes the latest values. The shop's configured Locking provider applies (Medusa's default in-memory provider serializes within one process only; use a shared provider such as@medusajs/locking-rediswhen server and worker are separate processes). The sync writes only differences, so a run against in-sync promotions is read-only. Changed rules are created before outdated ones are deleted, so an automatic promotion never exists without its rules (which would grant free shipping to every cart).- right after the settings are saved in the Admin
(
Adds the cart's affiliate qualification to the promotion context (hook on
updateCartPromotionsWorkflow). This uses theaffiliatemodule only if it is registered; without the Affiliate plugin, only the default threshold applies.
Settings
Stored by the base plugin (plugin_setting_state, plugin key
free_shipping); every setting has a default, so the plugin works without
ever saving the page. The plugin has no medusa-config options.
| Key | Type | Default | Label | Effect |
| ----------------------------------------- | ------- | ------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| default_threshold | number ≥ 0 | 50 | Kostenlose Lieferung ab (€) | item_total threshold of AUTO-FREE-SHIPPING-STANDARD |
| affiliate_threshold | number ≥ 0 | 100 | Für Affiliates ab (€) | item_total threshold of AUTO-FREE-SHIPPING-AFFILIATE |
| affiliate_applies_to_account_holders | boolean | on | Gilt, wenn der Kunde selbst Affiliate ist | the cart's customer is an affiliate → affiliate threshold |
| affiliate_applies_to_referred_customers | boolean | off | Gilt, wenn der Warenkorb über einen Affiliate-Link/-Code zugeordnet wurde | the cart is assigned to an affiliate referral → affiliate threshold |
The base validates the values (a negative threshold is rejected with 400).
Server code reads them with resolveFreeShippingSettings(container)
(src/utils/free-shipping-settings.ts, a typed wrapper around the base's
getPluginSettings). The two boolean rules are evaluated in the cart hook on
every promotion computation, so they take effect without a promotion resync;
the thresholds reach the promotions through the sync described above.
The cart hook is fail-safe: if the settings cannot be read, the error is
reported with logger.error (the base plugin's logger wrapper forwards it to
Sentry) and the cart is computed with the two rules this process last read
successfully, or with their declared defaults (account holders on, referred
customers off) when it has not read them yet. The cart request never fails
because of it.
The job and the subscribers let a read failure propagate (the job runs again,
the event is retried).
Events
| Event | Direction | Handling |
| ---------------------------- | --------- | ------------------------------------------------------------------------------------ |
| plugin-settings.updated.v1 | consumed | plugin_key === "free_shipping": re-sync both promotions with the current settings |
| plugin-features.updated.v1 | consumed | plugin_key === "free_shipping": re-sync (activate/deactivate) both promotions |
Both events are emitted by the base plugin after an Admin save; their
contracts are exported from
@hartl-services/medusa-food-supplements-contracts/events/plugin-features.
The plugin emits no events.
Installation in a shop
pnpm add @hartl-services/medusa-free-shipping @hartl-services/medusa-base @hartl-services/medusa-food-supplements-contracts@hartl-services/medusa-base (>= 1.0.0) is a required peer: it stores the
switch and the settings, renders the Admin page and emits the change events,
and must be registered as a plugin as well.
// medusa-config.ts
plugins: [
{ resolve: "@hartl-services/medusa-base", options: {} },
{
resolve: "@hartl-services/medusa-free-shipping",
options: {},
},
]Register @hartl-services/medusa-base before this plugin: medusa db:migrate
migrates the modules in configuration order, and the 0.2.0 upgrade migration
writes into the base plugin's tables. Run medusa db:migrate after installing
or upgrading.
Upgrading to 0.2.0
- The plugin's own settings table
free_shipping_settingsis dropped, and with itGET/POST /admin/free-shipping-settings, the former Admin page and the Contracts subpathstypes/free-shipping-admin,schemas/free-shippingandpaths/free-shipping. The migration carries the stored thresholds and both affiliate rules over into the base settings (plugin_setting_state, plugin keyfree_shipping) unless the base already holds settings for the plugin. - The former
enabledcolumn is replaced by the base switchfree_shipping; a storedenabled = falseis carried over as switch off (unless the base already holds a switch value for the plugin). - If the base tables do not exist yet when the migration runs (base not
registered, or registered after this plugin), nothing is carried over: the
migration logs a
NOTICE, drops the table, and the defaults (50 / 100 / on / off, switch on) apply until the values are entered on the plugin's base page.
Feature toggles
The plugin declares its switch under the plugin key free_shipping.
Storefronts read it from GET /store/plugin-features/free_shipping.
| Key | Label | Default | Gates | Keeps running while off |
| --------------- | ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| (plugin switch) | Versandkostenfrei | on | status of the promotions AUTO-FREE-SHIPPING-STANDARD and AUTO-FREE-SHIPPING-AFFILIATE (active while on, inactive while off) | the settings (thresholds stay editable and are synced into the inactive promotions), the free-shipping-promotion-sync job (it deactivates the promotions), the promotion-context hook on the cart path |
There are no sub-features. No route answers 404 because of the switch; the store-visible effect of "off" is that the two automatic promotions are inactive. The switch takes effect immediately on an Admin change and at the latest with the next 15-minute job run otherwise.
Peer dependencies are @hartl-services/medusa-base and the host's Medusa
2.19 server packages @medusajs/framework and @medusajs/medusa. The plugin
ships no Admin UI of its own (its page is the base plugin's generic one), so
it needs none of the Admin packages (@medusajs/admin-sdk, @medusajs/ui,
React, React Router, TanStack Query).
