> ## Documentation Index
> Fetch the complete documentation index at: https://engineering.clinikally.work/llms.txt
> Use this file to discover all available pages before exploring further.

# Promotion and campaign overview

> Custom rule-based promotion validation in Medusa, including first_order and first_mobile_order coupon eligibility logic.

# 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:

```mermaid theme={null}
flowchart LR
    A[Coupon applied] --> B{1. Eligibility validation}
    B -->|Rule engine| C[Allowed or Rejected]
    C -->|If allowed| D{2. Reward capping}
    D -->|enforcePromotionDiscountCaps| E[Final discount amount]
```

| Concern                | Question it answers             | Handled by                     |
| ---------------------- | ------------------------------- | ------------------------------ |
| Eligibility validation | Can this coupon be applied?     | Custom rule engine             |
| Reward capping         | Should the discount be reduced? | `enforcePromotionDiscountCaps` |

***

## 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

| Location              | Fields                                                                       |
| --------------------- | ---------------------------------------------------------------------------- |
| Promotion metadata    | `campaign_type`, `apply_rule`, `max_discount_amount`, `reward`, `is_visible` |
| Cart / order metadata | `coupon_apply_source`, `checkout_source`                                     |

### `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`):

```mermaid theme={null}
flowchart TD
    A[Read promotionDetails.metadata] --> B{apply_rule.campaign_type present?}
    B -->|Yes| C[Use apply_rule.campaign_type]
    B -->|No| D{metadata.campaign_type present?}
    D -->|Yes| E[Use metadata.campaign_type]
    D -->|No| F[No campaign type -> eligible by default]
```

**Example metadata — plain:**

```json theme={null}
{
  "campaign_type": "first_order"
}
```

**Example metadata — nested object:**

```json theme={null}
{
  "apply_rule": {
    "campaign_type": "first_mobile_order",
    "max_discount_amount": 500
  }
}
```

**Example metadata — nested as string (frontend quirk):**

```json theme={null}
{
  "apply_rule": "{\"campaign_type\":\"first_mobile_order\",\"max_discount_amount\":500}"
}
```

***

## 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`

```mermaid theme={null}
sequenceDiagram
    participant Admin as Admin UI
    participant Medusa as Medusa Promotion Workflow
    participant Hook as create-promotion-det hook
    participant PD as promotion_details

    Admin->>Medusa: Create promotion
    Medusa->>Hook: promotionsCreated hook fires
    Hook->>PD: createCustomFromPromotionWorkflow
    PD-->>Hook: promotion_details row created
    Hook->>Medusa: link promotion <-> promotion_details
```

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`.

```mermaid theme={null}
flowchart TD
    A[Read metadata from promotionDetails] --> B[Parse apply_rule if present]
    B --> C[Resolve campaign_type]
    C --> D{Registered rule for this campaign_type?}
    D -->|No campaign_type at all| E[eligible: true]
    D -->|Type set, no matching rule| E
    D -->|Yes, rule found| F[Delegate to matched rule.validate]
    F --> G{Rule result}
    G -->|eligible| H[Pass]
    G -->|not eligible| I[Throw with reason]
```

> ⚠️ **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: `validateCartPromotionsWorkflow` — `src/workflows/promotion-validation/index.ts:177`

### Rule registration

Registered at `:42`:

* `new FirstOrderPromotionRule()`
* `new FirstMobileOrderPromotionRule()`

### Validation step flow

`validateCartPromotionsStep` — `:47`

```mermaid theme={null}
flowchart TD
    A[Start] --> B{Any promo codes?}
    B -->|No| Z[Skip]
    B -->|Yes| C{Action is remove?}
    C -->|Yes| Z
    C -->|No| D[Resolve cart id and customer id]
    D --> E{Cart or customer identifiable?}
    E -->|Neither| F[Throw error]
    E -->|Yes| G[Fetch cart with metadata + customer info]
    G --> H{Metadata missing?}
    H -->|Yes| I[Fetch metadata again]
    H -->|No| J[Fetch promotions by promo code incl. promotion_details.*]
    I --> J
    J --> K[For each code: build PromotionEvaluationInput]
    K --> L[Run rule engine]
    L --> M{Eligible?}
    M -->|No| N[Throw MedusaError]
    M -->|Yes| O[Continue]
```

### 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

```mermaid theme={null}
flowchart TD
    A[Start] --> B{Customer logged in?}
    B -->|No| C[Fail: CUSTOMER_REQUIRED]
    B -->|Yes| D[Query orders for customer]
    D --> E{Any order with status pending or completed?}
    E -->|Yes| F[Reject: not first-order eligible]
    E -->|No| G[Allow]
    D -->|Query fails| H[Fail closed: 'Unable to verify eligibility at this time. Please try again.']
```

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:**

| Customer state                   | Result     |
| -------------------------------- | ---------- |
| Logged in, zero prior orders     | ✅ Eligible |
| Logged in, one `pending` order   | ❌ Blocked  |
| Logged in, one `completed` order | ❌ Blocked  |
| Guest (not logged in)            | ❌ Blocked  |

***

## 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

```mermaid theme={null}
flowchart TD
    A[Start] --> B{Customer logged in?}
    B -->|No| C[Fail: CUSTOMER_REQUIRED]
    B -->|Yes| D[Read current cart metadata]
    D --> E{Action == checkout_validate?}
    E -->|Yes| F[Use checkout_source]
    E -->|No| G[Use coupon_apply_source]
    F --> H[Fetch customer's past orders]
    G --> H
    H --> I{Any past order has mobile source in metadata?}
    I -->|Yes| J["Reject: This offer is valid for new App Users only!"]
    I -->|No| K[Allow]
```

### 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:

| Timing                   | What's known                                              |
| ------------------------ | --------------------------------------------------------- |
| Coupon apply time        | Only where the coupon was applied (`coupon_apply_source`) |
| Checkout validation time | The actual checkout source (`checkout_source`)            |

### 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:**

| Customer history                    | Applying from | Result                             |
| ----------------------------------- | ------------- | ---------------------------------- |
| No mobile orders ever               | App (Android) | ✅ Eligible                         |
| No mobile orders ever               | Web           | ❌ Blocked (not mobile now)         |
| One prior mobile order              | App           | ❌ Blocked (not first mobile order) |
| Several prior web orders, no mobile | App           | ✅ Eligible                         |

***

## Coupon apply vs checkout validation

Validation happens in **two places**.

```mermaid theme={null}
flowchart LR
    subgraph Apply["1. Coupon apply/update"]
        A1[Cart create or update with promo_codes] --> A2[validate-cart-promotions hook]
        A2 --> A3["action = ADD (or input.action)"]
    end
    subgraph Checkout["2. Explicit checkout revalidation"]
        B1["/store/carts/:id/promotions/validate"] --> B2[Same validation workflow]
        B2 --> B3["action = checkout_validate"]
    end
    A3 --> C[validateCartPromotionsWorkflow]
    B3 --> C
```

### 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`.

```mermaid theme={null}
flowchart TD
    A[Promotion already applied] --> B[Load cart adjustments: line items + shipping]
    B --> C[Load promotion metadata]
    C --> D[Resolve max_discount_amount]
    D --> E{Total discount > cap?}
    E -->|Yes| F[Proportionally scale down adjustments]
    E -->|No| G[Leave as-is]
    F --> H[Update existing adjustments]
```

### 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

```json theme={null}
{ "campaign_type": "first_order" }
```

| Scenario                                         | Result     |
| ------------------------------------------------ | ---------- |
| Logged-in user, no prior pending/completed order | ✅ Eligible |
| Guest user                                       | ❌ Blocked  |
| User with one prior order                        | ❌ Blocked  |

### Example 2 — First mobile app order coupon

```json theme={null}
{
  "apply_rule": { "campaign_type": "first_mobile_order" }
}
```

Cart metadata when coupon applied from app:

```json theme={null}
{ "coupon_apply_source": "mobile_android" }
```

Cart metadata during checkout:

```json theme={null}
{ "checkout_source": "mobile_android" }
```

| Scenario                              | Result     |
| ------------------------------------- | ---------- |
| Applies in app, no prior mobile order | ✅ Eligible |
| Applies on web                        | ❌ Blocked  |
| Has a previous mobile order           | ❌ Blocked  |

### Example 3 — Max discount cap

```json theme={null}
{
  "apply_rule": {
    "campaign_type": "first_mobile_order",
    "max_discount_amount": 300
  }
}
```

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

***

## How to add a new rule

```mermaid theme={null}
flowchart LR
    A[1. Create rule class] --> B[2. Register in workflow]
    B --> C[3. Add campaign metadata to promotion]
    C --> D[4. Update admin form if needed]
    D --> E[5. Test both validation timings]
```

### Step 1 — create a rule class

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

```tsx theme={null}
import { BasePromotionRule } from "./base-promotion-rule"
import type { PromotionEvaluationInput, PromotionEligibilityResult } from "./types"

export class ExamplePromotionRule extends BasePromotionRule {
  getCampaignType(): string {
    return "example_campaign"
  }

  async validate(input: PromotionEvaluationInput): Promise<PromotionEligibilityResult> {
    return this.pass()
  }
}
```

### Step 2 — register it in the workflow

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

```tsx theme={null}
const promotionRuleEngine = new PromotionRuleEngineService([
  new FirstOrderPromotionRule(),
  new FirstMobileOrderPromotionRule(),
  new ExamplePromotionRule(),
])
```

### Step 3 — add campaign metadata to the promotion

```json theme={null}
{ "campaign_type": "example_campaign" }
```

or inside `apply_rule`:

```json theme={null}
{ "apply_rule": { "campaign_type": "example_campaign" } }
```

### 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

| # | Check                                                                                                                | Where                                                              |
| - | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| 1 | Promotion metadata has valid `campaign_type` or `apply_rule.campaign_type` — missing/misspelled defaults to eligible | `src/services/promotion-rule-engine.ts:30`, `:36`                  |
| 2 | Cart metadata has expected source field (`coupon_apply_source` during apply, `checkout_source` during checkout)      | `first-mobile-order-promotion-rule.ts:32`, `:36`                   |
| 3 | Order metadata key consistency across old vs new orders                                                              | mobile rule's past-order scan                                      |
| 4 | What `action` is reaching the rule (apply uses `ADD`/update action, checkout uses `checkout_validate`)               | `validate-cart-promotions.ts:14`, `validate/route.ts:62`           |
| 5 | Cart loading / metadata refetch for incomplete graph results                                                         | `promotion-validation/index.ts:92`                                 |
| 6 | Logs: workflow debug log, rejection logs, checkout warning, cap logic logs                                           | `index.ts:111`, `:159`; `validate/route.ts:73`; `cap-logic.ts:190` |

***

## 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 decisioning** — `promotion_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 care** — `update-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**

| Campaign type        | File                                                                   |
| -------------------- | ---------------------------------------------------------------------- |
| `first_order`        | `src/services/promotion-rules/first-order-promotion-rule.ts:13`        |
| `first_mobile_order` | `src/services/promotion-rules/first-mobile-order-promotion-rule.ts:16` |

**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**

| Purpose                       | Location                                                  |
| ----------------------------- | --------------------------------------------------------- |
| Apply/update validation hook  | `src/workflows/hooks/validate-cart-promotions.ts:5`       |
| Checkout validation route     | `src/api/store/carts/[id]/promotions/validate/route.ts:5` |
| Cap logic                     | `src/utils/promotions/cap-logic.ts:113`                   |
| Promotion-details create hook | `src/workflows/hooks/create-promotion-det.ts:7`           |
| Promotion-details update hook | `src/workflows/hooks/update-promotion-det.ts:27`          |
