Files
mpm-ad-roi-cap-table/references/config_schema.md
T

6.0 KiB
Raw Blame History

Config schema for build_cap_table.py

A single JSON file. Everything except ad_revenue.monthly_gross and lines has a default.

{
  "project_name": "SNA John Wayne Airport Shuttle — Onboard Infotainment",
  "customer": "John Wayne Airport-Orange County",
  "sale_order": "S01027",
  "ad_proposal_ref": "Passent Media JWA001-071425 (14-Jul-2026)",
  "prepared_on": "2026-09-11",

  "start_date": "2027-03",
  "horizon_months": 60,
  "ramp_months": 12,
  "ramp_start_pct": 0.25,

  "finance": {
    "annual_rate": 0.12,
    "principal_basis": "cogs",
    "method": "declining_balance"
  },

  "client_contributions": [
    {"month": 1, "amount": 0}
  ],

  "ad_revenue": {
    "screen_count": 11,
    "monthly_onboardings": 64000,
    "journey_minutes": 6,
    "ad_slots": 24,
    "monthly_impressions": 1536000,
    "fill_rate": 0.65,
    "monthly_gross": 22138,
    "annual_gross": 265651,
    "sources": [
      {"name": "TransitAds.co (6 local ads/mo)", "monthly": 12000, "fill": 0.25},
      {"name": "Programmatic Exchange Min. (Ad Floor)", "monthly": 2765, "cpm": 12.00, "fill": 0.15},
      {"name": "National Direct Buy", "monthly": 7373, "cpm": 24.00, "fill": 0.20}
    ]
  },

  "surges": [
    {"label": "2028 LA Games", "start_month": 18, "months": 2.5,
     "total": 334200, "enabled": false}
  ],

  "notes": [
    "Optional MP.TV Fully Managed Support is included as quoted."
  ],

  "lines": [
    {
      "name": "M27V — Ruggedized, Intelligent Bus Display (Direct, G4)",
      "qty": 11,
      "msrp_unit": 4096.00,
      "discount": 25.0,
      "net_subtotal": 33792.00,
      "margin": 9918.98,
      "recurring": false,
      "is_display": true
    }
  ]
}

Models and splits

The three model variants are presets in the script — you do not declare them in the config. Run --model all and you get all three; the splits, milestones, interest behaviour and sheet set come from the preset:

Key Split Milestones Party Interest Recurring borne by
2A MPM 30 / Passent 30 / Reseller 10 / End User 30 COGS, sale, MSRP MPM yes MPM, at vendor cost
2B same none no MPM, at vendor cost
2C MPM 5 / Reseller 2.5 / Passent 22.5 / End User 70 MSRP End User no End User, at invoiced price

Override a preset only when a specific deal was negotiated differently:

"models": {
  "2A": {"shares": {"MPM": 0.35, "Passent": 0.30, "Reseller": 0.10, "End User": 0.25}}
}

Shares must sum to exactly 1.0 — the script raises rather than building a split that loses or duplicates a party's dollars.

Financing

finance.annual_rate is the cost of money (default 0.12). principal_basis picks which target the loan is drawn against — cogs (default, and the right answer: it is the cash MPM actually spends), sale, or msrp. Set principal_amount instead to hard-code a figure, e.g. when only part of the scope is financed.

Interest applies to 2A only. It accrues monthly on the declining balance; MPM's share services interest first, then principal. Milestones clear when cumulative net covers the target plus interest accrued to date, which makes the COGS milestone and the balance reaching zero the same month. If those two disagree in the output, the config is wrong.

The pairing fields

sale_order and ad_proposal_ref identify the one-to-one pair this model represents, and both appear at the top of the Summary sheet. A workbook that does not name both is not finished.

ad_revenue.screen_count and is_display drive an automatic reconciliation: the screen count the proposal's impression math assumes should equal the display quantity on the quote. Set is_display: true on the display/panel line (or lines) and the script compares the two, printing a warning and flagging the Summary sheet in red on a mismatch. A mismatch means the proposal and the quote describe different deployments — resolve it before delivering the model rather than shipping a projection built on a foreign assumption.

Field notes

lines — one object per Odoo sale.order.line, excluding line_section and line_note rows. Map straight across:

Config field Odoo field
name name (first line is enough; long descriptions clutter the sheet)
qty product_uom_qty
msrp_unit price_unit
discount discount (percent, e.g. 25.0)
net_subtotal price_subtotal
margin margin
recurring your classification — see SKILL.md step 3

COGS is derived as net_subtotal margin, so there is no separate cost field to fill in.

recurring: true means the line's net_subtotal and cost represent one year. The script divides it by 12 and charges it every month for the whole horizon, and excludes recurring lines from the payoff targets. Which basis is charged depends on who bears it: MPM pays vendor cost (net_subtotal margin), the End User pays the invoiced price (net_subtotal). The preset decides the bearer per model.

start_date — the month the screens go live and ads start, not the contract date. The quote's terms give a six-month standard delivery timeline, so this is usually the quotation month plus six.

ramp_start_pct / ramp_months — month 1 runs at ramp_start_pct of steady-state and climbs linearly to 100% at ramp_months. With the defaults (25%, 12 months) the ramp costs roughly three months of revenue versus an instant-on assumption.

client_contributions — dated one-off credits against the financed balance. Use this for deposits, milestone payments, or an agency capital contribution. Multiple entries in the same month are summed.

surgestotal is spread evenly across months starting at start_month. Leave enabled: false for the base model; flip it on to produce an upside variant. A disabled surge still appears in the Assumptions sheet as a documented exclusion, which is the point.

client_contributions credit only the milestone party (MPM in 2A, the End User in 2C), since a deposit offsets the capital of whoever fronted it.