Files
4ventures-client-portal/src/lib/simulate.ts
4Ventures 965a58540e Ship monthly goals, calendar creative zoom, and legal pages.
Editable targets vs actual, pipeline review-only with client reject notes, and published pieces opening from the calendar day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:17:15 +02:00

144 lines
4.3 KiB
TypeScript

/** Deterministic budget simulation from tenant ad_daily + kpi_weekly. */
import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics';
import { normalizePlatform } from '@/lib/ad-tracker-range';
export type SimChannel = 'meta' | 'google' | 'blended';
export type SimBaseline = {
historical_cpl: number;
conversion_rate_pct: number;
avg_ticket: number;
sample_spend: number;
sample_leads: number;
sample_deals: number;
sample_revenue: number;
channel: string;
source: 'tenant_metrics';
};
export type SimBand = {
leads: number;
deals: number;
revenue: number;
roas: number;
};
export type SimResult = {
target_channel: string;
baseline: SimBaseline;
conservative_p10: SimBand;
expected_p50: SimBand;
optimistic_p90: SimBand;
};
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
function matchChannel(platform: string | undefined, ch: SimChannel): boolean {
if (ch === 'blended') return true;
const p = normalizePlatform(platform);
if (ch === 'meta') return p === 'meta';
if (ch === 'google') return p === 'google';
return false;
}
function band(
spend: number,
cpl: number,
convRate: number,
ticket: number,
cplMult: number,
convMult: number
): SimBand {
const effectiveCpl = Math.max(1, cpl * cplMult);
const leads = Math.round(spend / effectiveCpl);
const deals = Math.round(leads * Math.min(0.95, convRate * convMult));
const revenue = deals * ticket;
const roas = spend > 0 ? Math.round((revenue / spend) * 100) / 100 : 0;
return { leads, deals, revenue: Math.round(revenue * 100) / 100, roas };
}
/** Build simulation from this tenant's warehouse rows. */
export function simulateFromTenantMetrics(opts: {
spend: number;
channel: SimChannel;
daily: AdDailyRow[];
weekly: KpiWeeklyRow[];
}): SimResult {
const { spend, channel, daily, weekly } = opts;
const dailyCh = daily.filter((r) => matchChannel(r.platform, channel));
const srcDaily = dailyCh.length ? dailyCh : daily;
const sampleSpend = srcDaily.reduce((a, r) => a + n(r.spend), 0);
const sampleLeads = srcDaily.reduce(
(a, r) => a + n(r.platform_leads ?? r.conversions),
0
);
const weeklyCh = weekly.filter((r) => {
if (channel === 'blended') return true;
const c = (r.channel || '').toLowerCase();
if (channel === 'meta') {
return (
c.includes('meta') ||
c.includes('facebook') ||
c.includes('instagram') ||
c.includes('fb')
);
}
if (channel === 'google') {
return c.includes('google') || c.includes('search') || c.includes('pmax');
}
return true;
});
const srcWeekly = weeklyCh.length ? weeklyCh : weekly;
const wSpend = srcWeekly.reduce((a, r) => a + n(r.total_spend), 0);
const wLeads = srcWeekly.reduce((a, r) => a + n(r.total_leads), 0);
const wDeals = srcWeekly.reduce((a, r) => a + n(r.cohort_deals_won), 0);
const wRevenue = srcWeekly.reduce((a, r) => a + n(r.cohort_revenue_won), 0);
const spendBase = sampleSpend > 0 ? sampleSpend : wSpend;
const leadsBase = sampleLeads > 0 ? sampleLeads : wLeads;
let historicalCpl =
leadsBase > 0
? spendBase / leadsBase
: sampleSpend > 0
? sampleSpend / Math.max(1, sampleLeads)
: 25;
if (!Number.isFinite(historicalCpl) || historicalCpl <= 0) historicalCpl = 25;
const convRate =
wLeads > 0
? wDeals / wLeads
: wDeals > 0 && leadsBase > 0
? wDeals / leadsBase
: 0.12;
const avgTicket =
wDeals > 0 ? wRevenue / wDeals : wRevenue > 0 ? wRevenue : 800;
const baseline: SimBaseline = {
historical_cpl: Math.round(historicalCpl * 100) / 100,
conversion_rate_pct: Math.round(Math.max(0.01, convRate) * 1000) / 10,
avg_ticket: Math.round(avgTicket * 100) / 100,
sample_spend: Math.round(spendBase * 100) / 100,
sample_leads: Math.round(leadsBase),
sample_deals: Math.round(wDeals),
sample_revenue: Math.round(wRevenue * 100) / 100,
channel: channel === 'blended' ? 'blended' : channel,
source: 'tenant_metrics',
};
const cr = Math.max(0.01, convRate);
return {
target_channel: baseline.channel,
baseline,
conservative_p10: band(spend, historicalCpl, cr, avgTicket, 1.35, 0.75),
expected_p50: band(spend, historicalCpl, cr, avgTicket, 1.0, 1.0),
optimistic_p90: band(spend, historicalCpl, cr, avgTicket, 0.75, 1.2),
};
}