@devx-retailos/discount
v0.2.0
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.
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.
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. - 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
