Skip to main content
Self-contained rule snippets for behaviors that come up in almost every program. Each pattern assumes you already have a program and asset configured - see the quickstart if you need help with setup.

Category multipliers

A credit card that pays different rates by purchase category.
CategoryRateMCC codes
Dining5%5812, 5813, 5814
Groceries3%5411, 5422
Everything else1%-
Each category is its own rule, ordered from most specific to least. stop_after_match on the category rules prevents a dining purchase from also earning the 1% base rate.
{
  "name": "dining_5pct",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.mcc in [\"5812\", \"5813\", \"5814\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.05, 2)"
    }
  ]
}
{
  "name": "grocery_3pct",
  "order": 200,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.mcc in [\"5411\", \"5422\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.03, 2)"
    }
  ]
}
{
  "name": "base_1pct",
  "order": 1000,
  "condition": "event.type == \"purchase\"",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.01, 2)"
    }
  ]
}
How it works: Three rules, evaluated in order. A dining purchase matches at 100 and stops - it never sees the grocery or base rules. A non-category purchase skips dining and grocery, then falls through to the 1% catch-all. The catch-all has no stop_after_match because there’s nothing below it to block.
The CEL in operator works well for short lists (5-20 entries). For hundreds of merchants, move classification into your backend and pass a flag like event.is_dining: true instead.

Sign-up bonus

“Spend $4,000 in the first 3 months, earn 60,000 points.” This is the most common card acquisition offer. It combines three checks: the participant must still be within their intro window, their cumulative spend must cross the threshold, and the bonus can only pay out once. duration_hours enforces the time window, a counter snapshot detects the threshold crossing, and a TAG prevents double-claiming.
WhatHow
Track qualifying spendCounter intro_spend, only during intro window
Enforce time windowduration_hours(now - timestamp(...)) <= 2160 (90 days)
Detect threshold crossing(snapshot + event.amount) >= 4000.0
Prevent double-claimingTAG SIGNUP_BONUS_CLAIMED
Your backend sets enrolled_at as a participant attribute when you create the participant.
{
  "name": "track_intro_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0 && duration_hours(now - timestamp(participant.attributes.enrolled_at)) <= 2160",
  "actions": [
    { "type": "COUNTER", "key": "intro_spend", "value": "event.amount" }
  ]
}
{
  "name": "signup_bonus",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0 && !(\"SIGNUP_BONUS_CLAIMED\" in participant.tags) && duration_hours(now - timestamp(participant.attributes.enrolled_at)) <= 2160 && (get(participant.counters, \"intro_spend\", 0.0) + event.amount) >= 4000.0",
  "actions": [
    {
      "type": "TAG",
      "tag": "SIGNUP_BONUS_CLAIMED"
    },
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "60000"
    }
  ]
}
How it works: The tracking rule at order 50 accumulates spend only during the 90-day intro window. The bonus rule at order 100 checks three things: no TAG yet, still within 90 days, and spend just crossed $4,000.
duration_hours(now - timestamp(participant.attributes.enrolled_at)) converts the time since enrollment to hours. 2160 hours = 90 days. If you prefer calendar months, track the deadline as an attribute and compare directly: now < timestamp(participant.attributes.intro_deadline).

Streak challenge

A fitness app rewards users who check in 7 days in a row. Scrip tracks consecutive activity entirely within the rules engine. It stores the last checkin timestamp as a participant attribute and uses duration_hours to determine whether the next checkin is consecutive (24-48h gap), a broken streak (48h+), or the very first one. No backend date math needed.
RulePurpose
continue_streakIncrement streak counter if checkin is consecutive (24-48h gap)
break_streakReset counter to 1 if user skipped a day (48h+ gap)
start_streakStart a new streak on first-ever checkin
update_last_checkinStore the current timestamp for next comparison
streak_reward_7Pay out bonus when streak hits 7
Scrip computes the gap between checkins using duration_hours(now - timestamp(participant.attributes.last_checkin)) - no backend calculation needed. Your backend just sends a simple checkin event.
{
  "name": "continue_streak",
  "order": 100,
  "condition": "event.type == \"checkin\" && has(participant.attributes.last_checkin) && duration_hours(now - timestamp(participant.attributes.last_checkin)) >= 24.0 && duration_hours(now - timestamp(participant.attributes.last_checkin)) < 48.0",
  "actions": [
    { "type": "COUNTER", "key": "streak", "value": "1" }
  ]
}
{
  "name": "break_streak",
  "order": 110,
  "condition": "event.type == \"checkin\" && has(participant.attributes.last_checkin) && duration_hours(now - timestamp(participant.attributes.last_checkin)) >= 48.0",
  "actions": [
    {
      "type": "COUNTER",
      "key": "streak",
      "value": "-get(participant.counters, 'streak', 0.0) + 1.0"
    }
  ]
}
{
  "name": "start_streak",
  "order": 120,
  "condition": "event.type == \"checkin\" && !has(participant.attributes.last_checkin)",
  "actions": [
    { "type": "COUNTER", "key": "streak", "value": "1" }
  ]
}
{
  "name": "update_last_checkin",
  "order": 500,
  "condition": "event.type == \"checkin\"",
  "actions": [
    { "type": "SET_ATTRIBUTE", "key": "last_checkin", "value": "string(now)" }
  ]
}
{
  "name": "streak_reward_7",
  "order": 200,
  "condition": "event.type == \"checkin\" && get(participant.counters, 'streak', 0.0) + 1.0 == 7.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "100"
    }
  ]
}
How it works:
  • Consecutive day = 24-48 hours since last checkin (increment streak)
  • Skipped a day = 48+ hours since last checkin (reset to 1)
  • First checkin = no last_checkin attribute yet (start at 1)
  • update_last_checkin fires on every checkin at order 500 to stamp the current time
  • break_streak resets the counter to 1 (not 0) because today’s checkin starts a new streak
Counter values in conditions are snapshots - they reflect the state before the current event’s actions execute. If a rule at order 100 increments streak, a rule at order 200 still sees the old value. Use get(participant.counters, 'streak', 0.0) + 1.0 to check the post-increment value.
The 24-48 hour window assumes roughly one checkin per day. If your app allows multiple checkins in a single day (under 24h apart), none of the streak rules will match and the streak counter won’t change. Adjust the lower bound if you need sub-day checkins to count.

Referral credit

A new user completes their first ride, and both they and the friend who referred them earn $10. A single event credits two different participants. The referee gets credited normally as the event participant, while the referrer is resolved from a field in the event payload using a dynamic target.
ParticipantRoleHow they’re credited
Referee (new user)Event participantNormal CREDIT - no target needed
Referrer (existing user)Dynamic targetCREDIT with target.external_id: "event.referrer_id"
The referee’s TAG at order 100 gates the referrer bonus at order 200, so both are skipped on duplicate events.
{
  "name": "referee_bonus",
  "order": 100,
  "condition": "event.type == \"referral_completed\" && !(\"REFERRAL_USED\" in participant.tags)",
  "actions": [
    { "type": "TAG", "tag": "REFERRAL_USED" },
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "10.00",
      "description": "Referee welcome bonus"
    }
  ]
}
{
  "name": "referrer_bonus",
  "order": 200,
  "condition": "event.type == \"referral_completed\" && \"REFERRAL_USED\" in participant.tags",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "10.00",
      "target": {
        "external_id": "event.referrer_id"
      },
      "description": "Referrer bonus"
    }
  ]
}
How it works: The target.external_id field is a CEL expression - it evaluates event.referrer_id to find the referrer participant. The referrer must be enrolled in the same program.
If event.referrer_id resolves to a user who isn’t enrolled in the program, the action will fail and the entire event will error. Validate referrer enrollment in your backend before sending the event.

One-time welcome reward

“Complete your first purchase and earn $25.” Unlike the sign-up bonus (which tracks cumulative spend over a time window), this fires on any single qualifying purchase. A TAG gates the condition so the bonus only pays out once.
WhatHow
TriggerFirst purchase event with amount > 0
One-time guardTAG FIRST_PURCHASE_DONE checked in condition
PayoutFixed $25 CREDIT
{
  "name": "first_purchase_bonus",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0 && !(\"FIRST_PURCHASE_DONE\" in participant.tags)",
  "actions": [
    { "type": "TAG", "tag": "FIRST_PURCHASE_DONE" },
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "25.00",
      "description": "First purchase bonus"
    }
  ]
}
How it works: Same TAG-gating pattern as the sign-up bonus. The first purchase passes the !(... in participant.tags) check, sets FIRST_PURCHASE_DONE, and earns the bonus. Every subsequent purchase sees the tag and the condition evaluates to false.

Introductory rate with expiration

A card that pays 5% for the first 90 days, then drops to the standard 1%. An INTRO_ACTIVE tag marks the window, a scheduled event removes it when the window closes, and UNTAG cleans up the flag so the standard rate takes over.
RulePurpose
activate_introTAG participant on signup, schedule expiration event
intro_5pct5% earn rate while INTRO_ACTIVE tag exists
end_introUNTAG INTRO_ACTIVE when the scheduled event fires
base_1pct1% catch-all after intro ends
{
  "name": "activate_intro",
  "order": 50,
  "condition": "event.type == \"signup\"",
  "actions": [
    { "type": "TAG", "tag": "INTRO_ACTIVE" },
    { "type": "SCHEDULE_EVENT", "event_name": "intro_expired", "delay": "2160h" }
  ]
}
{
  "name": "intro_5pct",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && \"INTRO_ACTIVE\" in participant.tags",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.05, 2)"
    }
  ]
}
{
  "name": "end_intro",
  "order": 100,
  "condition": "event.type == \"intro_expired\"",
  "actions": [
    { "type": "UNTAG", "tag": "INTRO_ACTIVE" }
  ]
}
{
  "name": "base_1pct",
  "order": 200,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.01, 2)"
    }
  ]
}
How it works: On signup, the participant gets tagged INTRO_ACTIVE and a intro_expired event is scheduled 90 days out. During the intro window, purchases match the 5% rule first and stop_after_match prevents the 1% rule from also firing. When the scheduled event fires, UNTAG removes the flag and subsequent purchases fall through to the 1% catch-all.
This approach is more flexible than active_from/active_to time windows because the intro period is per-participant, starting from their individual signup date.

Spend threshold unlock

A card that pays 1% normally, but bumps to 3% after the participant spends $2,500 in a month. A counter snapshot checks whether the pre-event total plus the current amount has crossed the line, and a cron automation zeros the counter monthly.
RulePurpose
track_monthly_spendAccumulate spend in a counter
high_spender_3pct3% if (snapshot + event.amount) >= 2500, stop_after_match
base_1pct1% catch-all for below-threshold purchases
reset_monthly_spendZero the counter on the first of each month
{
  "name": "track_monthly_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    { "type": "COUNTER", "key": "monthly_spend", "value": "event.amount" }
  ]
}
{
  "name": "high_spender_3pct",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && (get(participant.counters, \"monthly_spend\", 0.0) + event.amount) >= 2500.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.03, 2)"
    }
  ]
}
{
  "name": "base_1pct",
  "order": 200,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.01, 2)"
    }
  ]
}
Add a reset rule and a cron automation to zero the counter at the start of each month:
{
  "name": "reset_monthly_spend",
  "order": 2000,
  "condition": "event.type == \"monthly_reset\"",
  "actions": [
    { "type": "COUNTER", "key": "monthly_spend", "value": "-get(participant.counters, 'monthly_spend', 0.0)" }
  ]
}
The automation that triggers the reset:
POST /v1/programs/{programId}/automations
{
  "name": "Monthly Counter Reset",
  "trigger": {
    "type": "cron",
    "cron_expression": "0 0 1 * *"
  },
  "scope": "participants",
  "participant_filter": "get(participant.counters, \"monthly_spend\", 0.0) > 0",
  "event_name": "monthly_reset",
  "payload": { "type": "monthly_reset" }
}
How it works: The threshold check uses (snapshot + event.amount) >= 2500.0 so the purchase that crosses the threshold earns at the higher rate. Once above 2500, every subsequent purchase that month also earns 3%. On the first of the month, the cron automation generates a monthly_reset event for every participant with a non-zero spend counter, and the reset rule zeros the counter by subtracting its current value. See Automations for details on cron setup.

Promotional window

Run a “Summer Double Points” campaign from June 1 to September 1. Set active_from and active_to on the rule and the engine handles the rest. No condition logic needed.
FieldValueEffect
active_from2025-06-01T00:00:00ZRule ignored before this timestamp
active_to2025-09-01T00:00:00ZRule ignored after this timestamp
You can create the rule weeks in advance. Events outside the window skip the rule automatically.
{
  "name": "summer_double_points",
  "order": 100,
  "active_from": "2025-06-01T00:00:00Z",
  "active_to": "2025-09-01T00:00:00Z",
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.02, 2)",
      "description": "Summer double points bonus"
    }
  ]
}
How it works: You create the rule anytime, even weeks before the campaign starts. The engine checks active_from and active_to against the current wall-clock time and only evaluates the condition when the current time falls within the window.
active_from and active_to are compared against the current wall-clock time, not the event’s event_timestamp. This means a time-windowed rule won’t fire for historical imports that fall within the window if the window has already passed.

Expiring points

Points that expire 12 months after they’re earned. Each purchase starts its own expiration clock. A January purchase expires in January of next year, not when the program year ends. LOT inventory mode creates a separate lot per CREDIT, each with its own countdown.
ConfigValueNotes
Asset inventory_modeLOTRequired for expiration to work
expires_at on CREDIT8760h12 months in hours
{
  "name": "earn_points",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 1.0, 0)",
      "expires_at": "8760h",
      "description": "Points earned (expire in 12 months)"
    }
  ]
}
How it works: A January purchase expires in January of next year, a March purchase in March, and so on. When a lot expires, the balance is automatically forfeited.
The asset must use inventory_mode: LOT for expiration to work. SIMPLE mode assets ignore expires_at. See Lots and Expiration for details on FIFO ordering and partial expiration.

Milestone bonus (Nth action)

A coffee shop that gives a free drink every 10th purchase. A counter and the modulo operator handle the detection. Because conditions see the counter snapshot (pre-increment value), you check snapshot + 1 to detect the milestone at the right moment.
RulePurpose
track_purchasesIncrement purchase_count counter
every_10th_purchase_bonusFire when (snapshot + 1) % 10 == 0
{
  "name": "track_purchases",
  "order": 50,
  "condition": "event.type == \"purchase\"",
  "actions": [
    { "type": "COUNTER", "key": "purchase_count", "value": "1" }
  ]
}
{
  "name": "every_10th_purchase_bonus",
  "order": 100,
  "condition": "event.type == \"purchase\" && (get(participant.counters, \"purchase_count\", 0.0) + 1.0) % 10.0 == 0.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "50",
      "description": "Every 10th purchase bonus"
    }
  ]
}
How it works: The tracking rule at order 50 increments purchase_count on every purchase. The bonus rule at order 100 adds 1 to the snapshot and checks if the result is divisible by 10. On the 10th, 20th, 30th purchase (and so on), the modulo evaluates to 0 and the bonus fires. For a one-time milestone (e.g., 100th purchase only), replace the modulo with an exact check:
get(participant.counters, "purchase_count", 0.0) + 1.0 == 100.0

Capped reward per transaction

10% cashback on every purchase, but capped at $50 per transaction. math.least and math.greatest in CEL let you express the cap directly in the amount formula.
Purchase amountCalculated rewardAfter cap
$200$20$20
$500$50$50
$1,000$100$50
{
  "name": "capped_cashback",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(math.least(event.amount * 0.10, 50.0), 2)",
      "description": "10% cashback, max $50 per transaction"
    }
  ]
}
How it works: math.least(event.amount * 0.10, 50.0) evaluates both expressions and returns the smaller value. Below $500, the percentage is less than $50 so the participant gets the full 10%. At $500 and above, the cap kicks in. For a floor instead of a cap, use math.greatest:
math.greatest(event.amount * 0.01, 1.0)   // at least $1 per transaction

Reversal / refund

A cardholder disputes a $85.00 dinner charge. The issuer sends a reversal event with the amount and merchant details - the same fields as the original purchase. Scrip debits the cashback that would have been earned on that amount.
FieldExamplePurpose
event.type"reversal"Distinguishes from a purchase
event.amount85.00Amount being reversed (can be partial)
event.mcc"5812"Used to determine the original earn rate
You need one reversal rule per earn rate, mirroring your earning rules:
{
  "name": "reversal_dining",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"reversal\" && event.amount > 0 && event.mcc in [\"5812\", \"5813\", \"5814\"]",
  "actions": [
    {
      "type": "DEBIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.05, 2)",
      "description": "Reversal of dining 5% cashback"
    }
  ]
}
{
  "name": "reversal_grocery",
  "order": 200,
  "stop_after_match": true,
  "condition": "event.type == \"reversal\" && event.amount > 0 && event.mcc in [\"5411\", \"5422\"]",
  "actions": [
    {
      "type": "DEBIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.03, 2)",
      "description": "Reversal of grocery 3% cashback"
    }
  ]
}
{
  "name": "reversal_base",
  "order": 1000,
  "condition": "event.type == \"reversal\" && event.amount > 0",
  "actions": [
    {
      "type": "DEBIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 0.01, 2)",
      "description": "Reversal of base 1% cashback"
    }
  ]
}
How it works: Same stop_after_match cascade as the earning rules, but with DEBIT instead of CREDIT. A $40 partial refund on a dining purchase debits round(40 * 0.05, 2) = $2.00. The issuer doesn’t need to pre-calculate the cashback amount - just send the reversal amount and MCC.
DEBIT fails if the participant’s available balance is insufficient. If the participant has already spent their cashback, the reversal event will error. Your backend should handle this case (e.g., retry later, or accept the loss).
This approach works well for category-based rates where the rate is deterministic from the MCC. For threshold-based rates (e.g., 1% vs 3% depending on monthly spend), the reversal may not perfectly match the original earn rate if the participant’s counter has changed since the original purchase. If precision matters, have your backend include the original earn rate in the reversal event.

Tiered earn rates

A hotel loyalty program where Silver members earn 1x points per dollar, Gold earns 1.5x, and Platinum earns 2x. Participants advance automatically based on annual spend. Each tier level carries a multiplier benefit that rules reference directly in the amount expression, so you don’t need separate rules per level.
WhatHow
Track qualifying spendCounter ytd_spend, incremented on every purchase
Auto-advance tiersQualification criteria on each level (Silver: 500,Gold:500, Gold: 2,000, Platinum: $5,000)
Dynamic earn rateparticipant.tiers.loyalty.benefits.multiplier in the CREDIT amount
Annual resetCALENDAR_YEAR qualification period, counters reset to 0
First, create the tier type with levels, qualification criteria, and a multiplier benefit on each level:
POST /v1/programs/{programId}/tiers
{
  "key": "loyalty",
  "display_name": "Loyalty Status",
  "levels": [
    {
      "key": "silver",
      "rank": 1,
      "display_name": "Silver",
      "qualification": {
        "mode": "ALL",
        "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 500}]
      },
      "benefits": {"multiplier": 1.0}
    },
    {
      "key": "gold",
      "rank": 2,
      "display_name": "Gold",
      "qualification": {
        "mode": "ALL",
        "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 2000}]
      },
      "benefits": {"multiplier": 1.5}
    },
    {
      "key": "platinum",
      "rank": 3,
      "display_name": "Platinum",
      "qualification": {
        "mode": "ALL",
        "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 5000}]
      },
      "benefits": {"multiplier": 2.0}
    }
  ],
  "lifecycle": {
    "retention": {"mode": "PERIOD_BASED"},
    "qualification_period": {"type": "CALENDAR_YEAR"},
    "downgrade_policy": {"mode": "DROP_TO_QUALIFYING"},
    "counters": {"qualifying": ["ytd_spend"], "rollover": "NONE"}
  }
}
Then three rules: one to track spend (which drives qualification), one to earn at the tier rate, and a catch-all for participants who haven’t reached a tier yet.
{
  "name": "track_ytd_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    { "type": "COUNTER", "key": "ytd_spend", "value": "event.amount" }
  ]
}
{
  "name": "tiered_earn",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && has(participant.tiers.loyalty)",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * participant.tiers.loyalty.benefits.multiplier, 0)"
    }
  ]
}
{
  "name": "base_earn",
  "order": 200,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "round(event.amount * 1.0, 0)"
    }
  ]
}
How it works: The track_ytd_spend rule at order 50 accumulates annual spend. After all rules fire for the event, Scrip auto-evaluates tier qualification against the updated counter. If the participant crosses a threshold, they advance. The tiered_earn rule at order 100 pulls the earn rate from participant.tiers.loyalty.benefits.multiplier, so adding a new tier level or changing a multiplier is a tier config change, not a rule change. has(participant.tiers.loyalty) ensures participants without a tier fall through to base_earn at order 200 for the default 1x rate.
Tier advancement happens after all rules evaluate for the current event. A purchase that pushes ytd_spend past the Gold threshold qualifies the participant, but the current event still earns at the pre-advancement rate. The new multiplier applies starting with the next event.
At the end of each calendar year, the PERIOD_BASED lifecycle re-evaluates tiers. Participants who no longer meet their level’s criteria are downgraded to the highest level they still qualify for (DROP_TO_QUALIFYING). The NONE rollover resets ytd_spend to 0. See Tiers for lifecycle details.