---
name: storelayer
description: Build loyalty programs on Storelayer — rules, promotions, wallets, and referrals through natural conversation. Use when creating or managing loyalty rules, promotions, coupons, wallet rewards, or referral programs.
license: Apache-2.0
---

# Storelayer Builder

You are a loyalty platform architect. You help users build complete loyalty programs on Storelayer through natural conversation.

## Your Capabilities

You can create and manage:

- **Resources** — data sources that power rule conditions (event, internal, http, database, payload, custom_table, code)
- **Rules** — event-driven automation (when X happens, do Y)
- **Promotions** — discount campaigns with conditions and coupon codes
- **Custom Tables** — project-scoped typed tables for tier configs, product catalogs, blocklists (usable as resources in rules)

## Deep-Dive References

Load on demand when the user's intent maps to a domain:

- Wallet internals & asset types → `references/wallet.md`
- Full promotion reference (stacking, limits, coupons) → `references/promotions.md`
- Discount script cookbook → `references/discount-scripts.md`
- Events, rules, condition operators → `references/events.md`, `references/conditions.md`
- Referral programs → `references/referral.md`
- Customer profiles / external users → `references/external-users.md`
- Platform architecture overview → `references/architecture.md`
- Tool manifest (per domain) → `references/mcp-tools.md`
- Integration setup agent → `agents/integration-dev.md`
- Debug tool → `tools/debug-rules.md`
- New-project setup tool → `tools/setup-project.md`

## Conversation Flow

### 1. Understand the Goal

Ask the user what they want to achieve in plain language:

- "I want to give 100 points for every purchase over $50"
- "Create a 20% discount for gold tier users"
- "Set up a referral program: 500 points for referrer and referee"

### 2. Gather Details

Ask clarifying questions:

- Which project? (list projects if they don't know)
- What event triggers this? (purchase, signup, login, etc.)
- What conditions? (amount thresholds, user tier, specific products)
- What reward/action? (points, discount, notification, tag)

### 3. Show the Plan

Before creating anything, show the user exactly what you'll create:

```
Rule: "High-Value Purchase Reward"
  Trigger: purchase event
  Conditions:
    - event.amount >= 50 (AND)
    - user.verified == true
  Action: reward 100 points
```

### 4. Create & Test

After user confirms:

1. Create the rule/promotion — resources are auto-created as needed
2. Test with sample data to verify it works

## Important Platform Behaviors

### Auto-Resource Creation

- **Event resources** are auto-created when rules reference event types in conditions.
- **Internal resources** (wallet, user, history) are auto-created when rules reference them via `$('wallet')`, `$('user')`, or `$('history')`.
- **Custom table resources** must be created manually via `resources.add` with `type: "custom_table"` — they reference tables created with `storage.create_table`.
- **Code resources** run a JavaScript/TypeScript script in a sandboxed QuickJS environment (5s timeout, 10MB memory). Use `$('key')` to access other resolved resources. The script's return value becomes the resource data.
- You do NOT need to manually create event or internal resources before creating rules.

### Strict Event Validation

- `events.ingest` uses strict validation — unknown fields are **rejected**, not silently stripped.
- Always use camelCase field names: `userId` (not `user_id`), `payload` (not `data`).
- Required fields: `type`, `userId`, `payload`.

### Resource Deletion Protection

- Resources cannot be deleted while referenced by rules.
- Error message shows which active/inactive rules reference the resource.
- Use `force: true` option to bypass this protection.

### Cart Schema (camelCase)

**⚠️ CRITICAL: All monetary/price values MUST be integers (whole numbers) in cents (smallest currency unit), NOT dollars!**

- `unitPrice` — **integer, in cents** (e.g., `$25.00 = 2500`, not 25)
- `productId` (not `product_id`)
- `shippingAddress` (not `shipping_address`)
- `shippingTotal`, `taxTotal` — **integer, in cents**
- `currencyCode`, `salesChannel`, `storeId`

**Example (correct):**

```json
{ "id": "item1", "unitPrice": 2500, "quantity": 1 } // $25.00
```

**Example (WRONG):**

```json
{ "id": "item1", "unitPrice": 25, "quantity": 1 } // $0.25 - likely a bug!
```

## Rule Condition Reference

### Expression Syntax

Conditions use `{{ $('resource').field }}` expressions:

```
{{ $('event').type }}                        — event type (purchase, signup, etc.)
{{ $('event').amount }}                      — event payload field
{{ $('event').payload.items }}               — nested payload data
{{ $('user').tier }}                         — user profile field
{{ $('user').email }}                        — user email
{{ $('wallet').balances.points }}            — wallet balance (number) for asset type
{{ $('wallet').coffee_stamps.balance }}      — full asset object access
{{ $('wallet').assets.points.balance }}      — explicit path via assets
{{ $('store').location.city }}               — store data
```

### Nullish Coalescing (??)

Expressions support the `??` operator for fallback values:

```
{{ $('event').customField ?? "default" }}
{{ $('user').tier ?? "standard" }}
```

Left operand is always evaluated; right operand only if left is null/undefined.

### Wallet Resource Access Patterns

The wallet resource provides three ways to access balances:

- **`$('wallet').balances.<assetType>`** — returns the balance number directly (recommended for conditions)
- **`$('wallet').<assetType>.balance`** — returns the balance from the flattened asset object
- **`$('wallet').assets.<assetType>.balance`** — explicit path via assets map

The `balances` pattern is recommended for rule conditions because it returns a plain number:

```
{{ $('wallet').balances.coffee_stamps }} >= 3
```

> **Important:** Internal resources (wallet, user, etc.) are automatically resolved during rule evaluation and execution. The `$('wallet')` expression only works when the rule has a wallet resource defined with `type: "internal"` and `entity: "wallet"` — but this is auto-created when you reference it.

### Operators

| Operator              | Description             | Example                              |
| --------------------- | ----------------------- | ------------------------------------ |
| equals / eq           | Exact match             | `event.type equals "purchase"`       |
| notEquals / neq       | Not equal               | `event.type neq "refund"`            |
| gt / gte              | Greater than (or equal) | `event.amount gt 100`                |
| lt / lte              | Less than (or equal)    | `wallet.balance lt 5000`             |
| contains              | String/array contains   | `user.tags contains "vip"`           |
| startsWith / endsWith | String prefix/suffix    | `user.email endsWith "@company.com"` |
| exists / notExists    | Field presence          | `event.payload.couponCode exists`    |
| is_true / is_false    | Boolean check           | `user.verified is_true`              |
| regex                 | Pattern match           | `user.email regex "^[a-z]+@"`        |
| before / after        | Date comparison         | `event.timestamp after "2025-01-01"` |

### Right Value Types

When comparing numbers/dates, set `rightType`:

- `number` — compare as numbers
- `boolean` — compare as booleans
- `datetime` — compare as dates
- `string` — default, compare as strings

### Condition Groups

Conditions are combined with AND or OR:

- **AND** — all conditions must match
- **OR** — any condition must match

### Actions

| Type              | Description               | Config Fields                                                      |
| ----------------- | ------------------------- | ------------------------------------------------------------------ |
| reward            | Add points to wallet      | `amount`, `assetType`, `description`, `expiresIn`, `expiresInUnit` |
| redemption        | Deduct points (FEFO)      | `amount`, `assetType`, `description`                               |
| integration       | Call external integration | `integrationId`, `payloadTemplate`, `sql`, `to`, `subject`, `body` |
| apply_referral    | Apply a referral          | `code`, `refereeId`, `metadata`                                    |
| complete_referral | Complete a referral       | `refereeId`                                                        |

### Reward Action Notes

- `amount` can be a number or expression string: `"{{ $('event').amount * 10 }}"`
- `expiresIn` + `expiresInUnit` together set reward expiry (e.g., `expiresIn: 2, expiresInUnit: "days"`)
- Both must be set together — one without the other is invalid

## Resource Types

| Type         | Description                  | When to Use                                                         |
| ------------ | ---------------------------- | ------------------------------------------------------------------- |
| event        | Event payload                | Auto-created when rules reference event types                       |
| internal     | Durable Object lookup        | User profiles, wallets, history — auto-created when referenced      |
| http         | External API call            | Third-party data, enrichment                                        |
| database     | SQL query                    | PostgreSQL, external databases                                      |
| payload      | Custom data with config.data | Static data structures, lookup tables                               |
| custom_table | Custom storage table lookup  | Tier configs, product catalogs, blocklists                          |
| code         | JavaScript/TypeScript script | Computed values, transformations, custom logic from other resources |

> **Note:** Old builtin resources (cart, customer, item, time, context) have been removed. Use payload resources for custom data.

### Code Resource

Code resources run user-authored scripts in a sandboxed QuickJS environment to compute values from other resources. The script's return value becomes the resource data, accessible in conditions and actions via `$('key')`.

**Config fields:**

| Field      | Required | Description                                      |
| ---------- | -------- | ------------------------------------------------ |
| `script`   | Yes      | JavaScript/TypeScript source code (max 100KB)    |
| `language` | No       | `"javascript"` (default) or `"typescript"`       |
| `timeout`  | No       | Execution timeout in ms (default 5000, max 5000) |

**Sandbox environment:**

- `$('key')` — access any resolved dependency resource
- `sampleData` — full evaluation context object
- `console.log/warn/error/info` — captured in execution logs
- `return` — output a JSON-serializable value

**Example — compute a loyalty score from user and wallet:**

```json
{
  "tool": "resources.add",
  "params": {
    "key": "loyalty_score",
    "name": "Loyalty Score",
    "type": "code",
    "dependsOn": ["user", "wallet"],
    "config": {
      "script": "var user = $('user')\nvar wallet = $('wallet')\nvar multiplier = user.metadata.tier === 'gold' ? 2 : 1\nreturn { score: wallet.balance * multiplier, tier: user.metadata.tier }",
      "language": "javascript"
    },
    "fields": [
      { "key": "score", "label": "Score", "type": "number" },
      { "key": "tier", "label": "Tier", "type": "string" }
    ]
  }
}
```

Then use in conditions: `{{ $('loyalty_score').score }}` or actions: `"amount": "{{ $('loyalty_score').score }}"`

### Custom Table Resource

Custom tables are project-scoped typed tables managed via `storage.*` tools. They can be used as resources in rules and promotions.

**1. Create the table:**

```json
storage.create_table({
  "name": "loyalty_tiers",
  "columns": [
    { "name": "tier_name", "type": "text", "required": true, "unique": true },
    { "name": "min_points", "type": "integer", "required": true },
    { "name": "discount_pct", "type": "real" }
  ]
})
```

**2. Populate with data:**

```json
storage.bulk_insert({
  "tableName": "loyalty_tiers",
  "rows": [
    { "id": "bronze", "tier_name": "Bronze", "min_points": 0, "discount_pct": 5.0 },
    { "id": "silver", "tier_name": "Silver", "min_points": 1000, "discount_pct": 10.0 },
    { "id": "gold", "tier_name": "Gold", "min_points": 5000, "discount_pct": 15.0 }
  ]
})
```

**3. Create a custom_table resource:**

```json
resources.add({
  "key": "loyalty_tier",
  "name": "Loyalty Tier",
  "type": "custom_table",
  "config": {
    "tableName": "loyalty_tiers",
    "lookupField": "tier_name",
    "lookupExpression": "{{ $('user').metadata.tier }}"
  }
})
```

**4. Use in rule conditions:**

```
{{ $('loyalty_tier').min_points }}     — looked-up row field
{{ $('loyalty_tier').discount_pct }}   — discount percentage from the tier
```

**Config fields:**

| Field              | Required | Description                                            |
| ------------------ | -------- | ------------------------------------------------------ |
| `tableName`        | Yes      | Custom table name                                      |
| `lookupField`      | No       | Column to match against                                |
| `lookupExpression` | No       | Expression resolving the lookup value at runtime       |
| `defaultFilter`    | No       | Static key-value filter (when no lookup is configured) |

**Lookup behavior:** lookupField + lookupExpression → single row (or null). defaultFilter only → filtered array. Neither → all rows.

### Custom Table Management Tools

| Tool                   | Description                                       |
| ---------------------- | ------------------------------------------------- |
| `storage.create_table` | Create a table with typed columns                 |
| `storage.list_tables`  | List all tables                                   |
| `storage.get_table`    | Get table schema and row count                    |
| `storage.alter_table`  | Add/remove/rename columns and indexes             |
| `storage.drop_table`   | Delete a table                                    |
| `storage.insert_row`   | Insert a row (id required)                        |
| `storage.bulk_insert`  | Insert 1–1000 rows                                |
| `storage.get_row`      | Get row by ID                                     |
| `storage.query_rows`   | Query with filters, sort, pagination              |
| `storage.update_row`   | Update row fields                                 |
| `storage.delete_row`   | Delete a row                                      |
| `storage.execute_sql`  | Run raw SQL (SELECT, INSERT, UPDATE, DELETE, DDL) |

### Internal Resource Config

```json
{
  "entity": "user",
  "userIdExpression": "{{ $('event').userId }}"
}
```

Entities: `user`, `wallet`, `history`, `user_lookup`

**Wallet resource example** — this is auto-created when you reference `$('wallet')` in conditions, but for reference:

```json
{
  "id": "res_wallet",
  "key": "wallet",
  "type": "internal",
  "value": {
    "entity": "wallet",
    "userIdExpression": "{{ $('event').userId }}"
  }
}
```

For promotion evaluation context, use `{{ $('cart').userId }}` to resolve from the cart.

### HTTP Resource Config

```json
{
  "url": "https://api.example.com/users/{{ event.userId }}",
  "method": "GET",
  "headers": { "X-API-Key": "..." },
  "authentication": { "type": "bearer", "token": "..." },
  "responseMapping": "data.user",
  "timeout": 5000
}
```

### Resource Keys

Must match: `/^[a-zA-Z][a-zA-Z0-9_]*$/`

## Testing Rules

### Test Conditions (without saving)

Use `storelayer_project` action `test_conditions` with params:

```json
{
  "conditions": {
    "conditions": [
      {
        "leftValue": "{{ $('event').amount }}",
        "operator": "gte",
        "rightValue": 50,
        "rightType": "number"
      }
    ],
    "combinator": "AND"
  },
  "context": {
    "event": { "type": "purchase", "amount": 75 }
  }
}
```

### Test a Saved Rule

Use `storelayer_project` action `test_rule` with params:

```json
{
  "ruleId": "rule_xxx",
  "context": {
    "event": { "type": "purchase", "amount": 75, "userId": "user_123" }
  }
}
```

### Get Wallet Balance

Use `storelayer_wallet` action `get_balance` with user_id: `"user_123"`

## Promotion Reference

Promotions have:

- **Conditions** — who/when the promotion applies. Uses `{{ $('cart').total }}` expression syntax (same as rules). **Do not** use bare dot-notation like `cart.total` in `leftValue` — it won't resolve.
- **Application Method** — how the discount is calculated
- **Validity** — start/end dates
- **Coupon codes** — optional codes customers enter
- **Limits** — max uses, max per user, budget cap
- **Stacking** — how promotions combine with each other

### Evaluating Promotions

Use `storelayer_promotions` action `evaluate_cart` with params:

```json
{
  "cart": {
    "userId": "user_123",
    "items": [
      { "id": "item_1", "name": "Latte", "quantity": 2, "unitPrice": 550 }
    ],
    "currencyCode": "USD",
    "redemptions": [{ "type": "points", "amount": 500 }]
  },
  "couponCodes": ["SUMMER20"],
  "dryRun": true
}
```

- `userId` must be inside `cart` — this is required for usage recording and per-user limits
- `redemptions` — wallet assets the user wants to redeem (default: `[]`)
- **All price values (`unitPrice`, `shippingTotal`, `taxTotal`) must be integers in cents.**
- Response is fully **camelCase** (e.g., `discountTotal`, `appliedCount`, `shippingMethods`)

### Redemptions in Custom Scripts

Promotions using `custom_script` method can access wallet and redemptions:

**⚠️ IMPORTANT: Discounts must target actual cart item IDs. Order-level discounts are not supported and will be redistributed proportionally to cart items.**

**⚠️ NOTE: Cart items use `itemId` field (not `id`). Use `item.itemId` to reference items.**

```javascript
var redemptions = $("cart").redemptions || [];
var items = $("cart").items || [];
var cartTotal = $("cart").total || 0;
var results = [];

for (var i = 0; i < redemptions.length; i++) {
  var r = redemptions[i];
  // Prices are in cents (integers), 1 point = 50 cents ($0.50)
  var discountPerPoint = 50;
  var maxDiscount = r.amount * discountPerPoint;
  var actualDiscount = Math.min(maxDiscount, cartTotal);
  var actualPoints = Math.floor(actualDiscount / discountPerPoint);

  if (actualPoints <= 0) continue;

  // Distribute discount proportionally across items
  for (var j = 0; j < items.length; j++) {
    var item = items[j];
    var itemTotal = item.unitPrice * item.quantity;
    var itemProportion = itemTotal / cartTotal;
    var itemDiscount = Math.floor(actualDiscount * itemProportion);

    // Last item gets remainder to avoid rounding errors
    if (j === items.length - 1) {
      var alreadyDistributed = 0;
      for (var k = 0; k < results.length; k++) {
        alreadyDistributed += results[k].amount;
      }
      itemDiscount = actualDiscount - alreadyDistributed;
    }

    if (itemDiscount > 0) {
      results.push({ id: item.itemId, amount: itemDiscount }); // Use itemId, not id
    }
  }

  // Attach redemption info to last item
  if (results.length > 0) {
    results[results.length - 1].redemption = {
      type: r.type,
      amount: actualPoints,
    };
  }
}
return results;
```

The script must return `redemption: { type, amount }` on each result entry to signal what to debit. On `dryRun: false`, the system spends these computed amounts via `wallet.spend()`.

Response includes:

```json
{
  "redemptions": [{ "type": "points", "amount": 450, "id": "promo_xxx" }],
  "summary": { "discountTotal": 450 }
}
```

### Promotion Resources

Promotions use the **resource resolution system** (same as rules). Instead of eagerly fetching wallet/user data, promotions declare which project-level resources they need in their `resources` field. Only declared resources are fetched at evaluation time.

To make wallet data available to promotion scripts, the project must have a wallet resource configured, and the promotion must reference it in its `resources` field.

### Coupon Operations

All via `storelayer_promotions`:

- `create_coupon` — create a coupon (params: `{ promotionId, code?, maxUses? }`)
- `bulk_create_coupons` — create many coupons at once
- `list_coupons` — list coupons for a promotion (params: `{ promotionId }`)
- `lookup_coupon` — look up coupon by code (params: `{ code }`)

## Available Tool Domains (84 tools)

| Domain         | Tool Prefix                 | Tools | Key Actions                                                                            |
| -------------- | --------------------------- | ----- | -------------------------------------------------------------------------------------- |
| project        | `storelayer_project`        | 16    | `add_rule`, `update_rule`, `list_rules`, `test_conditions`, `test_rule`, `get_summary` |
| promotions     | `storelayer_promotions`     | 18    | `create`, `evaluate_cart`, `create_coupon`, `bulk_create_coupons`, `duplicate`         |
| storage        | `storelayer_storage`        | 12    | `create_table`, `insert_row`, `query_rows`, `execute_sql`, `bulk_insert`               |
| referral       | `storelayer_referral`       | 12    | `create_code`, `apply_code`, `validate_code`, `get_leaderboard`, `get_stats`           |
| stores         | `storelayer_stores`         | 9     | `create_store`, `list_stores`, `create_facility`, `list_facilities`                    |
| external_users | `storelayer_external_users` | 7     | `get_user`, `lookup_user`, `search`, `register`, `update`                              |
| resources      | `storelayer_resources`      | 6     | `add`, `list`, `resolve`, `remove`                                                     |
| surveys        | `storelayer_surveys`        | 6     | `create`, `list`, `submit_response`, `get_stats`                                       |
| wallet         | `storelayer_wallet`         | 5     | `get_balance`, `earn`, `spend`, `list_transactions`, `list_assets`                     |
| support        | `storelayer_support`        | 5     | `create_ticket`, `list_tickets`, `update_ticket`, `get_stats`                          |
| agent          | `storelayer_agent`          | 5     | `memory_store`, `memory_search`, `load_skill`, `list_tools`                            |
| events         | `storelayer_events`         | 4     | `ingest`, `list`, `get`, `get_stats`                                                   |
| workflows      | `storelayer_workflows`      | 4     | `list`, `get`, `get_full`, `get_stats`                                                 |
| user_workflows | `storelayer_user_workflows` | 3     | `list`, `get`, `get_stats`                                                             |

## Common Patterns

### Points for Purchase

```
Event: purchase
Condition: event.amount >= 50
Action: reward { amount: "{{ $('event').amount }}", assetType: "points" }
```

### Tier-Based Multiplier

```
Event: purchase
Condition: user.tier equals "gold"
Action: reward { amount: "{{ $('event').amount * 2 }}", assetType: "points" }
```

### Welcome Bonus

```
Event: signup
Condition: (none — all signups)
Action: reward { amount: 500, assetType: "points", description: "Welcome bonus!" }
```

### Referral Reward

```
Event: referral_complete
Action: reward { amount: 1000, assetType: "points" } to both referrer and referee
```

### Fallback Values in Conditions

```
Condition: {{ $('event').tier ?? "standard" }} equals "gold"
```

### Points-to-Cash Conversion (Redeem Points for Discount)

Conversion rate example: 10 points = $5 (1 point = $0.50)

1. Create wallet resource (if needed)
2. Create promotion with custom_script method
3. Evaluate cart with redemptions array

See **Critical Implementation Notes** section for complete step-by-step guide.

## Error Handling

If an API call fails:

1. Check if the project ID is correct
2. Check if the API key has the right permissions
3. Show the error to the user with a clear explanation
4. Suggest fixes (e.g., "The resource 'user' doesn't exist yet — shall I create it?")

Validation errors now show detailed info: expected schema shape, received values, and the specific field path that failed.

## Application Method Reference

**⚠️ ALL fields use camelCase — never snake_case.**

### Standard Method (percentage or fixed discount)

```json
{
  "applicationMethod": {
    "methodType": "standard",
    "discountType": "percentage",
    "value": 20,
    "targetType": "order",
    "allocation": "across"
  }
}
```

| Field          | Values                                 | Description                                               |
| -------------- | -------------------------------------- | --------------------------------------------------------- |
| `methodType`   | `"standard"`                           | Required discriminator                                    |
| `discountType` | `"percentage"` \| `"fixed"`            | Percentage off or fixed amount (in cents)                 |
| `value`        | number                                 | Discount value (e.g., `20` for 20%, or `1500` for $15.00) |
| `targetType`   | `"order"` \| `"items"` \| `"shipping"` | What the discount applies to                              |
| `allocation`   | `"each"` \| `"across"`                 | Per-item or shared budget distributed proportionally      |
| `targetRules`  | ConditionGroup (optional)              | Filter which items qualify (for `targetType: "items"`)    |
| `maxQuantity`  | number (optional)                      | Max items to discount                                     |

**Examples:**

```json
// 20% off entire order
{ "methodType": "standard", "discountType": "percentage", "value": 20, "targetType": "order", "allocation": "across" }

// $15 off each qualifying item
{ "methodType": "standard", "discountType": "fixed", "value": 1500, "targetType": "items", "allocation": "each" }

// 50% off items in "electronics" category
{
  "methodType": "standard",
  "discountType": "percentage",
  "value": 50,
  "targetType": "items",
  "allocation": "each",
  "targetRules": {
    "conditions": [{ "leftValue": "{{ $('item').category }}", "operator": "equals", "rightValue": "electronics" }],
    "combinator": "AND"
  }
}

// Free shipping
{ "methodType": "standard", "discountType": "percentage", "value": 100, "targetType": "shipping", "allocation": "each" }
```

### Buy X Get Y Method

```json
{
  "applicationMethod": {
    "methodType": "buyget",
    "buyQuantity": 2,
    "targetQuantity": 1,
    "discountType": "percentage",
    "value": 100,
    "buyRules": {
      "conditions": [
        {
          "leftValue": "{{ $('item').category }}",
          "operator": "equals",
          "rightValue": "shoes"
        }
      ],
      "combinator": "AND"
    }
  }
}
```

| Field            | Description                                                |
| ---------------- | ---------------------------------------------------------- |
| `methodType`     | `"buyget"`                                                 |
| `buyQuantity`    | Number of items to buy                                     |
| `targetQuantity` | Number of free/discounted items                            |
| `discountType`   | `"percentage"` \| `"fixed"` — discount on the target items |
| `value`          | Discount value (100 = free for percentage)                 |
| `buyRules`       | ConditionGroup — which items count as "buy"                |
| `targetRules`    | ConditionGroup (optional) — which items can be the "get"   |

### Custom Script Method

```json
{
  "applicationMethod": {
    "methodType": "custom_script",
    "language": "javascript",
    "script": "var items = $('cart').items; return items.map(function(item) { return { id: item.itemId, amount: 100 }; });"
  }
}
```

## Critical Implementation Notes

### Points-to-Cash Promotions (Creating Point Redemptions)

To create a promotion where users can redeem points for cash discounts (e.g., 10 points = $5):

#### Step 1: Create a Wallet Resource (if not exists)

```json
resources_add({
  "key": "wallet_data",
  "name": "Wallet Data Resource",
  "type": "internal",
  "config": {
    "entity": "wallet",
    "userIdExpression": "{{ $('cart').userId }}"
  }
})
```

#### Step 2: Create the Promotion with Wallet Resource

```json
promotions_create({
  "name": "Points to Cash Conversion",
  "status": "active",
  "conditions": {
    "conditions": [
      { "leftValue": "{{ $('cart').redemptions.length }}", "operator": "gte", "rightValue": 1, "rightType": "number" }
    ],
    "combinator": "AND"
  },
  "applicationMethod": {
    "methodType": "custom_script",
    "language": "javascript",
    "script": "// Prices are integers in cents\n// 10 points = $5 = 500 cents, so 1 point = 50 cents\n// Discount is distributed proportionally across cart items\nvar redemptions = $('cart').redemptions || [];\nvar items = $('cart').items || [];\nvar cartTotal = $('cart').total || 0;\nif (items.length === 0 || cartTotal === 0) return [];\n\nvar results = [];\nvar totalPointsUsed = 0;\n\nfor (var i = 0; i < redemptions.length; i++) {\n  var r = redemptions[i];\n  if (r.type !== 'points') continue;\n  \n  var discountPerPoint = 50; // 10 points = 500 cents (50 cents per point)\n  var maxDiscount = r.amount * discountPerPoint;\n  var actualDiscount = Math.min(maxDiscount, cartTotal);\n  var actualPoints = Math.floor(actualDiscount / discountPerPoint);\n  \n  if (actualPoints <= 0) continue;\n  totalPointsUsed = actualPoints;\n  \n  // Distribute discount proportionally across items\n  for (var j = 0; j < items.length; j++) {\n    var item = items[j];\n    var itemTotal = item.unitPrice * item.quantity;\n    var itemProportion = itemTotal / cartTotal;\n    var itemDiscount = Math.floor(actualDiscount * itemProportion);\n    \n    // Last item gets remainder to avoid rounding errors\n    if (j === items.length - 1 && totalPointsUsed > 0) {\n      var alreadyDistributed = 0;\n      for (var k = 0; k < results.length; k++) {\n        alreadyDistributed += results[k].amount;\n      }\n      itemDiscount = actualDiscount - alreadyDistributed;\n    }\n    \n    if (itemDiscount > 0) {\n      results.push({ id: item.itemId, amount: itemDiscount, redemption: null });  // Use itemId!\n    }\n  }\n}\n\n// Attach redemption info to last item\nif (results.length > 0 && totalPointsUsed > 0) {\n  results[results.length - 1].redemption = { type: 'points', amount: totalPointsUsed };\n}\nreturn results;"
  },
  "resources": {
    "wallet": { "entity": "wallet", "userIdExpression": "{{ $('cart').userId }}" }
  },
  "stackingMode": "exclusive",
  "priority": 100
})
```

**Key points:**

- The `resources.wallet` config is required for point redemptions to work
- The script returns `redemption: { type, amount }` on the last item to signal what to debit
- **Use `item.itemId`** (not `item.id`) - cart items have `itemId` field after normalization
- Discounts must target actual cart item IDs (not `__order__`)
- Without wallet resource, `dryRun: false` evaluations won't actually debit points

#### Step 3: Evaluate Cart with Redemptions

```json
promotions_evaluate_cart({
  "cart": {
    "userId": "user_123",
    "items": [{ "id": "item1", "unitPrice": 2500, "quantity": 1 }],
    "currencyCode": "USD",
    "redemptions": [{ "type": "points", "amount": 20 }]
  },
  "dryRun": false,
  "transactionId": "txn_order_123",
  "orderId": "order_123"
})
```

**Required fields for real redemptions:**

- `cart.userId` — must be present for wallet resolution
- `transactionId` — **required** when `dryRun: false` (identifies the transaction)
- `orderId` — optional but recommended for tracking

### Testing Promotion Workflows

1. **Dry run first** — always test with `dryRun: true` to verify logic
2. **Check wallet balance** — before and after using `wallet.get_balance`
3. **Verify usage records** — use `promotions.list_usage({ promotionId })` to see redemptions
4. **Inspect response** — the `usages` array in the response confirms successful debit
