feat: add scripts/build_cap_table.py (v1.0.0)
This commit is contained in:
@@ -0,0 +1,842 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build MPM advertising-financed ROI cap table workbooks.
|
||||
|
||||
Three model variants, each written as its own workbook:
|
||||
|
||||
2A Internal — MPM Financed, ROI & Payback
|
||||
MPM 30 / Passent 30 / Reseller 10 / End User 30.
|
||||
Carries COGS, sale-price and MSRP milestones against MPM's share,
|
||||
with interest accruing on the financed capital.
|
||||
|
||||
2B Internal — MPM Financed, Revenue Distribution
|
||||
Same split, no milestones and no cost basis. A straight projection of
|
||||
what each party earns.
|
||||
|
||||
2C Customer-Financed Acquisition
|
||||
MPM 5 / Reseller 2.5 / Passent 22.5 / End User 70.
|
||||
No MPM capital at risk, so no COGS or sale-price milestone and no
|
||||
interest. Carries the MSRP milestone against the End User's share —
|
||||
when the customer's 70% pays back what they bought at list.
|
||||
|
||||
Usage:
|
||||
python3 build_cap_table.py config.json --model all --outdir ./out
|
||||
python3 build_cap_table.py config.json --model 2A -o internal_roi.xlsx
|
||||
|
||||
See references/config_schema.md for the config format.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Model presets
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
MODELS = {
|
||||
"2A": {
|
||||
"key": "2A",
|
||||
"title": "Internal — MPM Financed (ROI & Payback)",
|
||||
"audience": "Internal. Contains cost and margin data.",
|
||||
"shares": {"MPM": 0.30, "Passent": 0.30, "Reseller": 0.10, "End User": 0.30},
|
||||
"milestones": ["cogs", "sale", "msrp"],
|
||||
"milestone_party": "MPM",
|
||||
"interest": True,
|
||||
"recurring_borne_by": "MPM",
|
||||
"show_cost_basis": True,
|
||||
},
|
||||
"2B": {
|
||||
"key": "2B",
|
||||
"title": "Internal — MPM Financed (Revenue Distribution)",
|
||||
"audience": "Internal. Revenue only — no cost, margin or payback data.",
|
||||
"shares": {"MPM": 0.30, "Passent": 0.30, "Reseller": 0.10, "End User": 0.30},
|
||||
"milestones": [],
|
||||
"milestone_party": None,
|
||||
"interest": False,
|
||||
"recurring_borne_by": "MPM",
|
||||
"show_cost_basis": False,
|
||||
},
|
||||
"2C": {
|
||||
"key": "2C",
|
||||
"title": "Customer-Financed Acquisition",
|
||||
"audience": "Customer-facing. No COGS, margin or sale-price data.",
|
||||
"shares": {"MPM": 0.05, "Reseller": 0.025, "Passent": 0.225, "End User": 0.70},
|
||||
"milestones": ["msrp"],
|
||||
"milestone_party": "End User",
|
||||
"interest": False,
|
||||
"recurring_borne_by": "End User",
|
||||
"show_cost_basis": False,
|
||||
},
|
||||
}
|
||||
|
||||
# Which cost basis a party is charged recurring licensing at. MPM pays its
|
||||
# vendor cost; the End User pays the invoiced price.
|
||||
RECURRING_BASIS = {"MPM": "cogs", "End User": "net", "Customer": "net"}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Styling
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
MPM_GOLD = "C8A951"
|
||||
DARK = "2F2F2F"
|
||||
LIGHT = "F2F2F2"
|
||||
|
||||
FILL_COGS = PatternFill("solid", fgColor="FFF200") # yellow
|
||||
FILL_SALE = PatternFill("solid", fgColor="FFA64D") # orange
|
||||
FILL_MSRP = PatternFill("solid", fgColor="7ED957") # green
|
||||
MILESTONE_FILL = {"cogs": FILL_COGS, "sale": FILL_SALE, "msrp": FILL_MSRP}
|
||||
MILESTONE_LABEL = {"cogs": "COGS", "sale": "Sale Price", "msrp": "MSRP"}
|
||||
|
||||
HDR_FILL = PatternFill("solid", fgColor=DARK)
|
||||
HDR_FONT = Font(bold=True, color="FFFFFF", size=10)
|
||||
SUB_FILL = PatternFill("solid", fgColor=MPM_GOLD)
|
||||
SUB_FONT = Font(bold=True, color="000000", size=10)
|
||||
TITLE_FONT = Font(bold=True, size=14)
|
||||
LABEL_FONT = Font(bold=True, size=10)
|
||||
NOTE_FONT = Font(italic=True, size=9, color="666666")
|
||||
|
||||
THIN = Side(style="thin", color="BFBFBF")
|
||||
BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
||||
|
||||
MONEY = '"$"#,##0.00'
|
||||
MONEY0 = '"$"#,##0'
|
||||
PCT = "0.0%"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Model
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def month_label(start_ym, offset):
|
||||
y, m = (int(x) for x in start_ym.split("-")[:2])
|
||||
total = (y * 12 + (m - 1)) + offset
|
||||
return f"{total // 12}-{total % 12 + 1:02d}"
|
||||
|
||||
|
||||
def ramp_factor(month, ramp_months, start_pct):
|
||||
if ramp_months <= 1 or month >= ramp_months:
|
||||
return 1.0
|
||||
return start_pct + (1.0 - start_pct) * (month - 1) / (ramp_months - 1)
|
||||
|
||||
|
||||
def line_totals(lines):
|
||||
msrp = sum(l["msrp_unit"] * l["qty"] for l in lines)
|
||||
net = sum(l["net_subtotal"] for l in lines)
|
||||
cogs = sum(l["net_subtotal"] - l["margin"] for l in lines)
|
||||
return msrp, net, cogs
|
||||
|
||||
|
||||
def resolve_model(cfg, key):
|
||||
"""Preset merged with any per-model overrides in the config."""
|
||||
spec = copy.deepcopy(MODELS[key])
|
||||
override = (cfg.get("models") or {}).get(key, {})
|
||||
spec.update(override)
|
||||
total = sum(spec["shares"].values())
|
||||
if abs(total - 1.0) > 1e-6:
|
||||
raise ValueError(f"Model {key} shares sum to {total:.4f}, not 1.0 — "
|
||||
f"every dollar of gross must be allocated.")
|
||||
return spec
|
||||
|
||||
|
||||
def build_model(cfg, model_key):
|
||||
spec = resolve_model(cfg, model_key)
|
||||
horizon = cfg.get("horizon_months", 60)
|
||||
ramp_months = cfg.get("ramp_months", 12)
|
||||
ramp_start = cfg.get("ramp_start_pct", 0.25)
|
||||
monthly_gross = cfg["ad_revenue"]["monthly_gross"]
|
||||
start_ym = cfg.get("start_date", "2027-01")
|
||||
|
||||
lines = cfg["lines"]
|
||||
one_time = [l for l in lines if not l.get("recurring")]
|
||||
recurring = [l for l in lines if l.get("recurring")]
|
||||
ot_msrp, ot_net, ot_cogs = line_totals(one_time)
|
||||
rc_msrp, rc_net, rc_cogs = line_totals(recurring)
|
||||
targets = {"cogs": ot_cogs, "sale": ot_net, "msrp": ot_msrp}
|
||||
|
||||
# Who pays the ongoing licensing, and at what basis.
|
||||
bearer = spec["recurring_borne_by"]
|
||||
basis = RECURRING_BASIS.get(bearer, "cogs")
|
||||
monthly_recurring = (rc_cogs if basis == "cogs" else rc_net) / 12.0
|
||||
|
||||
contributions = {}
|
||||
for c in cfg.get("client_contributions", []):
|
||||
m = int(c["month"])
|
||||
contributions[m] = contributions.get(m, 0.0) + float(c["amount"])
|
||||
|
||||
surge_by_month = {}
|
||||
for s in cfg.get("surges", []):
|
||||
if not s.get("enabled"):
|
||||
continue
|
||||
dur = max(1, int(round(float(s.get("months", 1)))))
|
||||
per = float(s["total"]) / dur
|
||||
for k in range(dur):
|
||||
m = int(s["start_month"]) + k
|
||||
if 1 <= m <= horizon:
|
||||
surge_by_month[m] = surge_by_month.get(m, 0.0) + per
|
||||
|
||||
# Financing. Principal is MPM's actual cash outlay — the one-time COGS —
|
||||
# not the price it would have sold for. You cannot borrow your own margin.
|
||||
fin = cfg.get("finance", {})
|
||||
rate = float(fin.get("annual_rate", 0.12))
|
||||
basis_key = fin.get("principal_basis", "cogs")
|
||||
principal = (float(fin["principal_amount"]) if fin.get("principal_amount")
|
||||
else targets.get(basis_key, ot_cogs))
|
||||
use_interest = bool(spec["interest"]) and rate > 0
|
||||
|
||||
parties = list(spec["shares"].keys())
|
||||
mparty = spec["milestone_party"]
|
||||
|
||||
rows = []
|
||||
cum = {p: 0.0 for p in parties}
|
||||
cum_net = 0.0 # milestone party's cumulative, net of what it bears
|
||||
cum_interest = 0.0
|
||||
balance = principal if use_interest else 0.0
|
||||
neg_amort_months = []
|
||||
|
||||
for m in range(1, horizon + 1):
|
||||
rf = ramp_factor(m, ramp_months, ramp_start)
|
||||
gross = monthly_gross * rf + surge_by_month.get(m, 0.0)
|
||||
contrib = contributions.get(m, 0.0)
|
||||
|
||||
row = {"month": m, "date": month_label(start_ym, m - 1),
|
||||
"ramp": rf, "gross": gross, "contribution": contrib,
|
||||
"parties": {}, "cum_parties": {}}
|
||||
for p in parties:
|
||||
amt = gross * spec["shares"][p]
|
||||
cum[p] += amt
|
||||
row["parties"][p] = amt
|
||||
row["cum_parties"][p] = cum[p]
|
||||
|
||||
# Cost borne by the milestone party (or by MPM in the 2B case, where
|
||||
# there is no milestone but the drag is still worth showing).
|
||||
charge_party = mparty or "MPM"
|
||||
charge = monthly_recurring if charge_party == bearer else 0.0
|
||||
row["recurring_charge"] = charge
|
||||
row["charged_party"] = charge_party
|
||||
|
||||
net = row["parties"].get(charge_party, 0.0) + contrib - charge
|
||||
row["net"] = net
|
||||
cum_net += net
|
||||
row["cum_net"] = cum_net
|
||||
|
||||
if use_interest:
|
||||
interest = balance * rate / 12.0 if balance > 1e-9 else 0.0
|
||||
balance += interest
|
||||
payment = max(0.0, min(net, balance))
|
||||
balance -= payment
|
||||
cum_interest += interest
|
||||
if interest > 0 and net < interest:
|
||||
neg_amort_months.append(m)
|
||||
else:
|
||||
interest = 0.0
|
||||
row["interest"] = interest
|
||||
row["cum_interest"] = cum_interest
|
||||
row["balance"] = max(0.0, balance) if use_interest else None
|
||||
rows.append(row)
|
||||
|
||||
# A milestone is cleared when the party has recovered the capital *and*
|
||||
# the cost of carrying it to that point.
|
||||
crossings = {}
|
||||
for key in spec["milestones"]:
|
||||
t = targets[key]
|
||||
hit = None
|
||||
for row in rows:
|
||||
if row["cum_net"] >= t + row["cum_interest"]:
|
||||
hit = row["month"]
|
||||
break
|
||||
crossings[key] = hit
|
||||
|
||||
prop_screens = cfg["ad_revenue"].get("screen_count")
|
||||
quote_screens = sum(l["qty"] for l in lines if l.get("is_display"))
|
||||
pairing = {"proposal": prop_screens, "quote": quote_screens or None,
|
||||
"ok": bool(prop_screens and quote_screens and
|
||||
abs(prop_screens - quote_screens) < 0.5)}
|
||||
|
||||
return {
|
||||
"cfg": cfg, "spec": spec, "rows": rows, "parties": parties,
|
||||
"targets": targets, "crossings": crossings, "horizon": horizon,
|
||||
"one_time": one_time, "recurring": recurring,
|
||||
"totals": {"one_time": (ot_msrp, ot_net, ot_cogs),
|
||||
"recurring_annual": (rc_msrp, rc_net, rc_cogs),
|
||||
"contract_msrp": ot_msrp + rc_msrp,
|
||||
"contract_net": ot_net + rc_net,
|
||||
"contract_cogs": ot_cogs + rc_cogs},
|
||||
"monthly_recurring": monthly_recurring,
|
||||
"recurring_bearer": bearer,
|
||||
"finance": {"rate": rate, "principal": principal,
|
||||
"active": use_interest,
|
||||
"total_interest": cum_interest,
|
||||
"neg_amort_months": neg_amort_months},
|
||||
"pairing": pairing,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Sheet helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _hdr(ws, row, col, text, fill=HDR_FILL, font=HDR_FONT):
|
||||
c = ws.cell(row=row, column=col, value=text)
|
||||
c.fill, c.font, c.border = fill, font, BOX
|
||||
c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
return c
|
||||
|
||||
|
||||
def _band(ws, row, text, width):
|
||||
c = ws.cell(row=row, column=1, value=text)
|
||||
c.fill, c.font = SUB_FILL, SUB_FONT
|
||||
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=width)
|
||||
return c
|
||||
|
||||
|
||||
def _note(ws, row, text, width=6, height=28):
|
||||
c = ws.cell(row=row, column=1, value=text)
|
||||
c.font = NOTE_FONT
|
||||
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=width)
|
||||
c.alignment = Alignment(wrap_text=True, vertical="top")
|
||||
ws.row_dimensions[row].height = height
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Summary sheet
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def write_summary(wb, model):
|
||||
cfg, spec = model["cfg"], model["spec"]
|
||||
ws = wb.create_sheet("Summary", 0)
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws.column_dimensions["A"].width = 40
|
||||
for col in "BCDEF":
|
||||
ws.column_dimensions[col].width = 18
|
||||
|
||||
ws["A1"] = f"Model {spec['key']} — {spec['title']}"
|
||||
ws["A1"].font = TITLE_FONT
|
||||
ws["A2"] = cfg.get("project_name", "")
|
||||
ws["A2"].font = Font(italic=True, size=11)
|
||||
ws["A3"] = spec["audience"]
|
||||
ws["A3"].font = Font(bold=True, size=9, color="B00000")
|
||||
|
||||
r = 5
|
||||
for label, val in [
|
||||
("Customer", cfg.get("customer", "")),
|
||||
("Sales Order", cfg.get("sale_order", "")),
|
||||
("Ad Proposal", cfg.get("ad_proposal_ref", "")),
|
||||
("Projection Start", cfg.get("start_date", "")),
|
||||
("Horizon (months)", model["horizon"]),
|
||||
("Prepared", cfg.get("prepared_on", date.today().isoformat())),
|
||||
]:
|
||||
ws.cell(row=r, column=1, value=label).font = LABEL_FONT
|
||||
ws.cell(row=r, column=2, value=val)
|
||||
r += 1
|
||||
|
||||
pc = model["pairing"]
|
||||
ws.cell(row=r, column=1, value="Screens — proposal vs. quote").font = LABEL_FONT
|
||||
if pc["proposal"] is None or pc["quote"] is None:
|
||||
ws.cell(row=r, column=2, value="not reconciled").font = Font(italic=True, color="999999")
|
||||
elif pc["ok"]:
|
||||
ws.cell(row=r, column=2, value=f"{pc['proposal']:g} / {pc['quote']:g} — match"
|
||||
).font = Font(color="1F7A1F", bold=True)
|
||||
else:
|
||||
ws.cell(row=r, column=2, value=f"{pc['proposal']:g} / {pc['quote']:g} — MISMATCH, verify scope"
|
||||
).font = Font(color="B00000", bold=True)
|
||||
r += 1
|
||||
_note(ws, r, "This model applies one advertising proposal to one sales order. Its revenue "
|
||||
"figures are specific to this deployment and are not transferable to any "
|
||||
"other quote.", 6, 26)
|
||||
r += 2
|
||||
|
||||
# --- Distribution ----------------------------------------------------
|
||||
_band(ws, r, "Revenue Distribution", 6)
|
||||
r += 1
|
||||
for i, h in enumerate(["Party", "Share", "Steady-State / mo", "Year 1",
|
||||
f"{model['horizon']}-Month Total"], start=1):
|
||||
_hdr(ws, r, i, h)
|
||||
r += 1
|
||||
last = model["rows"][-1]
|
||||
yr1 = min(12, model["horizon"]) - 1
|
||||
gross_ss = cfg["ad_revenue"]["monthly_gross"]
|
||||
for p in model["parties"]:
|
||||
share = spec["shares"][p]
|
||||
ws.cell(row=r, column=1, value=p).font = LABEL_FONT
|
||||
c = ws.cell(row=r, column=2, value=share); c.number_format = PCT
|
||||
c = ws.cell(row=r, column=3, value=gross_ss * share); c.number_format = MONEY
|
||||
c = ws.cell(row=r, column=4, value=model["rows"][yr1]["cum_parties"][p]); c.number_format = MONEY
|
||||
c = ws.cell(row=r, column=5, value=last["cum_parties"][p]); c.number_format = MONEY
|
||||
if p == "MPM":
|
||||
for col in range(1, 6):
|
||||
ws.cell(row=r, column=col).fill = PatternFill("solid", fgColor="FFF9E0")
|
||||
r += 1
|
||||
ws.cell(row=r, column=1, value="Total gross").font = LABEL_FONT
|
||||
c = ws.cell(row=r, column=2, value=1.0); c.number_format = PCT
|
||||
c = ws.cell(row=r, column=3, value=gross_ss); c.number_format = MONEY
|
||||
c = ws.cell(row=r, column=4, value=sum(model["rows"][yr1]["cum_parties"].values())); c.number_format = MONEY
|
||||
c = ws.cell(row=r, column=5, value=sum(last["cum_parties"].values())); c.number_format = MONEY
|
||||
for col in range(1, 6):
|
||||
ws.cell(row=r, column=col).font = LABEL_FONT
|
||||
ws.cell(row=r, column=col).border = Border(top=THIN)
|
||||
r += 2
|
||||
|
||||
# --- Financing -------------------------------------------------------
|
||||
fin = model["finance"]
|
||||
if fin["active"]:
|
||||
_band(ws, r, "Financed Capital and Cost of Money", 6)
|
||||
r += 1
|
||||
payoff = model["crossings"].get("cogs")
|
||||
for label, val, fmt in [
|
||||
("Principal financed (one-time COGS)", fin["principal"], MONEY),
|
||||
("Annual rate", fin["rate"], PCT),
|
||||
("Method", "Declining balance; MPM's share services interest first", None),
|
||||
("Total interest accrued", fin["total_interest"], MONEY),
|
||||
("Balance retired in month", (f"Month {payoff}" if payoff
|
||||
else f">{model['horizon']} months"), None),
|
||||
]:
|
||||
ws.cell(row=r, column=1, value=label).font = LABEL_FONT
|
||||
c = ws.cell(row=r, column=2, value=val)
|
||||
if fmt:
|
||||
c.number_format = fmt
|
||||
r += 1
|
||||
if fin["neg_amort_months"]:
|
||||
ws.cell(row=r, column=1,
|
||||
value=f"Negative amortization in months "
|
||||
f"{fin['neg_amort_months'][0]}–{fin['neg_amort_months'][-1]}: "
|
||||
f"MPM's share does not cover accruing interest during the ramp, "
|
||||
f"so the balance grows before it falls.").font = \
|
||||
Font(size=9, color="B00000")
|
||||
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=6)
|
||||
ws.cell(row=r, column=1).alignment = Alignment(wrap_text=True, vertical="top")
|
||||
ws.row_dimensions[r].height = 28
|
||||
r += 1
|
||||
r += 1
|
||||
|
||||
# --- Milestones ------------------------------------------------------
|
||||
if spec["milestones"]:
|
||||
mparty = spec["milestone_party"]
|
||||
_band(ws, r, f"Payoff Milestones — recovered from {mparty}'s share", 6)
|
||||
r += 1
|
||||
for i, h in enumerate(["Milestone", "Target", "Reached", "Fill"], start=1):
|
||||
_hdr(ws, r, i, h)
|
||||
r += 1
|
||||
for key in spec["milestones"]:
|
||||
hit = model["crossings"][key]
|
||||
ws.cell(row=r, column=1, value=MILESTONE_LABEL[key]).font = LABEL_FONT
|
||||
c = ws.cell(row=r, column=2, value=model["targets"][key]); c.number_format = MONEY
|
||||
c = ws.cell(row=r, column=3,
|
||||
value=(f"Month {hit}" if hit else f">{model['horizon']} months"))
|
||||
c.alignment = Alignment(horizontal="center")
|
||||
if not hit:
|
||||
c.font = Font(italic=True, color="B00000")
|
||||
ws.cell(row=r, column=4).fill = MILESTONE_FILL[key]
|
||||
ws.cell(row=r, column=4).border = BOX
|
||||
r += 1
|
||||
if fin["active"]:
|
||||
_note(ws, r, "Milestones clear when cumulative net recovers the target plus the "
|
||||
"interest accrued to that month, so they sit later than they would "
|
||||
"on a zero-cost-of-capital basis.", 6, 26)
|
||||
r += 1
|
||||
r += 1
|
||||
|
||||
# --- ROI ---------------------------------------------------------
|
||||
_band(ws, r, f"Return at Month {model['horizon']} — {mparty}", 6)
|
||||
r += 1
|
||||
cols = ["Basis", "Target", "Cumulative Net"]
|
||||
if fin["active"]:
|
||||
cols.append("Net of Interest")
|
||||
cols.append("ROI")
|
||||
for i, h in enumerate(cols, start=1):
|
||||
_hdr(ws, r, i, h)
|
||||
r += 1
|
||||
cum_final = last["cum_net"]
|
||||
net_of_int = cum_final - fin["total_interest"]
|
||||
for key in spec["milestones"]:
|
||||
t = model["targets"][key]
|
||||
ws.cell(row=r, column=1, value=MILESTONE_LABEL[key]).font = LABEL_FONT
|
||||
c = ws.cell(row=r, column=2, value=t); c.number_format = MONEY
|
||||
c = ws.cell(row=r, column=3, value=cum_final); c.number_format = MONEY
|
||||
col = 4
|
||||
if fin["active"]:
|
||||
c = ws.cell(row=r, column=4, value=net_of_int); c.number_format = MONEY
|
||||
col = 5
|
||||
c = ws.cell(row=r, column=col, value=(net_of_int - t) / t if t else 0)
|
||||
c.number_format = PCT
|
||||
c.font = Font(color="1F7A1F" if net_of_int >= t else "B00000", bold=True)
|
||||
r += 1
|
||||
if spec["key"] == "2C":
|
||||
_note(ws, r, "This is the End User's return on the system they purchased at list, "
|
||||
"funded by their 70% share of advertising revenue. It is not MPM's "
|
||||
"return — MPM's position in this model is its 5% share, shown above.",
|
||||
6, 26)
|
||||
r += 1
|
||||
r += 1
|
||||
|
||||
if not spec["milestones"]:
|
||||
_note(ws, r, "This model is a revenue distribution projection only. It carries no cost "
|
||||
"basis, no payback milestone and no margin data by design.", 6, 26)
|
||||
r += 2
|
||||
return ws
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Monthly projection sheet
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def write_projection(wb, model):
|
||||
spec = model["spec"]
|
||||
ws = wb.create_sheet("Monthly Projection")
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws["A1"] = f"{model['horizon']}-Month Projection — Model {spec['key']}"
|
||||
ws["A1"].font = TITLE_FONT
|
||||
|
||||
if spec["milestones"]:
|
||||
col = 1
|
||||
for key in spec["milestones"]:
|
||||
c = ws.cell(row=2, column=col, value=f"{MILESTONE_LABEL[key]} recovered")
|
||||
c.fill, c.font, c.border = MILESTONE_FILL[key], Font(bold=True, size=9), BOX
|
||||
c.alignment = Alignment(horizontal="center")
|
||||
col += 2
|
||||
|
||||
headers = ["Month", "Date", "Ramp %", "Gross Ad Revenue"]
|
||||
headers += [f"{p} ({spec['shares'][p]:.1%})" for p in model["parties"]]
|
||||
headers += [f"{p} cum." for p in model["parties"]]
|
||||
|
||||
mparty = spec["milestone_party"] or "MPM"
|
||||
detail_start = len(headers) + 1
|
||||
if spec["milestones"] or model["monthly_recurring"]:
|
||||
headers.append(f"{mparty} recurring cost")
|
||||
headers.append(f"{mparty} net")
|
||||
if spec["interest"]:
|
||||
headers += ["Interest", "Balance"]
|
||||
cum_net_col = None
|
||||
if spec["milestones"]:
|
||||
headers.append(f"{mparty} cumulative net")
|
||||
cum_net_col = len(headers)
|
||||
|
||||
hdr_row = 4
|
||||
for i, h in enumerate(headers, start=1):
|
||||
_hdr(ws, hdr_row, i, h)
|
||||
# Band the party columns so the split reads at a glance.
|
||||
ws.merge_cells(start_row=hdr_row - 1, start_column=5,
|
||||
end_row=hdr_row - 1, end_column=4 + 2 * len(model["parties"]))
|
||||
c = ws.cell(row=hdr_row - 1, column=5, value="Revenue Distribution (monthly, then cumulative)")
|
||||
c.fill, c.font = SUB_FILL, SUB_FONT
|
||||
c.alignment = Alignment(horizontal="center")
|
||||
if detail_start <= len(headers):
|
||||
ws.merge_cells(start_row=hdr_row - 1, start_column=detail_start,
|
||||
end_row=hdr_row - 1, end_column=len(headers))
|
||||
c = ws.cell(row=hdr_row - 1, column=detail_start,
|
||||
value=f"{mparty} Position" + (" and Debt Service" if spec["interest"] else ""))
|
||||
c.fill, c.font = SUB_FILL, SUB_FONT
|
||||
c.alignment = Alignment(horizontal="center")
|
||||
|
||||
first = hdr_row + 1
|
||||
np_ = len(model["parties"])
|
||||
for idx, row in enumerate(model["rows"]):
|
||||
r = first + idx
|
||||
ws.cell(row=r, column=1, value=row["month"]).alignment = Alignment(horizontal="center")
|
||||
ws.cell(row=r, column=2, value=row["date"]).alignment = Alignment(horizontal="center")
|
||||
c = ws.cell(row=r, column=3, value=row["ramp"]); c.number_format = PCT
|
||||
c = ws.cell(row=r, column=4, value=row["gross"]); c.number_format = MONEY0
|
||||
for j, p in enumerate(model["parties"]):
|
||||
c = ws.cell(row=r, column=5 + j, value=row["parties"][p]); c.number_format = MONEY0
|
||||
c = ws.cell(row=r, column=5 + np_ + j, value=row["cum_parties"][p])
|
||||
c.number_format = MONEY0
|
||||
col = detail_start
|
||||
if spec["milestones"] or model["monthly_recurring"]:
|
||||
c = ws.cell(row=r, column=col, value=-row["recurring_charge"]); c.number_format = MONEY0
|
||||
c = ws.cell(row=r, column=col + 1, value=row["net"]); c.number_format = MONEY0
|
||||
col += 2
|
||||
if spec["interest"]:
|
||||
c = ws.cell(row=r, column=col, value=-row["interest"]); c.number_format = MONEY0
|
||||
c = ws.cell(row=r, column=col + 1, value=row["balance"]); c.number_format = MONEY0
|
||||
col += 2
|
||||
if cum_net_col:
|
||||
c = ws.cell(row=r, column=cum_net_col, value=row["cum_net"])
|
||||
c.number_format = MONEY0
|
||||
c.font = Font(bold=True)
|
||||
if idx % 2 == 1:
|
||||
for cc in range(1, len(headers) + 1):
|
||||
if not ws.cell(row=r, column=cc).fill.fgColor.rgb or \
|
||||
ws.cell(row=r, column=cc).fill.fgColor.rgb == "00000000":
|
||||
ws.cell(row=r, column=cc).fill = PatternFill("solid", fgColor=LIGHT)
|
||||
|
||||
if cum_net_col:
|
||||
for key in ["cogs", "sale", "msrp"]:
|
||||
if key not in spec["milestones"]:
|
||||
continue
|
||||
hit = model["crossings"][key]
|
||||
if hit:
|
||||
cell = ws.cell(row=first + hit - 1, column=cum_net_col)
|
||||
cell.fill, cell.border = MILESTONE_FILL[key], BOX
|
||||
|
||||
for i, w in enumerate([8, 10, 9, 17], start=1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = w
|
||||
for i in range(5, len(headers) + 1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = 15
|
||||
ws.freeze_panes = ws.cell(row=first, column=3)
|
||||
return ws
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Cost basis sheet (2A only)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def write_cost_basis(wb, model):
|
||||
ws = wb.create_sheet("Cost Basis")
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws["A1"] = "Odoo Line-Item Cost Basis"
|
||||
ws["A1"].font = TITLE_FONT
|
||||
ws["A2"] = f"Source: {model['cfg'].get('sale_order','')}"
|
||||
ws["A2"].font = Font(italic=True, size=10)
|
||||
|
||||
headers = ["Line Item", "Qty", "MSRP / Unit", "Extended MSRP", "Disc %",
|
||||
"Net Subtotal", "Margin", "COGS", "Type"]
|
||||
hdr_row = 4
|
||||
for i, h in enumerate(headers, start=1):
|
||||
_hdr(ws, hdr_row, i, h)
|
||||
|
||||
r = hdr_row + 1
|
||||
for gname, group in [("One-Time (financed capital)", model["one_time"]),
|
||||
("Recurring (annual, ongoing)", model["recurring"])]:
|
||||
if not group:
|
||||
continue
|
||||
_band(ws, r, gname, len(headers))
|
||||
r += 1
|
||||
for l in group:
|
||||
cogs = l["net_subtotal"] - l["margin"]
|
||||
ws.cell(row=r, column=1, value=l["name"])
|
||||
ws.cell(row=r, column=2, value=l["qty"])
|
||||
for col, val, fmt in [(3, l["msrp_unit"], MONEY),
|
||||
(4, l["msrp_unit"] * l["qty"], MONEY),
|
||||
(5, l.get("discount", 0) / 100.0, PCT),
|
||||
(6, l["net_subtotal"], MONEY),
|
||||
(7, l["margin"], MONEY), (8, cogs, MONEY)]:
|
||||
cc = ws.cell(row=r, column=col, value=val); cc.number_format = fmt
|
||||
ws.cell(row=r, column=9,
|
||||
value="Recurring/yr" if l.get("recurring") else "One-time")
|
||||
if l["margin"] == 0 and l["net_subtotal"] > 0:
|
||||
ws.cell(row=r, column=7).font = Font(color="B00000", bold=True)
|
||||
elif abs(l["margin"] - l["net_subtotal"]) < 1e-6 and l["net_subtotal"] > 0:
|
||||
ws.cell(row=r, column=8).font = Font(color="B00000", bold=True)
|
||||
ws.cell(row=r, column=9, value="One-time — no cost set, verify")
|
||||
r += 1
|
||||
msrp, net, cogs = line_totals(group)
|
||||
ws.cell(row=r, column=1, value=f"Subtotal — {gname}").font = LABEL_FONT
|
||||
for col, val in [(4, msrp), (6, net), (8, cogs)]:
|
||||
cc = ws.cell(row=r, column=col, value=val)
|
||||
cc.number_format, cc.font = MONEY, LABEL_FONT
|
||||
cc.border = Border(top=THIN, bottom=Side(style="double"))
|
||||
r += 2
|
||||
|
||||
t = model["totals"]
|
||||
ws.cell(row=r, column=1, value="TOTAL CONTRACT (yr 1 as quoted)").font = Font(bold=True, size=11)
|
||||
for col, val in [(4, t["contract_msrp"]), (6, t["contract_net"]), (8, t["contract_cogs"])]:
|
||||
cc = ws.cell(row=r, column=col, value=val)
|
||||
cc.number_format, cc.font, cc.fill = MONEY, Font(bold=True, size=11), SUB_FILL
|
||||
r += 2
|
||||
mr = model["monthly_recurring"]
|
||||
_note(ws, r, f"Recurring cost carried monthly against {model['recurring_bearer']}: "
|
||||
f"${mr:,.2f}/mo (${mr * 12:,.2f}/yr, ${mr * model['horizon']:,.2f} over "
|
||||
f"{model['horizon']} months). Red COGS figures are lines with no standard "
|
||||
f"cost in Odoo — the true COGS target is higher than shown.", 9, 30)
|
||||
|
||||
ws.column_dimensions["A"].width = 62
|
||||
for col in "BCDEFGH":
|
||||
ws.column_dimensions[col].width = 15
|
||||
ws.column_dimensions["I"].width = 30
|
||||
return ws
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Assumptions sheet
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def write_assumptions(wb, model):
|
||||
cfg, spec = model["cfg"], model["spec"]
|
||||
ws = wb.create_sheet("Assumptions")
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws["A1"] = f"Model {spec['key']} — Assumptions"
|
||||
ws["A1"].font = TITLE_FONT
|
||||
ws.column_dimensions["A"].width = 44
|
||||
ws.column_dimensions["B"].width = 22
|
||||
ws.column_dimensions["C"].width = 54
|
||||
|
||||
r = 3
|
||||
ad = cfg["ad_revenue"]
|
||||
|
||||
def kv(label, value, note="", fmt=None):
|
||||
nonlocal r
|
||||
ws.cell(row=r, column=1, value=label).font = LABEL_FONT
|
||||
c = ws.cell(row=r, column=2, value=value)
|
||||
if fmt:
|
||||
c.number_format = fmt
|
||||
ws.cell(row=r, column=3, value=note).font = Font(size=9, color="666666")
|
||||
r += 1
|
||||
|
||||
_band(ws, r, "Advertising Revenue (per proposal)", 3); r += 1
|
||||
for k, label, fmt in [("screen_count", "Screens in proposal", "#,##0"),
|
||||
("monthly_onboardings", "Monthly on-boardings", "#,##0"),
|
||||
("journey_minutes", "Avg journey time (min)", "0.0"),
|
||||
("ad_slots", "15-second ad slots", "#,##0"),
|
||||
("monthly_impressions", "Total monthly impressions", "#,##0"),
|
||||
("fill_rate", "Assumed blended fill rate", PCT)]:
|
||||
if k in ad:
|
||||
kv(label, ad[k], "", fmt)
|
||||
kv("Gross monthly ad revenue", ad["monthly_gross"], "Steady-state total", MONEY)
|
||||
kv("Gross annual ad revenue", ad.get("annual_gross", ad["monthly_gross"] * 12), "", MONEY)
|
||||
r += 1
|
||||
|
||||
if ad.get("sources"):
|
||||
_band(ws, r, "Revenue Sources", 3); r += 1
|
||||
for s in ad["sources"]:
|
||||
ws.cell(row=r, column=1, value=s["name"])
|
||||
c = ws.cell(row=r, column=2, value=s["monthly"]); c.number_format = MONEY
|
||||
bits = []
|
||||
if s.get("cpm"):
|
||||
bits.append(f"CPM ${s['cpm']:.2f}")
|
||||
if s.get("fill"):
|
||||
bits.append(f"{s['fill']:.0%} fill")
|
||||
ws.cell(row=r, column=3, value=", ".join(bits)).font = Font(size=9, color="666666")
|
||||
r += 1
|
||||
r += 1
|
||||
|
||||
_band(ws, r, "Revenue Split", 3); r += 1
|
||||
for p, s in spec["shares"].items():
|
||||
kv(p, s, "", PCT)
|
||||
r += 1
|
||||
|
||||
_band(ws, r, "Model Parameters", 3); r += 1
|
||||
kv("Horizon", f"{model['horizon']} months")
|
||||
kv("Ramp length", f"{cfg.get('ramp_months', 12)} months",
|
||||
"Linear ramp reflecting the proposal's fill-rate caveat")
|
||||
kv("Month-1 revenue", cfg.get("ramp_start_pct", 0.25),
|
||||
"Fraction of steady-state at launch", PCT)
|
||||
fin = model["finance"]
|
||||
if fin["active"]:
|
||||
kv("Capital financed by", "MPM", "Principal is MPM's one-time COGS outlay")
|
||||
kv("Principal", fin["principal"], "", MONEY)
|
||||
kv("Annual cost of money", fin["rate"],
|
||||
"Interest accrues monthly on the declining balance", PCT)
|
||||
kv("Debt service", "MPM share, interest first",
|
||||
"Milestones clear only after capital and accrued interest are recovered")
|
||||
else:
|
||||
kv("Capital financed by",
|
||||
"Customer" if spec["key"] == "2C" else "MPM",
|
||||
"No interest modeled — " + ("the customer purchased the system outright"
|
||||
if spec["key"] == "2C"
|
||||
else "this variant is revenue-only"))
|
||||
kv("Recurring licensing borne by", model["recurring_bearer"],
|
||||
f"${model['monthly_recurring']:,.2f}/mo at "
|
||||
f"{'vendor cost' if model['recurring_bearer'] == 'MPM' else 'invoiced price'}")
|
||||
kv("Client contribution",
|
||||
sum(c["amount"] for c in cfg.get("client_contributions", [])) or 0,
|
||||
"Deposits or milestone payments offsetting financed capital", MONEY)
|
||||
r += 1
|
||||
|
||||
_band(ws, r, "Exclusions and Caveats", 3); r += 1
|
||||
notes = list(cfg.get("notes", []))
|
||||
for s in cfg.get("surges", []):
|
||||
if not s.get("enabled"):
|
||||
notes.append(f"{s.get('label', 'Event surge')} excluded from base model "
|
||||
f"(${s['total']:,.0f} over {s['months']} months) — treated as upside.")
|
||||
notes.append("Ad revenue is a forecast, not a contracted receivable. Fill rates may be "
|
||||
"materially lower until repeat advertisers and programmatic demand establish.")
|
||||
if spec["milestones"]:
|
||||
notes.append("Payoff targets represent the one-time capital at risk. Recurring "
|
||||
"licensing runs as an ongoing monthly charge for the full horizon.")
|
||||
if spec["key"] == "2C":
|
||||
notes.append("The End User purchased the system, so the MSRP milestone measures the "
|
||||
"customer's own payback from their 70% share — not MPM's return.")
|
||||
notes.append("This model pairs one advertising proposal with one sales order. Its revenue "
|
||||
"figures are not transferable to any other deployment.")
|
||||
for n in notes:
|
||||
ws.cell(row=r, column=1, value="• " + n).font = Font(size=9)
|
||||
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=3)
|
||||
ws.cell(row=r, column=1).alignment = Alignment(wrap_text=True, vertical="top")
|
||||
ws.row_dimensions[r].height = 28
|
||||
r += 1
|
||||
return ws
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_workbook(cfg, model_key, path):
|
||||
model = build_model(cfg, model_key)
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
write_summary(wb, model)
|
||||
write_projection(wb, model)
|
||||
if model["spec"]["show_cost_basis"]:
|
||||
write_cost_basis(wb, model)
|
||||
write_assumptions(wb, model)
|
||||
wb.save(path)
|
||||
return model
|
||||
|
||||
|
||||
def recap(model, path):
|
||||
spec, fin = model["spec"], model["finance"]
|
||||
last = model["rows"][-1]
|
||||
print(f"\n{os.path.basename(path)} — Model {spec['key']}: {spec['title']}")
|
||||
print(" split: " + ", ".join(f"{p} {s:.1%}" for p, s in spec["shares"].items()))
|
||||
print(f" {model['horizon']}-mo distribution: " +
|
||||
", ".join(f"{p} ${last['cum_parties'][p]:,.0f}" for p in model["parties"]))
|
||||
if fin["active"]:
|
||||
print(f" financed ${fin['principal']:,.2f} @ {fin['rate']:.1%} → "
|
||||
f"interest ${fin['total_interest']:,.2f}")
|
||||
if fin["neg_amort_months"]:
|
||||
print(f" !! negative amortization months "
|
||||
f"{fin['neg_amort_months'][0]}–{fin['neg_amort_months'][-1]}")
|
||||
for key in spec["milestones"]:
|
||||
hit = model["crossings"][key]
|
||||
print(f" {MILESTONE_LABEL[key]:<10} ${model['targets'][key]:>11,.2f} → "
|
||||
f"{'month ' + str(hit) if hit else 'not reached'}")
|
||||
if not spec["milestones"]:
|
||||
print(" (revenue distribution only — no cost basis or milestones)")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("config")
|
||||
ap.add_argument("--model", "-m", default="all", choices=["2A", "2B", "2C", "all"])
|
||||
ap.add_argument("-o", "--output", help="Output path (single model only)")
|
||||
ap.add_argument("--outdir", "-d", default=".", help="Directory for --model all")
|
||||
ap.add_argument("--prefix", default=None, help="Filename prefix; defaults to the sales order")
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(args.config) as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
keys = ["2A", "2B", "2C"] if args.model == "all" else [args.model]
|
||||
prefix = args.prefix or cfg.get("sale_order", "cap_table")
|
||||
slug = {"2A": "2A_Internal_MPM_Financed_ROI",
|
||||
"2B": "2B_Internal_MPM_Financed_Revenue",
|
||||
"2C": "2C_Customer_Financed_Acquisition"}
|
||||
|
||||
p = None
|
||||
for key in keys:
|
||||
if args.output and len(keys) == 1:
|
||||
path = args.output
|
||||
else:
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
path = os.path.join(args.outdir, f"{prefix}_{slug[key]}.xlsx")
|
||||
model = build_workbook(cfg, key, path)
|
||||
recap(model, path)
|
||||
p = model["pairing"]
|
||||
|
||||
if p and p["proposal"] and p["quote"] and not p["ok"]:
|
||||
print(f"\n!! SCREEN COUNT MISMATCH: proposal {p['proposal']:g} vs quote "
|
||||
f"{p['quote']:g} — verify these describe the same deployment")
|
||||
elif p and not (p["proposal"] and p["quote"]):
|
||||
print("\n.. screen count not reconciled (set ad_revenue.screen_count and "
|
||||
"is_display on the display line)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user