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

Files changed (2) hide show
  1. AIRULES.md +63 -94
  2. app.py +268 -166
AIRULES.md CHANGED
@@ -1,12 +1,12 @@
1
- # Sales Comp Calculator β€” AI Rules
2
 
3
  ## Project Overview
4
 
5
- This is a Streamlit-based sales compensation calculator hosted on HuggingFace Spaces. It models different comp plan structures and saves scenarios to a Google Sheet for comparison.
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 (private/unlisted) at `stepolan/salescomp`
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
- ## Saving / Updating Scenarios Programmatically
36
 
37
- Scenarios are stored in the Google Sheet. You can save or update them by POSTing to the Apps Script URL.
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
- import requests, json, datetime
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": datetime.datetime.now().isoformat(),
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], # One entry per ramp month, remaining months = 100%
65
  "cogs_pct": 20.0,
66
  "other_cost_per_rep": 20000,
67
- },
68
- "inputs": {
69
- "months_employed": 12,
70
- "actual_revenue": 1250000,
71
- },
72
- "results": {
73
- "effective_quota": 1093750.0,
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
- payload = {
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
- resp = requests.post(SCRIPT_URL, json=payload, timeout=60)
95
- ```
96
 
97
- ### Delete a Scenario
98
 
99
- ```python
100
- payload = {"action": "delete", "name": "Plan A - Conservative"}
101
- requests.post(SCRIPT_URL, json=payload, timeout=60)
 
102
  ```
103
 
104
- ### Commission Calculation Logic
105
 
106
- **Attainment-based rates** β€” tiered marginal rates on revenue bands relative to effective quota:
107
 
108
  ```python
109
- def calc_commission_attainment(revenue, quota, tiers):
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
- **Deal-size rates + accelerators** β€” flat % per deal ACV with attainment multiplier:
129
 
130
- ```python
131
- commission = revenue * (deal_rate / 100) * accelerator_multiplier
 
 
 
 
 
 
132
  ```
133
 
134
- ### Ramp-Adjusted Quota
135
 
136
  ```python
137
- def calc_ramp_quota(annual_quota, ramp_pcts, months=12):
138
- monthly = annual_quota / 12
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
- ### Key Formulas
 
 
 
 
 
 
149
 
150
- - **Attainment %** = actual_revenue / effective_quota * 100
151
- - **Draw paid** = draw_amount * (months_employed / draw_months)
 
 
 
 
 
 
 
 
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 * (1 - cogs_pct / 100)
156
- - **Total rep cost** = total_comp + prorated_other_cost
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 scenario names or author fields. The Google Sheet is accessible to anyone with the link.
163
 
164
- ## Current Saved Scenarios
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 load_scenarios() -> 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
- scenarios = {}
47
  for _, row in df.iterrows():
48
  try:
49
- scenarios[row["name"]] = json.loads(row["data_json"])
 
50
  except (json.JSONDecodeError, KeyError):
51
  continue
52
- return scenarios
53
  except Exception:
54
  return {}
55
 
56
 
57
- def save_scenario(name: str, data: dict):
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 delete_scenario(name: str):
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 Calculator", layout="wide")
179
- st.title("Sales Compensation Calculator")
180
 
181
  st.markdown("""
182
- Model different sales compensation plans and compare them side by side.
183
 
184
  **How to use:**
185
- 1. **Set plan parameters** in the sidebar on the left (base salary, quota, commission structure, ramp, etc.)
186
- 2. **Enter projected revenue** in the Scenario Calculator tab to see seller payout and company economics
187
- 3. **Save & compare** scenarios to evaluate different plan structures against each other
 
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
- # ---- Main tabs (deeplink via ?tab=scenario|deals|compare) ----
 
 
 
 
 
 
 
 
329
 
330
- TAB_KEYS = ["scenario", "deals", "compare"]
331
- TAB_LABELS = ["1. Scenario Calculator", "2. Per-Deal Calculator", "3. Compare Scenarios"]
332
  TAB_MAP = dict(zip(TAB_KEYS, TAB_LABELS))
333
 
334
  qp = st.query_params
335
- initial_tab = qp.get("tab", "scenario")
 
 
 
336
  if initial_tab not in TAB_KEYS:
337
- initial_tab = "scenario"
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: Scenario Calculator =====================
351
 
352
- if active_tab == "scenario":
353
- st.caption("Enter a revenue number to see what the seller earns and what it costs the company.")
354
  col_in, col_out = st.columns([1, 1.5])
355
 
356
  with col_in:
357
- st.subheader("Inputs")
358
- author = st.text_input("Scenario author", key="author",
359
  placeholder="e.g. 'Founder', 'VP Sales'",
360
- help="A generic label for who created this scenario β€” avoid real names")
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 tier quota: use full annual quota so OTE math is clean.
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 scenario.")
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 scenario ---
477
  st.divider()
478
- st.subheader("Save This Scenario")
479
- st.caption("Save the current plan configuration and results so you can compare it with other scenarios later.")
480
- scenario_data = {
 
 
 
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 scenarios are stored in a shared Google Sheet. "
515
  "Do NOT use real names, company names, or any identifying information "
516
- "in scenario names or the 'Your name' field. Use generic labels "
517
  "(e.g. 'Plan A - aggressive', 'Option 2 - conservative').",
518
  icon="πŸ”’",
519
  )
520
  scol1, scol2 = st.columns(2)
521
  with scol1:
522
- scenario_name = st.text_input("Scenario name", key="save_name",
523
- placeholder="e.g. 'plan-a-aggressive'")
524
- if st.button("Save Scenario", type="primary"):
525
- if scenario_name.strip():
526
  try:
527
- save_scenario(scenario_name.strip(), scenario_data)
528
- st.success(f"Saved '{scenario_name.strip()}'")
529
- load_scenarios.clear()
530
  st.rerun()
531
  except Exception as e:
532
  st.error(f"Failed to save: {e}")
533
  else:
534
- st.warning("Enter a scenario name")
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 Scenarios =====================
574
 
575
  if active_tab == "compare":
576
- st.subheader("Compare Saved Scenarios")
577
- st.caption("Select saved scenarios below to compare them side by side. "
578
- "Use this to evaluate different plan structures β€” e.g. higher base vs. higher commission rate.")
579
 
580
  if not sheet_ok:
581
- st.warning("Configure your Google Sheet URL in secrets to enable scenario saving.")
582
  else:
583
  if st.button("Refresh from Google Sheets"):
584
- load_scenarios.clear()
585
  st.rerun()
586
 
587
- scenarios = load_scenarios()
588
 
589
- if scenarios:
590
  col_a, col_b = st.columns(2)
591
  with col_b:
592
- to_delete = st.selectbox("Delete a scenario", [""] + list(scenarios.keys()), key="del_sel")
593
  if st.button("Delete"):
594
  if to_delete:
595
  try:
596
- delete_scenario(to_delete)
597
- load_scenarios.clear()
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(scenarios.keys())
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 scenarios", all_names,
614
  default=all_names, key="compare_sel")
 
615
  if selected:
 
616
  rows = []
617
  for name in selected:
618
- s = scenarios[name]
619
- r = s.get("results", {})
620
- p = s.get("params", {})
621
- inp = s.get("inputs", {})
622
- rev = inp.get("actual_revenue", 0)
623
- rows.append({
624
- "Scenario": name,
625
- "Author": s.get("author", ""),
626
- "Mode": s.get("commission_mode", "") + (" (retro)" if s.get("retroactive") else ""),
 
 
627
  "Base": f"${p.get('base_salary', 0):,.0f}",
628
  "Quota": f"${p.get('annual_quota', 0):,.0f}",
629
- "Revenue": f"${rev:,.0f}",
630
- "Attainment": f"{r.get('attainment_pct', 0)}%",
631
- "Commission": f"${r.get('commission', 0):,.0f}",
632
- "Draw": f"${r.get('draw_paid', 0):,.0f}",
633
- "Total Comp": f"${r.get('total_comp', 0):,.0f}",
634
- "Gross Profit": f"${r.get('gross_profit', 0):,.0f}",
635
- "Net After Rep": f"${r.get('net_after_rep', 0):,.0f}",
636
- "Rep Cost % Rev": f"{r.get('total_cost_of_rep', 0) / rev * 100:.1f}%" if rev > 0 else "N/A",
637
- "ROI": f"{r.get('roi_on_rep', 0):.1f}x",
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
- s = scenarios[name]
644
- r = s.get("results", {})
 
 
 
 
 
 
 
645
  fig.add_trace(go.Bar(
646
  name=name,
647
- x=["Commission", "Total Comp", "Gross Profit", "Net After Rep"],
648
- y=[r.get("commission", 0), r.get("total_comp", 0),
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
- # --- Scenario detail view ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  st.divider()
657
- st.markdown("**Scenario Details**")
658
- st.caption("Select a scenario to view agreement-ready compensation terms.")
659
  detail_name = st.selectbox("View details for", [""] + selected, key="detail_sel")
660
- if detail_name and detail_name in scenarios:
661
- s = scenarios[detail_name]
662
  p = s.get("params", {})
663
- inp = s.get("inputs", {})
664
- r = s.get("results", {})
 
 
 
 
 
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"~${r.get('total_comp', 0):,.0f}".ljust(22))
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 + ~${r.get('commission', 0):,.0f} variable = ~${r.get('total_comp', 0):,.0f} OTE")
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 = s.get("retroactive", False)
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
- upper, rate = tier[0], tier[1]
757
- if upper is None:
758
  prev_upper = int(comm_tiers[i-1][0])
759
- lines.append(f" {prev_upper}%+{'':<19} {rate:.1f}%")
760
  elif i == 0:
761
- lines.append(f" 0% – {int(upper)}%{'':<14} {rate:.1f}%")
762
  else:
763
  prev_upper = int(comm_tiers[i-1][0])
764
- lines.append(f" {prev_upper}% – {int(upper)}%{'':<12} {rate:.1f}%")
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
- mn, mx, rt = tier[0], tier[1], tier[2]
772
- if mx is None:
773
- lines.append(f" ${mn:,.0f}+{'':<19} {rt:.1f}%")
774
  else:
775
- lines.append(f" ${mn:,.0f} – ${mx:,.0f}{'':<8} {rt:.1f}%")
776
 
777
  lines.append("")
778
 
779
- # 3. Annual Quota & Deal Targets
780
  deal_targets = p.get("deal_targets")
 
781
  if deal_targets:
782
- lines.append("3. ANNUAL QUOTA & DEAL TARGETS")
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
- total_comm = 0
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
- total_comm += t_comm
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} ~${total_comm:,.0f}")
799
  lines.append("")
800
- section_num = 4
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
- i = 0
819
- while i < len(ramp_list):
820
- pct = ramp_list[i]
821
- start = i + 1
822
- while i + 1 < len(ramp_list) and ramp_list[i + 1] == pct:
823
- i += 1
824
- end = i + 1
825
- ramp_groups.append((start, end, pct))
826
- i += 1
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, pct in ramp_groups:
830
  month_label = f"{start}–{end}" if start != end else str(start)
831
- target = monthly_quota * pct / 100
832
  run_rate = target * 12
833
- note = ramp_notes.get(int(pct), "")
834
- if draw_amt > 0:
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
- upper, mult = tier[0], tier[1]
854
- if upper is None:
855
  prev_upper = int(accel_t[i-1][0])
856
- lines.append(f" {prev_upper}%+{'':<14} {mult:.1f}x multiplier")
857
  elif i == 0:
858
- lines.append(f" 0% – {int(upper)}%{'':<9} {mult:.1f}x (base rate)")
859
  else:
860
  prev_upper = int(accel_t[i-1][0])
861
- lines.append(f" {prev_upper}% – {int(upper)}%{'':<7} {mult:.1f}x multiplier")
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" At {r.get('attainment_pct', 0):.0f}% quota attainment (${inp.get('actual_revenue', 0):,.0f} revenue):")
878
- lines.append("")
879
- lines.append(f" Base Salary: ${p.get('base_salary', 0):,.0f}")
880
- if draw_amt > 0:
881
- lines.append(f" Draw: ${r.get('draw_paid', 0):,.0f}")
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 scenarios yet. Save one from the Scenario Calculator tab.")
 
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.")