# Partner Level Calculation

**Last Updated:** 2026-02-11

Reference for the rolling 90-day partner level calculation logic.

## Overview

Partner levels are calculated based on **won deals closed in the last 90 days** (rolling window). This replaces the previous fixed-quarter approach (Q1/Q2/Q3/Q4), which caused all partner levels to reset at the start of each quarter.

**Rationale:** Rolling windows are recommended for tiered affiliate programs—they are more flexible and fair, allow automatic tier adjustments without arbitrary calendar resets, and align with industry best practices.

## Level Thresholds

| Level | Deals in 90 Days | MRR Share |
|-------|------------------|-----------|
| Beginner | 0 | 20% (display; 0% until first deal) |
| Starter | 1–5 | 20% |
| Partner | 6–10 | 25% |
| Pro | 11+ | 30% |

## Implementation

### Core Functions

**Location:** `v2/helpers/affiliate-level-calculator.php`

- **`getRolling90DayWindow()`** – Returns `['start' => Y-m-d, 'end' => Y-m-d]` for the rolling 90-day window
- **`countDealsInRolling90Days($deals, $partnerId)`** – Counts won deals with `closedate` within the window
- **`calculatePartnerLevel($deals, $partnerId)`** – Returns level ID based on deal count
- **`getLevelCalculationPeriod()`** – Returns period info for display (includes `period_label`)

### Deal Criteria

A deal is counted for level calculation when:

1. `affiliate_partner_id` matches the partner
2. Deal stage is won (contains "won" or "closed")
3. `closedate` exists and falls within the last 90 days (inclusive)

### Configuration

**Location:** `v2/config/affiliate-config.php`

- `AFFILIATE_LEVEL_CALCULATION_PERIOD` = `'rolling_90_days'`
- Level thresholds in `$affiliate_levels` (key `min_deals_per_quarter` kept for backward compat; values 1/6/11)

## Data Flow

1. HubSpot sync fetches all deals (no date filter)
2. PHP filters by `closedate` within rolling 90-day window
3. Level is calculated and written to partner registry and HubSpot custom object
4. Dashboard and Levels API read from cache; no HubSpot API changes required

## Levels Page Display

**Location:** `v2/pages/partner-levels.php` (route `/partner/levels`)

The hero card shows:

- **Current level** (with "manuell" badge when `level_override` is active)
- **MRR share** for the current level
- **Progress to next level** (progress bar, deals in period, deals needed)
- **Berechnungszeitraum** – rolling window date range (e.g. `14.11.2025 – 11.02.2026`) using `period.start_formatted` and `period.end_formatted`

There is no quarter countdown – `days_remaining` is null for rolling 90-day logic. The third stat displays the actual date range of the rolling window for transparency.

## Testing

**Rolling 90-day logic:**

```bash
php v2/scripts/affiliate/test-rolling-level-logic.php
```

Expected: 14 tests pass (deal count boundaries, level thresholds, period info).

**Manual level override logic:**

```bash
php v2/scripts/affiliate/test-level-override-logic.php
```

Expected: 9 tests pass (override active, revert, expired until, invalid level).

## Manual Override

Admins can manually set a partner's level (e.g. "Pro for 6 months" for strategic partners) while keeping the automated rolling 90-day logic for everyone else.

### Data Model

| Field | Type | Purpose |
|-------|------|---------|
| `level_override` | bool | When true, use stored `level` instead of calculated; sync skips overwriting |
| `level_override_until` | string (ISO 8601 date) | Optional. When set and in the past, sync auto-clears override and reverts to automatic |

### Logic

- `level_override === true` and `level_override_until` not past → effective level = `partner['level']`; sync does not overwrite
- `level_override_until` set and `date('Y-m-d') > level_override_until` → sync clears override, reverts to calculated
- `level_override === false` or absent → effective level = calculated; sync overwrites as usual

### Implementation

- **`getEffectivePartnerLevel($partner, $deals, $partnerId)`** – Returns manual level when override active, else calculated level
- **Admin API** – `affiliate-admin-update-partner.php` accepts `level`, `level_override`, `level_override_until`; reverts with `level_override: false`
- **Audit** – Actions `level_change` and `level_revert` logged in `affiliate_admin_audit.json`
- **HubSpot** – See [HubSpot sync](#hubspot-sync-with-manual-override) below

### HubSpot sync with manual override

| Action | Platform JSON | HubSpot |
|--------|---------------|---------|
| Admin sets manual level | Saved immediately | PATCHed immediately with effective level |
| Admin reverts override | Override cleared immediately | PATCHed immediately with calculated level |
| Cron/manual sync runs | Uses `getEffectivePartnerLevel()` | Pushes **effective level** (manual or calculated) to HubSpot |

**Key behavior:** Manual level overrides HubSpot automated syncs. When override is active, the sync respects it and pushes the manual level to HubSpot; it does not overwrite with calculated values. If the HubSpot PATCH fails (e.g. network), the admin sees a warning and can run "Sync mit HubSpot" to retry.

### Revert Flow

1. Admin clicks "Zurück zur automatischen Berechnung" in Level modal
2. API receives `level_override: false`
3. Partner JSON: `level_override` and `level_override_until` removed
4. HubSpot: PATCH with calculated level (immediate API call)
5. Next sync: partner treated as automatic; level updated from deals

## Edge Cases

| Scenario | Result |
|----------|--------|
| Deal closed 89 days ago | Counted |
| Deal closed 91 days ago | Not counted |
| Deal with no `closedate` | Not counted |
| Deal not won/closed | Not counted |
| Partner with 6 deals, 4 drop out of window | Level drops to Starter (2 left) |
| Partner with `level_override` true | Effective level = stored level; sync does not overwrite |
| Partner with `level_override_until` in past | Sync clears override; reverts to calculated |

## Manual Verification (Post-Deployment)

1. **Set manual level:** In Admin (Verwaltung), click the Level icon for a partner. Choose level (e.g. Pro), optionally set "Gültig bis". Click "Level setzen". Confirm Level column shows level with "manuell" badge.
2. **Sync preserves override:** Run "Sync mit HubSpot". Level should remain unchanged for the overridden partner.
3. **Revert:** Click Level icon again, click "Zurück zur automatischen Berechnung". Level should follow calculated value on next sync.
4. **HubSpot:** In HubSpot, verify the affiliate_partner custom object shows the correct level after each change.
5. **Audit log:** Check "Letzte Aktionen" shows `level_change` and `level_revert` entries with correct details.

## Related Documentation

- [ARCHITECTURE.md](ARCHITECTURE.md) – Sync flow and level calculation
- [PARTNER_GUIDE.md](PARTNER_GUIDE.md) – Partner-facing level explanation
- [HUBSPOT_SALES_WORKFLOW.md](HUBSPOT_SALES_WORKFLOW.md) – Deal attribution
