Files
mpm-ad-roi-cap-table/SKILL.md
T

16 KiB
Raw Blame History

name, description
name description
mpm-ad-roi-cap-table Build a 60-month ROI / cap-table projection for MPM-financed digital signage hardware paid back out of advertising revenue. Use this whenever the user pairs an advertising revenue proposal (Passent Media / Galli Media, or any DOOH ad pro-forma) with the Odoo sales order for that same deployment and wants to know when the deal pays for itself — triggers include "ROI projection", "cap table", "payback model", "when does this pay off", "ad-financed hardware", "revenue share model", "how long to recover COGS", "free hardware paid by ads", "Passent proposal", "model this deal against ad revenue", or any request pairing an S##### quote with ad revenue numbers. Also use when the user asks what the ROI would be at MSRP versus at the discounted sale price, when they want the partner and end-user revenue split modeled (MPM / Passent / reseller / end user), when they want MPM-financed compared against customer-financed acquisition, or when they ask about the cost of money or interest on capital MPM fronts. One proposal models exactly one sales order — the skill models only the order the user names and never reuses one deal's ad revenue on another. Prefer this skill over generic spreadsheet work whenever MPM financing and advertising revenue appear together, even if the user does not say "ROI" or "cap table" explicitly.

MPM Advertising ROI Cap Table

What this models

A signage deployment generates advertising revenue, and that revenue is split among MPM, Passent, the reseller, and the end user. Who fronted the capital determines both the split and what "payback" even means, so the skill produces three distinct models from one set of inputs.

Model Split Carries Audience
2A Internal — MPM Financed, ROI & Payback MPM 30 / Passent 30 / Reseller 10 / End User 30 COGS, sale price and MSRP milestones against MPM's share, plus interest on the financed capital Internal — contains cost and margin
2B Internal — MPM Financed, Revenue Distribution same 30/30/10/30 Revenue only. No cost basis, no milestones, no margin Internal, but safe to share the numbers with partners
2C Customer-Financed Acquisition MPM 5 / Reseller 2.5 / Passent 22.5 / End User 70 MSRP milestone only, measured against the End User's 70% share Customer-facing — no COGS or sale price

Each is written as its own workbook. That separation is deliberate: 2A contains cost and margin data that must not reach a reseller or a customer, and the cheapest way to guarantee that is for the customer-facing file to never contain it. Do not merge them into one file to save an attachment.

The three payoff milestones, where a model carries them:

Milestone Meaning Fill
COGS Hard cost is recovered — the deal stops losing money Yellow
Project Sale Price The discounted contract value is recovered — MPM has earned what it would have booked on the sale Orange
Total MSRP Full undiscounted list value is recovered — the deal beat a full-price sale Green

In 2A these measure MPM's recovery of capital it put at risk. In 2C nothing is at risk for MPM, so the single MSRP milestone answers the customer's question instead: having bought at list, when does their 70% share pay them back? Keep that distinction sharp — the same green cell means different things in the two files, and the Summary sheet names the party it belongs to.

Cost of money

Capital fronted is capital not earning elsewhere, and management wants that visible rather than assumed away. In 2A the principal is MPM's one-time COGS — the actual cash out the door, not the price it would have sold for, because you cannot borrow your own margin. Interest accrues monthly on the declining balance at 12% APR by default, and MPM's monthly share services interest first, then principal.

A milestone therefore clears only when cumulative net recovers the target plus the interest accrued to that month. That definition makes the COGS milestone and the loan balance hitting zero the same event, which is a useful internal check: if the Summary reports a different month for "balance retired" and for the COGS milestone, something is wrong in the config.

Watch for negative amortization. During the ramp, MPM's share can be smaller than the interest accruing, so the balance grows for the first months before it falls. The script detects this and flags it in red on the Summary. It is not necessarily fatal, but it is exactly the thing a lender or a CFO will ask about, so never let it pass silently.

2B carries no interest because it is a revenue projection, not a payback model. 2C carries none because the customer bought the system outright and MPM has no capital at risk.

One proposal, one sales order

This is the constraint that governs everything else, so establish it before doing any math.

An advertising proposal is not a market rate card. Passent prices a specific deployment — these vehicles, this ridership, this journey length, this slot count, this fill assumption. The $22,138/month in the John Wayne proposal is a property of eleven screens on an airport shuttle loop carrying 64,000 boardings a month. It says nothing about what a wayside deployment in Annapolis or a fleet retrofit in Columbus would earn, and using it there — or scaling it, or borrowing "a ballpark from a similar market" — produces a number that looks rigorous and is fiction.

So: model only the sales order the user names, using only the proposal written for that same deployment. Concretely:

  • One proposal pairs with one sales order. Do not fan a single proposal across several quotes, and do not roll multiple quotes into one projection.
  • If the user names a sales order and there is no advertising proposal for that project, stop and say so. Ask for the proposal. Do not substitute figures from another deal, another agency, a rate card, or your own estimate of what that market might support — and do not proceed on a verbal ballpark unless the user is clearly quoting Passent's numbers for this deployment and confirms that is what they mean.
  • If the user asks you to reuse an earlier model for a new order, treat that as a request for a new proposal, not a copy of the old one.

Verify the pairing before you build

Even when both documents are in hand, confirm they describe the same deployment. Check:

  • Customer and project reference — the proposal's PROJECT/REF and the quote's customer should name the same agency and site.
  • Screen count — the proposal's slot and impression math assumes a screen count. It should reconcile with the display quantity on the quote. Eleven M27V units on S01027 against a proposal built on an eleven-screen shuttle loop is a match.
  • Deployment type — mobile/onboard versus wayside versus LED. A proposal built on onboard journey time cannot price a wayside deployment, where dwell time and impression math work completely differently.

When something doesn't reconcile, raise it rather than modeling through it. A mismatch usually means the proposal predates a scope change, and the model would quietly inherit the stale assumption.

Workflow

1. Gather the ad revenue proposal

Read the Passent Media / Galli Media proposal (usually a one-page PDF). Extract:

  • Monthly on-boardings, average journey time, ad slot count, total monthly impressions
  • Each revenue line: source name, impressions/K, CPM, % fill, monthly gross, annual gross
  • The TOTALS row — this is the gross ad revenue figure the whole model runs on
  • Any event surge block (e.g. "2028 LA 28 Games 2.5 Months"). Capture it but leave it disabled by default — surges are upside, not a payback assumption.

Note the proposal's own caveat about fill rates being lower early. That is why the model ramps (step 4).

2. Pull the sales order from Odoo

Pull the one order the user named — not sibling quotes for the same customer, not a superseded revision. If several revisions exist, ask which one is live rather than guessing. The margin fields are what make this work; do not try to reconstruct cost from product records.

mcp__plugin_odoo-mpm_odoo-mpm__search_sales_orders(query="S01027")
mcp__plugin_odoo-mpm_odoo-mpm__odoo_search_read(
  model="sale.order.line",
  domain=[["order_id","=",<id>]],
  fields=["name","product_id","product_uom_qty","price_unit","discount",
          "price_subtotal","margin","margin_percent","purchase_price",
          "display_type","sequence"],
  limit=60)

Per line:

  • MSRP extended = price_unit × product_uom_qty (Odoo's price_unit is the MSRP shown on the quote; the discount is applied separately)
  • Net / sale price = price_subtotal
  • COGS = price_subtotal margin (Andy's shorthand; equals purchase_price × qty)

Skip rows where display_type is line_section or line_note — they are headers, not money.

Do not silently trust a margin of 0 on a line with real dollars. [DBE-Dispatch] and [PS-Travel] are genuine pass-throughs at 100% cost, but a zero margin on a hardware line usually means cost data is missing in Odoo. Flag those lines to the user rather than modeling them as free money.

3. Classify one-time vs recurring — and confirm it

This is the single most consequential judgment call in the model, so surface it rather than deciding silently. An annual license booked once on the quote is a cost MPM pays every year for five years. Treating it as one-time understates the drag by roughly 4× over the horizon.

Recurring signals — product code prefixes LIC-, SUP-, API, or names containing license, licensing, support agreement, hosting, subscription, SaaS, annual, maintenance, extended warranty. Everything else (hardware, cabling, mounting kits, DBE installation, provisioning, PM/design/onsite labor, travel) is one-time.

Present the classification as a short table and ask the user to confirm before building. Note the effective monthly cost so it is easy to sanity-check — e.g. MPTV Professional at $199/unit/year across 11 units is $182.42/month, and Andy's mental benchmark is "roughly $20 per controller per month."

Recurring lines are excluded from the payoff targets (which represent capital MPM fronts) and instead run as a monthly expense against cash flow for the full horizon. This avoids double-counting year one and keeps the ongoing-cost drag visible.

4. Set the assumptions

Defaults, all overridable — state them back to the user:

  • Horizon: 60 months
  • Ramp: linear from 25% of steady-state in month 1 to 100% by month 12, reflecting the proposal's own fill-rate caveat
  • Splits: the 2A/2B and 2C presets above. They are built into the script; override them in the config only when a specific deal was negotiated differently, and say so if you do.
  • Cost of money: 12% APR, declining balance, principal = one-time COGS (2A only)
  • Recurring licensing: borne by MPM at vendor cost in 2A and 2B; borne by the End User at invoiced price in 2C, since a customer who bought the system is paying its renewals
  • Client contribution: $0 by default. If the deal is a hybrid where the client pays a deposit, enter those as dated contributions.
  • Event surges: off

Every share set must sum to 100% of gross. The script refuses to build otherwise, because a split that doesn't allocate every dollar means someone's cut was dropped or double-counted.

5. Build the config and run the script

Write one JSON config and run the bundled builder once — it emits all three workbooks from the same inputs, which is what keeps them consistent with each other.

python3 scripts/build_cap_table.py config.json --model all --outdir ./out

Use --model 2A with -o when the user wants a single variant rebuilt. The console recap prints each model's split, 60-month distribution, interest, and milestone months, so you can check the numbers before opening anything.

references/config_schema.md documents every field, and references/worked_example.md walks through the John Wayne Airport deal end to end with the numbers it should produce — read it if you are unsure how a field maps to the source documents.

6. Sanity-check before delivering

Fast checks that catch the errors that actually happen:

  • The workbook names one sales order and one ad proposal, and they are the pair the user asked about. Screen count reconciles between them.
  • Sum of line MSRP ≥ sum of net ≥ sum of COGS. If not, a discount or margin was misread.
  • Net subtotal across all lines equals the quote's printed Total.
  • Each model's party shares sum to 100%, and the party columns in any month add up to that month's gross.
  • In 2A, the month the loan balance reaches zero equals the COGS milestone month. A discrepancy means the principal basis or the interest config is off.
  • Month-60 cumulative for a party is roughly share × monthly_gross × ~57 (the ramp costs about three months of revenue), less that party's recurring charge.
  • 2B and 2C contain no COGS, margin, or sale-price figures anywhere. Open them and confirm. This is the check that matters most, because the cost of getting it wrong is showing a reseller or a customer MPM's margin.

Then present the workbooks with present_files and lead with the answer: for 2A the payoff months and interest paid, for 2C the customer's payback month. Andy reads the summary first and opens the monthly grid only when a number looks off.

Workbook structure

2A — Summary, Monthly Projection, Cost Basis, Assumptions. 2B — Summary, Monthly Projection, Assumptions. 2C — Summary, Monthly Projection, Assumptions.

  1. Summary — deal identity, the pairing check, the distribution table (each party's share, steady-state monthly, year one, 60-month total), then for models that carry them: the financing block (principal, rate, total interest, month the balance retires), the milestone table, and the ROI table.
  2. Monthly Projection — the 60-row grid. Month, date, ramp %, gross, then a column per party and a cumulative column per party. Models with a milestone party add that party's recurring charge, net, interest and balance where applicable, and a cumulative net column where milestone crossings are filled yellow/orange/green.
  3. Cost Basis (2A only) — every Odoo line with qty, MSRP unit, extended MSRP, discount %, net, margin, COGS, and the recurring flag. This is the audit trail; when someone challenges a number, this is the sheet you open. Lines whose margin equals their full net are flagged red — Odoo has no standard cost for them, so the COGS target is optimistic.
  4. Assumptions — ad revenue inputs, the split, ramp parameters, financing terms, who bears recurring licensing, and what was excluded and why.

Reading the result honestly

A model like this is persuasive precisely because it is specific, so keep the uncertainty visible rather than burying it. Ad revenue is a forecast built on an assumed 65% fill rate that the proposal itself warns may not hold early. Report payoff as "month 13 on a 30% share with a 12-month ramp and 12% money," not "month 13." When a milestone isn't reached inside 60 months, say so plainly and show how far short it lands — that is a more useful answer than extending the horizon until it looks good.

The comparison management will actually want is between 2A and 2C, and it is not simply "which nets MPM more." Financing (2A) puts real cash at risk for a real interest cost and returns a 30% share for five years; a customer-financed sale (2C) books the hardware immediately at 5% of ad revenue thereafter. Frame it as a capital allocation question — what MPM gives up by tying up the money versus what the larger share is worth — and give the interest figure and the negative-amortization window as explicit costs rather than letting the larger 60-month number speak for itself.

When presenting 2C to a customer, the honest framing is that their payback depends on a forecast they do not control. Say what the model assumes and what happens if fill comes in low, rather than handing over a payback month as though it were a term of sale.