UNPKG

business-as-code

Version:

Primitives for expressing business logic and processes as code

839 lines (753 loc) 19.4 kB
/** * Standardized SaaS Metrics * * First-class types for common SaaS/subscription business metrics * with auto-calculation over time periods. * * @packageDocumentation */ import type { Currency, TimePeriod } from './types.js' // ============================================================================= // Time Period Types // ============================================================================= /** * Date range for metric calculations */ export interface DateRange { start: Date end: Date } /** * Time period with explicit dates */ export interface MetricPeriod { period: TimePeriod range: DateRange label?: string // e.g., "Q4 2024", "December 2024" } /** * Time-series data point */ export interface DataPoint<T = number> { timestamp: Date value: T metadata?: Record<string, unknown> } /** * Time series of metric values */ export interface TimeSeries<T = number> { metric: string unit: string dataPoints: DataPoint<T>[] aggregation?: 'sum' | 'avg' | 'min' | 'max' | 'last' | 'first' } // ============================================================================= // Revenue Metrics // ============================================================================= /** * Monthly Recurring Revenue (MRR) */ export interface MRR { total: number newMRR: number // From new customers expansionMRR: number // From upgrades contractionMRR: number // From downgrades churnedMRR: number // From cancellations reactivationMRR: number // From reactivations netNewMRR: number // newMRR + expansionMRR - contractionMRR - churnedMRR + reactivationMRR currency: Currency period: MetricPeriod } /** * Annual Recurring Revenue (ARR) */ export interface ARR { total: number fromMRR?: number // MRR * 12 contracted?: number // From annual contracts currency: Currency asOf: Date } /** * Net Revenue Retention (NRR) / Dollar-based Net Retention (DBNR) */ export interface NRR { rate: number // Percentage (e.g., 115 = 115%) startingMRR: number endingMRR: number expansion: number contraction: number churn: number period: MetricPeriod } /** * Gross Revenue Retention (GRR) */ export interface GRR { rate: number // Percentage (max 100%) startingMRR: number endingMRR: number contraction: number churn: number period: MetricPeriod } /** * Average Revenue Per User/Account */ export interface ARPU { value: number totalRevenue: number totalUsers: number currency: Currency period: MetricPeriod segment?: string // Optional segment (e.g., "enterprise", "smb") } /** * Revenue by segment/cohort */ export interface RevenueSegment { name: string mrr: number arr: number customers: number arpu: number growth: number // MoM or YoY growth rate currency: Currency } // ============================================================================= // Customer Metrics // ============================================================================= /** * Customer Acquisition Cost (CAC) */ export interface CAC { value: number totalSalesMarketingSpend: number newCustomersAcquired: number currency: Currency period: MetricPeriod byChannel?: Record<string, number> } /** * Customer Lifetime Value (LTV/CLV) */ export interface LTV { value: number arpu: number grossMargin: number // Percentage churnRate: number // Monthly churn rate averageLifetimeMonths: number currency: Currency } /** * LTV:CAC Ratio */ export interface LTVtoCAC { ratio: number ltv: number cac: number paybackMonths: number // CAC / (ARPU * Gross Margin) healthy: boolean // > 3 is generally healthy } /** * Churn metrics */ export interface Churn { // Customer churn (logo churn) customerChurnRate: number // Percentage customersLost: number customersStart: number // Revenue churn revenueChurnRate: number // Percentage (gross churn) mrrChurned: number // Net revenue churn (can be negative with good expansion) netRevenueChurnRate: number period: MetricPeriod } /** * Retention cohort */ export interface RetentionCohort { cohortDate: Date cohortLabel: string // e.g., "Jan 2024" initialCustomers: number initialMRR: number retentionByMonth: number[] // Array of retention rates by month revenueByMonth: number[] // Array of MRR by month } // ============================================================================= // Growth Metrics // ============================================================================= /** * Growth rate metrics */ export interface GrowthRate { mom: number // Month-over-month qoq: number // Quarter-over-quarter yoy: number // Year-over-year cagr?: number // Compound annual growth rate metric: string // What metric this growth rate is for period: MetricPeriod } /** * Quick ratio (growth efficiency) * (New MRR + Expansion MRR) / (Churned MRR + Contraction MRR) */ export interface QuickRatio { ratio: number newMRR: number expansionMRR: number churnedMRR: number contractionMRR: number healthy: boolean // > 4 is good, > 1 means growing period: MetricPeriod } // ============================================================================= // Efficiency Metrics // ============================================================================= /** * Magic Number * Net New ARR / Sales & Marketing Spend (previous quarter) */ export interface MagicNumber { value: number netNewARR: number salesMarketingSpend: number efficient: boolean // > 0.75 is efficient period: MetricPeriod } /** * Burn Multiple * Net Burn / Net New ARR */ export interface BurnMultiple { value: number netBurn: number netNewARR: number efficient: boolean // < 1.5 is good period: MetricPeriod } /** * Rule of 40 * Growth Rate + Profit Margin >= 40% */ export interface RuleOf40 { score: number revenueGrowthRate: number profitMargin: number // Or EBITDA margin passing: boolean // >= 40 is passing period: MetricPeriod } /** * SaaS Efficiency Score * Combines multiple efficiency metrics */ export interface EfficiencyScore { overall: number // 0-100 score components: { ltvCacRatio: number magicNumber: number quickRatio: number nrr: number ruleOf40: number } period: MetricPeriod } // ============================================================================= // Pipeline & Sales Metrics // ============================================================================= /** * Sales pipeline metrics */ export interface Pipeline { totalValue: number weightedValue: number // Probability-adjusted stages: PipelineStage[] velocity: number // Average days to close conversionRate: number // Win rate currency: Currency asOf: Date } /** * Pipeline stage */ export interface PipelineStage { name: string value: number count: number probability: number averageDaysInStage: number } /** * Sales velocity * (Opportunities * Win Rate * Average Deal Size) / Sales Cycle Length */ export interface SalesVelocity { value: number // Revenue per day opportunities: number winRate: number averageDealSize: number salesCycleLength: number // Days currency: Currency period: MetricPeriod } // ============================================================================= // Operational Metrics // ============================================================================= /** * Net Promoter Score */ export interface NPS { score: number // -100 to 100 promoters: number // 9-10 passives: number // 7-8 detractors: number // 0-6 responses: number responseRate?: number asOf: Date } /** * Customer health score */ export interface CustomerHealth { averageScore: number // 0-100 healthy: number // Count atRisk: number // Count critical: number // Count factors: HealthFactor[] asOf: Date } /** * Health factor */ export interface HealthFactor { name: string weight: number score: number } // ============================================================================= // Financial Summary // ============================================================================= /** * Comprehensive SaaS metrics snapshot */ export interface SaaSMetrics { // Revenue mrr: MRR arr: ARR nrr: NRR grr: GRR arpu: ARPU // Customers cac: CAC ltv: LTV ltvCac: LTVtoCAC churn: Churn // Growth growthRate: GrowthRate quickRatio: QuickRatio // Efficiency magicNumber?: MagicNumber burnMultiple?: BurnMultiple ruleOf40?: RuleOf40 // Operational nps?: NPS customerHealth?: CustomerHealth // Period period: MetricPeriod generatedAt: Date } // ============================================================================= // Calculation Functions // ============================================================================= /** * Calculate MRR from components */ export function calculateMRR(input: { newMRR: number expansionMRR: number contractionMRR: number churnedMRR: number reactivationMRR?: number previousMRR: number currency?: Currency period: MetricPeriod }): MRR { const reactivationMRR = input.reactivationMRR || 0 const netNewMRR = input.newMRR + input.expansionMRR - input.contractionMRR - input.churnedMRR + reactivationMRR const total = input.previousMRR + netNewMRR return { total, newMRR: input.newMRR, expansionMRR: input.expansionMRR, contractionMRR: input.contractionMRR, churnedMRR: input.churnedMRR, reactivationMRR, netNewMRR, currency: input.currency || 'USD', period: input.period, } } /** * Calculate ARR from MRR */ export function calculateARRFromMRR(mrr: number, currency: Currency = 'USD'): ARR { return { total: mrr * 12, fromMRR: mrr * 12, currency, asOf: new Date(), } } /** * Calculate NRR */ export function calculateNRR(input: { startingMRR: number expansion: number contraction: number churn: number period: MetricPeriod }): NRR { const endingMRR = input.startingMRR + input.expansion - input.contraction - input.churn const rate = input.startingMRR > 0 ? (endingMRR / input.startingMRR) * 100 : 0 return { rate, startingMRR: input.startingMRR, endingMRR, expansion: input.expansion, contraction: input.contraction, churn: input.churn, period: input.period, } } /** * Calculate GRR */ export function calculateGRR(input: { startingMRR: number contraction: number churn: number period: MetricPeriod }): GRR { const endingMRR = input.startingMRR - input.contraction - input.churn const rate = input.startingMRR > 0 ? Math.min((endingMRR / input.startingMRR) * 100, 100) : 0 return { rate, startingMRR: input.startingMRR, endingMRR, contraction: input.contraction, churn: input.churn, period: input.period, } } /** * Calculate CAC */ export function calculateCACMetric(input: { salesMarketingSpend: number newCustomers: number currency?: Currency period: MetricPeriod byChannel?: Record<string, { spend: number; customers: number }> }): CAC { const value = input.newCustomers > 0 ? input.salesMarketingSpend / input.newCustomers : 0 const result: CAC = { value, totalSalesMarketingSpend: input.salesMarketingSpend, newCustomersAcquired: input.newCustomers, currency: input.currency || 'USD', period: input.period, } if (input.byChannel) { const byChannel: Record<string, number> = {} for (const [channel, data] of Object.entries(input.byChannel)) { byChannel[channel] = data.customers > 0 ? data.spend / data.customers : 0 } result.byChannel = byChannel } return result } /** * Calculate LTV */ export function calculateLTVMetric(input: { arpu: number grossMargin: number churnRate: number currency?: Currency }): LTV { // LTV = (ARPU * Gross Margin) / Churn Rate const averageLifetimeMonths = input.churnRate > 0 ? 1 / input.churnRate : 0 const value = input.churnRate > 0 ? (input.arpu * input.grossMargin) / 100 / input.churnRate : 0 return { value, arpu: input.arpu, grossMargin: input.grossMargin, churnRate: input.churnRate, averageLifetimeMonths, currency: input.currency || 'USD', } } /** * Calculate LTV:CAC ratio */ export function calculateLTVtoCACRatio(ltv: LTV, cac: CAC): LTVtoCAC { const ratio = cac.value > 0 ? ltv.value / cac.value : 0 const paybackMonths = ltv.arpu > 0 && ltv.grossMargin > 0 ? cac.value / ((ltv.arpu * ltv.grossMargin) / 100) : 0 return { ratio, ltv: ltv.value, cac: cac.value, paybackMonths, healthy: ratio >= 3, } } /** * Calculate Quick Ratio */ export function calculateQuickRatioMetric(mrr: MRR): QuickRatio { const growth = mrr.newMRR + mrr.expansionMRR const loss = mrr.churnedMRR + mrr.contractionMRR const ratio = loss > 0 ? growth / loss : growth > 0 ? Infinity : 0 return { ratio, newMRR: mrr.newMRR, expansionMRR: mrr.expansionMRR, churnedMRR: mrr.churnedMRR, contractionMRR: mrr.contractionMRR, healthy: ratio >= 4, period: mrr.period, } } /** * Calculate Magic Number */ export function calculateMagicNumberMetric(input: { netNewARR: number salesMarketingSpend: number period: MetricPeriod }): MagicNumber { const value = input.salesMarketingSpend > 0 ? input.netNewARR / input.salesMarketingSpend : 0 return { value, netNewARR: input.netNewARR, salesMarketingSpend: input.salesMarketingSpend, efficient: value >= 0.75, period: input.period, } } /** * Calculate Burn Multiple */ export function calculateBurnMultipleMetric(input: { netBurn: number netNewARR: number period: MetricPeriod }): BurnMultiple { const value = input.netNewARR > 0 ? input.netBurn / input.netNewARR : Infinity return { value, netBurn: input.netBurn, netNewARR: input.netNewARR, efficient: value <= 1.5, period: input.period, } } /** * Calculate Rule of 40 */ export function calculateRuleOf40Metric(input: { revenueGrowthRate: number profitMargin: number period: MetricPeriod }): RuleOf40 { const score = input.revenueGrowthRate + input.profitMargin return { score, revenueGrowthRate: input.revenueGrowthRate, profitMargin: input.profitMargin, passing: score >= 40, period: input.period, } } /** * Calculate growth rates */ export function calculateGrowthRates(input: { current: number previousMonth?: number previousQuarter?: number previousYear?: number metric: string period: MetricPeriod }): GrowthRate { const mom = input.previousMonth && input.previousMonth > 0 ? ((input.current - input.previousMonth) / input.previousMonth) * 100 : 0 const qoq = input.previousQuarter && input.previousQuarter > 0 ? ((input.current - input.previousQuarter) / input.previousQuarter) * 100 : 0 const yoy = input.previousYear && input.previousYear > 0 ? ((input.current - input.previousYear) / input.previousYear) * 100 : 0 return { mom, qoq, yoy, metric: input.metric, period: input.period, } } /** * Calculate churn metrics */ export function calculateChurnMetrics(input: { customersStart: number customersLost: number mrrStart: number mrrChurned: number expansionMRR: number period: MetricPeriod }): Churn { const customerChurnRate = input.customersStart > 0 ? (input.customersLost / input.customersStart) * 100 : 0 const revenueChurnRate = input.mrrStart > 0 ? (input.mrrChurned / input.mrrStart) * 100 : 0 const netRevenueChurnRate = input.mrrStart > 0 ? ((input.mrrChurned - input.expansionMRR) / input.mrrStart) * 100 : 0 return { customerChurnRate, customersLost: input.customersLost, customersStart: input.customersStart, revenueChurnRate, mrrChurned: input.mrrChurned, netRevenueChurnRate, period: input.period, } } // ============================================================================= // Aggregation Functions // ============================================================================= /** * Aggregate time series data by period */ export function aggregateTimeSeries<T extends number>( series: TimeSeries<T>, targetPeriod: TimePeriod ): TimeSeries<T> { const buckets = new Map<string, DataPoint<T>[]>() for (const point of series.dataPoints) { const key = getBucketKey(point.timestamp, targetPeriod) const existing = buckets.get(key) || [] buckets.set(key, [...existing, point]) } const aggregatedPoints: DataPoint<T>[] = [] const aggregation = series.aggregation || 'sum' for (const [key, points] of buckets) { const values = points.map((p) => p.value as number) let aggregatedValue: number switch (aggregation) { case 'sum': aggregatedValue = values.reduce((a, b) => a + b, 0) break case 'avg': aggregatedValue = values.reduce((a, b) => a + b, 0) / values.length break case 'min': aggregatedValue = Math.min(...values) break case 'max': aggregatedValue = Math.max(...values) break case 'last': aggregatedValue = values[values.length - 1] ?? 0 break case 'first': aggregatedValue = values[0] ?? 0 break default: aggregatedValue = values.reduce((a, b) => a + b, 0) } aggregatedPoints.push({ timestamp: new Date(key), value: aggregatedValue as T, }) } return { ...series, dataPoints: aggregatedPoints.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()), } } /** * Get bucket key for time aggregation */ function getBucketKey(date: Date, period: TimePeriod): string { switch (period) { case 'daily': return date.toISOString().split('T')[0] || date.toISOString() case 'weekly': const weekStart = new Date(date) weekStart.setDate(date.getDate() - date.getDay()) return weekStart.toISOString().split('T')[0] || weekStart.toISOString() case 'monthly': return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-01` case 'quarterly': const quarter = Math.floor(date.getMonth() / 3) return `${date.getFullYear()}-Q${quarter + 1}` case 'yearly': return `${date.getFullYear()}-01-01` default: return date.toISOString() } } /** * Create metric period from dates */ export function createMetricPeriod( period: TimePeriod, start: Date, end: Date, label?: string ): MetricPeriod { return { period, range: { start, end }, label: label || formatPeriodLabel(period, start, end), } } /** * Format period label */ function formatPeriodLabel(period: TimePeriod, start: Date, end: Date): string { const monthNames = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ] switch (period) { case 'monthly': return `${monthNames[start.getMonth()]} ${start.getFullYear()}` case 'quarterly': const quarter = Math.floor(start.getMonth() / 3) + 1 return `Q${quarter} ${start.getFullYear()}` case 'yearly': return `${start.getFullYear()}` default: return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}` } }