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 thefirst_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.metadatastores custom campaign metadata such ascampaign_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
- 🆕 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
Where the code lives
Rule engine coresrc/services/promotion-rule-engine.tssrc/services/promotion-rules/base-promotion-rule.tssrc/services/promotion-rules/types.tssrc/services/promotion-rules/first-order-promotion-rule.tssrc/services/promotion-rules/first-mobile-order-promotion-rule.ts
src/workflows/promotion-validation/index.tssrc/workflows/hooks/validate-cart-promotions.tssrc/api/store/carts/[id]/promotions/validate/route.ts
src/modules/promotion-details/index.tssrc/modules/promotion-details/service.tssrc/modules/promotion-details/models/promotion-details.tssrc/modules/promotion-details/migrations/Migration20250619115246.ts
src/workflows/create-promotional-details-from-promotions/index.tssrc/workflows/hooks/create-promotion-det.tssrc/workflows/hooks/update-promotion-det.ts
src/admin/hooks/promotion/usePromotionForm.tssrc/admin/hooks/promotion/usePromotionData.tssrc/admin/types/promotion.ts
src/utils/promotions/cap-logic.ts
Architecture at a glance
Two related but separate concerns:Data model and metadata contract
PromotionDetails model
Defined insrc/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 atsrc/modules/promotion-details/migrations/Migration20250619115246.ts:6 creates promotion_details with columns:
titledescriptionmetadata jsonbpromotion_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:
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:
- Medusa promotion is updated
- Promotion is reloaded with
promotion_details.* - Link consistency is checked/recreated
updateCustomFromPromotionWorkflowruns
⚠️ Watch this: the update workflow passespromotionWithDetails.promotion_details?.idaspromotion_idatsrc/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 insrc/services/promotion-rules/base-promotion-rule.ts:3. Every rule implements:
getCampaignType()validate(input)
pass()—:24fail(reason)—:17
Rule input / output
Defined insrc/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 insrc/services/promotion-rule-engine.ts:21.
⚠️ Important: unknown custom campaign types do not fail — they default toeligible: true(src/services/promotion-rule-engine.ts:36). Intentionally permissive, but typos in metadata can silently bypass rule validation.
Validation workflow
Main workflow:validateCartPromotionsWorkflow — src/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 returnsCUSTOMER_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 withid, code, promotion_details.* (:119).
First order rule
Implemented insrc/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 inpending 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 insrc/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 frominput.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 tocoupon_apply_source
: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
Coupon apply vs checkout validation
Validation happens in two places.1. During coupon add/update flows
Hooked insrc/workflows/hooks/validate-cart-promotions.ts.
- Create cart flow (
:5): ifpromo_codesexist during cart creation, run validation with actionPromotionActions.ADD - Update cart promotions flow (
:19): ifpromo_codesexist during update, run validation with actioninput.action ?? PromotionActions.ADD
2. During explicit checkout revalidation
Route:src/api/store/carts/[id]/promotions/validate/route.ts:5
- Fetches the cart and already-attached promotions
- Gets active promo codes from cart
- Runs the same validation workflow
- Passes custom action
checkout_validate - Returns
valid: trueorvalid: false
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 insrc/utils/promotions/cap-logic.ts:113.
Metadata sources checked (:38)
promotion_details.metadatapromotion.metadataapplication_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
Example 3 — Max discount cap
How to add a new rule
Step 1 — create a rule class
New file undersrc/services/promotion-rules/:
Step 2 — register it in the workflow
Insrc/workflows/promotion-validation/index.ts:42:
Step 3 — add campaign metadata to the promotion
apply_rule:
Step 4 — ensure the admin form supports it
If the new rule needs extra metadata, make sure the admin UI sends it throughadditional_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
- 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. - 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. - First order treats
pendingas disqualifying — a priorpendingorder is enough to reject eligibility (first-order-promotion-rule.ts:29). - 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. - 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.
- 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. - 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_rulesis stored but unused in decisioning —promotion_details.promotion_rulesis 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 bypromotion_rules. It’s stored but not read byPromotionRuleEngineService.- Unused
CART_VALUE_TOO_LOWerror 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.logexists atpromotion-validation/index.ts:111. Useful for debugging, noisy in production. - Promotion-details update hook needs care —
update-promotion-det.ts:74appears to pass a promotion-details ID into a field namedpromotion_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 filessrc/services/promotion-rule-engine.tssrc/workflows/promotion-validation/index.ts
Cart metadata keys used
coupon_apply_sourcecheckout_source
mobile_iosmobile_android
campaign_typeapply_rulemax_discount_amountrewardis_visible