npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devx-retailos/discount

v0.5.1

Published

Discount and coupon engine for retailOS. Pluggable DiscountStrategy and EligibilityCheck interfaces with built-in implementations for percentage, fixed, BOGO, tiered, bundle, manual, free-item, and shipping waiver discounts.

Readme

@devx-retailos/discount

Discount and coupon engine for POS checkouts. Pluggable DiscountStrategy and EligibilityCheck registries with eight built-in strategies (percentage, fixed, BOGO, tiered, bundle, manual, free item, shipping waiver) and an auditable application ledger. A pluggable DiscountSourceAdapter (Shopify built-in) mirrors externally-managed discounts/coupons into the same engine.

Part of retailOS, a Medusa v2 SDK for offline-store POS systems. Packages are installed independently and composed in a brand's Medusa backend.

Installation

npm install @devx-retailos/discount

Requires @medusajs/framework and @medusajs/medusa ^2.15.0 as peer dependencies. Depends on @devx-retailos/core and @devx-retailos/rbac.

Setup

// medusa-config.ts
export default defineConfig({
  // ...
  plugins: [
    {
      resolve: "@devx-retailos/discount",
      options: {},
    },
  ],
})

The module registers under the discount key (exported as DISCOUNT_MODULE).

Usage

Resolve DiscountModuleService from the container, then evaluate or apply a discount against a cart snapshot:

import { DISCOUNT_MODULE, type DiscountModuleService } from "@devx-retailos/discount"

const discounts = container.resolve<DiscountModuleService>(DISCOUNT_MODULE)

const evaluation = await discounts.evaluate({
  coupon_code: "WELCOME10", // or discount_id: "disc_..."
  cart: {
    currency: "INR",
    organization_id: "org_01",
    store_id: "store_01",
    customer_id: "cus_01",
    line_items: [
      { id: "li_1", quantity: 1, unit_price: 5000, subtotal: 5000 },
    ],
  },
})
// → { applies, reason, total_reduction, adjustments, requires_approval, ... }

const { evaluation: result, application } = await discounts.apply({
  discount_id: evaluation.discount_id,
  cart,
  order_id: "order_01",
  applied_by_employee_id: "emp_01",
})

evaluate checks scope (active, validity window, organization, store), runs the discount's eligibility_rules, then runs its strategy. apply persists a DiscountApplication row and increments coupon usage; it returns application: null when the discount does not apply or approval is required but approved_by_employee_id is missing.

Recording a discount this module did not price

apply() decides the amount itself. When the amount was decided elsewhere — by the source platform for a mirrored discount, or by your own stacking rules under a ceiling — use recordApplication, which writes the ledger row and nothing else:

await discounts.recordApplication({
  organization_id: "org_01",
  discount_id: "disc_01",
  coupon_id: "cpn_01",       // omit for a discount with no coupon
  cart_id: "cart_01",
  order_id: "order_01",
  reduction_amount: 300,     // whole currency units, persisted VERBATIM — never re-priced
  adjustments: [             // optional; [] is written when omitted
    { type: "line_reduction", line_item_id: "li_1", amount: 200 },
    { type: "line_reduction", line_item_id: "li_2", amount: 100 },
  ],
})

It differs from apply() in three ways, all deliberate: the amount you pass is persisted as-is, retailos_coupon.used_count is not incremented (a mirrored coupon is counted by its source, and the next sync overwrites ours), and source_evaluable = false does not block the write — a discount that cannot be priced offline can still be recorded as applied.

One row per coupon, always, including a single-coupon bill. source_id is derived from the coupon and is never read from your input.

When one cart becomes several orders (see @devx-retailos/order), call it once per order with that order's own share of the amount, derived by summing the per-line discount amounts for the lines that landed in it. Pass the same cart_id on each: usage limits count distinct redemptions per cart, not rows, so a split does not spend two allowances. How a whole-bill amount divides across lines is yours to decide — the only requirement is that the per-line amounts sum to the coupon's total exactly, or the ledger cannot reconcile against them.

Conventions worth knowing before you build a report on this table:

| | | | --- | --- | | reduction_amount | whole currency units (integer), not a decimal | | customer_id | whatever identifier the caller supplied — this module does not resolve identities | | source_id | the discount source, not an ecommerce store name; group discount reporting by this | | adjustments | NOT NULL — [] when there is no per-line breakdown, never null |

Reading back what was applied

const applied = await discounts.listAppliedCoupons({ order_id: "order_01" })
// → [{ code, coupon_id, discount_id, source_id, store_id,
//      reduction_amount, applied_at, adjustments, is_legacy }, ...]

One entry per coupon, in the order they were applied. source_id comes straight off the row — no join — so grouping a report by discount source costs nothing. Only code needs a second query, and it is one query however many rows come back.

Rows written before this ledger existed have no entries. Returning [] for them would make a report show every coupon vanishing before your deploy date, which reads as data loss rather than as history. Pass the row's own scalar columns and a one-entry breakdown is synthesised from them instead:

const applied = await discounts.listAppliedCoupons({
  order_id: order.id,
  fallback: {
    online_discount_code: order.online_discount_code,
    online_discount_amount: order.online_discount_amount,
    store_discount_code: order.store_discount_code,
    store_discount_amount: order.store_discount_amount,
  },
})

The fallback is used only when the ledger holds nothing for that row; once it has rows they win outright. Synthesised entries are flagged is_legacy: true and carry no discount_id, coupon_id or applied_at, because the scalar columns never recorded any of them — filter on the flag if a report must not mix the two.

You pass the values rather than this module reading them because those columns live on retailos_cart and retailos_order, which belong to packages this one does not depend on. The synthesis rule stays here so it cannot drift between deployments — including the case that is easy to get wrong alone: an amount with a blank code is still a real discount and still produces an entry.

Usage limits and externally-counted discounts

Both limits count distinct redemptions in retailos_discount_application — distinct (coupon_id, cart_id) — not rows. One cart can become several orders, each carrying its own row for the same coupon, so row-counting would spend one allowance per order: a coupon capped at 100 would retire at 50 on two-store bills.

The two limits treat a mirrored discount (one carrying a source_id) differently, and the difference is not an inconsistency:

| | Counted locally? | Why | | --- | --- | --- | | usage_limit_total | No — always 0 | The system that owns the discount counts its own redemptions, and the sync writes that figure into retailos_coupon.used_count every run. Coupon resolution already refuses a coupon whose synced count has reached max_uses, so counting local rows on top would count the same redemptions twice and retire it early. | | usage_limit_per_customer | Yes | Nothing per-customer is ever synced. A source reports once_per_customer as a flag, which becomes a usage_limit_per_customer rule with a limit of 1 — and only this ledger knows how often this customer redeemed it. Zeroing it too would let one customer redeem a once-per-customer discount indefinitely, since a global cap cannot see who is redeeming. |

Built-in strategies (strategy_type)

| Key | Behavior | | --- | --- | | percentage | Percentage off matching lines, optional max_reduction cap | | fixed | Flat amount, distributed proportionally across eligible lines | | bogo | Buy X get Y at Z% off (same-set or cross-set pools) | | tiered_percentage | Stepped percentage based on subtotal tier thresholds | | bundle | Specific items at a fixed combined price | | manual | Cashier/manager ad-hoc discount with reason; pairs with approval gating | | free_item | Emits a free_item adjustment for the cart layer to fulfil | | shipping_waiver | Waives shipping up to an optional max amount |

Built-in eligibility checks (eligibility_rules[].type)

min_cart_total, max_cart_total, min_cart_quantity, customer_groups, time_window, usage_limit_total, usage_limit_per_customer.

combines_with

A mirrored discount stores its source's combinability rules as { order: boolean, product: boolean, shipping: boolean } on retailos_discount.combines_with. is_stackable is a lossy collapse of the same data — true when any of the three is — so it cannot tell an order-combinable discount from a product-combinable one. Read combines_with when deciding whether two discounts may be stacked. null means unknown (locally authored, or the source never reported it), which is not the same as false.

Extension points

Register a custom strategy or eligibility check at boot — the interfaces live under the /types subpath:

import type { DiscountStrategy } from "@devx-retailos/discount/types"

const loyaltyTierStrategy: DiscountStrategy<{ percent: number }> = {
  type: "loyalty_tier",
  description: "Extra percentage off for loyalty members",
  validateConfig(config) {
    return config as { percent: number } // typically a zod parse
  },
  async evaluate(config, context) {
    const amount = Math.floor(
      (context.cart.line_items[0].subtotal * config.percent) / 100
    )
    return {
      applies: amount > 0,
      reason: `${config.percent}% loyalty discount`,
      adjustments: [
        { type: "line_reduction", line_item_id: context.cart.line_items[0].id, amount },
      ],
    }
  },
}

discounts.registerStrategy(loyaltyTierStrategy)
discounts.registerEligibilityCheck(myCheck) // EligibilityCheck from the same subpath

EligibilityCheck has the same shape with check(config, context) returning { passes, reason? }. Built-ins are also exported individually (e.g. percentageStrategy, minCartTotalCheck) along with createDefaultStrategyRegistry() / createDefaultEligibilityRegistry().

Discount sources (externally-managed discounts)

Brands whose promotions live in an external platform (e.g. Shopify) can mirror them into this engine instead of reimplementing fetch logic. A DiscountSourceAdapter fetches discounts; they're normalized and mapped onto the same strategies + eligibility checks above, then stored as local Discount/Coupon rows — so evaluate()/apply() work identically for local and sourced discounts. The full source payload is retained verbatim on Discount.source_payload, so no fetched field is lost even when the engine doesn't model it.

The built-in shopify adapter fetches all Shopify discount kinds — code and automatic, across Basic (percentage/fixed), BXGY, Free Shipping, and App/function. Discounts the engine can't reproduce offline are still stored but flagged non-evaluable (source_evaluable = false): App/function discounts (computed by Shopify server-side), customer-targeted discounts, and BXGY variants bogo can't express. Automatic (code-less) discounts are stored as coupon-less rows (source_is_automatic = true). Configure a source per organization:

await discounts.createDiscountSources([
  {
    organization_id: "org_01",
    name: "Shopify promos",
    adapter_type: "shopify",
    config: {
      shop_domain: "your-shop.myshopify.com",
      admin_access_token: "shpat_...",
      // api_version defaults to "2025-01", default_currency to "INR"
    },
    auto_sync_enabled: true,
  },
])

Two fetch paths, both ending in the same local rows:

  • On-demand (lazy) — when a coupon code isn't in the local mirror, evaluate({ coupon_code, cart }) falls back to the source (fetchDiscountByCode), mirrors it, then evaluates. cart.organization_id selects which source(s) to try.

Coupon codes are unique per source, not globally. Two sources may legitimately mirror the same code (one brand, two storefronts) as two independent coupons with different values. Pass resolveDiscount({ coupon_code, organization_id, source_id }) to target one source — it consults only that source and reports the code as not found if it is absent there, rather than silently resolving the other storefront's version. Omit source_id and the original behaviour is unchanged: the local mirror first, then every active source in turn, oldest source first. evaluate() does not yet take source_id; with a shared code it resolves the oldest matching coupon.

  • Scheduled / bulk — syncDiscountSource({ source_id }) crawls all discounts (incrementally by updated_at) and soft-deletes ones the source dropped. The shipped retailos-discount-source-sync job runs this hourly for sources with auto_sync_enabled; opt a source out by setting it to false.

Register a custom source (e.g. a different platform) the same way as strategies:

import type { DiscountSourceAdapter } from "@devx-retailos/discount/types"

discounts.registerDiscountSourceAdapter(myAdapter) // implements fetchDiscounts + fetchDiscountByCode

The built-in adapter and registry helpers are exported from the /sources subpath (shopifyDiscountSource, createDefaultDiscountSourceRegistry). Mapping helpers mapNormalizedDiscount / scopeToAppliesTo are exported from the package root.

v1 limits. Collection-scoped discounts map best-effort onto the line-item categories filter (a warning is logged). Usage counts refresh on fetch/sync, not on every checkout. Automatic discounts are stored but not auto-applied yet. Source webhooks are not yet consumed. The Shopify GraphQL field selection is pinned to the configured Admin API version — validate it against your store before relying on it in production.

Permissions

Registered via DISCOUNT_PERMISSIONS (subpath @devx-retailos/discount/permissions):

  • discount.read — view discounts
  • discount.create — create discounts
  • discount.update — edit discounts
  • discount.delete — deactivate discounts
  • discount.apply — apply a discount to a cart or order
  • discount.override — approve a manual or above-threshold discount
  • coupon.read — view coupons
  • coupon.create — issue coupon codes
  • coupon.redeem — redeem a coupon code against a cart
  • coupon.delete — deactivate coupons
  • discount_source.read — view external discount sources
  • discount_source.create — configure an external discount source
  • discount_source.sync — trigger a sync from an external source

API routes

Authenticated admin routes shipped by the plugin:

| Method | Path | Description | | --- | --- | --- | | GET | /admin/retailos/discounts | List discounts | | POST | /admin/retailos/discounts | Create a discount (validates strategy_type) | | GET | /admin/retailos/discounts/:id | Retrieve a discount | | POST | /admin/retailos/discounts/:id | Update a discount | | DELETE | /admin/retailos/discounts/:id | Deactivate a discount (is_active: false) | | POST | /admin/retailos/discounts/evaluate | Dry-run a discount against a cart | | POST | /admin/retailos/discounts/apply | Evaluate and record an application | | GET | /admin/retailos/discounts/strategies | List registered strategies and eligibility checks | | GET | /admin/retailos/coupons | List coupons | | POST | /admin/retailos/coupons | Create a coupon for a discount | | GET | /admin/retailos/coupons/:id | Retrieve a coupon | | DELETE | /admin/retailos/coupons/:id | Deactivate a coupon | | GET | /admin/retailos/discount-sources | List external discount sources | | POST | /admin/retailos/discount-sources | Configure a discount source (validates adapter_type) | | POST | /admin/retailos/discount-sources/:id/sync | Trigger a bulk sync from the source |

Related packages

  • @devx-retailos/core — shared types, Logger, RetailOSError, permission registry
  • @devx-retailos/rbac — organizations, stores, roles, permission enforcement
  • @devx-retailos/order — POS orders that consume discount applications
  • @devx-retailos/payments — pluggable payment adapters for tendering
  • @devx-retailos/gift-voucher — gift voucher issuance and redemption-as-tender
  • @devx-retailos/sdk-client — typed frontend client and React hooks

License

MIT