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

# Cashback Card

> Category multipliers, spend thresholds, and monthly resets

Build a cashback credit card program where participants earn different rates based on purchase category, with a higher rate that kicks in after they hit a monthly spend threshold.

## What you'll build

| Use case              | How it works                                                                  |
| --------------------- | ----------------------------------------------------------------------------- |
| Category multipliers  | 5% on dining, 3% on groceries, 1% on everything else                          |
| Spend threshold bonus | Rate jumps from 1% to 3% after \$2,500/month in total spend                   |
| Retroactive bonus     | When crossing the threshold, a one-time bonus covers the delta on prior spend |
| Monthly reset         | Counters reset on the 1st of each month via automation                        |

The end result is 8 rules and 1 automation.

## Assumptions

This guide assumes you already have:

* A **program** created
* An **asset** linked to that program (`CASHBACK_USD`, scale 2, `UNLIMITED`, `SIMPLE`)

See the [quickstart](/quickstart) if you need help with setup.

## The rules

### 1. Track monthly spend

Every rule in this program needs to know how much the participant has spent this month. We track two counters: total spend (for the threshold check) and non-category spend (for the retroactive bonus calculation). `monthly_spend` tracks everything, for the \$2,500 threshold:

```json theme={null}
{
  "name": "track_monthly_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    { "type": "COUNTER", "key": "monthly_spend", "value": "${{ event.amount }}" }
  ]
}
```

### 2. Track non-category spend

`monthly_base_spend` tracks only non-category purchases. This is what the retroactive bonus pays out on.

```json theme={null}
{
  "name": "track_monthly_base_spend",
  "order": 55,
  "condition": "event.type == \"purchase\" && event.amount > 0 && !(event.mcc in [\"5812\", \"5813\", \"5814\", \"5411\", \"5422\"])",
  "actions": [
    { "type": "COUNTER", "key": "monthly_base_spend", "value": "${{ event.amount }}" }
  ]
}
```

<Info>
  Counter values in conditions are **snapshots**: they reflect the state before the current event's actions execute. These rules increment the counters in the database, but all later rules still see the pre-increment values. This is important for threshold detection.
</Info>

### 3. Retroactive bonus at \$2,500

When a participant crosses the \$2,500 monthly threshold, they should retroactively earn an extra 2% on their non-category spend so far. This makes up the difference between the 1% they already earned and the 3% high-spender rate.

```json theme={null}
{
  "name": "threshold_retroactive_bonus",
  "order": 60,
  "condition": "event.type == \"purchase\" && event.amount > 0 && participant.counter.monthly_spend < 2500.0 && (participant.counter.monthly_spend + event.amount) >= 2500.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "${{ round(participant.counter.monthly_base_spend * 0.02, 2) }}",
      "description": "Retroactive 2% bonus on prior non-category spend"
    }
  ]
}
```

The condition has two parts that make it fire exactly once per month:

* `snapshot < 2500`: hasn't crossed yet
* `(snapshot + event.amount) >= 2500`: this event crosses it

The credit amount uses `monthly_base_spend` (not `monthly_spend`) so the retroactive bonus only applies to purchases that earned the 1% base rate. Dining and grocery purchases already earned their full 5%/3%. Paying an extra 2% on those would overshoot.

<Warning>
  Don't use `stop_after_match` here. The current purchase still needs to flow through the earning rules below to earn its own cashback.
</Warning>

### 4. Dining: 5%

```json theme={null}
{
  "name": "dining_cashback",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && event.mcc in [\"5812\", \"5813\", \"5814\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.05, 2) }}",
      "description": "Dining 5% cashback"
    }
  ]
}
```

MCC codes: 5812 (restaurants), 5813 (bars), 5814 (fast food).

`stop_after_match: true` ensures a dining purchase earns 5% and only 5%. It won't also match the grocery or base rules.

### 5. Groceries: 3%

```json theme={null}
{
  "name": "grocery_cashback",
  "order": 200,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && event.mcc in [\"5411\", \"5422\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.03, 2) }}",
      "description": "Grocery 3% cashback"
    }
  ]
}
```

MCC codes: 5411 (grocery stores), 5422 (freezer/meat lockers). Same pattern as dining: `stop_after_match` prevents the base and high-spender rules from stacking on top.

### 6. High-spender: 3% on everything else

After crossing the \$2,500 monthly threshold, all remaining non-category purchases earn 3% instead of 1%.

```json theme={null}
{
  "name": "high_spender_cashback",
  "order": 300,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && (participant.counter.monthly_spend + event.amount) >= 2500.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.03, 2) }}",
      "description": "High-spender 3% cashback"
    }
  ]
}
```

This uses `(snapshot + event.amount) >= 2500` so the threshold-crossing purchase itself earns at the higher rate.

### 7. Base: 1% on everything else

The catch-all for purchases below the threshold that don't match a category.

```json theme={null}
{
  "name": "base_cashback",
  "order": 1000,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.01, 2) }}",
      "description": "Base 1% cashback"
    }
  ]
}
```

Only fires when no higher-priority rule matched: dining (100), grocery (200), and high-spender (300) all use `stop_after_match`.

### 8. Monthly counter reset

```json theme={null}
{
  "name": "monthly_counter_reset",
  "order": 2000,
  "condition": "event.type == \"monthly_reset\"",
  "actions": [
    {
      "type": "COUNTER",
      "key": "monthly_spend",
      "value": "${{ -participant.counter.monthly_spend }}"
    },
    {
      "type": "COUNTER",
      "key": "monthly_base_spend",
      "value": "${{ -participant.counter.monthly_base_spend }}"
    }
  ]
}
```

Triggered by the automation below. Subtracting the current value zeros both counters.

## Automation

Create one automation to fire the monthly reset:

```json theme={null}
{
  "name": "Monthly Counter Reset",
  "trigger": {
    "type": "cron",
    "cron_expression": "0 0 1 * *"
  },
  "scope": "participants",
  "participant_filter": "participant.counter.monthly_spend > 0",
  "event_name": "monthly_reset"
}
```

On the 1st of each month at midnight UTC, this fans out a `monthly_reset` event to every participant with a non-zero spend counter. The filter avoids unnecessary events for inactive participants.

## Rule evaluation flow

Here's how a single purchase flows through the rules:

```
purchase event arrives
  │
  ├─ [50]  track_monthly_spend          always fires, increments total counter
  ├─ [55]  track_monthly_base_spend     increments base counter (non-category only)
  ├─ [60]  threshold_retroactive_bonus  fires once when crossing $2,500
  │
  ├─ [100] dining_cashback              5% if MCC matches → STOP
  ├─ [200] grocery_cashback             3% if MCC matches → STOP
  ├─ [300] high_spender_cashback        3% if monthly_spend >= $2,500 → STOP
  └─ [1000] base_cashback               1% catch-all
```

Rules 100-1000 are mutually exclusive via `stop_after_match`. Rules 50-60 always evaluate regardless of category.

## Example event

Your backend sends this when a card transaction settles:

```json theme={null}
{
  "program_id": "{{PROGRAM_ID}}",
  "external_id": "user_123",
  "idempotency_key": "txn_abc123_settled",
  "event_timestamp": "2026-01-15T10:30:00Z",
  "event_data": {
    "type": "purchase",
    "amount": 85.00,
    "mcc": "5812",
    "merchant_name": "Corner Bistro",
    "transaction_id": "txn_abc123"
  }
}
```

Only `type`, `amount`, and `mcc` are used by rule conditions. Include whatever else you need for your own analytics.

## Edge cases and watchouts

### Rounding

With `scale: 2`, amounts are stored to the cent. Use `round(expr, 2)` in every CREDIT to avoid precision issues:

```javascript theme={null}
round(event.amount * 0.05, 2)  // good: $85.00 * 0.05 = $4.25
event.amount * 0.05             // risky: may produce $4.250000000001
```

### The threshold-crossing purchase

The event that pushes `monthly_spend` past \$2,500 earns at the 3% rate (high-spender), **not** 1%. This is because `high_spender_cashback` checks `(snapshot + event.amount) >= 2500`, which is true for the crossing purchase. The retroactive bonus also fires on the same event, covering all prior spend.

### Category purchases don't care about the threshold

A dining purchase always earns 5% whether the participant has spent \$500 or \$5,000 this month. The `stop_after_match` on `dining_cashback` (order 100) fires before the threshold rules (300, 1000) are even evaluated. The threshold only affects non-category purchases.

### The second counter

`monthly_base_spend` exists so the retroactive bonus only pays out on purchases that earned 1%. Without it, a participant who spent \$2,000 on dining before crossing the threshold would get an extra 2% on those dining purchases, bumping them from 5% to 7%, which isn't intended. The retroactive bonus should only upgrade 1% purchases to 3%.

### Counter resets and tier status

On the 1st of each month, the counter drops to zero. There's no "tier" to maintain: the high-spender rate is purely counter-driven. A participant who spent \$10,000 last month starts fresh at 1% the next month.

### Refund handling

This example doesn't include refund rules. In production, you'd add rules to:

1. Debit cashback earned on the refunded transaction
2. Reduce the `monthly_spend` counter so the threshold status stays accurate

A refund rule might look like:

```json theme={null}
{
  "name": "refund_clawback",
  "order": 500,
  "condition": "event.type == \"refund\" && event.original_cashback > 0",
  "actions": [
    { "type": "DEBIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "${{ event.original_cashback }}", "allow_negative": true },
    { "type": "COUNTER", "key": "monthly_spend", "value": "${{ -event.original_amount }}" }
  ]
}
```

Your backend would include `original_cashback` and `original_amount` from the original transaction record. `allow_negative` lets the debit succeed even if the participant has already spent the cashback; without it, a DEBIT against an insufficient available balance fails and the refund event is marked `FAILED`.

### Excluded transaction types

The base rule doesn't exclude any MCC codes. In practice, you'd want to block non-qualifying transactions like cash advances or wire transfers:

```javascript theme={null}
event.type == "purchase" && event.amount > 0 && !(event.mcc in ["6010", "6011", "6051", "4829", "7995"])
```

### Large MCC lists

The CEL `in` operator works well for short lists (5-20 entries). If you need to match against hundreds of merchants or categories, move the classification into your backend and pass a flag like `event.is_dining: true` in the event payload instead.
