business-as-code
Version:
Primitives for expressing business logic and processes as code
839 lines (753 loc) • 19.4 kB
text/typescript
/**
* 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()}`
}
}