Expansion Opportunity Scorer
Analyses customer product adoption depth, maps whitespace for unused
features/products, estimates revenue opportunities, and prioritises
expansion plays by effort vs impact.
python expansion_opportunity_scorer.py customer_data.json
python expansion_opportunity_scorer.py customer_data.json --format json
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Tier pricing multipliers (relative to current plan price)
TIER_UPLIFT: Dict[str, float] = {
# Module revenue estimates as a fraction of base ARR
MODULE_REVENUE_FRACTION: Dict[str, float] = {
"core_platform": 0.00, # Already included in base
"analytics_module": 0.15,
"integrations_module": 0.12,
"advanced_reporting": 0.18,
"automation_module": 0.15,
"collaboration_module": 0.10,
"custom_workflows": 0.22,
# Effort classification for different expansion types
EFFORT_MAP: Dict[str, str] = {
"cross_sell_module": "low",
"department_expansion": "high",
# Usage thresholds for recommendations
HIGH_USAGE_THRESHOLD = 75 # % usage indicates readiness for more
LOW_ADOPTION_THRESHOLD = 30 # % usage is too low to push expansion there
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
"""Return numerator / denominator, or *default* when denominator is zero."""
return numerator / denominator
def clamp(value: float, lo: float = 0.0, hi: float = 100.0) -> float:
"""Clamp *value* between *lo* and *hi*."""
return max(lo, min(hi, value))
def estimate_seat_expansion_revenue(
arr: float, licensed: int, active: int, segment: str
"""Estimate revenue from seat expansion.
Returns (estimated_revenue, rationale).
utilisation = safe_divide(active, licensed)
# Near capacity -- likely needs more seats
growth_factor = {"enterprise": 0.25, "mid-market": 0.20, "smb": 0.15}
factor = growth_factor.get(segment.lower(), 0.15)
revenue = round(arr * factor, 0)
return revenue, f"Seat utilisation at {utilisation:.0%} -- likely needs {int(licensed * factor)} additional seats"
return 0.0, f"Seat utilisation at {utilisation:.0%} -- not yet at expansion threshold"
def estimate_tier_upgrade_revenue(
arr: float, current_tier: str, available_tiers: List[str]
) -> Tuple[float, Optional[str], str]:
"""Estimate revenue from tier upgrade.
Returns (estimated_revenue, target_tier, rationale).
current_mult = TIER_UPLIFT.get(current_tier.lower(), 1.0)
rationale = "Already on highest tier"
for tier in available_tiers:
tier_mult = TIER_UPLIFT.get(tier.lower(), 1.0)
if tier_mult > current_mult:
# Calculate revenue as the incremental ARR from upgrading
base_arr = safe_divide(arr, current_mult)
upgrade_arr = base_arr * tier_mult
incremental = upgrade_arr - arr
if incremental > best_revenue:
# Pick the next tier up (not skip tiers)
if best_tier is None or tier_mult < TIER_UPLIFT.get(best_tier.lower(), 999):
best_revenue = round(incremental, 0)
rationale = f"Upgrade from {current_tier} to {tier} adds ${incremental:,.0f} ARR"
return best_revenue, best_tier, rationale
def estimate_module_revenue(
arr: float, product_usage: Dict[str, Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Identify cross-sell opportunities from unadopted modules.
Returns list of opportunity dicts.
opportunities: List[Dict[str, Any]] = []
for module_name, module_data in product_usage.items():
adopted = module_data.get("adopted", False)
usage_pct = module_data.get("usage_pct", 0)
fraction = MODULE_REVENUE_FRACTION.get(module_name.lower(), 0.10)
if not adopted and fraction > 0:
revenue = round(arr * fraction, 0)
"estimated_revenue": revenue,
"rationale": f"Module not adopted -- ${revenue:,.0f} potential ARR",
elif adopted and usage_pct < LOW_ADOPTION_THRESHOLD and fraction > 0:
# Already adopted but underutilised -- focus on enablement, not expansion
pass # Skip -- needs enablement, not a sales motion
def estimate_department_expansion_revenue(
current_departments: List[str],
potential_departments: List[str],
) -> List[Dict[str, Any]]:
"""Estimate revenue from expanding to new departments."""
opportunities: List[Dict[str, Any]] = []
current_set = {d.lower() for d in current_departments}
per_dept_estimate = safe_divide(arr, max(len(current_departments), 1))
for dept in potential_departments:
if dept.lower() not in current_set:
# Estimate each new department at the average per-department ARR
revenue = round(per_dept_estimate * 0.8, 0) # Slight discount for new dept
"estimated_revenue": revenue,
"rationale": f"Expand to {dept} department -- est. ${revenue:,.0f} ARR",
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
def priority_score(revenue: float, effort: str) -> float:
"""Calculate priority score (higher = better).
Favours high revenue with low effort.
effort_multiplier = {"low": 3.0, "medium": 2.0, "high": 1.0}
mult = effort_multiplier.get(effort.lower(), 1.0)
# Normalise revenue to a 0-100 scale (assume max single opportunity is $200k)
rev_score = clamp(safe_divide(revenue, 2000.0)) # $200k => 100
return round(rev_score * mult, 1)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
def analyse_expansion(customer: Dict[str, Any]) -> Dict[str, Any]:
"""Analyse expansion opportunities for a single customer."""
arr = customer.get("arr", 0)
segment = customer.get("segment", "mid-market").lower()
contract = customer.get("contract", {})
product_usage = customer.get("product_usage", {})
departments = customer.get("departments", {})
all_opportunities: List[Dict[str, Any]] = []
licensed = contract.get("licensed_seats", 0)
active = contract.get("active_seats", 0)
seat_rev, seat_rationale = estimate_seat_expansion_revenue(arr, licensed, active, segment)
all_opportunities.append({
"category": "seat_expansion",
"estimated_revenue": seat_rev,
"rationale": seat_rationale,
"priority_score": priority_score(seat_rev, "low"),
current_tier = contract.get("plan_tier", "").lower()
available_tiers = contract.get("available_tiers", [])
tier_rev, target_tier, tier_rationale = estimate_tier_upgrade_revenue(arr, current_tier, available_tiers)
if tier_rev > 0 and target_tier:
all_opportunities.append({
"category": "tier_upgrade",
"target_tier": target_tier,
"estimated_revenue": tier_rev,
"rationale": tier_rationale,
"priority_score": priority_score(tier_rev, "medium"),
module_opps = estimate_module_revenue(arr, product_usage)
opp["category"] = "module_cross_sell"
opp["priority_score"] = priority_score(opp["estimated_revenue"], opp["effort"])
all_opportunities.append(opp)
# 4. Department expansion
current_depts = departments.get("current", [])
potential_depts = departments.get("potential", [])
dept_opps = estimate_department_expansion_revenue(arr, current_depts, potential_depts, segment)
opp["category"] = "department_expansion"
opp["priority_score"] = priority_score(opp["estimated_revenue"], opp["effort"])
all_opportunities.append(opp)
# Sort by priority score descending
all_opportunities.sort(key=lambda o: o["priority_score"], reverse=True)
total_modules = len(product_usage)
adopted_modules = sum(1 for m in product_usage.values() if m.get("adopted", False))
sum(m.get("usage_pct", 0) for m in product_usage.values() if m.get("adopted", False)),
total_estimated_revenue = sum(o["estimated_revenue"] for o in all_opportunities)
"customer_id": customer.get("customer_id", "unknown"),
"name": customer.get("name", "Unknown"),
"total_modules": total_modules,
"adopted_modules": adopted_modules,
"adoption_rate": round(safe_divide(adopted_modules, total_modules) * 100, 1) if total_modules > 0 else 0,
"avg_usage_pct": avg_usage,
"seat_utilisation": round(safe_divide(active, max(licensed, 1)) * 100, 1),
"current_tier": current_tier,
"departments_covered": len(current_depts),
"departments_potential": len(potential_depts),
"total_estimated_revenue": round(total_estimated_revenue, 0),
"opportunity_count": len(all_opportunities),
"opportunities": all_opportunities,
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
def format_text(results: List[Dict[str, Any]]) -> str:
"""Format results as human-readable text."""
lines.append("EXPANSION OPPORTUNITY REPORT")
total_rev = sum(r["total_estimated_revenue"] for r in results)
total_opps = sum(r["opportunity_count"] for r in results)
lines.append(f"Portfolio Summary: {len(results)} customers")
lines.append(f" Total Expansion Revenue Potential: ${total_rev:,.0f}")
lines.append(f" Total Opportunities Identified: {total_opps}")
# Sort customers by total estimated revenue descending
sorted_results = sorted(results, key=lambda r: r["total_estimated_revenue"], reverse=True)
lines.append(f"Customer: {r['name']} ({r['customer_id']})")
lines.append(f"Segment: {r['segment'].title()} | Current ARR: ${r['arr']:,.0f}")
lines.append(f"Total Expansion Potential: ${r['total_estimated_revenue']:,.0f} ({r['opportunity_count']} opportunities)")
adoption = r["adoption_summary"]
lines.append(" Adoption Summary:")
lines.append(f" Modules Adopted: {adoption['adopted_modules']}/{adoption['total_modules']} ({adoption['adoption_rate']}%)")
lines.append(f" Avg Module Usage: {adoption['avg_usage_pct']}%")
lines.append(f" Seat Utilisation: {adoption['seat_utilisation']}%")
lines.append(f" Current Tier: {adoption['current_tier'].title()}")
lines.append(f" Departments: {adoption['departments_covered']} active, {adoption['departments_potential']} potential")
lines.append(" Opportunities (ranked by priority):")
for i, opp in enumerate(r["opportunities"], 1):
opp_type = opp.get("type", "unknown").title()
category = opp.get("category", "").replace("_", " ").title()
rev = opp["estimated_revenue"]
effort = opp.get("effort", "unknown").title()
pri = opp.get("priority_score", 0)
lines.append(f" {i}. [{opp_type}] {category}")
lines.append(f" Revenue: ${rev:,.0f} | Effort: {effort} | Priority: {pri}")
lines.append(f" {opp.get('rationale', '')}")
lines.append(" No expansion opportunities identified at this time.")
def format_json(results: List[Dict[str, Any]]) -> str:
"""Format results as JSON."""
total_rev = sum(r["total_estimated_revenue"] for r in results)
total_opps = sum(r["opportunity_count"] for r in results)
"report": "expansion_opportunities",
"total_customers": len(results),
"total_estimated_revenue": total_rev,
"total_opportunities": total_opps,
"customers": sorted(results, key=lambda r: r["total_estimated_revenue"], reverse=True),
return json.dumps(output, indent=2)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
parser = argparse.ArgumentParser(
description="Score expansion opportunities with adoption analysis and revenue estimation."
parser.add_argument("input_file", help="Path to JSON file containing customer data")
choices=["text", "json"],
help="Output format (default: text)",
args = parser.parse_args()
with open(args.input_file, "r") as f:
except FileNotFoundError:
print(f"Error: File not found: {args.input_file}", file=sys.stderr)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {args.input_file}: {e}", file=sys.stderr)
customers = data.get("customers", [])
print("Error: No customer records found in input file.", file=sys.stderr)
results = [analyse_expansion(c) for c in customers]
if args.output_format == "json":
print(format_json(results))
print(format_text(results))
if __name__ == "__main__":