Quality-of-life admin customizations for Medusa v2 (notes, customer anonymization, guest order transfer, price-list discount control)
Quality-of-life admin customizations for Medusa v2, extracted from the Frecious backend so they can be reused across projects.
The plugin adds a Settings → Webbers QoL page in the admin with a switch per feature. Toggling a feature off:
Flags are persisted by the bundled module (table ) and default to ON when no row exists, so the plugin is fully active out of the box. The canonical list of features lives in (shared by the server and admin builds).
Always-free is widget-only gating: disabling it hides the promotion switch, but the actual link is still written by the host app's promotion hooks, so leave the hooks in place if you rely on existing always-free promotions.
After installing, run the migration so the flag table exists:
npx medusa db:migrate| Concern | Location |
|---|---|
| Feature catalog (single source of truth) | |
| Settings module + migration (feature flags) | |
| Settings admin page (Settings → Webbers QoL) | |
| Note module + migration | |
| Always-free () module + migration | |
| Price-list-ext () module + migration | |
| Links (customer/order note, promotion always-free, price-list-ext) | |
| Workflows (note, always-free, price-list-ext) | |
| Admin widgets | |
| Admin API routes | |
| Store API route (price-list-aware add-to-cart) | |
| HTTP middlewares (note + edit-email validators, promotion , price-list-ext) |
Install the plugin in your Medusa app:
1npm install @webbers/webbers-medusa2# or, during local development:3npx medusa plugin:add @webbers/webbers-medusaRegister it in the host app's :
1plugins: [2 {3 resolve: '@webbers/webbers-medusa',4 options: {},5 },6]After installing, run the migrations so the plugin's tables exist:
npx medusa db:migrateThis plugin has no dependency on any subscriptions package (or any other domain module). The anonymize / edit-email routines only touch , and tables out of the box; any extra tables (e.g. ) are declared by the host via plugin options — see "Configuring extra tables" below.
The anonymize and edit-email routines scrub the core Medusa tables on their own. To include tables owned by other modules — without this plugin depending on them — declare them in the plugin options in your . Every table/column name is validated () and all statements run inside the same transaction as the core scrub, so atomicity is preserved.
1plugins: [2 {3 resolve: '@webbers/webbers-medusa',4 options: {5 anonymize: {6 // Domain for the generated anonymized email (anon+<ts>@<domain>). Default: anonymized.invalid7 emailDomain: 'example.com',8 // Tables with a customer-id column: set email columns to the anon email, null the rest.9 customerScopedTables: [10 { table: 'subscription', emailColumns: ['email'], nullColumns: ['payment_config'] },11 ],12 // Address tables reached via a parent table's billing/shipping FKs (nulled out).13 // customerIdColumn/billingColumn/shippingColumn/nullColumns are optional (sensible defaults).14 addressTables: [15 { addressTable: 'subscription_address', parentTable: 'subscription' },16 ],17 },18 editEmail: {19 // Tables whose email column should follow the customer's new email.20 // emailColumn defaults to 'email', customerIdColumn to 'customer_id'.21 emailSyncTables: [{ table: 'subscription' }],22 },23 },24 },25]The module, the endpoint ( / are free-form strings) and the note component are generic. To add notes to another entity from a separate plugin or your app — e.g. subscriptions — depend on and:
Define the link between your module and the note module:
1import { defineLink } from '@medusajs/framework/utils';2import MyModule from '../modules/my-module';3import NoteModule from '@webbers/webbers-medusa/modules/note';4
5export default defineLink(MyModule.linkable.myEntity, NoteModule.linkable.note);Render a note widget in your entity's detail zone that calls with your (the module's registration key). The feature flag guards the endpoint, so the note stays hidden/blocked when notes are disabled in Settings → Webbers QoL.
This keeps the QoL plugin free of any dependency on your module while still owning the note storage.
Toggling the widget writes on the promotion (the plugin's middleware whitelists that field). Turning that flag into an link requires a handler on Medusa's promotion workflow hooks. Medusa allows only one handler per workflow hook, and a real app usually does other work in / too — so the plugin does not register these hooks for you. Add them in your app's :
1import { createPromotionsWorkflow } from '@medusajs/medusa/core-flows';2import {3 createAlwaysFreeFromPromotionWorkflow,4 CreateAlwaysFreeFromPromotionWorkflowInput,5} from '@webbers/webbers-medusa/workflows';6
7createPromotionsWorkflow.hooks.promotionsCreated(async ({ promotions, additional_data }, { container }) => {8 const workflow = createAlwaysFreeFromPromotionWorkflow(container);9
10 for (const promotion of promotions) {11 // ...any other app-specific work you already do here...12 await workflow.run({13 input: { promotion, additional_data } as CreateAlwaysFreeFromPromotionWorkflowInput,14 });15 }16});1import { updatePromotionsWorkflow } from '@medusajs/medusa/core-flows';2import { ContainerRegistrationKeys } from '@medusajs/framework/utils';3import {4 createAlwaysFreeFromPromotionWorkflow,5 CreateAlwaysFreeFromPromotionWorkflowInput,6 updateAlwaysFreeFromPromotionWorkflow,7 UpdateAlwaysFreeFromPromotionStepInput,8} from '@webbers/webbers-medusa/workflows';9import PromotionAlwaysFreeLink from '@webbers/webbers-medusa/links/promotion-always-free';10
11updatePromotionsWorkflow.hooks.promotionsUpdated(async ({ promotions, additional_data }, { container }) => {12 const query = container.resolve(ContainerRegistrationKeys.QUERY);13 const create = createAlwaysFreeFromPromotionWorkflow(container);14 const update = updateAlwaysFreeFromPromotionWorkflow(container);15
16 for (const promotion of promotions) {17 const existing = await query.graph({18 entity: PromotionAlwaysFreeLink.entryPoint,19 fields: ['*'],20 filters: { promotion_id: promotion.id },21 });22
23 if (!existing?.data?.length) {24 await create.run({ input: { promotion, additional_data } as CreateAlwaysFreeFromPromotionWorkflowInput });25 } else {26 await update.run({ input: { promotion, additional_data } as UpdateAlwaysFreeFromPromotionStepInput });27 }28 }29});The plugin emits no (, to avoid a type-portability error), so these imports resolve to in the host app unless it sets . That is intentional — keep the casts above so the hook bodies stay type-checked.
Reading the flag elsewhere (e.g. a fulfillment provider deciding shipping is free) can join the link table directly:
1SELECT af.always_free2FROM promotion p3LEFT JOIN promotion_promotion_promotion_addition_always_free paf ON paf.promotion_id = p.id4LEFT JOIN always_free af ON af.id = paf.always_free_id5WHERE p.id = ?The admin widget writes onto the price list's extended record (, linked to Medusa's ). The toggle only records intent — the enforcement happens when items are added to the cart.
The plugin ships the enforcement endpoint at . It behaves like Medusa's built-in add-to-cart, but when the body's points at a price list flagged , it sets on the line item (and stamps ). When the Disallow price-list discounts feature is toggled off, the endpoint skips that logic and behaves as a plain add-to-cart, so it is safe to call unconditionally.
To activate the behaviour, have the storefront add price-list items through this endpoint instead of the default :
1await sdk.client.fetch(`/store/carts/${cartId}/line-item-price-list`, {2 method: 'POST',3 body: {4 variant_id: variantId,5 quantity,6 metadata: { price_list_id: priceListId },7 },8});pnpm --filter @webbers/webbers-medusa build