diff --git a/scripts/build_cap_table.py b/scripts/build_cap_table.py new file mode 100644 index 0000000..afdd209 --- /dev/null +++ b/scripts/build_cap_table.py @@ -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())