@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.
Keywords
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/discountRequires @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 subpathEligibilityCheck 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_idselects 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. Omitsource_idand the original behaviour is unchanged: the local mirror first, then every active source in turn, oldest source first.evaluate()does not yet takesource_id; with a shared code it resolves the oldest matching coupon.
- Scheduled / bulk —
syncDiscountSource({ source_id })crawls all discounts (incrementally byupdated_at) and soft-deletes ones the source dropped. The shippedretailos-discount-source-syncjob runs this hourly for sources withauto_sync_enabled; opt a source out by setting it tofalse.
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 + fetchDiscountByCodeThe 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
categoriesfilter (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 discountsdiscount.create— create discountsdiscount.update— edit discountsdiscount.delete— deactivate discountsdiscount.apply— apply a discount to a cart or orderdiscount.override— approve a manual or above-threshold discountcoupon.read— view couponscoupon.create— issue coupon codescoupon.redeem— redeem a coupon code against a cartcoupon.delete— deactivate couponsdiscount_source.read— view external discount sourcesdiscount_source.create— configure an external discount sourcediscount_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
