Skip to main content

Written as a handoff doc — enough to understand the current flow, debug issues, and safely extend it.

This document explains the custom rule-based promotion validation system implemented in this Medusa project, including the first_order and first_mobile_order coupon logic.

Overview

This project adds a custom rule engine on top of Medusa promotions. The main idea:
  • A promotion has linked custom data in promotion_details
  • promotion_details.metadata stores custom campaign metadata such as campaign_type
  • When a coupon is applied or revalidated, a custom workflow loads the cart, customer, and promotion details
  • The rule engine looks at the promotion metadata and decides which rule to run
  • The selected rule validates eligibility and either passes or throws a user-facing error
This supports business rules like:
  • 🆕 First order coupon
  • 📱 First mobile app order coupon
  • 🔮 Future campaign-specific coupon rules

What problem this system solves

Medusa promotions already support standard promotion conditions, but business campaigns often need richer checks based on:
  • User order history
  • Cart metadata
  • Checkout source
  • Campaign-specific metadata
  • Custom validation timing
This system adds that custom decision layer without changing Medusa core.

Where the code lives

Rule engine core
  • src/services/promotion-rule-engine.ts
  • src/services/promotion-rules/base-promotion-rule.ts
  • src/services/promotion-rules/types.ts
  • src/services/promotion-rules/first-order-promotion-rule.ts
  • src/services/promotion-rules/first-mobile-order-promotion-rule.ts
Validation workflow
  • src/workflows/promotion-validation/index.ts
  • src/workflows/hooks/validate-cart-promotions.ts
  • src/api/store/carts/[id]/promotions/validate/route.ts
Promotion custom data storage
  • src/modules/promotion-details/index.ts
  • src/modules/promotion-details/service.ts
  • src/modules/promotion-details/models/promotion-details.ts
  • src/modules/promotion-details/migrations/Migration20250619115246.ts
Promotion details creation/update hooks
  • src/workflows/create-promotional-details-from-promotions/index.ts
  • src/workflows/hooks/create-promotion-det.ts
  • src/workflows/hooks/update-promotion-det.ts
Admin-side promotion form helpers
  • src/admin/hooks/promotion/usePromotionForm.ts
  • src/admin/hooks/promotion/usePromotionData.ts
  • src/admin/types/promotion.ts
Related utility logic
  • src/utils/promotions/cap-logic.ts

Architecture at a glance

Two related but separate concerns:

Data model and metadata contract

PromotionDetails model

Defined in src/modules/promotion-details/models/promotion-details.ts:3. Fields: id, title, description, metadata, promotion_rules This is a custom module linked to Medusa promotions.

Database table

Migration at src/modules/promotion-details/migrations/Migration20250619115246.ts:6 creates promotion_details with columns:
  • title
  • description
  • metadata jsonb
  • promotion_rules text[]

Metadata fields currently used

apply_rule format

The engine supports apply_rule being either an object or a JSON string. Parsing happens in src/services/promotion-rule-engine.ts:4. Campaign type resolution order (src/services/promotion-rule-engine.ts:28): Example metadata — plain:
Example metadata — nested object:
Example metadata — nested as string (frontend quirk):

Promotion details lifecycle

Custom promotion data is created and updated through workflows hooked into Medusa promotion workflows.

On promotion create

Hook file: src/workflows/hooks/create-promotion-det.ts:7 Implementation:
  • Workflow: src/workflows/create-promotional-details-from-promotions/index.ts:29
  • Link creation: src/workflows/create-promotional-details-from-promotions/index.ts:46

On promotion update

Hook file: src/workflows/hooks/update-promotion-det.ts:27 Flow:
  1. Medusa promotion is updated
  2. Promotion is reloaded with promotion_details.*
  3. Link consistency is checked/recreated
  4. updateCustomFromPromotionWorkflow runs
⚠️ Watch this: the update workflow passes promotionWithDetails.promotion_details?.id as promotion_id at src/workflows/hooks/update-promotion-det.ts:74. The name suggests a promotion ID, but the value looks like a promotion-details ID. Treat this carefully in future changes.

Admin-side form submission

src/admin/hooks/promotion/usePromotionForm.ts:55 builds additional_data and sends title, description, metadata, promotion_rules via sdk.admin.promotion.update (:67).

Admin-side data fetching

src/admin/hooks/promotion/usePromotionData.ts:12 fetches the promotion with fields: "+promotion_details.*" so the admin UI can edit custom campaign metadata.

Rule engine design

Intentionally small and extensible.

Base rule contract

Defined in src/services/promotion-rules/base-promotion-rule.ts:3. Every rule implements:
  • getCampaignType()
  • validate(input)
Helper methods:
  • pass():24
  • fail(reason):17

Rule input / output

Defined in src/services/promotion-rules/types.ts. Input (:3): promotion, promotionDetails, cart, customer, checkoutSourceChannel?, action?, container Output (:13): { eligible: true } or { eligible: false, reason }

Engine behavior

Implemented in src/services/promotion-rule-engine.ts:21.
⚠️ Important: unknown custom campaign types do not fail — they default to eligible: true (src/services/promotion-rule-engine.ts:36). Intentionally permissive, but typos in metadata can silently bypass rule validation.

Validation workflow

Main workflow: validateCartPromotionsWorkflowsrc/workflows/promotion-validation/index.ts:177

Rule registration

Registered at :42:
  • new FirstOrderPromotionRule()
  • new FirstMobileOrderPromotionRule()

Validation step flow

validateCartPromotionsStep:47

Error mapping

If a rule returns CUSTOMER_REQUIRED, the workflow converts it to “Please login to apply the coupon” (:162). Otherwise it uses the rule-provided reason.

Promotion lookup fields

Promotions are loaded with id, code, promotion_details.* (:119).

First order rule

Implemented in src/services/promotion-rules/first-order-promotion-rule.ts:12. Campaign type: first_order (getCampaignType() at :13)

Validation logic

Relevant code:
  • Customer check — :18
  • Order query — :25
  • Order existence rejection — :36
  • Fail-closed on lookup error — :41

Important business meaning

This rule treats users with any prior order in pending or completed as not first-order eligible. It is not strictly “first successful purchase only” — a customer with a merely-pending order is already disqualified. Example:

First mobile app order rule

Implemented in src/services/promotion-rules/first-mobile-order-promotion-rule.ts:15. Campaign type: first_mobile_order (:16) Supported mobile sources (:5): mobile_ios, mobile_android

Validation logic

Source detection logic

Current cart metadata is read from input.cart?.metadata, falling back to input.cart?.cart?.metadata (:27). Then the source field used depends on the action:
  • Default path starts from checkout_source
  • If action is not checkout_validate, it switches to coupon_apply_source
Code: :32 (initial read) and :36 (action branch).

Why this exists

The system distinguishes between:

Past order mobile detection

Fetches all matching customer orders (:47), then checks each order’s metadata for either checkout_source or coupon_apply_source (:58). If any prior order is from a mobile source, rejects with “This offer is valid for new App Users only!”

Business meaning

  • Coupon must be used in the app now
  • Customer must have no previous mobile-sourced order
  • A customer may still have non-mobile (web) orders and pass this rule
So this is first mobile app order, not necessarily first order overall. Example:

Coupon apply vs checkout validation

Validation happens in two places.

1. During coupon add/update flows

Hooked in src/workflows/hooks/validate-cart-promotions.ts.
  • Create cart flow (:5): if promo_codes exist during cart creation, run validation with action PromotionActions.ADD
  • Update cart promotions flow (:19): if promo_codes exist during update, run validation with action input.action ?? PromotionActions.ADD

2. During explicit checkout revalidation

Route: src/api/store/carts/[id]/promotions/validate/route.ts:5
  1. Fetches the cart and already-attached promotions
  2. Gets active promo codes from cart
  3. Runs the same validation workflow
  4. Passes custom action checkout_validate
  5. Returns valid: true or valid: false
This matters because a coupon valid when applied may no longer be valid at checkout time.

Why the custom checkout_validate string exists

Passed intentionally at :62. Used by the first_mobile_order rule to decide whether to check checkout_source (checkout time) or coupon_apply_source (apply time).

Discount cap logic

Separate from eligibility — discount amounts may be capped even for a valid coupon. Implemented in src/utils/promotions/cap-logic.ts:113.

Metadata sources checked (:38)

  • promotion_details.metadata
  • promotion.metadata
  • application_method.metadata

Accepted cap locations (:96)

  • apply_rule.max_discount_amount
  • top-level max_discount_amount
  • reward.max_discount_amount

Where it is triggered

From middleware response interception (src/api/middlewares.ts:95) and refreshed cart response handling (src/api/middlewares.ts:123). So eligibility and reward capping are complementary, not the same check.

Examples

Example 1 — First order coupon

Example 2 — First mobile app order coupon

Cart metadata when coupon applied from app:
Cart metadata during checkout:

Example 3 — Max discount cap

If the actual generated discount is 450, cap logic reduces the total applied adjustment to 300.

How to add a new rule

Step 1 — create a rule class

New file under src/services/promotion-rules/:

Step 2 — register it in the workflow

In src/workflows/promotion-validation/index.ts:42:

Step 3 — add campaign metadata to the promotion

or inside apply_rule:

Step 4 — ensure the admin form supports it

If the new rule needs extra metadata, make sure the admin UI sends it through additional_data.metadata using src/admin/hooks/promotion/usePromotionForm.ts:55.

Step 5 — test both validation timings

Always validate both the coupon apply flow and the checkout validation flow — especially if the rule depends on cart metadata or action.

How to debug issues


Important behaviors and edge cases

  1. Unknown campaign types pass automatically — an unregistered campaign type in metadata passes validation (promotion-rule-engine.ts:36). Prevents breakage, but can hide config mistakes.
  2. Guest users cannot use current custom rules — both rules require customer.id (first-order-promotion-rule.ts:18, first-mobile-order-promotion-rule.ts:23). These campaigns are login-only.
  3. First order treats pending as disqualifying — a prior pending order is enough to reject eligibility (first-order-promotion-rule.ts:29).
  4. First mobile order ≠ first order — a customer can have previous web orders and still pass first_mobile_order, as long as they have no previous mobile-sourced order.
  5. Checkout validation can fail after apply-time success — expected when source metadata changes, cart/customer state changes, or the coupon was applied under looser apply-time conditions than checkout-time conditions.
  6. Metadata may be object or string — multiple parts of the system defensively parse JSON strings (promotion-rule-engine.ts:4, cap-logic.ts:25) because frontend/backend payloads aren’t always normalized.
  7. Checkout validate route returns a generic frontend message — regardless of the specific rule reason, it returns: “Coupon is not applicable. Your bill summary has been updated.” (validate/route.ts:98). Checkout UX is intentionally generic.

Known gaps and developer notes

  • promotion_rules is stored but unused in decisioningpromotion_details.promotion_rules is stored (promotion-details.ts:8) and sent by the admin UI (usePromotionForm.ts:72), but the current rule engine selects rules by metadata/campaign type, not by promotion_rules. It’s stored but not read by PromotionRuleEngineService.
  • Unused CART_VALUE_TOO_LOW error constants — defined in both rules (first-order-promotion-rule.ts:8, first-mobile-order-promotion-rule.ts:11) but never used. Suggests minimum-cart-value logic was planned but never implemented here.
  • Debug logging in production path — a raw console.log exists at promotion-validation/index.ts:111. Useful for debugging, noisy in production.
  • Promotion-details update hook needs careupdate-promotion-det.ts:74 appears to pass a promotion-details ID into a field named promotion_id. Verify the expected identifier shape before refactoring this flow.
  • Cap logic and eligibility are separate — a promotion can be eligible but still have its discount reduced by cap logic. Don’t assume a valid coupon means an uncapped discount.

Quick reference

Main engine files
  • src/services/promotion-rule-engine.ts
  • src/workflows/promotion-validation/index.ts
Current rules Cart metadata keys used
  • coupon_apply_source
  • checkout_source
Mobile source values used
  • mobile_ios
  • mobile_android
Promotion metadata keys commonly used
  • campaign_type
  • apply_rule
  • max_discount_amount
  • reward
  • is_visible
Important entry points