stepolan commited on
Commit Β·
6795a21
1
Parent(s): f57f6c4
Refactor scenarios to plans with auto-projections
Browse files- Save a plan once (config only), auto-generate projections at 75/100/150/200%
- No more manually saving separate scenarios per attainment level
- New compute_projection() function computes results on-the-fly
- Compare tab shows all attainment levels per plan in one table
- Two charts: Total Comp and Net After Rep by attainment level
- Agreement text auto-generates illustrative earnings from projections
- Backward compatible with legacy scenarios via normalize_plan()
- Configurable attainment levels in sidebar
- Renamed: Scenario Calculator β Plan Builder, Compare Scenarios β Compare Plans
- AIRULES.md +63 -94
- app.py +268 -166
AIRULES.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
-
# Sales Comp
|
| 2 |
|
| 3 |
## Project Overview
|
| 4 |
|
| 5 |
-
This is a Streamlit-based sales compensation
|
| 6 |
|
| 7 |
- **App:** `app.py` (single-file Streamlit app)
|
| 8 |
- **Backend:** Google Sheets via public CSV reads + Google Apps Script (`apps_script.js`) for writes
|
| 9 |
-
- **Hosting:** HuggingFace Spaces (
|
| 10 |
- **Repo:** `github.com/DBDoyen/salescomp`
|
| 11 |
|
| 12 |
## Deploying Changes
|
|
@@ -32,138 +32,107 @@ You will need a HuggingFace token with write access to push. Log in with:
|
|
| 32 |
huggingface-cli login
|
| 33 |
```
|
| 34 |
|
| 35 |
-
##
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
### Apps Script URL
|
| 40 |
-
|
| 41 |
-
```
|
| 42 |
-
https://script.google.com/macros/s/AKfycbxJTi4RhN-CevolOEsk4egxoTKQ0qgn2eLlmzu5Yv903MVWR-tVBcg02Hr5bBjtX4vj/exec
|
| 43 |
-
```
|
| 44 |
-
|
| 45 |
-
### Save / Update a Scenario
|
| 46 |
-
|
| 47 |
-
POST JSON with `action: "save"`. If a scenario with the same `name` exists, it updates in place.
|
| 48 |
|
| 49 |
```python
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
SCRIPT_URL = "https://script.google.com/macros/s/AKfycbxJTi4RhN-CevolOEsk4egxoTKQ0qgn2eLlmzu5Yv903MVWR-tVBcg02Hr5bBjtX4vj/exec"
|
| 53 |
-
|
| 54 |
-
scenario_data = {
|
| 55 |
"author": "Founder", # Generic label, no real names
|
| 56 |
-
"timestamp":
|
| 57 |
"commission_mode": "Attainment-based rates", # or "Deal-size rates + accelerators"
|
|
|
|
|
|
|
|
|
|
| 58 |
"params": {
|
| 59 |
"base_salary": 150000,
|
| 60 |
"annual_quota": 1250000,
|
| 61 |
"draw_amount": 0,
|
| 62 |
"draw_months": 0,
|
| 63 |
"draw_recoverable": True,
|
| 64 |
-
"ramp_pcts": [25, 50, 75],
|
| 65 |
"cogs_pct": 20.0,
|
| 66 |
"other_cost_per_rep": 20000,
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
"
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
"
|
| 74 |
-
"attainment_pct": 114.3,
|
| 75 |
-
"commission": 51562.5,
|
| 76 |
-
"draw_paid": 0,
|
| 77 |
-
"total_variable": 51562.5,
|
| 78 |
-
"total_comp": 201562.5,
|
| 79 |
-
"gross_profit": 1000000.0,
|
| 80 |
-
"total_cost_of_rep": 221562.5,
|
| 81 |
-
"net_after_rep": 778437.5,
|
| 82 |
-
"roi_on_rep": 5.6,
|
| 83 |
},
|
| 84 |
}
|
|
|
|
| 85 |
|
| 86 |
-
|
| 87 |
-
"action": "save",
|
| 88 |
-
"name": "Plan A - Conservative", # Scenario name (used as key)
|
| 89 |
-
"author": scenario_data["author"],
|
| 90 |
-
"timestamp": scenario_data["timestamp"],
|
| 91 |
-
"data_json": json.dumps(scenario_data),
|
| 92 |
-
}
|
| 93 |
|
| 94 |
-
|
| 95 |
-
```
|
| 96 |
|
| 97 |
-
##
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
| 102 |
```
|
| 103 |
|
| 104 |
-
###
|
| 105 |
|
| 106 |
-
|
| 107 |
|
| 108 |
```python
|
| 109 |
-
|
| 110 |
-
"""tiers: list of (upper_attainment_pct | None, rate_pct)"""
|
| 111 |
-
commission = 0.0
|
| 112 |
-
prev = 0.0
|
| 113 |
-
for upper, rate in tiers:
|
| 114 |
-
if upper is None:
|
| 115 |
-
if revenue > prev * quota / 100:
|
| 116 |
-
commission += (revenue - prev * quota / 100) * (rate / 100)
|
| 117 |
-
break
|
| 118 |
-
boundary = upper * quota / 100
|
| 119 |
-
if revenue <= prev * quota / 100:
|
| 120 |
-
break
|
| 121 |
-
taxable = min(revenue, boundary) - (prev * quota / 100)
|
| 122 |
-
if taxable > 0:
|
| 123 |
-
commission += taxable * (rate / 100)
|
| 124 |
-
prev = upper
|
| 125 |
-
return commission
|
| 126 |
-
```
|
| 127 |
|
| 128 |
-
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
```
|
| 133 |
|
| 134 |
-
###
|
| 135 |
|
| 136 |
```python
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
total = 0.0
|
| 140 |
-
for m in range(min(months, 12)):
|
| 141 |
-
if m < len(ramp_pcts):
|
| 142 |
-
total += monthly * (ramp_pcts[m] / 100)
|
| 143 |
-
else:
|
| 144 |
-
total += monthly
|
| 145 |
-
return total
|
| 146 |
```
|
| 147 |
|
| 148 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
|
| 151 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
- **Total variable** (non-recoverable draw) = commission + draw_paid
|
| 153 |
- **Total variable** (recoverable draw) = max(commission, draw_paid)
|
| 154 |
- **Total comp** = prorated_base + total_variable
|
| 155 |
-
- **Gross profit** = revenue
|
| 156 |
-
- **
|
| 157 |
-
- **Net after rep** = gross_profit - total_rep_cost
|
| 158 |
-
- **ROI on rep** = revenue / total_rep_cost
|
| 159 |
|
| 160 |
## Privacy
|
| 161 |
|
| 162 |
-
Do NOT use real names, company names, or identifying information in
|
| 163 |
|
| 164 |
-
##
|
| 165 |
|
| 166 |
-
Scenarios are in the Google Sheet:
|
| 167 |
https://docs.google.com/spreadsheets/d/1SM9_8IRuoKwwtK_pQuF0wwb3DW-CQJgglB3XCBgH11M/edit
|
| 168 |
|
| 169 |
Use timeout=60 when POSTing to the Apps Script β it can be slow on first invocation.
|
|
|
|
| 1 |
+
# Sales Comp Plan Builder β AI Rules
|
| 2 |
|
| 3 |
## Project Overview
|
| 4 |
|
| 5 |
+
This is a Streamlit-based sales compensation plan builder hosted on HuggingFace Spaces. Users define comp plans (base salary, quota, commission tiers, ramp, draw, etc.) and the app auto-generates projections at multiple attainment levels (default: 75%, 100%, 150%, 200%).
|
| 6 |
|
| 7 |
- **App:** `app.py` (single-file Streamlit app)
|
| 8 |
- **Backend:** Google Sheets via public CSV reads + Google Apps Script (`apps_script.js`) for writes
|
| 9 |
+
- **Hosting:** HuggingFace Spaces (unlisted) at `stepolan/salescomp`
|
| 10 |
- **Repo:** `github.com/DBDoyen/salescomp`
|
| 11 |
|
| 12 |
## Deploying Changes
|
|
|
|
| 32 |
huggingface-cli login
|
| 33 |
```
|
| 34 |
|
| 35 |
+
## Data Model: Plans (v2)
|
| 36 |
|
| 37 |
+
Plans are saved as JSON to Google Sheets. A plan stores **config only** β results are computed on-the-fly via `compute_projection()`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
```python
|
| 40 |
+
plan_data = {
|
| 41 |
+
"version": 2,
|
|
|
|
|
|
|
|
|
|
| 42 |
"author": "Founder", # Generic label, no real names
|
| 43 |
+
"timestamp": "2026-03-25T...",
|
| 44 |
"commission_mode": "Attainment-based rates", # or "Deal-size rates + accelerators"
|
| 45 |
+
"retroactive": False, # True = retroactive rates (top rate applies to ALL revenue)
|
| 46 |
+
"months_employed": 12,
|
| 47 |
+
"attainment_levels": [75, 100, 150, 200], # Configurable projection levels
|
| 48 |
"params": {
|
| 49 |
"base_salary": 150000,
|
| 50 |
"annual_quota": 1250000,
|
| 51 |
"draw_amount": 0,
|
| 52 |
"draw_months": 0,
|
| 53 |
"draw_recoverable": True,
|
| 54 |
+
"ramp_pcts": [25, 50, 75],
|
| 55 |
"cogs_pct": 20.0,
|
| 56 |
"other_cost_per_rep": 20000,
|
| 57 |
+
"commission_tiers": [[100, 8.0], [150, 14.0], [200, 26.0], [null, 50.0]],
|
| 58 |
+
"deal_rate_tiers": null,
|
| 59 |
+
"accel_tiers": null,
|
| 60 |
+
# Optional rich fields for agreement text:
|
| 61 |
+
"equity": {"pct": "0.5", "vesting": "4-year vest with 1-year cliff"},
|
| 62 |
+
"policies": {...},
|
| 63 |
+
"illustrative_scenarios": [...],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
},
|
| 65 |
}
|
| 66 |
+
```
|
| 67 |
|
| 68 |
+
### Backward Compatibility
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
Legacy scenarios (version 1, no version field) are auto-normalized by `normalize_plan()`. They had `inputs` and `results` baked in β these are ignored and projections are computed fresh from `params`.
|
|
|
|
| 71 |
|
| 72 |
+
## Saving / Updating Plans Programmatically
|
| 73 |
|
| 74 |
+
### Apps Script URL
|
| 75 |
+
|
| 76 |
+
```
|
| 77 |
+
https://script.google.com/macros/s/AKfycbxJTi4RhN-CevolOEsk4egxoTKQ0qgn2eLlmzu5Yv903MVWR-tVBcg02Hr5bBjtX4vj/exec
|
| 78 |
```
|
| 79 |
|
| 80 |
+
### Save / Update a Plan
|
| 81 |
|
| 82 |
+
POST JSON with `action: "save"`. If a plan with the same `name` exists, it updates in place.
|
| 83 |
|
| 84 |
```python
|
| 85 |
+
import requests, json, datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
+
SCRIPT_URL = "https://script.google.com/macros/s/AKfycbxJTi4RhN-CevolOEsk4egxoTKQ0qgn2eLlmzu5Yv903MVWR-tVBcg02Hr5bBjtX4vj/exec"
|
| 88 |
|
| 89 |
+
payload = {
|
| 90 |
+
"action": "save",
|
| 91 |
+
"name": "Plan A",
|
| 92 |
+
"author": plan_data["author"],
|
| 93 |
+
"timestamp": plan_data["timestamp"],
|
| 94 |
+
"data_json": json.dumps(plan_data),
|
| 95 |
+
}
|
| 96 |
+
resp = requests.post(SCRIPT_URL, json=payload, timeout=60)
|
| 97 |
```
|
| 98 |
|
| 99 |
+
### Delete a Plan
|
| 100 |
|
| 101 |
```python
|
| 102 |
+
payload = {"action": "delete", "name": "Plan A"}
|
| 103 |
+
requests.post(SCRIPT_URL, json=payload, timeout=60)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
```
|
| 105 |
|
| 106 |
+
## Key Functions
|
| 107 |
+
|
| 108 |
+
### `compute_projection(params, commission_mode, retroactive, attainment_pct, months_employed=12)`
|
| 109 |
+
The core function. Takes plan params and an attainment %, returns a full results dict (commission, total_comp, net_after_rep, etc.). Used for:
|
| 110 |
+
- Auto-generating projections in the Compare tab
|
| 111 |
+
- Agreement text illustrative earnings
|
| 112 |
+
- Can be called programmatically for batch scenario generation
|
| 113 |
|
| 114 |
+
### Commission Calculation
|
| 115 |
+
- **Attainment-based (marginal):** Each tier rate applies only to revenue in that band
|
| 116 |
+
- **Attainment-based (retroactive):** Highest tier reached applies to ALL revenue from dollar one
|
| 117 |
+
- **Deal-size + accelerators:** Flat rate per deal ACV Γ attainment multiplier
|
| 118 |
+
- Commission tiers are calculated against the **full annual quota** (not ramp-adjusted)
|
| 119 |
+
|
| 120 |
+
### Key Formulas
|
| 121 |
+
- **Attainment %** = actual_revenue / effective_quota Γ 100
|
| 122 |
+
- **Revenue at X% attainment** = annual_quota Γ X / 100
|
| 123 |
+
- **Draw paid** = draw_amount Γ (months_employed / draw_months)
|
| 124 |
- **Total variable** (non-recoverable draw) = commission + draw_paid
|
| 125 |
- **Total variable** (recoverable draw) = max(commission, draw_paid)
|
| 126 |
- **Total comp** = prorated_base + total_variable
|
| 127 |
+
- **Gross profit** = revenue Γ (1 - cogs_pct / 100)
|
| 128 |
+
- **Net after rep** = gross_profit - total_cost_of_rep
|
|
|
|
|
|
|
| 129 |
|
| 130 |
## Privacy
|
| 131 |
|
| 132 |
+
Do NOT use real names, company names, or identifying information in plan names or author fields. The Google Sheet is accessible to anyone with the link.
|
| 133 |
|
| 134 |
+
## Google Sheet
|
| 135 |
|
|
|
|
| 136 |
https://docs.google.com/spreadsheets/d/1SM9_8IRuoKwwtK_pQuF0wwb3DW-CQJgglB3XCBgH11M/edit
|
| 137 |
|
| 138 |
Use timeout=60 when POSTing to the Apps Script β it can be slow on first invocation.
|
app.py
CHANGED
|
@@ -36,25 +36,26 @@ def _get_apps_script_url():
|
|
| 36 |
|
| 37 |
|
| 38 |
@st.cache_data(ttl=30)
|
| 39 |
-
def
|
| 40 |
sheet_id = _get_sheet_id()
|
| 41 |
if not sheet_id:
|
| 42 |
return {}
|
| 43 |
try:
|
| 44 |
csv_url = SHEET_CSV_TMPL.format(sheet_id=sheet_id)
|
| 45 |
df = pd.read_csv(csv_url)
|
| 46 |
-
|
| 47 |
for _, row in df.iterrows():
|
| 48 |
try:
|
| 49 |
-
|
|
|
|
| 50 |
except (json.JSONDecodeError, KeyError):
|
| 51 |
continue
|
| 52 |
-
return
|
| 53 |
except Exception:
|
| 54 |
return {}
|
| 55 |
|
| 56 |
|
| 57 |
-
def
|
| 58 |
url = _get_apps_script_url()
|
| 59 |
if not url:
|
| 60 |
raise ValueError("Apps Script URL not configured in secrets")
|
|
@@ -70,7 +71,7 @@ def save_scenario(name: str, data: dict):
|
|
| 70 |
raise ValueError(f"Apps Script returned {resp.status_code}")
|
| 71 |
|
| 72 |
|
| 73 |
-
def
|
| 74 |
url = _get_apps_script_url()
|
| 75 |
if not url:
|
| 76 |
raise ValueError("Apps Script URL not configured in secrets")
|
|
@@ -80,6 +81,15 @@ def delete_scenario(name: str):
|
|
| 80 |
raise ValueError(f"Apps Script returned {resp.status_code}")
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
# ---------------------------------------------------------------------------
|
| 84 |
# Commission engine
|
| 85 |
# ---------------------------------------------------------------------------
|
|
@@ -90,7 +100,6 @@ def calc_commission_attainment(revenue, quota, tiers, retroactive=False):
|
|
| 90 |
If retroactive=False (marginal): each rate applies only to revenue in that band.
|
| 91 |
If retroactive=True: the highest tier reached applies to ALL revenue."""
|
| 92 |
if retroactive:
|
| 93 |
-
# Find the highest tier the rep has reached
|
| 94 |
applicable_rate = tiers[0][1]
|
| 95 |
for upper_pct, rate in tiers:
|
| 96 |
if upper_pct is None:
|
|
@@ -100,7 +109,6 @@ def calc_commission_attainment(revenue, quota, tiers, retroactive=False):
|
|
| 100 |
applicable_rate = rate
|
| 101 |
break
|
| 102 |
applicable_rate = rate
|
| 103 |
-
# Check if we exceeded the last non-None tier
|
| 104 |
last_upper = tiers[-2][0] if tiers[-1][0] is None else tiers[-1][0]
|
| 105 |
if tiers[-1][0] is None and revenue > last_upper * quota / 100:
|
| 106 |
applicable_rate = tiers[-1][1]
|
|
@@ -124,10 +132,7 @@ def calc_commission_attainment(revenue, quota, tiers, retroactive=False):
|
|
| 124 |
|
| 125 |
|
| 126 |
def calc_commission_deal_rate(deals, deal_rate_tiers, attainment_pct, accel_tiers):
|
| 127 |
-
"""Deal-size-based commission with attainment accelerators.
|
| 128 |
-
deal_rate_tiers: list of (min_acv, max_acv | None, rate_pct)
|
| 129 |
-
accel_tiers: list of (upper_attainment_pct | None, multiplier)
|
| 130 |
-
"""
|
| 131 |
multiplier = 1.0
|
| 132 |
for upper_pct, mult in accel_tiers:
|
| 133 |
if upper_pct is None or attainment_pct <= upper_pct:
|
|
@@ -171,20 +176,87 @@ def calc_ramp_adjusted_quota(annual_quota, ramp_pcts, months_employed=12):
|
|
| 171 |
return total
|
| 172 |
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
# ---------------------------------------------------------------------------
|
| 175 |
# App
|
| 176 |
# ---------------------------------------------------------------------------
|
| 177 |
|
| 178 |
-
st.set_page_config(page_title="Sales Comp
|
| 179 |
-
st.title("Sales
|
| 180 |
|
| 181 |
st.markdown("""
|
| 182 |
-
Model different
|
| 183 |
|
| 184 |
**How to use:**
|
| 185 |
-
1. **Set plan parameters** in the sidebar
|
| 186 |
-
2. **
|
| 187 |
-
3. **Save
|
|
|
|
| 188 |
""")
|
| 189 |
|
| 190 |
# Check config
|
|
@@ -269,7 +341,6 @@ if commission_mode == "Attainment-based rates":
|
|
| 269 |
rate = c2.number_input(f"Tier {i+1} rate %", value=d_rate, step=0.5, format="%.1f", key=f"at_r_{i}")
|
| 270 |
att_tiers.append((upper, rate))
|
| 271 |
else:
|
| 272 |
-
# -- Deal-size tiers --
|
| 273 |
with st.sidebar.expander("Deal-Size Commission Rates", expanded=True):
|
| 274 |
num_deal_tiers = st.number_input("Number of deal-size tiers", 1, 6, 3, key="num_deal_tiers")
|
| 275 |
deal_defaults = [(0, 29_999, 20.0), (30_000, 100_000, 40.0), (100_001, None, 40.0)]
|
|
@@ -286,7 +357,6 @@ else:
|
|
| 286 |
rt = c3.number_input(f"Tier {i+1} rate %", value=d[2], step=1.0, format="%.1f", key=f"dt_rt_{i}")
|
| 287 |
deal_rate_tiers.append((mn, mx, rt))
|
| 288 |
|
| 289 |
-
# -- Accelerators --
|
| 290 |
with st.sidebar.expander("Attainment Accelerators", expanded=True):
|
| 291 |
st.caption("Multiplier applied to deal commission based on annual attainment")
|
| 292 |
num_accels = st.number_input("Number of accelerator tiers", 1, 6, 3, key="num_accels")
|
|
@@ -325,16 +395,27 @@ with st.sidebar.expander("Company Economics", expanded=False):
|
|
| 325 |
other_cost_per_rep = st.number_input("Other cost per rep ($)", value=20_000, step=5_000, key="other_cost",
|
| 326 |
help="Annual cost for benefits, tools, travel, etc. β pro-rated if less than 12 months")
|
| 327 |
|
| 328 |
-
# --
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
-
TAB_KEYS = ["
|
| 331 |
-
TAB_LABELS = ["1.
|
| 332 |
TAB_MAP = dict(zip(TAB_KEYS, TAB_LABELS))
|
| 333 |
|
| 334 |
qp = st.query_params
|
| 335 |
-
initial_tab = qp.get("tab", "
|
|
|
|
|
|
|
|
|
|
| 336 |
if initial_tab not in TAB_KEYS:
|
| 337 |
-
initial_tab = "
|
| 338 |
|
| 339 |
active_tab = st.radio(
|
| 340 |
"Navigate",
|
|
@@ -347,17 +428,17 @@ active_tab = st.radio(
|
|
| 347 |
)
|
| 348 |
st.query_params["tab"] = active_tab
|
| 349 |
|
| 350 |
-
# ===================== TAB 1:
|
| 351 |
|
| 352 |
-
if active_tab == "
|
| 353 |
-
st.caption("
|
| 354 |
col_in, col_out = st.columns([1, 1.5])
|
| 355 |
|
| 356 |
with col_in:
|
| 357 |
-
st.subheader("
|
| 358 |
-
author = st.text_input("
|
| 359 |
placeholder="e.g. 'Founder', 'VP Sales'",
|
| 360 |
-
help="A generic label for who created this
|
| 361 |
months_employed = st.slider("Months in plan year", 1, 12, 12, key="months_emp",
|
| 362 |
help="Set to less than 12 for mid-year hires β ramp schedule adjusts the quota automatically")
|
| 363 |
|
|
@@ -369,8 +450,7 @@ if active_tab == "scenario":
|
|
| 369 |
help="Actual or projected closed revenue for the period")
|
| 370 |
attainment_pct = (actual_revenue / effective_quota * 100) if effective_quota > 0 else 0
|
| 371 |
|
| 372 |
-
# Commission
|
| 373 |
-
# Ramp adjusts attainment % display but not the commission tier boundaries.
|
| 374 |
commission_quota = annual_quota
|
| 375 |
|
| 376 |
# --- Commission calc ---
|
|
@@ -444,7 +524,7 @@ if active_tab == "scenario":
|
|
| 444 |
help="Revenue generated per dollar spent on the rep")
|
| 445 |
|
| 446 |
st.markdown("**Commission Curve**")
|
| 447 |
-
st.caption("How commission scales with attainment β the red dot is your current
|
| 448 |
rev_pts = [commission_quota * x / 100 for x in range(0, 201, 5)]
|
| 449 |
if commission_mode == "Attainment-based rates":
|
| 450 |
comm_pts = [calc_commission_attainment(r, commission_quota, att_tiers, retroactive_mode) for r in rev_pts]
|
|
@@ -473,15 +553,20 @@ if active_tab == "scenario":
|
|
| 473 |
height=350, margin=dict(l=0, r=0, t=30, b=0))
|
| 474 |
st.plotly_chart(fig, use_container_width=True)
|
| 475 |
|
| 476 |
-
# --- Save
|
| 477 |
st.divider()
|
| 478 |
-
st.subheader("Save This
|
| 479 |
-
st.caption("Save the
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
| 481 |
"author": author,
|
| 482 |
"timestamp": datetime.datetime.now().isoformat(),
|
| 483 |
"commission_mode": commission_mode,
|
| 484 |
"retroactive": retroactive_mode,
|
|
|
|
|
|
|
| 485 |
"params": {
|
| 486 |
"base_salary": base_salary, "annual_quota": annual_quota,
|
| 487 |
"draw_amount": draw_amount,
|
|
@@ -492,46 +577,30 @@ if active_tab == "scenario":
|
|
| 492 |
"deal_rate_tiers": deal_rate_tiers if commission_mode != "Attainment-based rates" else None,
|
| 493 |
"accel_tiers": accel_tiers if commission_mode != "Attainment-based rates" else None,
|
| 494 |
},
|
| 495 |
-
"inputs": {
|
| 496 |
-
"months_employed": months_employed,
|
| 497 |
-
"actual_revenue": actual_revenue,
|
| 498 |
-
},
|
| 499 |
-
"results": {
|
| 500 |
-
"effective_quota": round(effective_quota, 2),
|
| 501 |
-
"attainment_pct": round(attainment_pct, 1),
|
| 502 |
-
"commission": round(commission, 2),
|
| 503 |
-
"draw_paid": round(draw_paid, 2),
|
| 504 |
-
"total_variable": round(total_variable, 2),
|
| 505 |
-
"total_comp": round(total_comp, 2),
|
| 506 |
-
"gross_profit": round(gross_profit, 2),
|
| 507 |
-
"total_cost_of_rep": round(total_cost_of_rep, 2),
|
| 508 |
-
"net_after_rep": round(net_after_rep, 2),
|
| 509 |
-
"roi_on_rep": round(roi_on_rep, 2),
|
| 510 |
-
},
|
| 511 |
}
|
| 512 |
|
| 513 |
st.warning(
|
| 514 |
-
"**Privacy:** Saved
|
| 515 |
"Do NOT use real names, company names, or any identifying information "
|
| 516 |
-
"in
|
| 517 |
"(e.g. 'Plan A - aggressive', 'Option 2 - conservative').",
|
| 518 |
icon="π",
|
| 519 |
)
|
| 520 |
scol1, scol2 = st.columns(2)
|
| 521 |
with scol1:
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
if st.button("Save
|
| 525 |
-
if
|
| 526 |
try:
|
| 527 |
-
|
| 528 |
-
st.success(f"Saved '{
|
| 529 |
-
|
| 530 |
st.rerun()
|
| 531 |
except Exception as e:
|
| 532 |
st.error(f"Failed to save: {e}")
|
| 533 |
else:
|
| 534 |
-
st.warning("Enter a
|
| 535 |
|
| 536 |
# ===================== TAB 2: Per-Deal Calculator =====================
|
| 537 |
|
|
@@ -570,37 +639,37 @@ if active_tab == "deals":
|
|
| 570 |
st.dataframe(pd.DataFrame(deal_details), use_container_width=True, hide_index=True)
|
| 571 |
st.metric("Total Commission from Deals", f"${total_deal_comm:,.0f}")
|
| 572 |
|
| 573 |
-
# ===================== TAB 3: Compare
|
| 574 |
|
| 575 |
if active_tab == "compare":
|
| 576 |
-
st.subheader("Compare
|
| 577 |
-
st.caption("
|
| 578 |
-
"
|
| 579 |
|
| 580 |
if not sheet_ok:
|
| 581 |
-
st.warning("Configure your Google Sheet URL in secrets to enable
|
| 582 |
else:
|
| 583 |
if st.button("Refresh from Google Sheets"):
|
| 584 |
-
|
| 585 |
st.rerun()
|
| 586 |
|
| 587 |
-
|
| 588 |
|
| 589 |
-
if
|
| 590 |
col_a, col_b = st.columns(2)
|
| 591 |
with col_b:
|
| 592 |
-
to_delete = st.selectbox("Delete a
|
| 593 |
if st.button("Delete"):
|
| 594 |
if to_delete:
|
| 595 |
try:
|
| 596 |
-
|
| 597 |
-
|
| 598 |
st.success(f"Deleted '{to_delete}'")
|
| 599 |
st.rerun()
|
| 600 |
except Exception as e:
|
| 601 |
st.error(f"Failed to delete: {e}")
|
| 602 |
|
| 603 |
-
all_names = list(
|
| 604 |
tc1, tc2 = st.columns([3, 1])
|
| 605 |
with tc2:
|
| 606 |
if st.button("Clear all"):
|
|
@@ -610,58 +679,104 @@ if active_tab == "compare":
|
|
| 610 |
st.session_state["compare_sel"] = all_names
|
| 611 |
st.rerun()
|
| 612 |
with tc1:
|
| 613 |
-
selected = st.multiselect("Filter
|
| 614 |
default=all_names, key="compare_sel")
|
|
|
|
| 615 |
if selected:
|
|
|
|
| 616 |
rows = []
|
| 617 |
for name in selected:
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
"
|
|
|
|
|
|
|
| 627 |
"Base": f"${p.get('base_salary', 0):,.0f}",
|
| 628 |
"Quota": f"${p.get('annual_quota', 0):,.0f}",
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
"
|
| 634 |
-
"
|
| 635 |
-
"
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
| 640 |
|
|
|
|
|
|
|
| 641 |
fig = go.Figure()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
for name in selected:
|
| 643 |
-
|
| 644 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
fig.add_trace(go.Bar(
|
| 646 |
name=name,
|
| 647 |
-
x=["
|
| 648 |
-
y=
|
| 649 |
-
r.get("gross_profit", 0), r.get("net_after_rep", 0)],
|
| 650 |
))
|
| 651 |
fig.update_layout(barmode="group", height=400,
|
|
|
|
| 652 |
margin=dict(l=0, r=0, t=30, b=0))
|
| 653 |
st.plotly_chart(fig, use_container_width=True)
|
| 654 |
|
| 655 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
st.divider()
|
| 657 |
-
st.markdown("**
|
| 658 |
-
st.caption("Select a
|
| 659 |
detail_name = st.selectbox("View details for", [""] + selected, key="detail_sel")
|
| 660 |
-
if detail_name and detail_name in
|
| 661 |
-
s =
|
| 662 |
p = s.get("params", {})
|
| 663 |
-
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
|
| 666 |
# Build agreement text
|
| 667 |
lines = []
|
|
@@ -675,7 +790,7 @@ if active_tab == "compare":
|
|
| 675 |
lines.append("")
|
| 676 |
|
| 677 |
# Header summary
|
| 678 |
-
lines.append(f" ${p.get('base_salary', 0):,.0f}".ljust(22) + f"~${
|
| 679 |
lines.append(" Base Salary".ljust(22) + "OTE".ljust(22))
|
| 680 |
draw_amt = p.get("draw_amount", 0)
|
| 681 |
if draw_amt > 0 or equity:
|
|
@@ -714,7 +829,7 @@ if active_tab == "compare":
|
|
| 714 |
lines.append("")
|
| 715 |
|
| 716 |
lines.append(" On-Target Earnings (OTE)")
|
| 717 |
-
lines.append(f" β’ ${p.get('base_salary', 0):,.0f} base + ~${
|
| 718 |
lines.append(f" β’ Uncapped β no ceiling on commissions")
|
| 719 |
lines.append("")
|
| 720 |
|
|
@@ -742,7 +857,7 @@ if active_tab == "compare":
|
|
| 742 |
lines.append(" issued. Paid on the first payroll cycle of the following month.")
|
| 743 |
lines.append(" Churn within 90 days triggers a proportional clawback.")
|
| 744 |
elif comm_tiers:
|
| 745 |
-
is_retro =
|
| 746 |
if is_retro:
|
| 747 |
lines.append(" Commission rates are applied retroactively. Once a higher attainment")
|
| 748 |
lines.append(" threshold is crossed, that rate applies to ALL revenue from dollar one.")
|
|
@@ -753,33 +868,34 @@ if active_tab == "compare":
|
|
| 753 |
lines.append(f" {'Attainment Level':<24} {'Commission Rate':<18}")
|
| 754 |
lines.append(f" {'-' * 22:<24} {'-' * 16:<18}")
|
| 755 |
for i, tier in enumerate(comm_tiers):
|
| 756 |
-
|
| 757 |
-
if
|
| 758 |
prev_upper = int(comm_tiers[i-1][0])
|
| 759 |
-
lines.append(f" {prev_upper}%+{'':<19} {
|
| 760 |
elif i == 0:
|
| 761 |
-
lines.append(f" 0% β {int(
|
| 762 |
else:
|
| 763 |
prev_upper = int(comm_tiers[i-1][0])
|
| 764 |
-
lines.append(f" {prev_upper}% β {int(
|
| 765 |
elif deal_tiers_data:
|
| 766 |
lines.append(" Commission is calculated as a percentage of deal ACV based on deal size.")
|
| 767 |
lines.append("")
|
| 768 |
lines.append(f" {'Deal ACV Range':<28} {'Commission Rate':<18}")
|
| 769 |
lines.append(f" {'-' * 26:<28} {'-' * 16:<18}")
|
| 770 |
for tier in deal_tiers_data:
|
| 771 |
-
|
| 772 |
-
if
|
| 773 |
-
lines.append(f" ${
|
| 774 |
else:
|
| 775 |
-
lines.append(f" ${
|
| 776 |
|
| 777 |
lines.append("")
|
| 778 |
|
| 779 |
-
#
|
| 780 |
deal_targets = p.get("deal_targets")
|
|
|
|
| 781 |
if deal_targets:
|
| 782 |
-
lines.append("
|
| 783 |
lines.append("")
|
| 784 |
lines.append(f" On-target quota is ${p.get('annual_quota', 0):,.0f} ACV.")
|
| 785 |
lines.append("")
|
|
@@ -787,19 +903,17 @@ if active_tab == "compare":
|
|
| 787 |
lines.append(f" {'-'*13:<14} {'-'*8:<10} {'-'*12:<14} {'-'*12:<14} {'-'*6:<8} {'-'*12}")
|
| 788 |
total_deals = 0
|
| 789 |
total_acv = 0
|
| 790 |
-
|
| 791 |
for dt in deal_targets:
|
| 792 |
t_acv = dt['num_deals'] * dt['avg_acv']
|
| 793 |
t_comm = t_acv * dt['rate'] / 100
|
| 794 |
total_deals += dt['num_deals']
|
| 795 |
total_acv += t_acv
|
| 796 |
-
|
| 797 |
lines.append(f" {dt['segment']:<14} {dt['num_deals']:<10} ${dt['avg_acv']:,.0f}{'':<6} ${t_acv:,.0f}{'':<6} {dt['rate']:.0f}%{'':<5} ${t_comm:,.0f}")
|
| 798 |
-
lines.append(f" {'Total':<14} {total_deals:<10} {'β':<14} ${total_acv:,.0f}{'':<6} {'β':<8} ~${
|
| 799 |
lines.append("")
|
| 800 |
-
section_num =
|
| 801 |
-
else:
|
| 802 |
-
section_num = 3
|
| 803 |
|
| 804 |
# Ramp
|
| 805 |
ramp_list = p.get("ramp_pcts", [])
|
|
@@ -813,59 +927,50 @@ if active_tab == "compare":
|
|
| 813 |
monthly_quota = p.get('annual_quota', 0) / 12
|
| 814 |
lines.append(f" {'Month':<10} {'Quota %':<12} {'Monthly Target':<18} {'Ann. Run Rate':<16} {'Notes'}")
|
| 815 |
lines.append(f" {'-'*8:<10} {'-'*10:<12} {'-'*16:<18} {'-'*14:<16} {'-'*20}")
|
| 816 |
-
# Group consecutive months with same pct
|
| 817 |
ramp_groups = []
|
| 818 |
-
|
| 819 |
-
while
|
| 820 |
-
|
| 821 |
-
start =
|
| 822 |
-
while
|
| 823 |
-
|
| 824 |
-
end =
|
| 825 |
-
ramp_groups.append((start, end,
|
| 826 |
-
|
| 827 |
ramp_notes = {0: "Onboarding, no quota", 25: "First demos, early pipeline",
|
| 828 |
50: "Close first deals", 75: "Full funnel operating", 100: "Full quota in effect"}
|
| 829 |
-
for start, end,
|
| 830 |
month_label = f"{start}β{end}" if start != end else str(start)
|
| 831 |
-
target = monthly_quota *
|
| 832 |
run_rate = target * 12
|
| 833 |
-
note = ramp_notes.get(int(
|
| 834 |
-
|
| 835 |
-
monthly_draw_val = draw_amt / max(p.get('draw_months', 1), 1)
|
| 836 |
-
lines.append(f" {month_label:<10} {int(pct)}%{'':<9} ${target:,.0f}{'':<8} ${run_rate:,.0f}{'':<6} {note}")
|
| 837 |
-
else:
|
| 838 |
-
lines.append(f" {month_label:<10} {int(pct)}%{'':<9} ${target:,.0f}{'':<8} ${run_rate:,.0f}{'':<6} {note}")
|
| 839 |
-
# Full quota months
|
| 840 |
if len(ramp_list) < 12:
|
| 841 |
month_label = f"{len(ramp_list)+1}β12"
|
| 842 |
lines.append(f" {month_label:<10} 100%{'':<8} ${monthly_quota:,.0f}{'':<8} ${monthly_quota*12:,.0f}{'':<6} Full quota in effect")
|
| 843 |
lines.append("")
|
| 844 |
section_num += 1
|
| 845 |
|
| 846 |
-
# Accelerators
|
| 847 |
if accel_t:
|
| 848 |
lines.append(f"{section_num}. ACCELERATORS & OVERACHIEVEMENT")
|
| 849 |
lines.append("")
|
| 850 |
lines.append(f" {'Attainment':<18} {'Rate / Multiplier':<22} {'Notes'}")
|
| 851 |
lines.append(f" {'-'*16:<18} {'-'*20:<22} {'-'*20}")
|
| 852 |
for i, tier in enumerate(accel_t):
|
| 853 |
-
|
| 854 |
-
if
|
| 855 |
prev_upper = int(accel_t[i-1][0])
|
| 856 |
-
lines.append(f" {prev_upper}%+{'':<14} {
|
| 857 |
elif i == 0:
|
| 858 |
-
lines.append(f" 0% β {int(
|
| 859 |
else:
|
| 860 |
prev_upper = int(accel_t[i-1][0])
|
| 861 |
-
lines.append(f" {prev_upper}% β {int(
|
| 862 |
lines.append("")
|
| 863 |
section_num += 1
|
| 864 |
-
elif comm_tiers:
|
| 865 |
-
# Attainment-based tiers already shown above, no separate accelerator section needed
|
| 866 |
-
pass
|
| 867 |
|
| 868 |
-
# Illustrative earnings
|
| 869 |
lines.append(f"{section_num}. ILLUSTRATIVE EARNINGS SCENARIOS")
|
| 870 |
lines.append("")
|
| 871 |
if illustrative:
|
|
@@ -874,13 +979,11 @@ if active_tab == "compare":
|
|
| 874 |
for sc in illustrative:
|
| 875 |
lines.append(f" {sc['label']:<22} {sc.get('deal_mix', 'β'):<20} ${sc['acv']:,.0f}{'':<4} ${sc['commission']:,.0f}{'':<4} ${sc['total_cash']:,.0f}{'':<4} {sc.get('notes', '')}")
|
| 876 |
else:
|
| 877 |
-
lines.append(f"
|
| 878 |
-
lines.append("")
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
lines.append(f"
|
| 882 |
-
lines.append(f" Commission: ${r.get('commission', 0):,.0f}")
|
| 883 |
-
lines.append(f" Total Compensation: ${r.get('total_comp', 0):,.0f}")
|
| 884 |
lines.append("")
|
| 885 |
section_num += 1
|
| 886 |
|
|
@@ -920,7 +1023,6 @@ if active_tab == "compare":
|
|
| 920 |
|
| 921 |
st.code(agreement_text, language=None)
|
| 922 |
|
| 923 |
-
# Copy button
|
| 924 |
st.download_button(
|
| 925 |
label="Download as text file",
|
| 926 |
data=agreement_text,
|
|
@@ -928,4 +1030,4 @@ if active_tab == "compare":
|
|
| 928 |
mime="text/plain",
|
| 929 |
)
|
| 930 |
else:
|
| 931 |
-
st.info("No saved
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
@st.cache_data(ttl=30)
|
| 39 |
+
def load_plans() -> dict:
|
| 40 |
sheet_id = _get_sheet_id()
|
| 41 |
if not sheet_id:
|
| 42 |
return {}
|
| 43 |
try:
|
| 44 |
csv_url = SHEET_CSV_TMPL.format(sheet_id=sheet_id)
|
| 45 |
df = pd.read_csv(csv_url)
|
| 46 |
+
plans = {}
|
| 47 |
for _, row in df.iterrows():
|
| 48 |
try:
|
| 49 |
+
data = json.loads(row["data_json"])
|
| 50 |
+
plans[row["name"]] = normalize_plan(data)
|
| 51 |
except (json.JSONDecodeError, KeyError):
|
| 52 |
continue
|
| 53 |
+
return plans
|
| 54 |
except Exception:
|
| 55 |
return {}
|
| 56 |
|
| 57 |
|
| 58 |
+
def save_plan(name: str, data: dict):
|
| 59 |
url = _get_apps_script_url()
|
| 60 |
if not url:
|
| 61 |
raise ValueError("Apps Script URL not configured in secrets")
|
|
|
|
| 71 |
raise ValueError(f"Apps Script returned {resp.status_code}")
|
| 72 |
|
| 73 |
|
| 74 |
+
def delete_plan(name: str):
|
| 75 |
url = _get_apps_script_url()
|
| 76 |
if not url:
|
| 77 |
raise ValueError("Apps Script URL not configured in secrets")
|
|
|
|
| 81 |
raise ValueError(f"Apps Script returned {resp.status_code}")
|
| 82 |
|
| 83 |
|
| 84 |
+
def normalize_plan(data):
|
| 85 |
+
"""Ensure loaded data works as a plan regardless of version (backward compat)."""
|
| 86 |
+
if "version" not in data:
|
| 87 |
+
data["version"] = 1
|
| 88 |
+
data.setdefault("months_employed", data.get("inputs", {}).get("months_employed", 12))
|
| 89 |
+
data.setdefault("attainment_levels", [75, 100, 150, 200])
|
| 90 |
+
return data
|
| 91 |
+
|
| 92 |
+
|
| 93 |
# ---------------------------------------------------------------------------
|
| 94 |
# Commission engine
|
| 95 |
# ---------------------------------------------------------------------------
|
|
|
|
| 100 |
If retroactive=False (marginal): each rate applies only to revenue in that band.
|
| 101 |
If retroactive=True: the highest tier reached applies to ALL revenue."""
|
| 102 |
if retroactive:
|
|
|
|
| 103 |
applicable_rate = tiers[0][1]
|
| 104 |
for upper_pct, rate in tiers:
|
| 105 |
if upper_pct is None:
|
|
|
|
| 109 |
applicable_rate = rate
|
| 110 |
break
|
| 111 |
applicable_rate = rate
|
|
|
|
| 112 |
last_upper = tiers[-2][0] if tiers[-1][0] is None else tiers[-1][0]
|
| 113 |
if tiers[-1][0] is None and revenue > last_upper * quota / 100:
|
| 114 |
applicable_rate = tiers[-1][1]
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
def calc_commission_deal_rate(deals, deal_rate_tiers, attainment_pct, accel_tiers):
|
| 135 |
+
"""Deal-size-based commission with attainment accelerators."""
|
|
|
|
|
|
|
|
|
|
| 136 |
multiplier = 1.0
|
| 137 |
for upper_pct, mult in accel_tiers:
|
| 138 |
if upper_pct is None or attainment_pct <= upper_pct:
|
|
|
|
| 176 |
return total
|
| 177 |
|
| 178 |
|
| 179 |
+
def compute_projection(params, commission_mode, retroactive, attainment_pct, months_employed=12):
|
| 180 |
+
"""Compute full results for a plan at a given attainment level."""
|
| 181 |
+
annual_quota = params.get("annual_quota", 0)
|
| 182 |
+
base_salary = params.get("base_salary", 0)
|
| 183 |
+
draw_amount = params.get("draw_amount", 0)
|
| 184 |
+
draw_months_val = params.get("draw_months", 0)
|
| 185 |
+
draw_recoverable = params.get("draw_recoverable", False)
|
| 186 |
+
ramp_pcts = params.get("ramp_pcts", [])
|
| 187 |
+
cogs_pct = params.get("cogs_pct", 20.0)
|
| 188 |
+
other_cost = params.get("other_cost_per_rep", 0)
|
| 189 |
+
|
| 190 |
+
effective_quota = calc_ramp_adjusted_quota(annual_quota, ramp_pcts, months_employed)
|
| 191 |
+
# Revenue = attainment % of full annual quota (not ramp-adjusted)
|
| 192 |
+
actual_revenue = annual_quota * attainment_pct / 100
|
| 193 |
+
|
| 194 |
+
# Commission
|
| 195 |
+
if commission_mode == "Attainment-based rates":
|
| 196 |
+
tiers = params.get("commission_tiers", [])
|
| 197 |
+
commission = calc_commission_attainment(actual_revenue, annual_quota, tiers, retroactive)
|
| 198 |
+
else:
|
| 199 |
+
deal_rate_tiers = params.get("deal_rate_tiers", [])
|
| 200 |
+
accel_tiers = params.get("accel_tiers", [])
|
| 201 |
+
avg_rate = 0.0
|
| 202 |
+
for mn, mx, rt in deal_rate_tiers:
|
| 203 |
+
if mx is None or actual_revenue / max(1, 12) <= mx:
|
| 204 |
+
avg_rate = rt
|
| 205 |
+
break
|
| 206 |
+
mult = 1.0
|
| 207 |
+
for upper, m in accel_tiers:
|
| 208 |
+
if upper is None or attainment_pct <= upper:
|
| 209 |
+
mult = m
|
| 210 |
+
break
|
| 211 |
+
commission = actual_revenue * (avg_rate / 100) * mult
|
| 212 |
+
|
| 213 |
+
# Draw
|
| 214 |
+
active_draw_months = min(int(draw_months_val), months_employed)
|
| 215 |
+
draw_paid = draw_amount * (active_draw_months / max(int(draw_months_val), 1)) if draw_months_val > 0 else 0
|
| 216 |
+
if draw_recoverable:
|
| 217 |
+
total_variable = max(commission, draw_paid)
|
| 218 |
+
else:
|
| 219 |
+
total_variable = commission + draw_paid
|
| 220 |
+
|
| 221 |
+
prorated_base = base_salary * (months_employed / 12)
|
| 222 |
+
total_comp = prorated_base + total_variable
|
| 223 |
+
|
| 224 |
+
# Company economics
|
| 225 |
+
gross_profit = actual_revenue * (1 - cogs_pct / 100)
|
| 226 |
+
total_cost_of_rep = total_comp + other_cost * (months_employed / 12)
|
| 227 |
+
net_after_rep = gross_profit - total_cost_of_rep
|
| 228 |
+
roi_on_rep = (actual_revenue / total_cost_of_rep) if total_cost_of_rep > 0 else 0
|
| 229 |
+
|
| 230 |
+
return {
|
| 231 |
+
"actual_revenue": round(actual_revenue, 2),
|
| 232 |
+
"effective_quota": round(effective_quota, 2),
|
| 233 |
+
"attainment_pct": round(attainment_pct, 1),
|
| 234 |
+
"commission": round(commission, 2),
|
| 235 |
+
"draw_paid": round(draw_paid, 2),
|
| 236 |
+
"total_variable": round(total_variable, 2),
|
| 237 |
+
"total_comp": round(total_comp, 2),
|
| 238 |
+
"gross_profit": round(gross_profit, 2),
|
| 239 |
+
"total_cost_of_rep": round(total_cost_of_rep, 2),
|
| 240 |
+
"net_after_rep": round(net_after_rep, 2),
|
| 241 |
+
"roi_on_rep": round(roi_on_rep, 2),
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
# ---------------------------------------------------------------------------
|
| 246 |
# App
|
| 247 |
# ---------------------------------------------------------------------------
|
| 248 |
|
| 249 |
+
st.set_page_config(page_title="Sales Comp Plan Builder", layout="wide")
|
| 250 |
+
st.title("Sales Comp Plan Builder")
|
| 251 |
|
| 252 |
st.markdown("""
|
| 253 |
+
Model different compensation plans and compare them side by side.
|
| 254 |
|
| 255 |
**How to use:**
|
| 256 |
+
1. **Set plan parameters** in the sidebar (base salary, quota, commission structure, ramp, draw, etc.)
|
| 257 |
+
2. **Explore what-if scenarios** in the Plan Builder tab with any revenue number
|
| 258 |
+
3. **Save the plan** and it auto-generates projections at 75%, 100%, 150%, 200% attainment
|
| 259 |
+
4. **Compare plans** side by side in the Compare tab
|
| 260 |
""")
|
| 261 |
|
| 262 |
# Check config
|
|
|
|
| 341 |
rate = c2.number_input(f"Tier {i+1} rate %", value=d_rate, step=0.5, format="%.1f", key=f"at_r_{i}")
|
| 342 |
att_tiers.append((upper, rate))
|
| 343 |
else:
|
|
|
|
| 344 |
with st.sidebar.expander("Deal-Size Commission Rates", expanded=True):
|
| 345 |
num_deal_tiers = st.number_input("Number of deal-size tiers", 1, 6, 3, key="num_deal_tiers")
|
| 346 |
deal_defaults = [(0, 29_999, 20.0), (30_000, 100_000, 40.0), (100_001, None, 40.0)]
|
|
|
|
| 357 |
rt = c3.number_input(f"Tier {i+1} rate %", value=d[2], step=1.0, format="%.1f", key=f"dt_rt_{i}")
|
| 358 |
deal_rate_tiers.append((mn, mx, rt))
|
| 359 |
|
|
|
|
| 360 |
with st.sidebar.expander("Attainment Accelerators", expanded=True):
|
| 361 |
st.caption("Multiplier applied to deal commission based on annual attainment")
|
| 362 |
num_accels = st.number_input("Number of accelerator tiers", 1, 6, 3, key="num_accels")
|
|
|
|
| 395 |
other_cost_per_rep = st.number_input("Other cost per rep ($)", value=20_000, step=5_000, key="other_cost",
|
| 396 |
help="Annual cost for benefits, tools, travel, etc. β pro-rated if less than 12 months")
|
| 397 |
|
| 398 |
+
# -- Projection settings --
|
| 399 |
+
with st.sidebar.expander("Projection Settings", expanded=False):
|
| 400 |
+
st.caption("Attainment levels used when auto-generating plan projections.")
|
| 401 |
+
attainment_str = st.text_input("Attainment levels (%)", value="75, 100, 150, 200", key="att_levels")
|
| 402 |
+
attainment_levels = [int(x.strip()) for x in attainment_str.split(",") if x.strip().isdigit()]
|
| 403 |
+
if not attainment_levels:
|
| 404 |
+
attainment_levels = [75, 100, 150, 200]
|
| 405 |
+
|
| 406 |
+
# ---- Main tabs (deeplink via ?tab=builder|deals|compare) ----
|
| 407 |
|
| 408 |
+
TAB_KEYS = ["builder", "deals", "compare"]
|
| 409 |
+
TAB_LABELS = ["1. Plan Builder", "2. Per-Deal Calculator", "3. Compare Plans"]
|
| 410 |
TAB_MAP = dict(zip(TAB_KEYS, TAB_LABELS))
|
| 411 |
|
| 412 |
qp = st.query_params
|
| 413 |
+
initial_tab = qp.get("tab", "builder")
|
| 414 |
+
# backward compat for old ?tab=scenario links
|
| 415 |
+
if initial_tab == "scenario":
|
| 416 |
+
initial_tab = "builder"
|
| 417 |
if initial_tab not in TAB_KEYS:
|
| 418 |
+
initial_tab = "builder"
|
| 419 |
|
| 420 |
active_tab = st.radio(
|
| 421 |
"Navigate",
|
|
|
|
| 428 |
)
|
| 429 |
st.query_params["tab"] = active_tab
|
| 430 |
|
| 431 |
+
# ===================== TAB 1: Plan Builder =====================
|
| 432 |
|
| 433 |
+
if active_tab == "builder":
|
| 434 |
+
st.caption("Configure the plan in the sidebar, then explore any revenue number below. Save the plan to auto-generate projections.")
|
| 435 |
col_in, col_out = st.columns([1, 1.5])
|
| 436 |
|
| 437 |
with col_in:
|
| 438 |
+
st.subheader("What-If Calculator")
|
| 439 |
+
author = st.text_input("Plan author", key="author",
|
| 440 |
placeholder="e.g. 'Founder', 'VP Sales'",
|
| 441 |
+
help="A generic label for who created this plan β avoid real names")
|
| 442 |
months_employed = st.slider("Months in plan year", 1, 12, 12, key="months_emp",
|
| 443 |
help="Set to less than 12 for mid-year hires β ramp schedule adjusts the quota automatically")
|
| 444 |
|
|
|
|
| 450 |
help="Actual or projected closed revenue for the period")
|
| 451 |
attainment_pct = (actual_revenue / effective_quota * 100) if effective_quota > 0 else 0
|
| 452 |
|
| 453 |
+
# Commission uses full annual quota for tier boundaries
|
|
|
|
| 454 |
commission_quota = annual_quota
|
| 455 |
|
| 456 |
# --- Commission calc ---
|
|
|
|
| 524 |
help="Revenue generated per dollar spent on the rep")
|
| 525 |
|
| 526 |
st.markdown("**Commission Curve**")
|
| 527 |
+
st.caption("How commission scales with attainment β the red dot is your current what-if.")
|
| 528 |
rev_pts = [commission_quota * x / 100 for x in range(0, 201, 5)]
|
| 529 |
if commission_mode == "Attainment-based rates":
|
| 530 |
comm_pts = [calc_commission_attainment(r, commission_quota, att_tiers, retroactive_mode) for r in rev_pts]
|
|
|
|
| 553 |
height=350, margin=dict(l=0, r=0, t=30, b=0))
|
| 554 |
st.plotly_chart(fig, use_container_width=True)
|
| 555 |
|
| 556 |
+
# --- Save plan ---
|
| 557 |
st.divider()
|
| 558 |
+
st.subheader("Save This Plan")
|
| 559 |
+
st.caption("Save the plan configuration. Projections at each attainment level are generated automatically.")
|
| 560 |
+
|
| 561 |
+
# Build plan data (config only, no baked-in results)
|
| 562 |
+
plan_data = {
|
| 563 |
+
"version": 2,
|
| 564 |
"author": author,
|
| 565 |
"timestamp": datetime.datetime.now().isoformat(),
|
| 566 |
"commission_mode": commission_mode,
|
| 567 |
"retroactive": retroactive_mode,
|
| 568 |
+
"months_employed": months_employed,
|
| 569 |
+
"attainment_levels": attainment_levels,
|
| 570 |
"params": {
|
| 571 |
"base_salary": base_salary, "annual_quota": annual_quota,
|
| 572 |
"draw_amount": draw_amount,
|
|
|
|
| 577 |
"deal_rate_tiers": deal_rate_tiers if commission_mode != "Attainment-based rates" else None,
|
| 578 |
"accel_tiers": accel_tiers if commission_mode != "Attainment-based rates" else None,
|
| 579 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
}
|
| 581 |
|
| 582 |
st.warning(
|
| 583 |
+
"**Privacy:** Saved plans are stored in a shared Google Sheet. "
|
| 584 |
"Do NOT use real names, company names, or any identifying information "
|
| 585 |
+
"in plan names or the author field. Use generic labels "
|
| 586 |
"(e.g. 'Plan A - aggressive', 'Option 2 - conservative').",
|
| 587 |
icon="π",
|
| 588 |
)
|
| 589 |
scol1, scol2 = st.columns(2)
|
| 590 |
with scol1:
|
| 591 |
+
plan_name = st.text_input("Plan name", key="save_name",
|
| 592 |
+
placeholder="e.g. 'plan-a-aggressive'")
|
| 593 |
+
if st.button("Save Plan", type="primary"):
|
| 594 |
+
if plan_name.strip():
|
| 595 |
try:
|
| 596 |
+
save_plan(plan_name.strip(), plan_data)
|
| 597 |
+
st.success(f"Saved '{plan_name.strip()}'")
|
| 598 |
+
load_plans.clear()
|
| 599 |
st.rerun()
|
| 600 |
except Exception as e:
|
| 601 |
st.error(f"Failed to save: {e}")
|
| 602 |
else:
|
| 603 |
+
st.warning("Enter a plan name")
|
| 604 |
|
| 605 |
# ===================== TAB 2: Per-Deal Calculator =====================
|
| 606 |
|
|
|
|
| 639 |
st.dataframe(pd.DataFrame(deal_details), use_container_width=True, hide_index=True)
|
| 640 |
st.metric("Total Commission from Deals", f"${total_deal_comm:,.0f}")
|
| 641 |
|
| 642 |
+
# ===================== TAB 3: Compare Plans =====================
|
| 643 |
|
| 644 |
if active_tab == "compare":
|
| 645 |
+
st.subheader("Compare Plans")
|
| 646 |
+
st.caption("Each plan auto-generates projections at multiple attainment levels. "
|
| 647 |
+
"Compare different plan structures to see how they perform across scenarios.")
|
| 648 |
|
| 649 |
if not sheet_ok:
|
| 650 |
+
st.warning("Configure your Google Sheet URL in secrets to enable plan saving.")
|
| 651 |
else:
|
| 652 |
if st.button("Refresh from Google Sheets"):
|
| 653 |
+
load_plans.clear()
|
| 654 |
st.rerun()
|
| 655 |
|
| 656 |
+
plans = load_plans()
|
| 657 |
|
| 658 |
+
if plans:
|
| 659 |
col_a, col_b = st.columns(2)
|
| 660 |
with col_b:
|
| 661 |
+
to_delete = st.selectbox("Delete a plan", [""] + list(plans.keys()), key="del_sel")
|
| 662 |
if st.button("Delete"):
|
| 663 |
if to_delete:
|
| 664 |
try:
|
| 665 |
+
delete_plan(to_delete)
|
| 666 |
+
load_plans.clear()
|
| 667 |
st.success(f"Deleted '{to_delete}'")
|
| 668 |
st.rerun()
|
| 669 |
except Exception as e:
|
| 670 |
st.error(f"Failed to delete: {e}")
|
| 671 |
|
| 672 |
+
all_names = list(plans.keys())
|
| 673 |
tc1, tc2 = st.columns([3, 1])
|
| 674 |
with tc2:
|
| 675 |
if st.button("Clear all"):
|
|
|
|
| 679 |
st.session_state["compare_sel"] = all_names
|
| 680 |
st.rerun()
|
| 681 |
with tc1:
|
| 682 |
+
selected = st.multiselect("Filter plans", all_names,
|
| 683 |
default=all_names, key="compare_sel")
|
| 684 |
+
|
| 685 |
if selected:
|
| 686 |
+
# Build comparison table with projections at each attainment level
|
| 687 |
rows = []
|
| 688 |
for name in selected:
|
| 689 |
+
plan = plans[name]
|
| 690 |
+
p = plan.get("params", {})
|
| 691 |
+
months = plan.get("months_employed", 12)
|
| 692 |
+
levels = plan.get("attainment_levels", [75, 100, 150, 200])
|
| 693 |
+
retro = plan.get("retroactive", False)
|
| 694 |
+
mode = plan.get("commission_mode", "Attainment-based rates")
|
| 695 |
+
|
| 696 |
+
row = {
|
| 697 |
+
"Plan": name,
|
| 698 |
+
"Author": plan.get("author", ""),
|
| 699 |
+
"Mode": mode + (" (retro)" if retro else ""),
|
| 700 |
"Base": f"${p.get('base_salary', 0):,.0f}",
|
| 701 |
"Quota": f"${p.get('annual_quota', 0):,.0f}",
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
for level in levels:
|
| 705 |
+
proj = compute_projection(p, mode, retro, level, months)
|
| 706 |
+
row[f"{level}% Comm"] = f"${proj['commission']:,.0f}"
|
| 707 |
+
row[f"{level}% Total"] = f"${proj['total_comp']:,.0f}"
|
| 708 |
+
row[f"{level}% Net"] = f"${proj['net_after_rep']:,.0f}"
|
| 709 |
+
|
| 710 |
+
rows.append(row)
|
| 711 |
+
|
| 712 |
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
| 713 |
|
| 714 |
+
# Chart: total comp at each attainment level per plan
|
| 715 |
+
st.markdown("**Total Compensation by Attainment**")
|
| 716 |
fig = go.Figure()
|
| 717 |
+
# Use the union of all attainment levels across selected plans
|
| 718 |
+
all_levels = sorted(set(
|
| 719 |
+
lvl for name in selected
|
| 720 |
+
for lvl in plans[name].get("attainment_levels", [75, 100, 150, 200])
|
| 721 |
+
))
|
| 722 |
for name in selected:
|
| 723 |
+
plan = plans[name]
|
| 724 |
+
p = plan.get("params", {})
|
| 725 |
+
months = plan.get("months_employed", 12)
|
| 726 |
+
retro = plan.get("retroactive", False)
|
| 727 |
+
mode = plan.get("commission_mode", "Attainment-based rates")
|
| 728 |
+
comps = []
|
| 729 |
+
for level in all_levels:
|
| 730 |
+
proj = compute_projection(p, mode, retro, level, months)
|
| 731 |
+
comps.append(proj["total_comp"])
|
| 732 |
fig.add_trace(go.Bar(
|
| 733 |
name=name,
|
| 734 |
+
x=[f"{l}%" for l in all_levels],
|
| 735 |
+
y=comps,
|
|
|
|
| 736 |
))
|
| 737 |
fig.update_layout(barmode="group", height=400,
|
| 738 |
+
xaxis_title="Attainment", yaxis_title="Total Comp ($)",
|
| 739 |
margin=dict(l=0, r=0, t=30, b=0))
|
| 740 |
st.plotly_chart(fig, use_container_width=True)
|
| 741 |
|
| 742 |
+
# Net after rep chart
|
| 743 |
+
st.markdown("**Net After Rep Cost by Attainment**")
|
| 744 |
+
fig2 = go.Figure()
|
| 745 |
+
for name in selected:
|
| 746 |
+
plan = plans[name]
|
| 747 |
+
p = plan.get("params", {})
|
| 748 |
+
months = plan.get("months_employed", 12)
|
| 749 |
+
retro = plan.get("retroactive", False)
|
| 750 |
+
mode = plan.get("commission_mode", "Attainment-based rates")
|
| 751 |
+
nets = []
|
| 752 |
+
for level in all_levels:
|
| 753 |
+
proj = compute_projection(p, mode, retro, level, months)
|
| 754 |
+
nets.append(proj["net_after_rep"])
|
| 755 |
+
fig2.add_trace(go.Bar(
|
| 756 |
+
name=name,
|
| 757 |
+
x=[f"{l}%" for l in all_levels],
|
| 758 |
+
y=nets,
|
| 759 |
+
))
|
| 760 |
+
fig2.update_layout(barmode="group", height=400,
|
| 761 |
+
xaxis_title="Attainment", yaxis_title="Net After Rep ($)",
|
| 762 |
+
margin=dict(l=0, r=0, t=30, b=0))
|
| 763 |
+
st.plotly_chart(fig2, use_container_width=True)
|
| 764 |
+
|
| 765 |
+
# --- Plan detail view ---
|
| 766 |
st.divider()
|
| 767 |
+
st.markdown("**Plan Details**")
|
| 768 |
+
st.caption("Select a plan to view agreement-ready compensation terms.")
|
| 769 |
detail_name = st.selectbox("View details for", [""] + selected, key="detail_sel")
|
| 770 |
+
if detail_name and detail_name in plans:
|
| 771 |
+
s = plans[detail_name]
|
| 772 |
p = s.get("params", {})
|
| 773 |
+
months = s.get("months_employed", 12)
|
| 774 |
+
retro = s.get("retroactive", False)
|
| 775 |
+
mode = s.get("commission_mode", "Attainment-based rates")
|
| 776 |
+
levels = s.get("attainment_levels", [75, 100, 150, 200])
|
| 777 |
+
|
| 778 |
+
# Compute 100% projection for OTE reference
|
| 779 |
+
proj_100 = compute_projection(p, mode, retro, 100, months)
|
| 780 |
|
| 781 |
# Build agreement text
|
| 782 |
lines = []
|
|
|
|
| 790 |
lines.append("")
|
| 791 |
|
| 792 |
# Header summary
|
| 793 |
+
lines.append(f" ${p.get('base_salary', 0):,.0f}".ljust(22) + f"~${proj_100['total_comp']:,.0f}".ljust(22))
|
| 794 |
lines.append(" Base Salary".ljust(22) + "OTE".ljust(22))
|
| 795 |
draw_amt = p.get("draw_amount", 0)
|
| 796 |
if draw_amt > 0 or equity:
|
|
|
|
| 829 |
lines.append("")
|
| 830 |
|
| 831 |
lines.append(" On-Target Earnings (OTE)")
|
| 832 |
+
lines.append(f" β’ ${p.get('base_salary', 0):,.0f} base + ~${proj_100['commission']:,.0f} variable = ~${proj_100['total_comp']:,.0f} OTE")
|
| 833 |
lines.append(f" β’ Uncapped β no ceiling on commissions")
|
| 834 |
lines.append("")
|
| 835 |
|
|
|
|
| 857 |
lines.append(" issued. Paid on the first payroll cycle of the following month.")
|
| 858 |
lines.append(" Churn within 90 days triggers a proportional clawback.")
|
| 859 |
elif comm_tiers:
|
| 860 |
+
is_retro = retro
|
| 861 |
if is_retro:
|
| 862 |
lines.append(" Commission rates are applied retroactively. Once a higher attainment")
|
| 863 |
lines.append(" threshold is crossed, that rate applies to ALL revenue from dollar one.")
|
|
|
|
| 868 |
lines.append(f" {'Attainment Level':<24} {'Commission Rate':<18}")
|
| 869 |
lines.append(f" {'-' * 22:<24} {'-' * 16:<18}")
|
| 870 |
for i, tier in enumerate(comm_tiers):
|
| 871 |
+
upper_val, rate_val = tier[0], tier[1]
|
| 872 |
+
if upper_val is None:
|
| 873 |
prev_upper = int(comm_tiers[i-1][0])
|
| 874 |
+
lines.append(f" {prev_upper}%+{'':<19} {rate_val:.1f}%")
|
| 875 |
elif i == 0:
|
| 876 |
+
lines.append(f" 0% β {int(upper_val)}%{'':<14} {rate_val:.1f}%")
|
| 877 |
else:
|
| 878 |
prev_upper = int(comm_tiers[i-1][0])
|
| 879 |
+
lines.append(f" {prev_upper}% β {int(upper_val)}%{'':<12} {rate_val:.1f}%")
|
| 880 |
elif deal_tiers_data:
|
| 881 |
lines.append(" Commission is calculated as a percentage of deal ACV based on deal size.")
|
| 882 |
lines.append("")
|
| 883 |
lines.append(f" {'Deal ACV Range':<28} {'Commission Rate':<18}")
|
| 884 |
lines.append(f" {'-' * 26:<28} {'-' * 16:<18}")
|
| 885 |
for tier in deal_tiers_data:
|
| 886 |
+
mn_v, mx_v, rt_v = tier[0], tier[1], tier[2]
|
| 887 |
+
if mx_v is None:
|
| 888 |
+
lines.append(f" ${mn_v:,.0f}+{'':<19} {rt_v:.1f}%")
|
| 889 |
else:
|
| 890 |
+
lines.append(f" ${mn_v:,.0f} β ${mx_v:,.0f}{'':<8} {rt_v:.1f}%")
|
| 891 |
|
| 892 |
lines.append("")
|
| 893 |
|
| 894 |
+
# Deal Targets (if present in legacy data)
|
| 895 |
deal_targets = p.get("deal_targets")
|
| 896 |
+
section_num = 3
|
| 897 |
if deal_targets:
|
| 898 |
+
lines.append(f"{section_num}. ANNUAL QUOTA & DEAL TARGETS")
|
| 899 |
lines.append("")
|
| 900 |
lines.append(f" On-target quota is ${p.get('annual_quota', 0):,.0f} ACV.")
|
| 901 |
lines.append("")
|
|
|
|
| 903 |
lines.append(f" {'-'*13:<14} {'-'*8:<10} {'-'*12:<14} {'-'*12:<14} {'-'*6:<8} {'-'*12}")
|
| 904 |
total_deals = 0
|
| 905 |
total_acv = 0
|
| 906 |
+
total_comm_dt = 0
|
| 907 |
for dt in deal_targets:
|
| 908 |
t_acv = dt['num_deals'] * dt['avg_acv']
|
| 909 |
t_comm = t_acv * dt['rate'] / 100
|
| 910 |
total_deals += dt['num_deals']
|
| 911 |
total_acv += t_acv
|
| 912 |
+
total_comm_dt += t_comm
|
| 913 |
lines.append(f" {dt['segment']:<14} {dt['num_deals']:<10} ${dt['avg_acv']:,.0f}{'':<6} ${t_acv:,.0f}{'':<6} {dt['rate']:.0f}%{'':<5} ${t_comm:,.0f}")
|
| 914 |
+
lines.append(f" {'Total':<14} {total_deals:<10} {'β':<14} ${total_acv:,.0f}{'':<6} {'β':<8} ~${total_comm_dt:,.0f}")
|
| 915 |
lines.append("")
|
| 916 |
+
section_num += 1
|
|
|
|
|
|
|
| 917 |
|
| 918 |
# Ramp
|
| 919 |
ramp_list = p.get("ramp_pcts", [])
|
|
|
|
| 927 |
monthly_quota = p.get('annual_quota', 0) / 12
|
| 928 |
lines.append(f" {'Month':<10} {'Quota %':<12} {'Monthly Target':<18} {'Ann. Run Rate':<16} {'Notes'}")
|
| 929 |
lines.append(f" {'-'*8:<10} {'-'*10:<12} {'-'*16:<18} {'-'*14:<16} {'-'*20}")
|
|
|
|
| 930 |
ramp_groups = []
|
| 931 |
+
idx = 0
|
| 932 |
+
while idx < len(ramp_list):
|
| 933 |
+
pct_val = ramp_list[idx]
|
| 934 |
+
start = idx + 1
|
| 935 |
+
while idx + 1 < len(ramp_list) and ramp_list[idx + 1] == pct_val:
|
| 936 |
+
idx += 1
|
| 937 |
+
end = idx + 1
|
| 938 |
+
ramp_groups.append((start, end, pct_val))
|
| 939 |
+
idx += 1
|
| 940 |
ramp_notes = {0: "Onboarding, no quota", 25: "First demos, early pipeline",
|
| 941 |
50: "Close first deals", 75: "Full funnel operating", 100: "Full quota in effect"}
|
| 942 |
+
for start, end, pct_val in ramp_groups:
|
| 943 |
month_label = f"{start}β{end}" if start != end else str(start)
|
| 944 |
+
target = monthly_quota * pct_val / 100
|
| 945 |
run_rate = target * 12
|
| 946 |
+
note = ramp_notes.get(int(pct_val), "")
|
| 947 |
+
lines.append(f" {month_label:<10} {int(pct_val)}%{'':<9} ${target:,.0f}{'':<8} ${run_rate:,.0f}{'':<6} {note}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
if len(ramp_list) < 12:
|
| 949 |
month_label = f"{len(ramp_list)+1}β12"
|
| 950 |
lines.append(f" {month_label:<10} 100%{'':<8} ${monthly_quota:,.0f}{'':<8} ${monthly_quota*12:,.0f}{'':<6} Full quota in effect")
|
| 951 |
lines.append("")
|
| 952 |
section_num += 1
|
| 953 |
|
| 954 |
+
# Accelerators (for deal-size mode)
|
| 955 |
if accel_t:
|
| 956 |
lines.append(f"{section_num}. ACCELERATORS & OVERACHIEVEMENT")
|
| 957 |
lines.append("")
|
| 958 |
lines.append(f" {'Attainment':<18} {'Rate / Multiplier':<22} {'Notes'}")
|
| 959 |
lines.append(f" {'-'*16:<18} {'-'*20:<22} {'-'*20}")
|
| 960 |
for i, tier in enumerate(accel_t):
|
| 961 |
+
upper_val, mult_val = tier[0], tier[1]
|
| 962 |
+
if upper_val is None:
|
| 963 |
prev_upper = int(accel_t[i-1][0])
|
| 964 |
+
lines.append(f" {prev_upper}%+{'':<14} {mult_val:.1f}x multiplier")
|
| 965 |
elif i == 0:
|
| 966 |
+
lines.append(f" 0% β {int(upper_val)}%{'':<9} {mult_val:.1f}x (base rate)")
|
| 967 |
else:
|
| 968 |
prev_upper = int(accel_t[i-1][0])
|
| 969 |
+
lines.append(f" {prev_upper}% β {int(upper_val)}%{'':<7} {mult_val:.1f}x multiplier")
|
| 970 |
lines.append("")
|
| 971 |
section_num += 1
|
|
|
|
|
|
|
|
|
|
| 972 |
|
| 973 |
+
# Illustrative earnings β auto-generated from projections
|
| 974 |
lines.append(f"{section_num}. ILLUSTRATIVE EARNINGS SCENARIOS")
|
| 975 |
lines.append("")
|
| 976 |
if illustrative:
|
|
|
|
| 979 |
for sc in illustrative:
|
| 980 |
lines.append(f" {sc['label']:<22} {sc.get('deal_mix', 'β'):<20} ${sc['acv']:,.0f}{'':<4} ${sc['commission']:,.0f}{'':<4} ${sc['total_cash']:,.0f}{'':<4} {sc.get('notes', '')}")
|
| 981 |
else:
|
| 982 |
+
lines.append(f" {'Attainment':<16} {'Revenue':<16} {'Commission':<16} {'Total Comp':<16} {'Net After Rep'}")
|
| 983 |
+
lines.append(f" {'-'*14:<16} {'-'*14:<16} {'-'*14:<16} {'-'*14:<16} {'-'*14}")
|
| 984 |
+
for level in levels:
|
| 985 |
+
proj = compute_projection(p, mode, retro, level, months)
|
| 986 |
+
lines.append(f" {level}%{'':<12} ${proj['actual_revenue']:,.0f}{'':<4} ${proj['commission']:,.0f}{'':<6} ${proj['total_comp']:,.0f}{'':<6} ${proj['net_after_rep']:,.0f}")
|
|
|
|
|
|
|
| 987 |
lines.append("")
|
| 988 |
section_num += 1
|
| 989 |
|
|
|
|
| 1023 |
|
| 1024 |
st.code(agreement_text, language=None)
|
| 1025 |
|
|
|
|
| 1026 |
st.download_button(
|
| 1027 |
label="Download as text file",
|
| 1028 |
data=agreement_text,
|
|
|
|
| 1030 |
mime="text/plain",
|
| 1031 |
)
|
| 1032 |
else:
|
| 1033 |
+
st.info("No saved plans yet. Save one from the Plan Builder tab.")
|