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

# Score Streaks

> Reward customers for consistent behavior by building streaks that unlock milestone rewards.

Score Streaks are a campaign type that tracks how consistently a customer repeats a specific action over time. Each qualifying event extends the streak and increases the customer's score. When the score reaches a configured milestone, rewards are automatically granted.

## How score streaks work

A score streak campaign tracks three layered ideas: **time windows (periods)**, **consecutive activity (streaks)**, and **cumulative score (milestones)**.

```mermaid theme={null}
flowchart LR
  E[Customer event] --> S{Qualifies?}
  S -- No --> Skip[Ignored]
  S -- Yes --> P{Period expired?}
  P -- Yes --> Loss[Cycle lost\nNew cycle starts]
  P -- No --> Gain[Score +1\nPeriod extended]
  Loss --> Gain
  Gain --> M{Milestone\ncrossed?}
  M -- Yes --> Reward[Reward granted]
  M -- No --> Done[Done]
```

### Periods and loss conditions

Every time a customer earns a score point, the campaign opens a **period window** — a countdown timer during which the customer must send the next qualifying event to keep the streak alive. If the timer runs out before the next event, the cycle is **lost**: the streak resets to zero and a new cycle begins.

The loss window is defined by `loss.conditions[0]`:

```json theme={null}
{
  "type": "NO_TRIGGER_EVENT_WITHIN",
  "amount": 1,
  "unit": "DAYS"
}
```

Supported units: `MINUTES`, `HOURS`, `DAYS`, `WEEKS`, `MONTHS`.

### Cycles

A **cycle** is one continuous run of activity without a loss. Each loss or reset starts a fresh cycle identified by a new `current_cycle_id`. This means multiple cycles can exist in a single streak record over its lifetime, and the history events are stamped with the cycle they belong to.

### Milestones

Rules in a score streak campaign define **score thresholds** that trigger rewards. When a customer's `current_score` reaches or exceeds a milestone, that milestone's effects (rewards) are granted once per milestone per cycle.

```
Score  1: Milestone reward (first milestone)
Score  5: Higher milestone reward
Score 10: Highest milestone reward
Score 11: Highest milestone reward again (repeats)
```

If the score crosses multiple milestones at once (e.g. from 0 to 5), only the highest applicable milestone fires.

### Scheduled resets

Optionally, a campaign can define a **reset calendar** that forcefully closes the active cycle on a schedule — daily, weekly, or monthly. This is independent of the loss condition: a customer with a perfect streak will still be reset on the configured date.

```json theme={null}
{
  "enabled": true,
  "calendar": {
    "frequency": "MONTHLY",
    "day_of_month": 1
  }
}
```

## Score streak lifecycle

```mermaid theme={null}
stateDiagram-v2
  [*] --> Active: First qualifying event
  Active --> Active: Qualifying event within period
  Active --> Lost: Period expired (cron)
  Lost --> Active: Next qualifying event
  Active --> Reset: Scheduled reset date
  Reset --> Active: Next qualifying event
  Active --> Closed: Campaign ended
  Closed --> [*]
```

| State    | Description                               |
| -------- | ----------------------------------------- |
| `ACTIVE` | Customer has an active streak in progress |
| `CLOSED` | Campaign ended; streak is archived        |

History events stored in the `events` JSON column:

| Type             | Trigger                                                             |
| ---------------- | ------------------------------------------------------------------- |
| `GAIN`           | Qualifying event processed; score incremented                       |
| `MILESTONE`      | Score crossed a milestone threshold                                 |
| `LOSS`           | Period expired without a qualifying event                           |
| `RESET`          | Scheduled reset fired                                               |
| `MANUAL_RESET`   | Support agent triggered a manual reset                              |
| `EVENT_REVERSAL` | A source event was reversed, rolling back the associated score gain |

## Campaign configuration

| Field                           | Type                                                    | Description                                                                           |
| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `trigger`                       | `string`                                                | Event type that increments the score (e.g. `"PURCHASE"`)                              |
| `privacy`                       | `"PRIVATE" \| "PUBLIC"`                                 | Controls visibility in public-facing APIs                                             |
| `loss.conditions[0].type`       | `"NO_TRIGGER_EVENT_WITHIN"`                             | Always this value                                                                     |
| `loss.conditions[0].amount`     | `number`                                                | How many units before the streak is lost                                              |
| `loss.conditions[0].unit`       | `"MINUTES" \| "HOURS" \| "DAYS" \| "WEEKS" \| "MONTHS"` | Time unit                                                                             |
| `reset.enabled`                 | `boolean`                                               | Whether scheduled resets are active                                                   |
| `reset.calendar.frequency`      | `"DAILY" \| "WEEKLY" \| "MONTHLY"`                      | Reset cadence                                                                         |
| `reset.calendar.week_starts_on` | `0–6`                                                   | First day of week (0 = Sunday). Required when `WEEKLY`                                |
| `reset.calendar.day_of_month`   | `1–31`                                                  | Day of month for reset. Required when `MONTHLY`. Clamped to last day for short months |

## Milestone rules

Each rule in a score streak campaign must have a `milestone` score value. When the customer's score reaches that value within a cycle, the rule's effects are applied. Multiple rules can define different reward tiers:

```json theme={null}
{
  "rules": [
    {
      "milestone": 1,
      "conditions": [],
      "effects": [{ "type": "POINTS", "amount": 10 }]
    },
    {
      "milestone": 5,
      "conditions": [],
      "effects": [{ "type": "POINTS", "amount": 50 }]
    },
    {
      "milestone": 10,
      "conditions": [],
      "effects": [{ "type": "POINTS", "amount": 200 }]
    }
  ]
}
```

* Conditions in milestone rules are ignored; only `milestone` and `effects` matter.
* When the score reaches milestone 10, only that rule fires — the lower milestones (1, 5) do not fire again for the same score.
* If a score crosses a milestone for the first time in a cycle, the reward is granted. If the same milestone is crossed again later in the same cycle (e.g. after a reversal), the reward is granted again.

## Event reversal behavior

When a source event is reversed:

1. All `GAIN` and `MILESTONE` history events from that source are marked `reversed_at`.
2. An `EVENT_REVERSAL` history event is appended with the negative score delta.
3. If the reversed event was in the **active cycle**, `current_score`, `current_streak`, `period_score`, and `period_event_count` are decremented.
4. The `highest_milestone_score` is recalculated from surviving milestone events.
5. If the score drops below a previously reached milestone, re-triggering the qualifying event will grant the milestone reward again.

Reversal is idempotent: reversing the same event twice has no additional effect.

## Manual admin reset

Support operators can force-reset a customer's active streak cycle without waiting for the loss cron or a scheduled reset. This appends a `MANUAL_RESET` history event, starts a new `current_cycle_id`, and zeros score counters for the active cycle.

**Endpoint (internal):** `POST /api/admin/score-streaks/reset`

| Field         | Description                                                            |
| ------------- | ---------------------------------------------------------------------- |
| `account_id`  | Account UUID                                                           |
| `campaign_id` | Score streak campaign UUID                                             |
| `customer_id` | Customer external id                                                   |
| `reason`      | Audit reason stored in history (for example `Support requested reset`) |
| `actor_id`    | Optional operator id for the audit trail                               |

Requires the `CRON_CHECKSUM` header (same secret used by Masivo cron routes). This endpoint is not part of the public Storefront API.

## Comparison with punch cards

|               | Score Streaks                | Punch Cards       |
| ------------- | ---------------------------- | ----------------- |
| Trigger       | Any event type               | Any event type    |
| Progress unit | Score (unlimited)            | Punches (capped)  |
| Completion    | Milestones (repeating)       | Single completion |
| Time pressure | Loss window + optional reset | None              |
| Event history | Full JSON log                | None              |
| Reversal      | Score rollback               | Standard wallet   |
| Cron required | Yes (expire lost streaks)    | No                |
